tirtc-react-native 2.3.1 → 2.4.0-alpha.4

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.
Files changed (77) hide show
  1. package/README.md +23 -7
  2. package/TiRtcReactNative.podspec +1 -1
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/AndroidManifest.xml +2 -1
  5. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcModule.kt +622 -28
  6. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeCleanup.kt +115 -0
  7. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeEventBinder.kt +32 -8
  8. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeEventSink.kt +16 -0
  9. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeMaps.kt +62 -0
  10. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeModuleSupport.kt +6 -6
  11. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeRegistry.kt +27 -0
  12. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcReactNativeStorageSupport.kt +64 -0
  13. package/android/src/main/java/com/tange/ai/tirtc/reactnative/TiRtcVideoOutputViewManager.kt +17 -2
  14. package/ios/TiRtcReactNative+Conn.mm +173 -2
  15. package/ios/TiRtcReactNative+Input.mm +18 -4
  16. package/ios/TiRtcReactNative+Output.mm +41 -4
  17. package/ios/TiRtcReactNative+Private.h +4 -0
  18. package/ios/TiRtcReactNative+Storage.mm +513 -0
  19. package/ios/TiRtcReactNative.h +2 -1
  20. package/ios/TiRtcReactNative.mm +109 -1
  21. package/ios/TiRtcReactNativeEventBinder.h +8 -0
  22. package/ios/TiRtcReactNativeEventBinder.mm +102 -0
  23. package/ios/TiRtcReactNativeEventSink.h +3 -0
  24. package/ios/TiRtcReactNativeEventSink.mm +16 -0
  25. package/ios/TiRtcReactNativeRegistry.h +5 -0
  26. package/ios/TiRtcReactNativeRegistry.mm +43 -0
  27. package/ios/TiRtcVideoOutputView.mm +11 -0
  28. package/lib/module/audio_input.js +33 -17
  29. package/lib/module/audio_input.js.map +1 -1
  30. package/lib/module/audio_output.js +27 -10
  31. package/lib/module/audio_output.js.map +1 -1
  32. package/lib/module/conn.js +31 -7
  33. package/lib/module/conn.js.map +1 -1
  34. package/lib/module/errors.js +4 -3
  35. package/lib/module/errors.js.map +1 -1
  36. package/lib/module/index.js +2 -0
  37. package/lib/module/index.js.map +1 -1
  38. package/lib/module/logging.js +9 -1
  39. package/lib/module/logging.js.map +1 -1
  40. package/lib/module/logging_internal.js +27 -0
  41. package/lib/module/logging_internal.js.map +1 -0
  42. package/lib/module/recording.js +210 -0
  43. package/lib/module/recording.js.map +1 -0
  44. package/lib/module/runtime.js +13 -5
  45. package/lib/module/runtime.js.map +1 -1
  46. package/lib/module/specs/NativeTiRtc.js.map +1 -1
  47. package/lib/module/storage.js +706 -0
  48. package/lib/module/storage.js.map +1 -0
  49. package/lib/module/video_input.js +40 -22
  50. package/lib/module/video_input.js.map +1 -1
  51. package/lib/module/video_output.js +51 -10
  52. package/lib/module/video_output.js.map +1 -1
  53. package/lib/typescript/audio_input.d.ts +1 -0
  54. package/lib/typescript/conn.d.ts +2 -0
  55. package/lib/typescript/errors.d.ts +3 -2
  56. package/lib/typescript/index.d.ts +4 -0
  57. package/lib/typescript/logging_internal.d.ts +2 -0
  58. package/lib/typescript/recording.d.ts +53 -0
  59. package/lib/typescript/runtime.d.ts +2 -0
  60. package/lib/typescript/specs/NativeTiRtc.d.ts +88 -0
  61. package/lib/typescript/storage.d.ts +204 -0
  62. package/lib/typescript/video_input.d.ts +1 -0
  63. package/lib/typescript/video_output.d.ts +3 -0
  64. package/package.json +1 -1
  65. package/src/audio_input.ts +85 -59
  66. package/src/audio_output.ts +30 -17
  67. package/src/conn.ts +46 -28
  68. package/src/errors.ts +4 -3
  69. package/src/index.ts +54 -0
  70. package/src/logging.ts +7 -1
  71. package/src/logging_internal.ts +23 -0
  72. package/src/recording.ts +271 -0
  73. package/src/runtime.ts +14 -5
  74. package/src/specs/NativeTiRtc.ts +123 -0
  75. package/src/storage.ts +769 -0
  76. package/src/video_input.ts +97 -62
  77. package/src/video_output.ts +54 -16
@@ -1,6 +1,13 @@
1
1
  package com.tange.ai.tirtc.reactnative
2
2
 
3
- import android.util.Log
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.os.Handler
9
+ import android.os.Looper
10
+ import android.provider.MediaStore
4
11
  import com.facebook.react.bridge.Promise
5
12
  import com.facebook.react.bridge.ReactApplicationContext
6
13
  import com.facebook.react.bridge.ReactMethod
@@ -13,21 +20,46 @@ import com.tange.ai.tirtc.TiRtcAudioInput
13
20
  import com.tange.ai.tirtc.TiRtcAudioOutput
14
21
  import com.tange.ai.tirtc.TiRtcConn
15
22
  import com.tange.ai.tirtc.TiRtcInitOptions
23
+ import com.tange.ai.tirtc.TiRtcLogging
24
+ import com.tange.ai.tirtc.TiRtcRecordingFile
25
+ import com.tange.ai.tirtc.TiRtcSnapshotFile
16
26
  import com.tange.ai.tirtc.TiRtcVideoInput
17
27
  import com.tange.ai.tirtc.TiRtcVideoOutput
28
+ import com.tange.ai.tirtc.TiCloudStorage
29
+ import com.tange.ai.tirtc.TiCloudStorageAudioOutput
30
+ import com.tange.ai.tirtc.TiCloudStorageExportRequest
31
+ import com.tange.ai.tirtc.TiCloudStorageRecordingFile
32
+ import com.tange.ai.tirtc.TiCloudStorageRecordingResult
33
+ import com.tange.ai.tirtc.TiCloudStorageReplaySpeed
34
+ import com.tange.ai.tirtc.TiCloudStorageSnapshotFile
35
+ import com.tange.ai.tirtc.TiCloudStorageVideoOutput
36
+ import java.util.concurrent.ConcurrentHashMap
37
+ import java.util.concurrent.ArrayBlockingQueue
38
+ import java.util.concurrent.CountDownLatch
39
+ import java.util.concurrent.RejectedExecutionException
40
+ import java.util.concurrent.ThreadPoolExecutor
41
+ import java.util.concurrent.TimeUnit
42
+ import java.util.concurrent.atomic.AtomicBoolean
43
+ import java.util.concurrent.atomic.AtomicInteger
18
44
 
19
45
  @ReactModule(name = TiRtcModule.NAME)
20
46
  class TiRtcModule(
21
47
  private val reactContext: ReactApplicationContext,
22
48
  ) : NativeTiRtcSpec(reactContext) {
23
49
  private val registry = TiRtcReactNativeRegistry
50
+ private val invalidated = AtomicBoolean(false)
24
51
  private val events =
25
52
  TiRtcReactNativeEventSink(registry) { event ->
26
- reactContext.runOnJSQueueThread {
27
- emitOnTiRtcEvent(event)
53
+ if (!invalidated.get()) {
54
+ reactContext.runOnJSQueueThread {
55
+ if (!invalidated.get()) emitOnTiRtcEvent(event)
56
+ }
28
57
  }
29
58
  }
30
59
  private val eventBinder = TiRtcReactNativeEventBinder(events)
60
+ private val nextMediaFileId = AtomicInteger(1)
61
+ private val mediaFiles = ConcurrentHashMap<Int, Any>()
62
+ private val mainHandler = Handler(Looper.getMainLooper())
31
63
 
32
64
  init {
33
65
  registry.bindEventSink(events)
@@ -35,13 +67,22 @@ class TiRtcModule(
35
67
 
36
68
  override fun getName(): String = NAME
37
69
 
70
+ override fun invalidate() {
71
+ if (!invalidated.compareAndSet(false, true)) return
72
+ val objects = registry.drain(events)
73
+ val files = mediaFiles.values.toList()
74
+ mediaFiles.clear()
75
+ TiRtcReactNativeCleanup.release(objects, files)
76
+ super.invalidate()
77
+ }
78
+
38
79
  @ReactMethod
39
80
  override fun initialize(
40
81
  options: ReadableMap,
41
82
  promise: Promise,
42
83
  ) {
43
84
  val code =
44
- TiRtc.initialize(
85
+ TiRtc.init(
45
86
  reactContext,
46
87
  TiRtcInitOptions(
47
88
  appId = options.getString("appId") ?: "",
@@ -54,6 +95,11 @@ class TiRtcModule(
54
95
 
55
96
  override fun shutdown(): Double {
56
97
  if (registry.hasLiveObjects()) {
98
+ TiRtcLogging.w(
99
+ TAG,
100
+ "[react_native_shutdown] rejected: dispose all React Native objects before shutdown; " +
101
+ "error_code=1007 reason=objects_live",
102
+ )
57
103
  return TiRtcReactNativeErrors.RUNTIME_OBJECTS_LIVE.toDouble()
58
104
  }
59
105
  return TiRtcReactNativeErrors.normalizeAndroid(TiRtc.shutdown()).toDouble()
@@ -67,16 +113,322 @@ class TiRtcModule(
67
113
  message: String,
68
114
  ) {
69
115
  when (level.toInt()) {
70
- 0 -> com.tange.ai.tirtc.TiRtcLogging.d(tag, message)
71
- 1 -> com.tange.ai.tirtc.TiRtcLogging.i(tag, message)
72
- 2 -> com.tange.ai.tirtc.TiRtcLogging.w(tag, message)
73
- 3 -> com.tange.ai.tirtc.TiRtcLogging.e(tag, message)
116
+ 0 -> TiRtcLogging.d(tag, message)
117
+ 1 -> TiRtcLogging.i(tag, message)
118
+ 2 -> TiRtcLogging.w(tag, message)
119
+ 3 -> TiRtcLogging.e(tag, message)
120
+ }
121
+ }
122
+
123
+ @ReactMethod
124
+ override fun tiCloudStorageInitialize(options: ReadableMap, promise: Promise) {
125
+ val code = TiCloudStorage.init(
126
+ reactContext,
127
+ options.getString("appId") ?: "",
128
+ options.getString("endpoint") ?: "",
129
+ options.getBoolean("consoleLogEnabled"),
130
+ )
131
+ promise.resolve(code.toDouble())
132
+ }
133
+
134
+ override fun tiCloudStorageShutdown(): Double = TiCloudStorage.shutdown().toDouble()
135
+
136
+ override fun tiCloudStorageCreate(token: String): WritableMap =
137
+ registerReactNativeObject(
138
+ registry,
139
+ factory = { TiCloudStorage(token) },
140
+ onRegistered = { entry, value -> eventBinder.wireCloudStorage(entry.id, value as TiCloudStorage) },
141
+ )
142
+
143
+ override fun tiCloudStorageUpdateToken(id: Double, token: String): Double =
144
+ registry.cloudStorage(id.toInt())?.updateToken(token).normalized()
145
+
146
+ @ReactMethod
147
+ override fun tiCloudStorageListRecordings(id: Double, startTimeMs: Double, endTimeMs: Double, promise: Promise) {
148
+ val cloudStorage = registry.cloudStorage(id.toInt())
149
+ if (cloudStorage == null) {
150
+ promise.resolve(tiCloudStorageRecordingsResult(TiRtcReactNativeErrors.OBJECT_RELEASED, emptyList()))
151
+ return
152
+ }
153
+ cloudStorage.listRecordings(startTimeMs.toLong(), endTimeMs.toLong()) { result ->
154
+ reactContext.runOnJSQueueThread {
155
+ promise.resolve(tiCloudStorageRecordingsResult(result.code, result.recordings))
156
+ }
157
+ }
158
+ }
159
+
160
+ @ReactMethod
161
+ override fun tiCloudStorageListRecordingDays(
162
+ id: Double,
163
+ startDate: String,
164
+ endDate: String,
165
+ timeZoneId: String,
166
+ promise: Promise,
167
+ ) {
168
+ val cloudStorage = registry.cloudStorage(id.toInt())
169
+ if (cloudStorage == null) {
170
+ promise.resolve(tiCloudStorageRecordingDaysResult(TiRtcReactNativeErrors.OBJECT_RELEASED, emptyList()))
171
+ return
172
+ }
173
+ cloudStorage.listRecordingDays(startDate, endDate, timeZoneId) { result ->
174
+ reactContext.runOnJSQueueThread {
175
+ promise.resolve(tiCloudStorageRecordingDaysResult(result.code, result.days))
176
+ }
177
+ }
178
+ }
179
+
180
+ override fun tiCloudStorageCreateReplay(id: Double): WritableMap {
181
+ val cloudStorage = registry.cloudStorage(id.toInt())
182
+ ?: return createResult(TiRtcReactNativeErrors.OBJECT_RELEASED, 0, 0)
183
+ return registerReactNativeObject(
184
+ registry,
185
+ factory = { cloudStorage.createReplay() },
186
+ onRegistered = { entry, value -> eventBinder.wireCloudStorageReplay(entry.id, value as com.tange.ai.tirtc.TiCloudStorageReplay) },
187
+ )
188
+ }
189
+
190
+ override fun tiCloudStorageExportRecording(
191
+ id: Double,
192
+ startTimeMs: Double,
193
+ endTimeMs: Double,
194
+ videoChannelId: Double,
195
+ audioChannelId: Double?,
196
+ ): WritableMap {
197
+ val cloudStorage = registry.cloudStorage(id.toInt())
198
+ ?: return recordingStartResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
199
+ val bridge = CloudStorageExportBridge(::resolveCloudStorageRecording)
200
+ val started = cloudStorage.exportRecording(
201
+ TiCloudStorageExportRequest(
202
+ startTimeMs.toLong(),
203
+ endTimeMs.toLong(),
204
+ videoChannelId.toInt(),
205
+ audioChannelId?.toInt(),
206
+ ),
207
+ null,
208
+ ) { result -> bridge.complete(result) }
209
+ val task = started.task ?: return recordingStartResult(started.code, null)
210
+ bridge.task = task
211
+ val entry = registry.add(bridge)
212
+ bridge.onDrained = { registry.release(entry.id) }
213
+ return recordingStartResult(started.code, entry.id)
214
+ }
215
+
216
+ override fun tiCloudStorageExportProgress(id: Double): Double =
217
+ registry.tiCloudStorageExportTask(id.toInt())?.task?.progress ?: 0.0
218
+
219
+ @ReactMethod
220
+ override fun tiCloudStorageExportResult(id: Double, promise: Promise) {
221
+ registry.tiCloudStorageExportTask(id.toInt())?.await(promise)
222
+ ?: promise.resolve(mp4Result(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null, null))
223
+ }
224
+
225
+ override fun tiCloudStorageExportStop(id: Double): Double =
226
+ registry.tiCloudStorageExportTask(id.toInt())?.task?.stop().normalized()
227
+
228
+ override fun tiCloudStorageDispose(id: Double): Double {
229
+ val objectId = id.toInt()
230
+ val code = registry.cloudStorage(objectId)?.dispose().normalized()
231
+ if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
232
+ return code
233
+ }
234
+
235
+ override fun tiCloudStorageReplayGetInfo(id: Double): WritableMap {
236
+ val replay = registry.tiCloudStorageReplay(id.toInt())
237
+ ?: return tiCloudStorageReplayInfoResult(TiRtcReactNativeErrors.OBJECT_RELEASED, 0, null)
238
+ val speed = when (replay.speed) {
239
+ TiCloudStorageReplaySpeed.X0_125 -> 6
240
+ TiCloudStorageReplaySpeed.X0_25 -> 5
241
+ TiCloudStorageReplaySpeed.X0_5 -> 4
242
+ TiCloudStorageReplaySpeed.X1 -> 0
243
+ TiCloudStorageReplaySpeed.X2 -> 1
244
+ TiCloudStorageReplaySpeed.X4 -> 2
245
+ TiCloudStorageReplaySpeed.X8 -> 3
246
+ }
247
+ return tiCloudStorageReplayInfoResult(0, speed, replay.currentTimeMs)
248
+ }
249
+
250
+ override fun tiCloudStorageReplayPlay(
251
+ id: Double,
252
+ startTimeMs: Double,
253
+ endTimeMs: Double,
254
+ initialTimeMs: Double?,
255
+ ): Double {
256
+ val replay = registry.tiCloudStorageReplay(id.toInt()) ?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
257
+ return replay.play(startTimeMs.toLong(), endTimeMs.toLong(), initialTimeMs?.toLong()).toDouble()
258
+ }
259
+
260
+ override fun tiCloudStorageReplayPause(id: Double): Double = registry.tiCloudStorageReplay(id.toInt())?.pause().normalized()
261
+
262
+ override fun tiCloudStorageReplayResume(id: Double): Double = registry.tiCloudStorageReplay(id.toInt())?.resume().normalized()
263
+
264
+ override fun tiCloudStorageReplaySeek(id: Double, timeMs: Double): Double =
265
+ registry.tiCloudStorageReplay(id.toInt())?.seek(timeMs.toLong()).normalized()
266
+
267
+ override fun tiCloudStorageReplaySetSpeed(id: Double, speed: Double): Double {
268
+ val value = when (speed.toInt()) {
269
+ 6 -> TiCloudStorageReplaySpeed.X0_125
270
+ 5 -> TiCloudStorageReplaySpeed.X0_25
271
+ 4 -> TiCloudStorageReplaySpeed.X0_5
272
+ 0 -> TiCloudStorageReplaySpeed.X1
273
+ 1 -> TiCloudStorageReplaySpeed.X2
274
+ 2 -> TiCloudStorageReplaySpeed.X4
275
+ 3 -> TiCloudStorageReplaySpeed.X8
276
+ else -> return TiRtcReactNativeErrors.INVALID_ARGUMENT.toDouble()
277
+ }
278
+ return registry.tiCloudStorageReplay(id.toInt())?.setSpeed(value).normalized()
279
+ }
280
+
281
+ override fun tiCloudStorageReplayStartRecording(
282
+ id: Double,
283
+ videoChannelId: Double,
284
+ audioChannelId: Double?,
285
+ ): WritableMap {
286
+ val replay = registry.tiCloudStorageReplay(id.toInt())
287
+ ?: return recordingStartResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
288
+ val result = replay.startRecording(videoChannelId.toInt(), audioChannelId?.toInt())
289
+ val entry = result.task?.let(registry::add)
290
+ return recordingStartResult(result.code, entry?.id)
291
+ }
292
+
293
+ @ReactMethod
294
+ override fun tiCloudStorageRecordingTaskStop(id: Double, promise: Promise) {
295
+ val objectId = id.toInt()
296
+ val task = registry.tiCloudStorageRecordingTask(objectId)
297
+ if (task == null) {
298
+ promise.resolve(mp4Result(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null, null))
299
+ return
300
+ }
301
+ task.stop { result ->
302
+ registry.release(objectId)
303
+ resolveCloudStorageRecording(promise, result)
304
+ }
305
+ }
306
+
307
+ override fun tiCloudStorageReplayStop(id: Double): Double = registry.tiCloudStorageReplay(id.toInt())?.stop().normalized()
308
+
309
+ override fun tiCloudStorageReplayDispose(id: Double): Double {
310
+ val objectId = id.toInt()
311
+ val code = registry.tiCloudStorageReplay(objectId)?.dispose().normalized()
312
+ if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
313
+ return code
314
+ }
315
+
316
+ override fun tiCloudStorageCreateAudioOutput(): WritableMap =
317
+ registerReactNativeObject(
318
+ registry,
319
+ factory = { TiCloudStorageAudioOutput() },
320
+ onRegistered = { entry, value -> eventBinder.wireCloudStorageAudioOutput(entry.id, value as TiCloudStorageAudioOutput) },
321
+ )
322
+
323
+ override fun tiCloudStorageAudioOutputGetState(id: Double): WritableMap {
324
+ val output = registry.tiCloudStorageAudioOutput(id.toInt())
325
+ ?: return stateResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
326
+ return stateResult(0, output.state.name.lowercase())
327
+ }
328
+
329
+ override fun tiCloudStorageAudioOutputSetVolume(id: Double, volumePercent: Double): Double =
330
+ registry.tiCloudStorageAudioOutput(id.toInt())?.setVolume(volumePercent.toInt()).normalized()
331
+
332
+ override fun tiCloudStorageAudioOutputAttach(outputId: Double, replayId: Double, channelId: Double): Double {
333
+ val replay = registry.tiCloudStorageReplay(replayId.toInt())
334
+ ?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
335
+ return registry.tiCloudStorageAudioOutput(outputId.toInt())?.attach(replay, channelId.toInt()).normalized()
336
+ }
337
+
338
+ override fun tiCloudStorageAudioOutputDetach(outputId: Double): Double =
339
+ registry.tiCloudStorageAudioOutput(outputId.toInt())?.detach().normalized()
340
+
341
+ override fun tiCloudStorageAudioOutputDispose(id: Double): Double {
342
+ val objectId = id.toInt()
343
+ val code = registry.tiCloudStorageAudioOutput(objectId)?.dispose().normalized()
344
+ if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
345
+ return code
346
+ }
347
+
348
+ override fun tiCloudStorageCreateVideoOutput(): WritableMap =
349
+ registerReactNativeObject(
350
+ registry,
351
+ factory = { TiCloudStorageVideoOutput() },
352
+ onRegistered = { entry, value -> eventBinder.wireCloudStorageVideoOutput(entry.id, value as TiCloudStorageVideoOutput) },
353
+ )
354
+
355
+ override fun tiCloudStorageVideoOutputGetState(id: Double): WritableMap {
356
+ val output = registry.tiCloudStorageVideoOutput(id.toInt())
357
+ ?: return stateResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
358
+ return stateResult(0, output.state.name.lowercase())
359
+ }
360
+
361
+ override fun tiCloudStorageVideoOutputAttach(outputId: Double, replayId: Double, channelId: Double): Double {
362
+ val replay = registry.tiCloudStorageReplay(replayId.toInt())
363
+ ?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
364
+ return registry.tiCloudStorageVideoOutput(outputId.toInt())?.attach(replay, channelId.toInt()).normalized()
365
+ }
366
+
367
+ override fun tiCloudStorageVideoOutputDetach(outputId: Double): Double =
368
+ registry.tiCloudStorageVideoOutput(outputId.toInt())?.detach().normalized()
369
+
370
+ @ReactMethod
371
+ override fun tiCloudStorageVideoOutputTakeSnapshot(id: Double, promise: Promise) {
372
+ val output = registry.tiCloudStorageVideoOutput(id.toInt())
373
+ if (output == null) {
374
+ promise.resolve(mediaSnapshotResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null))
375
+ return
376
+ }
377
+ output.takeSnapshot { result ->
378
+ val fileId = result.file?.let { file ->
379
+ nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
380
+ }
381
+ reactContext.runOnJSQueueThread {
382
+ promise.resolve(mediaSnapshotResult(result.code, fileId, result.file?.path))
383
+ }
384
+ }
385
+ }
386
+
387
+ override fun tiCloudStorageVideoOutputDispose(id: Double): Double {
388
+ val objectId = id.toInt()
389
+ val output = registry.tiCloudStorageVideoOutput(objectId) ?: return TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble()
390
+ val detachCode = detachCloudStorageVideoView(output)
391
+ val nativeCode = if (detachCode == TiRtcReactNativeErrors.OK) output.dispose() else detachCode
392
+ val code = TiRtcReactNativeErrors.normalizeAndroid(nativeCode).toDouble()
393
+ if (code.toInt() == TiRtcReactNativeErrors.OK) registry.release(objectId)
394
+ return code
395
+ }
396
+
397
+ private fun detachCloudStorageVideoView(output: TiCloudStorageVideoOutput): Int {
398
+ if (Looper.myLooper() == Looper.getMainLooper()) return output.detachView()
399
+ val result = AtomicInteger(TiRtcReactNativeErrors.PLATFORM_INTERNAL)
400
+ val completed = CountDownLatch(1)
401
+ if (!mainHandler.post {
402
+ result.set(output.detachView())
403
+ completed.countDown()
404
+ }
405
+ ) {
406
+ return TiRtcReactNativeErrors.PLATFORM_INTERNAL
407
+ }
408
+ return try {
409
+ if (completed.await(VIEW_DETACH_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
410
+ result.get()
411
+ } else {
412
+ TiRtcReactNativeErrors.PLATFORM_INTERNAL
413
+ }
414
+ } catch (_: InterruptedException) {
415
+ Thread.currentThread().interrupt()
416
+ TiRtcReactNativeErrors.PLATFORM_INTERNAL
417
+ }
418
+ }
419
+
420
+ private fun resolveCloudStorageRecording(promise: Promise, result: TiCloudStorageRecordingResult) {
421
+ val fileId = result.file?.let { file ->
422
+ nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
423
+ }
424
+ reactContext.runOnJSQueueThread {
425
+ promise.resolve(mp4Result(result.code, fileId, result.file?.path, result.file?.durationMs))
74
426
  }
75
427
  }
76
428
 
77
429
  @ReactMethod
78
430
  override fun uploadLogs(promise: Promise) {
79
- uploadLogsToPromise(promise, TAG)
431
+ uploadLogsToPromise(promise)
80
432
  }
81
433
 
82
434
  override fun createConn(): WritableMap =
@@ -95,22 +447,9 @@ class TiRtcModule(
95
447
  id: Double,
96
448
  remoteId: String,
97
449
  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
- }
450
+ ): Double = registry.conn(id.toInt())?.connect(remoteId, token).normalized()
107
451
 
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
- }
452
+ override fun connDisconnect(id: Double): Double = registry.conn(id.toInt())?.disconnect().normalized()
114
453
 
115
454
  override fun connSendCommand(
116
455
  id: Double,
@@ -143,6 +482,196 @@ class TiRtcModule(
143
482
  override fun connRequestKeyFrame(id: Double, streamId: Double): Double =
144
483
  registry.conn(id.toInt())?.requestKeyFrame(streamId.toInt()).normalized()
145
484
 
485
+ override fun connStartRecording(id: Double, options: ReadableMap): WritableMap {
486
+ val conn = registry.conn(id.toInt())
487
+ if (conn == null) {
488
+ return recordingStartResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null)
489
+ }
490
+ val result = conn.startRecording(
491
+ videoStreamId = options.getInt("videoStreamId"),
492
+ audioStreamId = if (options.isNull("audioStreamId")) null else options.getInt("audioStreamId"),
493
+ )
494
+ val entry = result.task?.let { registry.add(it) }
495
+ return recordingStartResult(result.code, entry?.id)
496
+ }
497
+
498
+ @ReactMethod
499
+ override fun recordingTaskStop(id: Double, promise: Promise) {
500
+ val objectId = id.toInt()
501
+ val task = registry.recordingTask(objectId)
502
+ if (task == null) {
503
+ promise.resolve(mp4Result(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null, null))
504
+ return
505
+ }
506
+ task.stop { result ->
507
+ registry.release(objectId)
508
+ val fileId = result.file?.let { file ->
509
+ nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
510
+ }
511
+ reactContext.runOnJSQueueThread {
512
+ promise.resolve(mp4Result(result.code, fileId, result.file?.path, result.file?.durationMs))
513
+ }
514
+ }
515
+ }
516
+
517
+ @ReactMethod
518
+ override fun mediaFileMoveToGallery(id: Double, fileName: String?, promise: Promise) {
519
+ val fileId = id.toInt()
520
+ val mediaFile = mediaFiles[fileId]
521
+ val path = when (mediaFile) {
522
+ is TiRtcRecordingFile -> mediaFile.path
523
+ is TiRtcSnapshotFile -> mediaFile.path
524
+ is TiCloudStorageRecordingFile -> mediaFile.path
525
+ is TiCloudStorageSnapshotFile -> mediaFile.path
526
+ else -> {
527
+ promise.resolve(galleryResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null))
528
+ return
529
+ }
530
+ }
531
+ val video = mediaFile is TiRtcRecordingFile || mediaFile is TiCloudStorageRecordingFile
532
+ val normalizedName = normalizeGalleryFileName(fileName, video)
533
+ if (normalizedName == null) {
534
+ promise.resolve(galleryResult(ERROR_INVALID_ARGUMENT, null))
535
+ return
536
+ }
537
+ if (
538
+ Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
539
+ reactContext.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
540
+ ) {
541
+ promise.resolve(galleryResult(ERROR_PERMISSION_DENIED, null))
542
+ return
543
+ }
544
+ try {
545
+ mediaFileExecutor.execute {
546
+ publishMediaFile(
547
+ fileId,
548
+ mediaFile,
549
+ path,
550
+ video,
551
+ normalizedName,
552
+ promise,
553
+ )
554
+ }
555
+ } catch (_: RejectedExecutionException) {
556
+ promise.resolve(galleryResult(ERROR_RESOURCE_EXHAUSTED, null))
557
+ }
558
+ }
559
+
560
+ private fun publishMediaFile(
561
+ fileId: Int,
562
+ mediaFile: Any,
563
+ path: String,
564
+ video: Boolean,
565
+ fileName: String,
566
+ promise: Promise,
567
+ ) {
568
+ val source = java.io.File(path)
569
+ if (!source.isFile) {
570
+ reactContext.runOnJSQueueThread { promise.resolve(galleryResult(ERROR_FILE_READ_FAILED, null)) }
571
+ return
572
+ }
573
+ val collection = if (video) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
574
+ val displayName = fileName
575
+ val legacyTarget = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
576
+ val directory = if (video) Environment.DIRECTORY_MOVIES else Environment.DIRECTORY_PICTURES
577
+ val targetDirectory = java.io.File(
578
+ Environment.getExternalStoragePublicDirectory(directory),
579
+ "TiRTC",
580
+ )
581
+ if (!targetDirectory.exists() && !targetDirectory.mkdirs()) {
582
+ reactContext.runOnJSQueueThread {
583
+ promise.resolve(galleryResult(ERROR_FILE_WRITE_FAILED, null))
584
+ }
585
+ return
586
+ }
587
+ java.io.File(targetDirectory, displayName)
588
+ } else {
589
+ null
590
+ }
591
+ val values = ContentValues().apply {
592
+ put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
593
+ put(MediaStore.MediaColumns.MIME_TYPE, if (video) "video/mp4" else "image/jpeg")
594
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
595
+ put(MediaStore.MediaColumns.RELATIVE_PATH, "${if (video) "Movies" else "Pictures"}/TiRTC")
596
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
597
+ } else {
598
+ @Suppress("DEPRECATION")
599
+ put(MediaStore.MediaColumns.DATA, legacyTarget!!.absolutePath)
600
+ }
601
+ }
602
+ val resolver = reactContext.contentResolver
603
+ val target = try {
604
+ resolver.insert(collection, values)
605
+ } catch (_: SecurityException) {
606
+ reactContext.runOnJSQueueThread { promise.resolve(galleryResult(ERROR_PERMISSION_DENIED, null)) }
607
+ return
608
+ } catch (_: Throwable) {
609
+ null
610
+ }
611
+ if (target == null) {
612
+ reactContext.runOnJSQueueThread { promise.resolve(galleryResult(ERROR_FILE_WRITE_FAILED, null)) }
613
+ return
614
+ }
615
+ try {
616
+ resolver.openOutputStream(target)?.use { output ->
617
+ source.inputStream().use { input -> input.copyTo(output) }
618
+ } ?: error("unable to open media asset")
619
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
620
+ values.clear()
621
+ values.put(MediaStore.MediaColumns.IS_PENDING, 0)
622
+ if (resolver.update(target, values, null, null) != 1) error("unable to publish media asset")
623
+ }
624
+ } catch (error: Throwable) {
625
+ try {
626
+ resolver.delete(target, null, null)
627
+ } catch (_: Throwable) {
628
+ }
629
+ val code = if (error is SecurityException) ERROR_PERMISSION_DENIED else ERROR_FILE_WRITE_FAILED
630
+ reactContext.runOnJSQueueThread { promise.resolve(galleryResult(code, null)) }
631
+ return
632
+ }
633
+ val onDelete: (Int) -> Unit = { code ->
634
+ if (code == 0) {
635
+ mediaFiles.remove(fileId, mediaFile)
636
+ } else {
637
+ try {
638
+ resolver.delete(target, null, null)
639
+ } catch (_: Throwable) {
640
+ }
641
+ }
642
+ reactContext.runOnJSQueueThread {
643
+ promise.resolve(galleryResult(code, if (code == 0) target.toString() else null))
644
+ }
645
+ }
646
+ when (mediaFile) {
647
+ is TiRtcRecordingFile -> mediaFile.delete(onDelete)
648
+ is TiRtcSnapshotFile -> mediaFile.delete(onDelete)
649
+ is TiCloudStorageRecordingFile -> mediaFile.delete(onDelete)
650
+ is TiCloudStorageSnapshotFile -> mediaFile.delete(onDelete)
651
+ }
652
+ }
653
+
654
+ @ReactMethod
655
+ override fun mediaFileDelete(id: Double, promise: Promise) {
656
+ val fileId = id.toInt()
657
+ val mediaFile = mediaFiles[fileId]
658
+ val callback: (Int) -> Unit = { code ->
659
+ if (code == 0) mediaFiles.remove(fileId)
660
+ reactContext.runOnJSQueueThread { promise.resolve(code.toDouble()) }
661
+ }
662
+ when (mediaFile) {
663
+ is TiRtcRecordingFile -> mediaFile.delete(callback)
664
+ is TiRtcSnapshotFile -> mediaFile.delete(callback)
665
+ is TiCloudStorageRecordingFile -> mediaFile.delete(callback)
666
+ is TiCloudStorageSnapshotFile -> mediaFile.delete(callback)
667
+ else -> promise.resolve(TiRtcReactNativeErrors.OBJECT_RELEASED.toDouble())
668
+ }
669
+ }
670
+
671
+ override fun mediaFileRelease(id: Double) {
672
+ mediaFiles.remove(id.toInt())
673
+ }
674
+
146
675
  override fun connGetMetricsSnapshot(id: Double): WritableMap {
147
676
  val conn = registry.conn(id.toInt()) ?: return releasedSnapshotResult()
148
677
  return connMetricsResult(conn.getMetricsSnapshot())
@@ -181,6 +710,11 @@ class TiRtcModule(
181
710
  volumePercent < 0.0 ||
182
711
  volumePercent > 100.0
183
712
  ) {
713
+ TiRtcLogging.w(
714
+ TAG,
715
+ "[react_native_audio_output_set_volume] rejected: volume must be an integer from 0 to 100; " +
716
+ "error_code=6000 reason=invalid_volume",
717
+ )
184
718
  return TiRtcReactNativeErrors.INVALID_ARGUMENT.toDouble()
185
719
  }
186
720
  return registry.audioOutput(id.toInt())?.setVolume(volumePercent.toInt()).normalized()
@@ -259,6 +793,23 @@ class TiRtcModule(
259
793
  return videoDebugResult(output.getDebugSnapshot())
260
794
  }
261
795
 
796
+ @ReactMethod
797
+ override fun videoOutputTakeSnapshot(id: Double, promise: Promise) {
798
+ val output = registry.videoOutput(id.toInt())
799
+ if (output == null) {
800
+ promise.resolve(mediaSnapshotResult(TiRtcReactNativeErrors.OBJECT_RELEASED, null, null))
801
+ return
802
+ }
803
+ output.takeSnapshot { snapshotResult ->
804
+ val fileId = snapshotResult.file?.let { file ->
805
+ nextMediaFileId.getAndIncrement().also { mediaFiles[it] = file }
806
+ }
807
+ reactContext.runOnJSQueueThread {
808
+ promise.resolve(mediaSnapshotResult(snapshotResult.code, fileId, snapshotResult.file?.path))
809
+ }
810
+ }
811
+ }
812
+
262
813
  override fun videoOutputDispose(id: Double): Double {
263
814
  val objectId = id.toInt()
264
815
  val code = registry.videoOutput(objectId)?.dispose().normalized()
@@ -310,7 +861,6 @@ class TiRtcModule(
310
861
  ?: TiRtcReactNativeErrors.OBJECT_RELEASED,
311
862
  )
312
863
  }
313
- Log.i(TAG, "audioInputAttach objectId=${id.toInt()} connId=${connId.toInt()} streamId=${streamId.toInt()} code=$code")
314
864
  promise.resolve(code.toDouble())
315
865
  }
316
866
 
@@ -324,7 +874,6 @@ class TiRtcModule(
324
874
  registry.audioInput(id.toInt())?.start()
325
875
  ?: TiRtcReactNativeErrors.OBJECT_RELEASED,
326
876
  )
327
- Log.i(TAG, "audioInputStart objectId=${id.toInt()} code=$code")
328
877
  promise.resolve(code.toDouble())
329
878
  }
330
879
 
@@ -420,7 +969,6 @@ class TiRtcModule(
420
969
  ?: TiRtcReactNativeErrors.OBJECT_RELEASED,
421
970
  )
422
971
  }
423
- Log.i(TAG, "videoInputAttach objectId=${id.toInt()} connId=${connId.toInt()} streamId=${streamId.toInt()} code=$code")
424
972
  promise.resolve(code.toDouble())
425
973
  }
426
974
 
@@ -434,7 +982,6 @@ class TiRtcModule(
434
982
  registry.videoInput(id.toInt())?.start()
435
983
  ?: TiRtcReactNativeErrors.OBJECT_RELEASED,
436
984
  )
437
- Log.i(TAG, "videoInputStart objectId=${id.toInt()} code=$code")
438
985
  promise.resolve(code.toDouble())
439
986
  }
440
987
 
@@ -478,5 +1025,52 @@ class TiRtcModule(
478
1025
  companion object {
479
1026
  const val NAME = "TiRtc"
480
1027
  private const val TAG = "TiRtcReactNative"
1028
+ private const val VIEW_DETACH_TIMEOUT_SECONDS = 5L
1029
+ private const val ERROR_INVALID_ARGUMENT = 6000
1030
+ private const val ERROR_PERMISSION_DENIED = 6024
1031
+ private const val ERROR_RESOURCE_EXHAUSTED = 6043
1032
+ private const val ERROR_FILE_READ_FAILED = 6045
1033
+ private const val ERROR_FILE_WRITE_FAILED = 6046
1034
+ private val mediaFileExecutor =
1035
+ ThreadPoolExecutor(
1036
+ 1,
1037
+ 2,
1038
+ 30L,
1039
+ TimeUnit.SECONDS,
1040
+ ArrayBlockingQueue(16),
1041
+ { work -> Thread(work, "tirtc-rn-media-file").apply { isDaemon = true } },
1042
+ ThreadPoolExecutor.AbortPolicy(),
1043
+ )
1044
+
1045
+ private fun normalizeGalleryFileName(fileName: String?, video: Boolean): String? {
1046
+ if (fileName == null) {
1047
+ return "${System.currentTimeMillis()}.${if (video) "mp4" else "jpg"}"
1048
+ }
1049
+ if (
1050
+ fileName.isEmpty() ||
1051
+ fileName.isBlank() ||
1052
+ fileName == "." ||
1053
+ fileName == ".." ||
1054
+ fileName.contains('/') ||
1055
+ fileName.contains('\\') ||
1056
+ fileName.contains('\u0000')
1057
+ ) {
1058
+ return null
1059
+ }
1060
+ val extension = if (video) ".mp4" else ".jpg"
1061
+ val stem: String
1062
+ val normalized: String
1063
+ if (fileName.endsWith(extension, ignoreCase = true)) {
1064
+ stem = fileName.dropLast(extension.length)
1065
+ normalized = fileName
1066
+ } else if (fileName.contains('.')) {
1067
+ return null
1068
+ } else {
1069
+ stem = fileName
1070
+ normalized = fileName + extension
1071
+ }
1072
+ val stemBytes = stem.toByteArray(Charsets.UTF_8).size
1073
+ return normalized.takeIf { stemBytes in 1..200 }
1074
+ }
481
1075
  }
482
1076
  }