@bota.dev/react-native-app-sdk 2.0.0-beta.1 → 2.0.0-beta.11

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 (152) hide show
  1. package/README.md +115 -4
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
  9. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
  10. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  11. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  12. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  13. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  14. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  15. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  16. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
  17. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  18. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  19. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  20. package/generated/codegen-contract.json +751 -32
  21. package/ios/BotaDeviceSDK.mm +114 -0
  22. package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
  23. package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
  24. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  25. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  26. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  27. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  28. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  29. package/ios/BotaUploadV2Backend.mm +49 -0
  30. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  31. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  32. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  33. package/ios/UploadBackend/UploadJournal.swift +187 -0
  34. package/ios/UploadBackend/UploadModels.swift +273 -0
  35. package/ios/UploadBackend/UploadOperation.swift +391 -0
  36. package/lib/commonjs/BotaClient.js +16 -2
  37. package/lib/commonjs/BotaClient.js.map +1 -1
  38. package/lib/commonjs/ble/constants.js +5 -1
  39. package/lib/commonjs/ble/constants.js.map +1 -1
  40. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  41. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  42. package/lib/commonjs/client.js +183 -28
  43. package/lib/commonjs/client.js.map +1 -1
  44. package/lib/commonjs/clientPresence.js +26 -0
  45. package/lib/commonjs/clientPresence.js.map +1 -0
  46. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  47. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  48. package/lib/commonjs/index.js +34 -0
  49. package/lib/commonjs/index.js.map +1 -1
  50. package/lib/commonjs/managers/DeviceManager.js +23 -0
  51. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  52. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  53. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  54. package/lib/commonjs/managers/RecordingManager.js +83 -102
  55. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  56. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  57. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  58. package/lib/commonjs/sdkIdentity.js +10 -0
  59. package/lib/commonjs/sdkIdentity.js.map +1 -0
  60. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  61. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  62. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  63. package/lib/commonjs/uploadRecovery.js +68 -0
  64. package/lib/commonjs/uploadRecovery.js.map +1 -0
  65. package/lib/module/BotaClient.js +16 -2
  66. package/lib/module/BotaClient.js.map +1 -1
  67. package/lib/module/ble/constants.js +4 -0
  68. package/lib/module/ble/constants.js.map +1 -1
  69. package/lib/module/ble/deviceDiagnostics.js +234 -0
  70. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  71. package/lib/module/client.js +183 -28
  72. package/lib/module/client.js.map +1 -1
  73. package/lib/module/clientPresence.js +22 -0
  74. package/lib/module/clientPresence.js.map +1 -0
  75. package/lib/module/encryptedUploadV2Backend.js +196 -0
  76. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  77. package/lib/module/index.js +7 -0
  78. package/lib/module/index.js.map +1 -1
  79. package/lib/module/managers/DeviceManager.js +23 -0
  80. package/lib/module/managers/DeviceManager.js.map +1 -1
  81. package/lib/module/managers/NativeUploadQueue.js +380 -0
  82. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  83. package/lib/module/managers/RecordingManager.js +83 -102
  84. package/lib/module/managers/RecordingManager.js.map +1 -1
  85. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  86. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  87. package/lib/module/sdkIdentity.js +6 -0
  88. package/lib/module/sdkIdentity.js.map +1 -0
  89. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  90. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  91. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  92. package/lib/module/uploadRecovery.js +64 -0
  93. package/lib/module/uploadRecovery.js.map +1 -0
  94. package/lib/typescript/BotaClient.d.ts +2 -0
  95. package/lib/typescript/BotaClient.d.ts.map +1 -1
  96. package/lib/typescript/ble/constants.d.ts +4 -0
  97. package/lib/typescript/ble/constants.d.ts.map +1 -1
  98. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  99. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  100. package/lib/typescript/client.d.ts +24 -3
  101. package/lib/typescript/client.d.ts.map +1 -1
  102. package/lib/typescript/clientPresence.d.ts +26 -0
  103. package/lib/typescript/clientPresence.d.ts.map +1 -0
  104. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  105. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  106. package/lib/typescript/index.d.ts +13 -4
  107. package/lib/typescript/index.d.ts.map +1 -1
  108. package/lib/typescript/managers/DeviceManager.d.ts +5 -1
  109. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  110. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  111. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  112. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  113. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  114. package/lib/typescript/managers/types.d.ts +6 -1
  115. package/lib/typescript/managers/types.d.ts.map +1 -1
  116. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  117. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  118. package/lib/typescript/models/Device.d.ts +42 -0
  119. package/lib/typescript/models/Device.d.ts.map +1 -1
  120. package/lib/typescript/models/Recording.d.ts +47 -0
  121. package/lib/typescript/models/Recording.d.ts.map +1 -1
  122. package/lib/typescript/models/Status.d.ts +5 -1
  123. package/lib/typescript/models/Status.d.ts.map +1 -1
  124. package/lib/typescript/sdkIdentity.d.ts +3 -0
  125. package/lib/typescript/sdkIdentity.d.ts.map +1 -0
  126. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
  127. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  128. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  129. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  130. package/lib/typescript/uploadRecovery.d.ts +27 -0
  131. package/lib/typescript/uploadRecovery.d.ts.map +1 -0
  132. package/package.json +1 -1
  133. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  134. package/src/BotaClient.ts +17 -3
  135. package/src/ble/constants.ts +5 -0
  136. package/src/ble/deviceDiagnostics.ts +238 -0
  137. package/src/client.ts +189 -18
  138. package/src/clientPresence.ts +33 -0
  139. package/src/encryptedUploadV2Backend.ts +171 -0
  140. package/src/index.ts +24 -0
  141. package/src/managers/DeviceManager.ts +23 -0
  142. package/src/managers/NativeUploadQueue.ts +330 -0
  143. package/src/managers/RecordingManager.ts +83 -116
  144. package/src/managers/types.ts +7 -1
  145. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  146. package/src/models/Device.ts +43 -0
  147. package/src/models/Recording.ts +52 -0
  148. package/src/models/Status.ts +5 -1
  149. package/src/sdkIdentity.ts +3 -0
  150. package/src/specs/NativeBotaDeviceSDK.ts +66 -0
  151. package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
  152. package/src/uploadRecovery.ts +66 -0
@@ -11,6 +11,7 @@ import dev.bota.sdk.EncryptedUploadV2Material
11
11
  import dev.bota.sdk.EncryptedUploadV2ProfileProvider
12
12
  import dev.bota.sdk.EncryptedUploadV2ProviderContext
13
13
  import dev.bota.sdk.EncryptedUploadV2Recording
14
+ import dev.bota.sdk.PendingRecording
14
15
  import dev.bota.sdk.RecordingSyncEvent
15
16
  import dev.bota.sdk.RecordingTransferMetadata
16
17
  import dev.bota.sdk.UploadOwnershipEvent
@@ -28,11 +29,19 @@ import dev.bota.sdk.model.StreamingUploadMethod
28
29
  import java.util.UUID
29
30
  import java.util.concurrent.ConcurrentHashMap
30
31
  import kotlinx.coroutines.CompletableDeferred
32
+ import kotlinx.coroutines.CancellationException
33
+ import kotlinx.coroutines.Job
34
+ import kotlinx.coroutines.NonCancellable
35
+ import kotlinx.coroutines.currentCoroutineContext
36
+ import kotlinx.coroutines.ensureActive
37
+ import kotlinx.coroutines.job
38
+ import kotlinx.coroutines.withContext
31
39
  import kotlinx.coroutines.flow.Flow
32
40
  import kotlinx.coroutines.flow.collect
33
41
 
34
42
  internal interface BotaDeviceSDKAndroidRecordingClient {
35
43
  suspend fun listRecordings(device: ConnectedDevice): List<DeviceRecording>
44
+ suspend fun listPendingRecordings(device: ConnectedDevice): List<PendingRecording>
36
45
 
37
46
  fun syncRecording(
38
47
  device: ConnectedDevice,
@@ -45,9 +54,12 @@ internal interface BotaDeviceSDKAndroidRecordingClient {
45
54
  suspend fun syncEncryptedRecordingV2(
46
55
  device: ConnectedDevice,
47
56
  recording: EncryptedUploadV2Recording,
57
+ operationId: UUID,
48
58
  provider: EncryptedUploadV2ProfileProvider,
49
59
  )
50
60
 
61
+ suspend fun cancelEncryptedRecordingV2(operationId: UUID)
62
+
51
63
  suspend fun confirmRecording(device: ConnectedDevice, recordingUuid: String)
52
64
 
53
65
  fun observeUploadOwnership(
@@ -74,6 +86,7 @@ internal fun BotaDeviceSDKAndroidRecordings.BotaRecordingFile.toWritableMap(): W
74
86
  Arguments.createMap().apply {
75
87
  putString("localPath", localPath)
76
88
  putBoolean("e2eEncrypted", isE2EEncrypted)
89
+ putDouble("fileSizeBytes", fileSizeBytes.toDouble())
77
90
  contentSha256Hex?.let { putString("contentSha256", it) }
78
91
  }
79
92
 
@@ -89,6 +102,10 @@ internal data class BotaEncryptedUploadV2Recording(
89
102
  val generation: UInt,
90
103
  val ciphertextLength: String,
91
104
  val ciphertextSha256: String,
105
+ val startedAtMs: String,
106
+ val durationMs: String,
107
+ val plaintextLength: String,
108
+ val storageFormat: Int,
92
109
  )
93
110
 
94
111
  internal data class BotaEncryptedUploadV2Capability(
@@ -199,11 +216,15 @@ internal fun BotaEncryptedUploadV2Progress.toWritableMap(): WritableMap = Argume
199
216
  protocolStatus?.let { putDouble("protocolStatus", it.toDouble()) }
200
217
  }
201
218
 
202
- private fun BotaEncryptedUploadV2Recording.toWritableMap(): WritableMap = Arguments.createMap().apply {
219
+ internal fun BotaEncryptedUploadV2Recording.toWritableMap(): WritableMap = Arguments.createMap().apply {
203
220
  putString("uuid", uuid)
204
221
  putDouble("generation", generation.toDouble())
205
222
  putString("ciphertextLength", ciphertextLength)
206
223
  putString("ciphertextSha256", ciphertextSha256)
224
+ putString("startedAtMs", startedAtMs)
225
+ putString("durationMs", durationMs)
226
+ putString("plaintextLength", plaintextLength)
227
+ putInt("storageFormat", storageFormat)
207
228
  }
208
229
 
209
230
  private fun BotaEncryptedUploadV2Capability.toWritableMap(): WritableMap = Arguments.createMap().apply {
@@ -240,6 +261,9 @@ internal class BotaDeviceSDKSharedAndroidRecordingClient(
240
261
  override suspend fun listRecordings(device: ConnectedDevice): List<DeviceRecording> =
241
262
  client.recordings.listRecordings(device)
242
263
 
264
+ override suspend fun listPendingRecordings(device: ConnectedDevice): List<PendingRecording> =
265
+ client.recordings.listPendingRecordings(device)
266
+
243
267
  override fun syncRecording(
244
268
  device: ConnectedDevice,
245
269
  recording: DeviceRecording,
@@ -257,9 +281,14 @@ internal class BotaDeviceSDKSharedAndroidRecordingClient(
257
281
  override suspend fun syncEncryptedRecordingV2(
258
282
  device: ConnectedDevice,
259
283
  recording: EncryptedUploadV2Recording,
284
+ operationId: UUID,
260
285
  provider: EncryptedUploadV2ProfileProvider,
261
286
  ) {
262
- client.recordings.syncEncryptedRecordingV2(device, recording, provider)
287
+ client.recordings.syncEncryptedRecordingV2(device, recording, operationId, provider)
288
+ }
289
+
290
+ override suspend fun cancelEncryptedRecordingV2(operationId: UUID) {
291
+ client.recordings.cancelEncryptedRecordingV2(operationId)
263
292
  }
264
293
 
265
294
  override suspend fun confirmRecording(device: ConnectedDevice, recordingUuid: String) {
@@ -304,17 +333,28 @@ internal class BotaDeviceSDKSharedAndroidRecordingClient(
304
333
  internal class BotaDeviceSDKAndroidRecordings(
305
334
  private val client: BotaDeviceSDKAndroidRecordingClient =
306
335
  BotaDeviceSDKSharedAndroidRecordingClient(),
336
+ private val fileSize: (String) -> Long = { path ->
337
+ val file = java.nio.file.Paths.get(path)
338
+ check(java.nio.file.Files.isRegularFile(file)) { "recording transfer did not produce a regular file" }
339
+ java.nio.file.Files.size(file)
340
+ },
307
341
  ) {
308
342
  private val destinationRequests = ConcurrentHashMap<
309
343
  String,
310
344
  CompletableDeferred<StreamingUploadDestination>
311
345
  >()
312
346
  private data class EncryptedUploadV2Request(
347
+ val operationId: String,
313
348
  val recording: EncryptedUploadV2Recording,
314
349
  val material: CompletableDeferred<EncryptedUploadV2Material>,
315
- )
350
+ ) {
351
+ @Volatile var selectedMaterial: EncryptedUploadV2Material? = null
352
+ }
316
353
  private val encryptedUploadV2Requests =
317
354
  ConcurrentHashMap<String, EncryptedUploadV2Request>()
355
+ private data class EncryptedUploadV2Operation(val id: UUID, val job: Job)
356
+ private val encryptedUploadV2Operations = ConcurrentHashMap<String, EncryptedUploadV2Operation>()
357
+ private val cancelledEncryptedUploadV2Operations = ConcurrentHashMap.newKeySet<String>()
318
358
  private val finalizeRequests = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
319
359
  @Volatile private var activeStreamingSessionId: String? = null
320
360
 
@@ -322,11 +362,37 @@ internal class BotaDeviceSDKAndroidRecordings(
322
362
  val localPath: String,
323
363
  val isE2EEncrypted: Boolean,
324
364
  val contentSha256Hex: String?,
365
+ val fileSizeBytes: Long,
325
366
  )
326
367
 
327
368
  suspend fun listRecordings(device: ConnectedDevice): List<DeviceRecording> =
328
369
  client.listRecordings(device)
329
370
 
371
+ suspend fun listPendingRecordings(device: ConnectedDevice): List<PendingRecording> =
372
+ client.listPendingRecordings(device)
373
+
374
+ suspend fun cancelEncryptedRecordingV2(operationId: String) {
375
+ val caller = currentCoroutineContext().job
376
+ cancelledEncryptedUploadV2Operations.add(operationId)
377
+ val operation = encryptedUploadV2Operations[operationId]
378
+ operation?.job?.cancel()
379
+ encryptedUploadV2Requests.entries.forEach { (id, request) ->
380
+ if (request.operationId == operationId && encryptedUploadV2Requests.remove(id, request)) {
381
+ request.material.completeExceptionally(CancellationException())
382
+ }
383
+ }
384
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(operationId)
385
+ if (operation != null) {
386
+ withContext(NonCancellable) {
387
+ try {
388
+ client.cancelEncryptedRecordingV2(operation.id)
389
+ } finally {
390
+ if (operation.job !== caller) operation.job.join()
391
+ }
392
+ }
393
+ }
394
+ }
395
+
330
396
  suspend fun syncRecording(
331
397
  device: ConnectedDevice,
332
398
  recording: DeviceRecording,
@@ -346,6 +412,7 @@ internal class BotaDeviceSDKAndroidRecordings(
346
412
  localPath,
347
413
  metadata?.isE2EEncrypted ?: false,
348
414
  metadata?.contentSha256Hex,
415
+ fileSize(localPath),
349
416
  )
350
417
  }
351
418
 
@@ -356,10 +423,16 @@ internal class BotaDeviceSDKAndroidRecordings(
356
423
  onProfileRequest: (BotaEncryptedUploadV2ProfileRequest) -> Unit,
357
424
  onProgress: (BotaEncryptedUploadV2Progress) -> Unit,
358
425
  ) {
426
+ val operation = EncryptedUploadV2Operation(UUID.randomUUID(), currentCoroutineContext().job)
427
+ check(encryptedUploadV2Operations.putIfAbsent(operationId, operation) == null) {
428
+ "encrypted upload v2 operation is already active"
429
+ }
359
430
  try {
431
+ if (cancelledEncryptedUploadV2Operations.contains(operationId)) throw CancellationException()
360
432
  client.syncEncryptedRecordingV2(
361
433
  device,
362
434
  recording,
435
+ operation.id,
363
436
  EncryptedUploadV2ProfileProvider { context ->
364
437
  val completedBytes = context.checkpoint?.nextCiphertextOffset?.toString() ?: "0"
365
438
  val checkpointRevision = context.checkpoint?.revision
@@ -376,17 +449,24 @@ internal class BotaDeviceSDKAndroidRecordings(
376
449
  context,
377
450
  onProfileRequest,
378
451
  )
379
- onProgress(BotaEncryptedUploadV2Progress(
380
- operationId,
381
- recording.uuid,
382
- "transferring",
383
- completedBytes,
384
- recording.ciphertextLength.toString(),
385
- checkpointRevision,
386
- ))
387
- material
452
+ try {
453
+ currentCoroutineContext().ensureActive()
454
+ onProgress(BotaEncryptedUploadV2Progress(
455
+ operationId,
456
+ recording.uuid,
457
+ "transferring",
458
+ completedBytes,
459
+ recording.ciphertextLength.toString(),
460
+ checkpointRevision,
461
+ ))
462
+ material
463
+ } catch (error: Throwable) {
464
+ withContext(NonCancellable) { material.cancelPreparation() }
465
+ throw error
466
+ }
388
467
  },
389
468
  )
469
+ currentCoroutineContext().ensureActive()
390
470
  onProgress(BotaEncryptedUploadV2Progress(
391
471
  operationId,
392
472
  recording.uuid,
@@ -407,10 +487,13 @@ internal class BotaDeviceSDKAndroidRecordings(
407
487
  protocolStatus = failure.protocolStatus,
408
488
  ))
409
489
  throw error
490
+ } finally {
491
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(operationId)
492
+ encryptedUploadV2Operations.remove(operationId, operation)
410
493
  }
411
494
  }
412
495
 
413
- fun resolveEncryptedUploadV2Profile(
496
+ suspend fun resolveEncryptedUploadV2Profile(
414
497
  requestId: String,
415
498
  profile: String,
416
499
  uploadSessionId: String,
@@ -419,26 +502,41 @@ internal class BotaDeviceSDKAndroidRecordings(
419
502
  materialRegistrationId: String,
420
503
  ) {
421
504
  val request = encryptedUploadV2Requests.remove(requestId)
422
- ?: error("encrypted upload v2 profile request is no longer pending")
423
- runCatching {
505
+ if (request == null) {
506
+ withContext(NonCancellable) { BotaDeviceSDKEncryptedUploadV2Materials.release(materialRegistrationId) }
507
+ error("encrypted upload v2 profile request is no longer pending")
508
+ }
509
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(request.operationId)
510
+ var selected: EncryptedUploadV2Material? = null
511
+ try {
424
512
  require(profile == "encrypted_upload_v2") {
425
513
  "encrypted upload v2 requires an explicit matching profile"
426
514
  }
427
- BotaDeviceSDKEncryptedUploadV2Materials.consume(
515
+ selected = BotaDeviceSDKEncryptedUploadV2Materials.consume(
428
516
  materialRegistrationId,
429
517
  request.recording,
430
518
  UUID.fromString(uploadSessionId),
431
519
  ownerRevision,
432
520
  securityPolicy,
433
521
  )
434
- }.onSuccess { request.material.complete(it) }
435
- .onFailure { request.material.completeExceptionally(it) }
436
- .getOrThrow()
522
+ request.selectedMaterial = selected
523
+ check(request.material.complete(selected)) {
524
+ "encrypted upload v2 profile request is no longer pending"
525
+ }
526
+ } catch (error: Throwable) {
527
+ request.material.completeExceptionally(error)
528
+ withContext(NonCancellable) {
529
+ if (selected != null) selected.cancelPreparation()
530
+ else BotaDeviceSDKEncryptedUploadV2Materials.release(materialRegistrationId)
531
+ }
532
+ throw error
533
+ }
437
534
  }
438
535
 
439
536
  fun rejectEncryptedUploadV2Profile(requestId: String, errorCode: String) {
440
537
  val request = encryptedUploadV2Requests.remove(requestId)
441
538
  ?: error("encrypted upload v2 profile request is no longer pending")
539
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(request.operationId)
442
540
  val error = if (errorCode == "application_material_rejected") {
443
541
  IllegalStateException("encrypted upload v2 material was rejected")
444
542
  } else {
@@ -567,6 +665,9 @@ internal class BotaDeviceSDKAndroidRecordings(
567
665
  }
568
666
 
569
667
  suspend fun cancelAll() {
668
+ val operations = encryptedUploadV2Operations.toMap()
669
+ operations.values.forEach { it.job.cancel() }
670
+ operations.keys.forEach { runCatching { cancelEncryptedRecordingV2(it) } }
570
671
  rejectPendingRequests("recording operations were cancelled")
571
672
  runCatching { client.cancelCurrentOperation() }
572
673
  }
@@ -600,21 +701,34 @@ internal class BotaDeviceSDKAndroidRecordings(
600
701
  val requestId = UUID.randomUUID().toString()
601
702
  val deferred = CompletableDeferred<EncryptedUploadV2Material>()
602
703
  val request = EncryptedUploadV2Request(
704
+ operationId,
603
705
  context.recording,
604
706
  deferred,
605
707
  )
708
+ BotaDeviceSDKEncryptedUploadV2Materials.registerContext(operationId, context)
606
709
  encryptedUploadV2Requests[requestId] = request
607
- onRequest(BotaEncryptedUploadV2ProfileRequest(
608
- requestId,
609
- operationId,
610
- context.recording.toBridgeValue(),
611
- context.capability.toBridgeValue(),
612
- context.checkpoint?.toBridgeValue(),
613
- ))
710
+ var delivered = false
614
711
  return try {
615
- deferred.await()
712
+ if (cancelledEncryptedUploadV2Operations.contains(operationId)) throw CancellationException()
713
+ onRequest(BotaEncryptedUploadV2ProfileRequest(
714
+ requestId,
715
+ operationId,
716
+ context.recording.toBridgeValue(),
717
+ context.capability.toBridgeValue(),
718
+ context.checkpoint?.toBridgeValue(),
719
+ ))
720
+ deferred.await().also {
721
+ currentCoroutineContext().ensureActive()
722
+ delivered = true
723
+ }
616
724
  } finally {
617
725
  encryptedUploadV2Requests.remove(requestId, request)
726
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(operationId)
727
+ if (!delivered) {
728
+ // A completed deferred can still lose to prompt coroutine cancellation.
729
+ deferred.cancel()
730
+ withContext(NonCancellable) { request.selectedMaterial?.cancelPreparation() }
731
+ }
618
732
  }
619
733
  }
620
734
 
@@ -643,7 +757,10 @@ internal class BotaDeviceSDKAndroidRecordings(
643
757
 
644
758
  private fun rejectPendingRequests(message: String) {
645
759
  val error = IllegalStateException(message)
646
- encryptedUploadV2Requests.values.forEach { it.material.completeExceptionally(error) }
760
+ encryptedUploadV2Requests.values.forEach {
761
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(it.operationId)
762
+ it.material.completeExceptionally(error)
763
+ }
647
764
  destinationRequests.values.forEach { it.completeExceptionally(error) }
648
765
  finalizeRequests.values.forEach { it.completeExceptionally(error) }
649
766
  destinationRequests.clear()
@@ -652,12 +769,16 @@ internal class BotaDeviceSDKAndroidRecordings(
652
769
  }
653
770
  }
654
771
 
655
- private fun EncryptedUploadV2Recording.toBridgeValue(): BotaEncryptedUploadV2Recording =
772
+ internal fun EncryptedUploadV2Recording.toBridgeValue(): BotaEncryptedUploadV2Recording =
656
773
  BotaEncryptedUploadV2Recording(
657
774
  uuid,
658
775
  generation,
659
776
  ciphertextLength.toString(),
660
777
  ciphertextSha256.toHex(),
778
+ startedAtMs.toString(),
779
+ durationMs.toString(),
780
+ plaintextLength.toString(),
781
+ storageFormat.toInt(),
661
782
  )
662
783
 
663
784
  private fun EncryptedUploadV2CapabilitySnapshot.toBridgeValue(): BotaEncryptedUploadV2Capability {
@@ -1,6 +1,7 @@
1
1
  package dev.bota.sdk.reactnative
2
2
 
3
3
  import dev.bota.sdk.EncryptedUploadV2Material
4
+ import dev.bota.sdk.EncryptedUploadV2ProviderContext
4
5
  import dev.bota.sdk.EncryptedUploadV2Recording
5
6
  import dev.bota.sdk.EncryptedUploadV2SecurityPolicy
6
7
  import java.util.UUID
@@ -9,6 +10,32 @@ import java.util.concurrent.ConcurrentHashMap
9
10
  /** Holds v2 application material in native memory and exposes only opaque IDs to JavaScript. */
10
11
  public object BotaDeviceSDKEncryptedUploadV2Materials {
11
12
  private val materials = ConcurrentHashMap<String, EncryptedUploadV2Material>()
13
+ private data class Context(val lease: UUID, val value: EncryptedUploadV2ProviderContext)
14
+ private val contexts = ConcurrentHashMap<String, Context>()
15
+
16
+ public suspend fun readAuthNonce(operationId: String): ByteArray {
17
+ val context = contexts[operationId]
18
+ ?: error("encrypted upload v2 provider context is unavailable")
19
+ val nonce = context.value.readAuthNonce()
20
+ check(contexts[operationId]?.lease == context.lease) {
21
+ "encrypted upload v2 provider context is unavailable"
22
+ }
23
+ return nonce.copyOf()
24
+ }
25
+
26
+ public suspend fun release(registrationId: String) {
27
+ materials.remove(registrationId)?.cancelPreparation()
28
+ }
29
+
30
+ internal fun registerContext(operationId: String, context: EncryptedUploadV2ProviderContext) {
31
+ check(contexts.putIfAbsent(operationId, Context(UUID.randomUUID(), context)) == null) {
32
+ "encrypted upload v2 provider context is already active"
33
+ }
34
+ }
35
+
36
+ internal fun removeContext(operationId: String) {
37
+ contexts.remove(operationId)
38
+ }
12
39
 
13
40
  public fun register(material: EncryptedUploadV2Material): String =
14
41
  UUID.randomUUID().toString().also { materials[it] = material }
@@ -4,6 +4,7 @@ import com.facebook.react.bridge.Arguments
4
4
  import com.facebook.react.bridge.Promise
5
5
  import com.facebook.react.bridge.ReactApplicationContext
6
6
  import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.ReadableArray
7
8
  import com.facebook.react.module.annotations.ReactModule
8
9
  import dev.bota.sdk.DeviceReconnectHint
9
10
  import dev.bota.sdk.DeviceApiEnvironment
@@ -42,7 +43,13 @@ internal class BotaDeviceSDKModule(
42
43
  } else {
43
44
  null
44
45
  }
45
- launch(promise) { lifecycle.configure(storageDirectory) }
46
+ launch(promise) {
47
+ lifecycle.configure(storageDirectory)
48
+ recordingUploads.configure(storageDirectory)
49
+ devices.startConnectionUpdates {
50
+ emitOnDeviceDisconnected(Arguments.createMap())
51
+ }
52
+ }
46
53
  }
47
54
 
48
55
  override fun destroy(promise: Promise) {
@@ -223,6 +230,37 @@ internal class BotaDeviceSDKModule(
223
230
  }
224
231
  }
225
232
 
233
+ override fun listPendingRecordings(device: ReadableMap, promise: Promise) {
234
+ launchValue(promise) {
235
+ Arguments.createArray().apply {
236
+ recordings.listPendingRecordings(device.toConnectedDevice()).forEach { recording ->
237
+ pushMap(Arguments.createMap().apply {
238
+ when (recording) {
239
+ is dev.bota.sdk.PendingRecording.Legacy -> {
240
+ putString("profile", "legacy")
241
+ putMap("legacy", recording.recording.toWritableMap())
242
+ }
243
+ is dev.bota.sdk.PendingRecording.EncryptedV2 -> {
244
+ putString("profile", "encrypted_upload_v2")
245
+ putMap("encrypted", recording.recording.toBridgeValue().toWritableMap())
246
+ }
247
+ }
248
+ })
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ override fun cancelEncryptedRecordingV2(operationId: String, promise: Promise) {
255
+ launchEncryptedUploadV2(promise) { recordings.cancelEncryptedRecordingV2(operationId) }
256
+ }
257
+
258
+ override fun releaseEncryptedUploadV2Material(materialRegistrationId: String, promise: Promise) {
259
+ launchEncryptedUploadV2(promise) {
260
+ BotaDeviceSDKEncryptedUploadV2Materials.release(materialRegistrationId)
261
+ }
262
+ }
263
+
226
264
  override fun syncRecording(
227
265
  device: ReadableMap,
228
266
  recording: ReadableMap,
@@ -264,7 +302,7 @@ internal class BotaDeviceSDKModule(
264
302
  decision: ReadableMap,
265
303
  promise: Promise,
266
304
  ) {
267
- launch(promise) {
305
+ launchEncryptedUploadV2(promise) {
268
306
  recordings.resolveEncryptedUploadV2Profile(
269
307
  requestId,
270
308
  decision.getString("profile")
@@ -285,7 +323,7 @@ internal class BotaDeviceSDKModule(
285
323
  errorCode: String,
286
324
  promise: Promise,
287
325
  ) {
288
- launch(promise) { recordings.rejectEncryptedUploadV2Profile(requestId, errorCode) }
326
+ launchEncryptedUploadV2(promise) { recordings.rejectEncryptedUploadV2Profile(requestId, errorCode) }
289
327
  }
290
328
 
291
329
  override fun startStreaming(
@@ -385,6 +423,10 @@ internal class BotaDeviceSDKModule(
385
423
  launch(promise) { recordingUploads.cancel(taskId) }
386
424
  }
387
425
 
426
+ override fun releaseRecordingFile(taskId: String, localPath: String, promise: Promise) {
427
+ launch(promise) { recordingUploads.release(taskId, localPath) }
428
+ }
429
+
388
430
  override fun loadCompatibilityUploadQueue(promise: Promise) {
389
431
  launchValue(promise) { recordingUploads.loadQueue() }
390
432
  }
@@ -453,10 +495,36 @@ internal class BotaDeviceSDKModule(
453
495
  launch(promise) { logs.stop() }
454
496
  }
455
497
 
498
+ override fun readDiagnosticEvents(device: ReadableMap, promise: Promise) {
499
+ launchValue(promise) { logs.readDiagnosticEvents(device.toConnectedDevice()).toWritableMap() }
500
+ }
501
+
502
+ override fun acknowledgeDiagnosticEvents(device: ReadableMap, acceptedEventIds: ReadableArray, promise: Promise) {
503
+ launch(promise) {
504
+ val ids = (0 until acceptedEventIds.size()).map { acceptedEventIds.getString(it) ?: error("diagnostic event ID is required") }
505
+ logs.acknowledgeDiagnosticEvents(device.toConnectedDevice(), ids)
506
+ }
507
+ }
508
+
456
509
  override fun readStatus(promise: Promise) {
457
510
  launchValue(promise) { devices.readStatus().toWritableMap() }
458
511
  }
459
512
 
513
+ override fun nextClientPresence(deviceId: String, promise: Promise) {
514
+ launchValue(promise) {
515
+ devices.nextClientPresence(deviceId)?.let { report ->
516
+ Arguments.createMap().apply {
517
+ putInt("schemaVersion", report.schemaVersion)
518
+ putString("sessionId", report.sessionId)
519
+ putDouble("sequence", report.sequence.toDouble())
520
+ putString("platform", report.platform)
521
+ putString("sdkPackage", report.sdkPackage)
522
+ putString("sdkVersion", report.sdkVersion)
523
+ }
524
+ }
525
+ }
526
+ }
527
+
460
528
  override fun startStatusUpdates(promise: Promise) {
461
529
  launch(promise) {
462
530
  devices.startStatusUpdates(
@@ -712,9 +780,24 @@ private fun ReadableMap.toEncryptedUploadV2Recording(): EncryptedUploadV2Recordi
712
780
  getDouble("generation").toUnsignedInt(),
713
781
  length,
714
782
  digest,
783
+ optionalCatalogInteger("startedAtMs"),
784
+ optionalCatalogInteger("durationMs"),
785
+ optionalCatalogInteger("plaintextLength"),
786
+ if (hasKey("storageFormat") && !isNull("storageFormat")) {
787
+ getDouble("storageFormat").toUnsignedInt().also { require(it <= 255u) }.toUByte()
788
+ } else 3u,
715
789
  )
716
790
  }
717
791
 
792
+ private fun ReadableMap.optionalCatalogInteger(key: String): ULong {
793
+ if (!hasKey(key) || isNull(key)) return 0u
794
+ val value = getString(key) ?: error("encrypted upload v2 catalog integer is required")
795
+ require(value.matches(Regex("0|[1-9][0-9]*"))) {
796
+ "encrypted upload v2 catalog integer is invalid"
797
+ }
798
+ return value.toULongOrNull() ?: error("encrypted upload v2 catalog integer exceeds its bound")
799
+ }
800
+
718
801
  private fun String.sha256Bytes(): ByteArray {
719
802
  require(length == 64 && all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
720
803
  "encrypted upload v2 ciphertext SHA-256 must contain exactly 32 hexadecimal bytes"
@@ -1,5 +1,6 @@
1
1
  package dev.bota.sdk.reactnative
2
2
 
3
+ import dev.bota.sdk.reactnative.upload.BotaUploadV2BackendModule
3
4
  import com.facebook.react.BaseReactPackage
4
5
  import com.facebook.react.bridge.NativeModule
5
6
  import com.facebook.react.bridge.ReactApplicationContext
@@ -13,6 +14,8 @@ public class BotaDeviceSDKPackage : BaseReactPackage() {
13
14
  ): NativeModule? =
14
15
  if (name == BotaDeviceSDKModule.NAME) {
15
16
  BotaDeviceSDKModule(reactContext)
17
+ } else if (name == BotaUploadV2BackendModule.NAME) {
18
+ BotaUploadV2BackendModule(reactContext)
16
19
  } else {
17
20
  null
18
21
  }
@@ -20,6 +23,12 @@ public class BotaDeviceSDKPackage : BaseReactPackage() {
20
23
  override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
21
24
  ReactModuleInfoProvider {
22
25
  mapOf(
26
+ BotaUploadV2BackendModule.NAME to ReactModuleInfo(
27
+ name = BotaUploadV2BackendModule.NAME,
28
+ className = BotaUploadV2BackendModule::class.java.name,
29
+ canOverrideExistingModule = false, needsEagerInit = false,
30
+ isCxxModule = false, isTurboModule = true,
31
+ ),
23
32
  BotaDeviceSDKModule.NAME to ReactModuleInfo(
24
33
  name = BotaDeviceSDKModule.NAME,
25
34
  className = BotaDeviceSDKModule::class.java.name,
@@ -0,0 +1,66 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import android.system.Os
4
+ import android.system.OsConstants
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.module.annotations.ReactModule
9
+ import dev.bota.sdk.reactnative.BotaDeviceSDKEncryptedUploadV2Materials
10
+ import dev.bota.sdk.reactnative.NativeBotaUploadV2BackendSpec
11
+ import java.io.File
12
+ import kotlinx.coroutines.CoroutineScope
13
+ import kotlinx.coroutines.Dispatchers
14
+ import kotlinx.coroutines.SupervisorJob
15
+ import kotlinx.coroutines.launch
16
+ import org.json.JSONObject
17
+
18
+ @ReactModule(name = BotaUploadV2BackendModule.NAME)
19
+ internal class BotaUploadV2BackendModule(context: ReactApplicationContext) : NativeBotaUploadV2BackendSpec(context) {
20
+ companion object { const val NAME = "BotaUploadV2Backend" }
21
+ private val work = CoroutineScope(SupervisorJob() + Dispatchers.IO)
22
+ private val credentials = CredentialBroker { requestId, operationId ->
23
+ emitOnCredentialsRequested(Arguments.createMap().apply {
24
+ putString("requestId", requestId); putString("operationId", operationId)
25
+ })
26
+ }
27
+ private val upload = NativeUpload(
28
+ UploadJournal(File(context.noBackupFilesDir, "BotaSDKUploadV2"), object : JournalDurability {
29
+ override fun syncDirectory(directory: File) {
30
+ val fd = Os.open(directory.absolutePath, OsConstants.O_RDONLY, 0)
31
+ try {
32
+ demand(OsConstants.S_ISDIR(Os.fstat(fd).st_mode), "PERSISTENCE")
33
+ Os.fsync(fd)
34
+ } finally { Os.close(fd) }
35
+ }
36
+ }), BackendUploadHttp(), credentials::request, BotaDeviceSDKEncryptedUploadV2Materials::readAuthNonce,
37
+ )
38
+ @Volatile private var destroyed = false
39
+
40
+ private fun invoke(promise: Promise, block: suspend () -> Any?) {
41
+ work.launch {
42
+ try { demand(!destroyed, "CANCELLED"); promise.resolve(block()) }
43
+ catch (error: Exception) {
44
+ val code = (error as? UploadFailure)?.code ?: "BOTA_UPLOAD_NATIVE"
45
+ promise.reject(code, code, null)
46
+ }
47
+ }
48
+ }
49
+ override fun prepare(inputJSON: String, promise: Promise) = invoke(promise) {
50
+ JSONObject(upload.prepare(inputJSON)).toString()
51
+ }
52
+ override fun cancel(operationId: String, promise: Promise) = invoke(promise) { upload.cancel(operationId); null }
53
+ override fun complete(operationId: String, promise: Promise) = invoke(promise) { upload.complete(operationId); null }
54
+ override fun resolveCredentials(requestId: String, token: String, promise: Promise) = invoke(promise) {
55
+ credentials.resolve(requestId, token); null
56
+ }
57
+ override fun rejectCredentials(requestId: String, promise: Promise) = invoke(promise) {
58
+ credentials.reject(requestId); null
59
+ }
60
+ override fun invalidate() {
61
+ destroyed = true
62
+ upload.cancelAll()
63
+ // Dispatched identity-creating requests must drain into their original journal.
64
+ super.invalidate()
65
+ }
66
+ }