tirtc-react-native 2.3.1 → 2.4.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/TiRtcReactNative.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/AndroidManifest.xml +2 -1
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcModule.kt +585 -28
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeCleanup.kt +115 -0
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeEventBinder.kt +35 -8
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeEventSink.kt +20 -0
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeMaps.kt +62 -0
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeModuleSupport.kt +6 -6
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeRegistry.kt +27 -0
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeStoreSupport.kt +64 -0
- package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcVideoOutputViewManager.kt +17 -2
- package/ios/TiRtcReactNative+Conn.mm +173 -2
- package/ios/TiRtcReactNative+Input.mm +18 -4
- package/ios/TiRtcReactNative+Output.mm +41 -4
- package/ios/TiRtcReactNative+Private.h +4 -0
- package/ios/TiRtcReactNative+Store.mm +496 -0
- package/ios/TiRtcReactNative.h +2 -1
- package/ios/TiRtcReactNative.mm +109 -1
- package/ios/TiRtcReactNativeEventBinder.h +8 -0
- package/ios/TiRtcReactNativeEventBinder.mm +104 -0
- package/ios/TiRtcReactNativeEventSink.h +4 -0
- package/ios/TiRtcReactNativeEventSink.mm +20 -0
- package/ios/TiRtcReactNativeRegistry.h +5 -0
- package/ios/TiRtcReactNativeRegistry.mm +43 -0
- package/ios/TiRtcVideoOutputView.mm +11 -0
- package/lib/module/audio_input.js +33 -17
- package/lib/module/audio_input.js.map +1 -1
- package/lib/module/audio_output.js +27 -10
- package/lib/module/audio_output.js.map +1 -1
- package/lib/module/conn.js +31 -7
- package/lib/module/conn.js.map +1 -1
- package/lib/module/errors.js +4 -3
- package/lib/module/errors.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/logging.js +9 -1
- package/lib/module/logging.js.map +1 -1
- package/lib/module/logging_internal.js +27 -0
- package/lib/module/logging_internal.js.map +1 -0
- package/lib/module/recording.js +210 -0
- package/lib/module/recording.js.map +1 -0
- package/lib/module/runtime.js +13 -5
- package/lib/module/runtime.js.map +1 -1
- package/lib/module/specs/NativeTiRtc.js.map +1 -1
- package/lib/module/store.js +698 -0
- package/lib/module/store.js.map +1 -0
- package/lib/module/video_input.js +40 -22
- package/lib/module/video_input.js.map +1 -1
- package/lib/module/video_output.js +51 -10
- package/lib/module/video_output.js.map +1 -1
- package/lib/typescript/audio_input.d.ts +1 -0
- package/lib/typescript/conn.d.ts +2 -0
- package/lib/typescript/errors.d.ts +3 -2
- package/lib/typescript/index.d.ts +4 -0
- package/lib/typescript/logging_internal.d.ts +2 -0
- package/lib/typescript/recording.d.ts +53 -0
- package/lib/typescript/runtime.d.ts +2 -0
- package/lib/typescript/specs/NativeTiRtc.d.ts +88 -0
- package/lib/typescript/store.d.ts +202 -0
- package/lib/typescript/video_input.d.ts +1 -0
- package/lib/typescript/video_output.d.ts +3 -0
- package/package.json +1 -1
- package/src/audio_input.ts +85 -59
- package/src/audio_output.ts +30 -17
- package/src/conn.ts +46 -28
- package/src/errors.ts +4 -3
- package/src/index.ts +54 -0
- package/src/logging.ts +7 -1
- package/src/logging_internal.ts +23 -0
- package/src/recording.ts +271 -0
- package/src/runtime.ts +14 -5
- package/src/specs/NativeTiRtc.ts +123 -0
- package/src/store.ts +760 -0
- package/src/video_input.ts +97 -62
- package/src/video_output.ts +54 -16
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
package com.tange.ai.tirtc.reactnative
|
|
2
2
|
|
|
3
|
-
import android.
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.ContentValues
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import android.os.Environment
|
|
8
|
+
import android.provider.MediaStore
|
|
4
9
|
import com.facebook.react.bridge.Promise
|
|
5
10
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
11
|
import com.facebook.react.bridge.ReactMethod
|
|
@@ -13,21 +18,44 @@ import com.tange.ai.tirtc.TiRtcAudioInput
|
|
|
13
18
|
import com.tange.ai.tirtc.TiRtcAudioOutput
|
|
14
19
|
import com.tange.ai.tirtc.TiRtcConn
|
|
15
20
|
import com.tange.ai.tirtc.TiRtcInitOptions
|
|
21
|
+
import com.tange.ai.tirtc.TiRtcLogging
|
|
22
|
+
import com.tange.ai.tirtc.TiRtcRecordingFile
|
|
23
|
+
import com.tange.ai.tirtc.TiRtcSnapshotFile
|
|
16
24
|
import com.tange.ai.tirtc.TiRtcVideoInput
|
|
17
25
|
import com.tange.ai.tirtc.TiRtcVideoOutput
|
|
26
|
+
import com.tange.ai.tirtc.TiStore
|
|
27
|
+
import com.tange.ai.tirtc.TiStoreAudioOutput
|
|
28
|
+
import com.tange.ai.tirtc.TiStoreExportRequest
|
|
29
|
+
import com.tange.ai.tirtc.TiStoreRecordingFile
|
|
30
|
+
import com.tange.ai.tirtc.TiStoreRecordingResult
|
|
31
|
+
import com.tange.ai.tirtc.TiStoreReplaySpeed
|
|
32
|
+
import com.tange.ai.tirtc.TiStoreSnapshotFile
|
|
33
|
+
import com.tange.ai.tirtc.TiStoreVideoOutput
|
|
34
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
35
|
+
import java.util.concurrent.ArrayBlockingQueue
|
|
36
|
+
import java.util.concurrent.RejectedExecutionException
|
|
37
|
+
import java.util.concurrent.ThreadPoolExecutor
|
|
38
|
+
import java.util.concurrent.TimeUnit
|
|
39
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
40
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
18
41
|
|
|
19
42
|
@ReactModule(name = TiRtcModule.NAME)
|
|
20
43
|
class TiRtcModule(
|
|
21
44
|
private val reactContext: ReactApplicationContext,
|
|
22
45
|
) : NativeTiRtcSpec(reactContext) {
|
|
23
46
|
private val registry = TiRtcReactNativeRegistry
|
|
47
|
+
private val invalidated = AtomicBoolean(false)
|
|
24
48
|
private val events =
|
|
25
49
|
TiRtcReactNativeEventSink(registry) { event ->
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
if (!invalidated.get()) {
|
|
51
|
+
reactContext.runOnJSQueueThread {
|
|
52
|
+
if (!invalidated.get()) emitOnTiRtcEvent(event)
|
|
53
|
+
}
|
|
28
54
|
}
|
|
29
55
|
}
|
|
30
56
|
private val eventBinder = TiRtcReactNativeEventBinder(events)
|
|
57
|
+
private val nextMediaFileId = AtomicInteger(1)
|
|
58
|
+
private val mediaFiles = ConcurrentHashMap<Int, Any>()
|
|
31
59
|
|
|
32
60
|
init {
|
|
33
61
|
registry.bindEventSink(events)
|
|
@@ -35,13 +63,22 @@ class TiRtcModule(
|
|
|
35
63
|
|
|
36
64
|
override fun getName(): String = NAME
|
|
37
65
|
|
|
66
|
+
override fun invalidate() {
|
|
67
|
+
if (!invalidated.compareAndSet(false, true)) return
|
|
68
|
+
val objects = registry.drain(events)
|
|
69
|
+
val files = mediaFiles.values.toList()
|
|
70
|
+
mediaFiles.clear()
|
|
71
|
+
TiRtcReactNativeCleanup.release(objects, files)
|
|
72
|
+
super.invalidate()
|
|
73
|
+
}
|
|
74
|
+
|
|
38
75
|
@ReactMethod
|
|
39
76
|
override fun initialize(
|
|
40
77
|
options: ReadableMap,
|
|
41
78
|
promise: Promise,
|
|
42
79
|
) {
|
|
43
80
|
val code =
|
|
44
|
-
TiRtc.
|
|
81
|
+
TiRtc.init(
|
|
45
82
|
reactContext,
|
|
46
83
|
TiRtcInitOptions(
|
|
47
84
|
appId = options.getString("appId") ?: "",
|
|
@@ -54,6 +91,11 @@ class TiRtcModule(
|
|
|
54
91
|
|
|
55
92
|
override fun shutdown(): Double {
|
|
56
93
|
if (registry.hasLiveObjects()) {
|
|
94
|
+
TiRtcLogging.w(
|
|
95
|
+
TAG,
|
|
96
|
+
"[react_native_shutdown] rejected: dispose all React Native objects before shutdown; " +
|
|
97
|
+
"error_code=1007 reason=objects_live",
|
|
98
|
+
)
|
|
57
99
|
return TiRtcReactNativeErrors.RUNTIME_OBJECTS_LIVE.toDouble()
|
|
58
100
|
}
|
|
59
101
|
return TiRtcReactNativeErrors.normalizeAndroid(TiRtc.shutdown()).toDouble()
|
|
@@ -67,16 +109,290 @@ class TiRtcModule(
|
|
|
67
109
|
message: String,
|
|
68
110
|
) {
|
|
69
111
|
when (level.toInt()) {
|
|
70
|
-
0 ->
|
|
71
|
-
1 ->
|
|
72
|
-
2 ->
|
|
73
|
-
3 ->
|
|
112
|
+
0 -> TiRtcLogging.d(tag, message)
|
|
113
|
+
1 -> TiRtcLogging.i(tag, message)
|
|
114
|
+
2 -> TiRtcLogging.w(tag, message)
|
|
115
|
+
3 -> TiRtcLogging.e(tag, message)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@ReactMethod
|
|
120
|
+
override fun storeInitialize(options: ReadableMap, promise: Promise) {
|
|
121
|
+
val code = TiStore.init(
|
|
122
|
+
reactContext,
|
|
123
|
+
options.getString("appId") ?: "",
|
|
124
|
+
options.getString("endpoint") ?: "",
|
|
125
|
+
options.getBoolean("consoleLogEnabled"),
|
|
126
|
+
)
|
|
127
|
+
promise.resolve(code.toDouble())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
override fun storeShutdown(): Double = TiStore.shutdown().toDouble()
|
|
131
|
+
|
|
132
|
+
override fun storeCreate(token: String): WritableMap =
|
|
133
|
+
registerReactNativeObject(
|
|
134
|
+
registry,
|
|
135
|
+
factory = { TiStore(token) },
|
|
136
|
+
onRegistered = { entry, value -> eventBinder.wireStore(entry.id, value as TiStore) },
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
override fun storeUpdateToken(id: Double, token: String): Double =
|
|
140
|
+
registry.store(id.toInt())?.updateToken(token).normalized()
|
|
141
|
+
|
|
142
|
+
@ReactMethod
|
|
143
|
+
override fun storeListRecordings(id: Double, startTimeMs: Double, endTimeMs: Double, promise: Promise) {
|
|
144
|
+
val store = registry.store(id.toInt())
|
|
145
|
+
if (store == null) {
|
|
146
|
+
promise.resolve(storeRecordingsResult(TiRtcReactNativeErrors.OBJECT_RELEASED, emptyList()))
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
store.listRecordings(startTimeMs.toLong(), endTimeMs.toLong()) { result ->
|
|
150
|
+
reactContext.runOnJSQueueThread {
|
|
151
|
+
promise.resolve(storeRecordingsResult(result.code, result.recordings))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@ReactMethod
|
|
157
|
+
override fun storeListRecordingDays(
|
|
158
|
+
id: Double,
|
|
159
|
+
startDate: String,
|
|
160
|
+
endDate: String,
|
|
161
|
+
timeZoneId: String,
|
|
162
|
+
promise: Promise,
|
|
163
|
+
) {
|
|
164
|
+
val store = registry.store(id.toInt())
|
|
165
|
+
if (store == null) {
|
|
166
|
+
promise.resolve(storeRecordingDaysResult(TiRtcReactNativeErrors.OBJECT_RELEASED, emptyList()))
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
store.listRecordingDays(startDate, endDate, timeZoneId) { result ->
|
|
170
|
+
reactContext.runOnJSQueueThread {
|
|
171
|
+
promise.resolve(storeRecordingDaysResult(result.code, result.days))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
override fun storeCreateReplay(id: Double): WritableMap {
|
|
177
|
+
val store = registry.store(id.toInt())
|
|
178
|
+
?: return createResult(TiRtcReactNativeErrors.OBJECT_RELEASED, 0, 0)
|
|
179
|
+
return registerReactNativeObject(
|
|
180
|
+
registry,
|
|
181
|
+
factory = { store.createReplay() },
|
|
182
|
+
onRegistered = { entry, value -> eventBinder.wireStoreReplay(entry.id, value as com.tange.ai.tirtc.TiStoreReplay) },
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
override fun storeExportRecording(
|
|
187
|
+
id: Double,
|
|
188
|
+
startTimeMs: Double,
|
|
189
|
+
endTimeMs: Double,
|
|
190
|
+
videoChannelId: Double,
|
|
191
|
+
audioChannelId: Double?,
|
|
192
|
+
): WritableMap {
|
|
193
|
+
val store = registry.store(id.toInt())
|
|
194
|
+
?: return recordingStartResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
|
|
195
|
+
val bridge = StoreExportBridge(::resolveStoreRecording)
|
|
196
|
+
val started = store.exportRecording(
|
|
197
|
+
TiStoreExportRequest(
|
|
198
|
+
startTimeMs.toLong(),
|
|
199
|
+
endTimeMs.toLong(),
|
|
200
|
+
videoChannelId.toInt(),
|
|
201
|
+
audioChannelId?.toInt(),
|
|
202
|
+
),
|
|
203
|
+
null,
|
|
204
|
+
) { result -> bridge.complete(result) }
|
|
205
|
+
val task = started.task ?: return recordingStartResult(started.code, null)
|
|
206
|
+
bridge.task = task
|
|
207
|
+
val entry = registry.add(bridge)
|
|
208
|
+
bridge.onDrained = { registry.release(entry.id) }
|
|
209
|
+
return recordingStartResult(started.code, entry.id)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
override fun storeExportProgress(id: Double): Double =
|
|
213
|
+
registry.storeExportTask(id.toInt())?.task?.progress ?: 0.0
|
|
214
|
+
|
|
215
|
+
@ReactMethod
|
|
216
|
+
override fun storeExportResult(id: Double, promise: Promise) {
|
|
217
|
+
registry.storeExportTask(id.toInt())?.await(promise)
|
|
218
|
+
?: promise.resolve(mp4Result(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null, null))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
override fun storeExportStop(id: Double): Double =
|
|
222
|
+
registry.storeExportTask(id.toInt())?.task?.stop().normalized()
|
|
223
|
+
|
|
224
|
+
override fun storeDispose(id: Double): Double {
|
|
225
|
+
val objectId = id.toInt()
|
|
226
|
+
val code = registry.store(objectId)?.dispose().normalized()
|
|
227
|
+
if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
|
|
228
|
+
return code
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
override fun storeReplayGetInfo(id: Double): WritableMap {
|
|
232
|
+
val replay = registry.storeReplay(id.toInt())
|
|
233
|
+
?: return storeReplayInfoResult(TiRtcReactNativeErrors.OBJECT_RELEASED, 0, null)
|
|
234
|
+
val speed = when (replay.speed) {
|
|
235
|
+
TiStoreReplaySpeed.X1 -> 0
|
|
236
|
+
TiStoreReplaySpeed.X2 -> 1
|
|
237
|
+
TiStoreReplaySpeed.X4 -> 2
|
|
238
|
+
TiStoreReplaySpeed.X8 -> 3
|
|
239
|
+
}
|
|
240
|
+
return storeReplayInfoResult(0, speed, replay.currentTimeMs)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
override fun storeReplayPlay(
|
|
244
|
+
id: Double,
|
|
245
|
+
startTimeMs: Double,
|
|
246
|
+
endTimeMs: Double,
|
|
247
|
+
initialTimeMs: Double?,
|
|
248
|
+
): Double {
|
|
249
|
+
val replay = registry.storeReplay(id.toInt()) ?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
|
|
250
|
+
return replay.play(startTimeMs.toLong(), endTimeMs.toLong(), initialTimeMs?.toLong()).toDouble()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
override fun storeReplayPause(id: Double): Double = registry.storeReplay(id.toInt())?.pause().normalized()
|
|
254
|
+
|
|
255
|
+
override fun storeReplayResume(id: Double): Double = registry.storeReplay(id.toInt())?.resume().normalized()
|
|
256
|
+
|
|
257
|
+
override fun storeReplaySeek(id: Double, timeMs: Double): Double =
|
|
258
|
+
registry.storeReplay(id.toInt())?.seek(timeMs.toLong()).normalized()
|
|
259
|
+
|
|
260
|
+
override fun storeReplaySetSpeed(id: Double, speed: Double): Double {
|
|
261
|
+
val value = when (speed.toInt()) {
|
|
262
|
+
0 -> TiStoreReplaySpeed.X1
|
|
263
|
+
1 -> TiStoreReplaySpeed.X2
|
|
264
|
+
2 -> TiStoreReplaySpeed.X4
|
|
265
|
+
3 -> TiStoreReplaySpeed.X8
|
|
266
|
+
else -> return TiRtcReactNativeErrors.INVALID_ARGUMENT.toDouble()
|
|
267
|
+
}
|
|
268
|
+
return registry.storeReplay(id.toInt())?.setSpeed(value).normalized()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
override fun storeReplayStartRecording(
|
|
272
|
+
id: Double,
|
|
273
|
+
videoChannelId: Double,
|
|
274
|
+
audioChannelId: Double?,
|
|
275
|
+
): WritableMap {
|
|
276
|
+
val replay = registry.storeReplay(id.toInt())
|
|
277
|
+
?: return recordingStartResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
|
|
278
|
+
val result = replay.startRecording(videoChannelId.toInt(), audioChannelId?.toInt())
|
|
279
|
+
val entry = result.task?.let(registry::add)
|
|
280
|
+
return recordingStartResult(result.code, entry?.id)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@ReactMethod
|
|
284
|
+
override fun storeRecordingTaskStop(id: Double, promise: Promise) {
|
|
285
|
+
val objectId = id.toInt()
|
|
286
|
+
val task = registry.storeRecordingTask(objectId)
|
|
287
|
+
if (task == null) {
|
|
288
|
+
promise.resolve(mp4Result(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null, null))
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
task.stop { result ->
|
|
292
|
+
registry.release(objectId)
|
|
293
|
+
resolveStoreRecording(promise, result)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
override fun storeReplayStop(id: Double): Double = registry.storeReplay(id.toInt())?.stop().normalized()
|
|
298
|
+
|
|
299
|
+
override fun storeReplayDispose(id: Double): Double {
|
|
300
|
+
val objectId = id.toInt()
|
|
301
|
+
val code = registry.storeReplay(objectId)?.dispose().normalized()
|
|
302
|
+
if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
|
|
303
|
+
return code
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
override fun storeCreateAudioOutput(): WritableMap =
|
|
307
|
+
registerReactNativeObject(
|
|
308
|
+
registry,
|
|
309
|
+
factory = { TiStoreAudioOutput() },
|
|
310
|
+
onRegistered = { entry, value -> eventBinder.wireStoreAudioOutput(entry.id, value as TiStoreAudioOutput) },
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
override fun storeAudioOutputGetState(id: Double): WritableMap {
|
|
314
|
+
val output = registry.storeAudioOutput(id.toInt())
|
|
315
|
+
?: return stateResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
|
|
316
|
+
return stateResult(0, output.state.name.lowercase())
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
override fun storeAudioOutputSetVolume(id: Double, volumePercent: Double): Double =
|
|
320
|
+
registry.storeAudioOutput(id.toInt())?.setVolume(volumePercent.toInt()).normalized()
|
|
321
|
+
|
|
322
|
+
override fun storeAudioOutputAttach(outputId: Double, replayId: Double, channelId: Double): Double {
|
|
323
|
+
val replay = registry.storeReplay(replayId.toInt())
|
|
324
|
+
?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
|
|
325
|
+
return registry.storeAudioOutput(outputId.toInt())?.attach(replay, channelId.toInt()).normalized()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
override fun storeAudioOutputDetach(outputId: Double): Double =
|
|
329
|
+
registry.storeAudioOutput(outputId.toInt())?.detach().normalized()
|
|
330
|
+
|
|
331
|
+
override fun storeAudioOutputDispose(id: Double): Double {
|
|
332
|
+
val objectId = id.toInt()
|
|
333
|
+
val code = registry.storeAudioOutput(objectId)?.dispose().normalized()
|
|
334
|
+
if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
|
|
335
|
+
return code
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
override fun storeCreateVideoOutput(): WritableMap =
|
|
339
|
+
registerReactNativeObject(
|
|
340
|
+
registry,
|
|
341
|
+
factory = { TiStoreVideoOutput() },
|
|
342
|
+
onRegistered = { entry, value -> eventBinder.wireStoreVideoOutput(entry.id, value as TiStoreVideoOutput) },
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
override fun storeVideoOutputGetState(id: Double): WritableMap {
|
|
346
|
+
val output = registry.storeVideoOutput(id.toInt())
|
|
347
|
+
?: return stateResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
|
|
348
|
+
return stateResult(0, output.state.name.lowercase())
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
override fun storeVideoOutputAttach(outputId: Double, replayId: Double, channelId: Double): Double {
|
|
352
|
+
val replay = registry.storeReplay(replayId.toInt())
|
|
353
|
+
?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
|
|
354
|
+
return registry.storeVideoOutput(outputId.toInt())?.attach(replay, channelId.toInt()).normalized()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
override fun storeVideoOutputDetach(outputId: Double): Double =
|
|
358
|
+
registry.storeVideoOutput(outputId.toInt())?.detach().normalized()
|
|
359
|
+
|
|
360
|
+
@ReactMethod
|
|
361
|
+
override fun storeVideoOutputTakeSnapshot(id: Double, promise: Promise) {
|
|
362
|
+
val output = registry.storeVideoOutput(id.toInt())
|
|
363
|
+
if (output == null) {
|
|
364
|
+
promise.resolve(mediaSnapshotResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null))
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
output.takeSnapshot { result ->
|
|
368
|
+
val fileId = result.file?.let { file ->
|
|
369
|
+
nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
|
|
370
|
+
}
|
|
371
|
+
reactContext.runOnJSQueueThread {
|
|
372
|
+
promise.resolve(mediaSnapshotResult(result.code, fileId, result.file?.path))
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
override fun storeVideoOutputDispose(id: Double): Double {
|
|
378
|
+
val objectId = id.toInt()
|
|
379
|
+
val code = registry.storeVideoOutput(objectId)?.dispose().normalized()
|
|
380
|
+
if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
|
|
381
|
+
return code
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private fun resolveStoreRecording(promise: Promise, result: TiStoreRecordingResult) {
|
|
385
|
+
val fileId = result.file?.let { file ->
|
|
386
|
+
nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
|
|
387
|
+
}
|
|
388
|
+
reactContext.runOnJSQueueThread {
|
|
389
|
+
promise.resolve(mp4Result(result.code, fileId, result.file?.path, result.file?.durationMs))
|
|
74
390
|
}
|
|
75
391
|
}
|
|
76
392
|
|
|
77
393
|
@ReactMethod
|
|
78
394
|
override fun uploadLogs(promise: Promise) {
|
|
79
|
-
uploadLogsToPromise(promise
|
|
395
|
+
uploadLogsToPromise(promise)
|
|
80
396
|
}
|
|
81
397
|
|
|
82
398
|
override fun createConn(): WritableMap =
|
|
@@ -95,22 +411,9 @@ class TiRtcModule(
|
|
|
95
411
|
id: Double,
|
|
96
412
|
remoteId: String,
|
|
97
413
|
token: String,
|
|
98
|
-
): Double
|
|
99
|
-
val objectId = id.toInt()
|
|
100
|
-
val code = registry.conn(objectId)?.connect(remoteId, token).normalized()
|
|
101
|
-
Log.i(
|
|
102
|
-
TAG,
|
|
103
|
-
"connConnect objectId=$objectId remoteIdPresent=${remoteId.isNotEmpty()} tokenPresent=${token.isNotEmpty()} code=${code.toInt()}",
|
|
104
|
-
)
|
|
105
|
-
return code
|
|
106
|
-
}
|
|
414
|
+
): Double = registry.conn(id.toInt())?.connect(remoteId, token).normalized()
|
|
107
415
|
|
|
108
|
-
override fun connDisconnect(id: Double): Double
|
|
109
|
-
val objectId = id.toInt()
|
|
110
|
-
val code = registry.conn(objectId)?.disconnect().normalized()
|
|
111
|
-
Log.i(TAG, "connDisconnect objectId=$objectId code=${code.toInt()}")
|
|
112
|
-
return code
|
|
113
|
-
}
|
|
416
|
+
override fun connDisconnect(id: Double): Double = registry.conn(id.toInt())?.disconnect().normalized()
|
|
114
417
|
|
|
115
418
|
override fun connSendCommand(
|
|
116
419
|
id: Double,
|
|
@@ -143,6 +446,196 @@ class TiRtcModule(
|
|
|
143
446
|
override fun connRequestKeyFrame(id: Double, streamId: Double): Double =
|
|
144
447
|
registry.conn(id.toInt())?.requestKeyFrame(streamId.toInt()).normalized()
|
|
145
448
|
|
|
449
|
+
override fun connStartRecording(id: Double, options: ReadableMap): WritableMap {
|
|
450
|
+
val conn = registry.conn(id.toInt())
|
|
451
|
+
if (conn == null) {
|
|
452
|
+
return recordingStartResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
|
|
453
|
+
}
|
|
454
|
+
val result = conn.startRecording(
|
|
455
|
+
videoStreamId = options.getInt("videoStreamId"),
|
|
456
|
+
audioStreamId = if (options.isNull("audioStreamId")) null else options.getInt("audioStreamId"),
|
|
457
|
+
)
|
|
458
|
+
val entry = result.task?.let { registry.add(it) }
|
|
459
|
+
return recordingStartResult(result.code, entry?.id)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
@ReactMethod
|
|
463
|
+
override fun recordingTaskStop(id: Double, promise: Promise) {
|
|
464
|
+
val objectId = id.toInt()
|
|
465
|
+
val task = registry.recordingTask(objectId)
|
|
466
|
+
if (task == null) {
|
|
467
|
+
promise.resolve(mp4Result(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null, null))
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
task.stop { result ->
|
|
471
|
+
registry.release(objectId)
|
|
472
|
+
val fileId = result.file?.let { file ->
|
|
473
|
+
nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
|
|
474
|
+
}
|
|
475
|
+
reactContext.runOnJSQueueThread {
|
|
476
|
+
promise.resolve(mp4Result(result.code, fileId, result.file?.path, result.file?.durationMs))
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
@ReactMethod
|
|
482
|
+
override fun mediaFileMoveToGallery(id: Double, fileName: String?, promise: Promise) {
|
|
483
|
+
val fileId = id.toInt()
|
|
484
|
+
val mediaFile = mediaFiles[fileId]
|
|
485
|
+
val path = when (mediaFile) {
|
|
486
|
+
is TiRtcRecordingFile -> mediaFile.path
|
|
487
|
+
is TiRtcSnapshotFile -> mediaFile.path
|
|
488
|
+
is TiStoreRecordingFile -> mediaFile.path
|
|
489
|
+
is TiStoreSnapshotFile -> mediaFile.path
|
|
490
|
+
else -> {
|
|
491
|
+
promise.resolve(galleryResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null))
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
val video = mediaFile is TiRtcRecordingFile || mediaFile is TiStoreRecordingFile
|
|
496
|
+
val normalizedName = normalizeGalleryFileName(fileName, video)
|
|
497
|
+
if (normalizedName == null) {
|
|
498
|
+
promise.resolve(galleryResult(ERROR_INVALID_ARGUMENT, null))
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
|
|
503
|
+
reactContext.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
|
|
504
|
+
) {
|
|
505
|
+
promise.resolve(galleryResult(ERROR_PERMISSION_DENIED, null))
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
mediaFileExecutor.execute {
|
|
510
|
+
publishMediaFile(
|
|
511
|
+
fileId,
|
|
512
|
+
mediaFile,
|
|
513
|
+
path,
|
|
514
|
+
video,
|
|
515
|
+
normalizedName,
|
|
516
|
+
promise,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
} catch (_: RejectedExecutionException) {
|
|
520
|
+
promise.resolve(galleryResult(ERROR_RESOURCE_EXHAUSTED, null))
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private fun publishMediaFile(
|
|
525
|
+
fileId: Int,
|
|
526
|
+
mediaFile: Any,
|
|
527
|
+
path: String,
|
|
528
|
+
video: Boolean,
|
|
529
|
+
fileName: String,
|
|
530
|
+
promise: Promise,
|
|
531
|
+
) {
|
|
532
|
+
val source = java.io.File(path)
|
|
533
|
+
if (!source.isFile) {
|
|
534
|
+
reactContext.runOnJSQueueThread { promise.resolve(galleryResult(ERROR_FILE_READ_FAILED, null)) }
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
val collection = if (video) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
538
|
+
val displayName = fileName
|
|
539
|
+
val legacyTarget = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
540
|
+
val directory = if (video) Environment.DIRECTORY_MOVIES else Environment.DIRECTORY_PICTURES
|
|
541
|
+
val targetDirectory = java.io.File(
|
|
542
|
+
Environment.getExternalStoragePublicDirectory(directory),
|
|
543
|
+
"TiRTC",
|
|
544
|
+
)
|
|
545
|
+
if (!targetDirectory.exists() && !targetDirectory.mkdirs()) {
|
|
546
|
+
reactContext.runOnJSQueueThread {
|
|
547
|
+
promise.resolve(galleryResult(ERROR_FILE_WRITE_FAILED, null))
|
|
548
|
+
}
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
java.io.File(targetDirectory, displayName)
|
|
552
|
+
} else {
|
|
553
|
+
null
|
|
554
|
+
}
|
|
555
|
+
val values = ContentValues().apply {
|
|
556
|
+
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
|
557
|
+
put(MediaStore.MediaColumns.MIME_TYPE, if (video) "video/mp4" else "image/jpeg")
|
|
558
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
559
|
+
put(MediaStore.MediaColumns.RELATIVE_PATH, "${if (video) "Movies" else "Pictures"}/TiRTC")
|
|
560
|
+
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
|
561
|
+
} else {
|
|
562
|
+
@Suppress("DEPRECATION")
|
|
563
|
+
put(MediaStore.MediaColumns.DATA, legacyTarget!!.absolutePath)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
val resolver = reactContext.contentResolver
|
|
567
|
+
val target = try {
|
|
568
|
+
resolver.insert(collection, values)
|
|
569
|
+
} catch (_: SecurityException) {
|
|
570
|
+
reactContext.runOnJSQueueThread { promise.resolve(galleryResult(ERROR_PERMISSION_DENIED, null)) }
|
|
571
|
+
return
|
|
572
|
+
} catch (_: Throwable) {
|
|
573
|
+
null
|
|
574
|
+
}
|
|
575
|
+
if (target == null) {
|
|
576
|
+
reactContext.runOnJSQueueThread { promise.resolve(galleryResult(ERROR_FILE_WRITE_FAILED, null)) }
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
resolver.openOutputStream(target)?.use { output ->
|
|
581
|
+
source.inputStream().use { input -> input.copyTo(output) }
|
|
582
|
+
} ?: error("unable to open media asset")
|
|
583
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
584
|
+
values.clear()
|
|
585
|
+
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
|
586
|
+
if (resolver.update(target, values, null, null) != 1) error("unable to publish media asset")
|
|
587
|
+
}
|
|
588
|
+
} catch (error: Throwable) {
|
|
589
|
+
try {
|
|
590
|
+
resolver.delete(target, null, null)
|
|
591
|
+
} catch (_: Throwable) {
|
|
592
|
+
}
|
|
593
|
+
val code = if (error is SecurityException) ERROR_PERMISSION_DENIED else ERROR_FILE_WRITE_FAILED
|
|
594
|
+
reactContext.runOnJSQueueThread { promise.resolve(galleryResult(code, null)) }
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
val onDelete: (Int) -> Unit = { code ->
|
|
598
|
+
if (code == 0) {
|
|
599
|
+
mediaFiles.remove(fileId, mediaFile)
|
|
600
|
+
} else {
|
|
601
|
+
try {
|
|
602
|
+
resolver.delete(target, null, null)
|
|
603
|
+
} catch (_: Throwable) {
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
reactContext.runOnJSQueueThread {
|
|
607
|
+
promise.resolve(galleryResult(code, if (code == 0) target.toString() else null))
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
when (mediaFile) {
|
|
611
|
+
is TiRtcRecordingFile -> mediaFile.delete(onDelete)
|
|
612
|
+
is TiRtcSnapshotFile -> mediaFile.delete(onDelete)
|
|
613
|
+
is TiStoreRecordingFile -> mediaFile.delete(onDelete)
|
|
614
|
+
is TiStoreSnapshotFile -> mediaFile.delete(onDelete)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
@ReactMethod
|
|
619
|
+
override fun mediaFileDelete(id: Double, promise: Promise) {
|
|
620
|
+
val fileId = id.toInt()
|
|
621
|
+
val mediaFile = mediaFiles[fileId]
|
|
622
|
+
val callback: (Int) -> Unit = { code ->
|
|
623
|
+
if (code == 0) mediaFiles.remove(fileId)
|
|
624
|
+
reactContext.runOnJSQueueThread { promise.resolve(code.toDouble()) }
|
|
625
|
+
}
|
|
626
|
+
when (mediaFile) {
|
|
627
|
+
is TiRtcRecordingFile -> mediaFile.delete(callback)
|
|
628
|
+
is TiRtcSnapshotFile -> mediaFile.delete(callback)
|
|
629
|
+
is TiStoreRecordingFile -> mediaFile.delete(callback)
|
|
630
|
+
is TiStoreSnapshotFile -> mediaFile.delete(callback)
|
|
631
|
+
else -> promise.resolve(TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble())
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
override fun mediaFileRelease(id: Double) {
|
|
636
|
+
mediaFiles.remove(id.toInt())
|
|
637
|
+
}
|
|
638
|
+
|
|
146
639
|
override fun connGetMetricsSnapshot(id: Double): WritableMap {
|
|
147
640
|
val conn = registry.conn(id.toInt()) ?: return releasedSnapshotResult()
|
|
148
641
|
return connMetricsResult(conn.getMetricsSnapshot())
|
|
@@ -181,6 +674,11 @@ class TiRtcModule(
|
|
|
181
674
|
volumePercent < 0.0 ||
|
|
182
675
|
volumePercent > 100.0
|
|
183
676
|
) {
|
|
677
|
+
TiRtcLogging.w(
|
|
678
|
+
TAG,
|
|
679
|
+
"[react_native_audio_output_set_volume] rejected: volume must be an integer from 0 to 100; " +
|
|
680
|
+
"error_code=6000 reason=invalid_volume",
|
|
681
|
+
)
|
|
184
682
|
return TiRtcReactNativeErrors.INVALID_ARGUMENT.toDouble()
|
|
185
683
|
}
|
|
186
684
|
return registry.audioOutput(id.toInt())?.setVolume(volumePercent.toInt()).normalized()
|
|
@@ -259,6 +757,23 @@ class TiRtcModule(
|
|
|
259
757
|
return videoDebugResult(output.getDebugSnapshot())
|
|
260
758
|
}
|
|
261
759
|
|
|
760
|
+
@ReactMethod
|
|
761
|
+
override fun videoOutputTakeSnapshot(id: Double, promise: Promise) {
|
|
762
|
+
val output = registry.videoOutput(id.toInt())
|
|
763
|
+
if (output == null) {
|
|
764
|
+
promise.resolve(mediaSnapshotResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null))
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
output.takeSnapshot { snapshotResult ->
|
|
768
|
+
val fileId = snapshotResult.file?.let { file ->
|
|
769
|
+
nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
|
|
770
|
+
}
|
|
771
|
+
reactContext.runOnJSQueueThread {
|
|
772
|
+
promise.resolve(mediaSnapshotResult(snapshotResult.code, fileId, snapshotResult.file?.path))
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
262
777
|
override fun videoOutputDispose(id: Double): Double {
|
|
263
778
|
val objectId = id.toInt()
|
|
264
779
|
val code = registry.videoOutput(objectId)?.dispose().normalized()
|
|
@@ -310,7 +825,6 @@ class TiRtcModule(
|
|
|
310
825
|
?: TiRtcReactNativeErrors.OBJECT_RELEASED,
|
|
311
826
|
)
|
|
312
827
|
}
|
|
313
|
-
Log.i(TAG, "audioInputAttach objectId=${id.toInt()} connId=${connId.toInt()} streamId=${streamId.toInt()} code=$code")
|
|
314
828
|
promise.resolve(code.toDouble())
|
|
315
829
|
}
|
|
316
830
|
|
|
@@ -324,7 +838,6 @@ class TiRtcModule(
|
|
|
324
838
|
registry.audioInput(id.toInt())?.start()
|
|
325
839
|
?: TiRtcReactNativeErrors.OBJECT_RELEASED,
|
|
326
840
|
)
|
|
327
|
-
Log.i(TAG, "audioInputStart objectId=${id.toInt()} code=$code")
|
|
328
841
|
promise.resolve(code.toDouble())
|
|
329
842
|
}
|
|
330
843
|
|
|
@@ -420,7 +933,6 @@ class TiRtcModule(
|
|
|
420
933
|
?: TiRtcReactNativeErrors.OBJECT_RELEASED,
|
|
421
934
|
)
|
|
422
935
|
}
|
|
423
|
-
Log.i(TAG, "videoInputAttach objectId=${id.toInt()} connId=${connId.toInt()} streamId=${streamId.toInt()} code=$code")
|
|
424
936
|
promise.resolve(code.toDouble())
|
|
425
937
|
}
|
|
426
938
|
|
|
@@ -434,7 +946,6 @@ class TiRtcModule(
|
|
|
434
946
|
registry.videoInput(id.toInt())?.start()
|
|
435
947
|
?: TiRtcReactNativeErrors.OBJECT_RELEASED,
|
|
436
948
|
)
|
|
437
|
-
Log.i(TAG, "videoInputStart objectId=${id.toInt()} code=$code")
|
|
438
949
|
promise.resolve(code.toDouble())
|
|
439
950
|
}
|
|
440
951
|
|
|
@@ -478,5 +989,51 @@ class TiRtcModule(
|
|
|
478
989
|
companion object {
|
|
479
990
|
const val NAME = "TiRtc"
|
|
480
991
|
private const val TAG = "TiRtcReactNative"
|
|
992
|
+
private const val ERROR_INVALID_ARGUMENT = 6000
|
|
993
|
+
private const val ERROR_PERMISSION_DENIED = 6024
|
|
994
|
+
private const val ERROR_RESOURCE_EXHAUSTED = 6043
|
|
995
|
+
private const val ERROR_FILE_READ_FAILED = 6045
|
|
996
|
+
private const val ERROR_FILE_WRITE_FAILED = 6046
|
|
997
|
+
private val mediaFileExecutor =
|
|
998
|
+
ThreadPoolExecutor(
|
|
999
|
+
1,
|
|
1000
|
+
2,
|
|
1001
|
+
30L,
|
|
1002
|
+
TimeUnit.SECONDS,
|
|
1003
|
+
ArrayBlockingQueue(16),
|
|
1004
|
+
{ work -> Thread(work, "tirtc-rn-media-file").apply { isDaemon = true } },
|
|
1005
|
+
ThreadPoolExecutor.AbortPolicy(),
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
private fun normalizeGalleryFileName(fileName: String?, video: Boolean): String? {
|
|
1009
|
+
if (fileName == null) {
|
|
1010
|
+
return "${System.currentTimeMillis()}.${if (video) "mp4" else "jpg"}"
|
|
1011
|
+
}
|
|
1012
|
+
if (
|
|
1013
|
+
fileName.isEmpty() ||
|
|
1014
|
+
fileName.isBlank() ||
|
|
1015
|
+
fileName == "." ||
|
|
1016
|
+
fileName == ".." ||
|
|
1017
|
+
fileName.contains('/') ||
|
|
1018
|
+
fileName.contains('\\') ||
|
|
1019
|
+
fileName.contains('\u0000')
|
|
1020
|
+
) {
|
|
1021
|
+
return null
|
|
1022
|
+
}
|
|
1023
|
+
val extension = if (video) ".mp4" else ".jpg"
|
|
1024
|
+
val stem: String
|
|
1025
|
+
val normalized: String
|
|
1026
|
+
if (fileName.endsWith(extension, ignoreCase = true)) {
|
|
1027
|
+
stem = fileName.dropLast(extension.length)
|
|
1028
|
+
normalized = fileName
|
|
1029
|
+
} else if (fileName.contains('.')) {
|
|
1030
|
+
return null
|
|
1031
|
+
} else {
|
|
1032
|
+
stem = fileName
|
|
1033
|
+
normalized = fileName + extension
|
|
1034
|
+
}
|
|
1035
|
+
val stemBytes = stem.toByteArray(Charsets.UTF_8).size
|
|
1036
|
+
return normalized.takeIf { stemBytes in 1..200 }
|
|
1037
|
+
}
|
|
481
1038
|
}
|
|
482
1039
|
}
|