@bota.dev/react-native-app-sdk 2.0.0-beta.13 → 2.0.0-beta.15

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 (105) hide show
  1. package/README.md +49 -7
  2. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +2 -2
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +3 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +1 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +37 -3
  6. package/android/src/main/java/dev/bota/sdk/reactnative/upload/ByoBridgeDriver.kt +79 -0
  7. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +30 -2
  8. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +5 -2
  9. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadStreamingStatus.kt +67 -0
  10. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +23 -0
  11. package/generated/codegen-contract.json +110 -8
  12. package/ios/BotaDeviceSDK.mm +1 -0
  13. package/ios/BotaDeviceSDKAppleBridge.swift +4 -2
  14. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +3 -3
  15. package/ios/BotaDeviceSDKAppleRecordings.swift +1 -0
  16. package/ios/BotaUploadV2Backend.mm +21 -0
  17. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +52 -2
  18. package/ios/UploadBackend/ByoBridgeDriver.swift +94 -0
  19. package/ios/UploadBackend/UploadModels.swift +6 -1
  20. package/ios/UploadBackend/UploadOperation.swift +41 -3
  21. package/ios/UploadBackend/UploadStreamingStatus.swift +43 -0
  22. package/lib/commonjs/byoStorageBackend.js +144 -0
  23. package/lib/commonjs/byoStorageBackend.js.map +1 -0
  24. package/lib/commonjs/client.js +2 -1
  25. package/lib/commonjs/client.js.map +1 -1
  26. package/lib/commonjs/encryptedUploadV2Backend.js +4 -1
  27. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -1
  28. package/lib/commonjs/index.js +7 -1
  29. package/lib/commonjs/index.js.map +1 -1
  30. package/lib/commonjs/managers/DeviceManager.js +6 -0
  31. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  32. package/lib/commonjs/managers/NativeUploadQueue.js +22 -8
  33. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -1
  34. package/lib/commonjs/managers/uploadRecoveryMetadata.js +4 -0
  35. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -1
  36. package/lib/commonjs/managers/uploadVerificationPending.js +20 -0
  37. package/lib/commonjs/managers/uploadVerificationPending.js.map +1 -0
  38. package/lib/commonjs/protectedStreamingStatus.js +120 -0
  39. package/lib/commonjs/protectedStreamingStatus.js.map +1 -0
  40. package/lib/commonjs/sdkIdentity.js +1 -1
  41. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  42. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -1
  43. package/lib/commonjs/uploadRecovery.js +50 -7
  44. package/lib/commonjs/uploadRecovery.js.map +1 -1
  45. package/lib/module/byoStorageBackend.js +140 -0
  46. package/lib/module/byoStorageBackend.js.map +1 -0
  47. package/lib/module/client.js +2 -1
  48. package/lib/module/client.js.map +1 -1
  49. package/lib/module/encryptedUploadV2Backend.js +4 -1
  50. package/lib/module/encryptedUploadV2Backend.js.map +1 -1
  51. package/lib/module/index.js +4 -0
  52. package/lib/module/index.js.map +1 -1
  53. package/lib/module/managers/DeviceManager.js +6 -0
  54. package/lib/module/managers/DeviceManager.js.map +1 -1
  55. package/lib/module/managers/NativeUploadQueue.js +22 -8
  56. package/lib/module/managers/NativeUploadQueue.js.map +1 -1
  57. package/lib/module/managers/uploadRecoveryMetadata.js +4 -0
  58. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -1
  59. package/lib/module/managers/uploadVerificationPending.js +14 -0
  60. package/lib/module/managers/uploadVerificationPending.js.map +1 -0
  61. package/lib/module/protectedStreamingStatus.js +116 -0
  62. package/lib/module/protectedStreamingStatus.js.map +1 -0
  63. package/lib/module/sdkIdentity.js +1 -1
  64. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  65. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -1
  66. package/lib/module/uploadRecovery.js +50 -7
  67. package/lib/module/uploadRecovery.js.map +1 -1
  68. package/lib/typescript/byoStorageBackend.d.ts +36 -0
  69. package/lib/typescript/byoStorageBackend.d.ts.map +1 -0
  70. package/lib/typescript/client.d.ts.map +1 -1
  71. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -1
  72. package/lib/typescript/index.d.ts +6 -0
  73. package/lib/typescript/index.d.ts.map +1 -1
  74. package/lib/typescript/managers/DeviceManager.d.ts +2 -0
  75. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  76. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -1
  77. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -1
  78. package/lib/typescript/managers/uploadVerificationPending.d.ts +6 -0
  79. package/lib/typescript/managers/uploadVerificationPending.d.ts.map +1 -0
  80. package/lib/typescript/models/Recording.d.ts +6 -0
  81. package/lib/typescript/models/Recording.d.ts.map +1 -1
  82. package/lib/typescript/protectedStreamingStatus.d.ts +37 -0
  83. package/lib/typescript/protectedStreamingStatus.d.ts.map +1 -0
  84. package/lib/typescript/sdkIdentity.d.ts +1 -1
  85. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +1 -0
  86. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  87. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +4 -0
  88. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -1
  89. package/lib/typescript/uploadRecovery.d.ts +1 -1
  90. package/lib/typescript/uploadRecovery.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/src/byoStorageBackend.ts +104 -0
  93. package/src/client.ts +2 -0
  94. package/src/encryptedUploadV2Backend.ts +2 -1
  95. package/src/index.ts +11 -0
  96. package/src/managers/DeviceManager.ts +6 -0
  97. package/src/managers/NativeUploadQueue.ts +18 -9
  98. package/src/managers/uploadRecoveryMetadata.ts +2 -0
  99. package/src/managers/uploadVerificationPending.ts +10 -0
  100. package/src/models/Recording.ts +6 -0
  101. package/src/protectedStreamingStatus.ts +137 -0
  102. package/src/sdkIdentity.ts +1 -1
  103. package/src/specs/NativeBotaDeviceSDK.ts +1 -0
  104. package/src/specs/NativeBotaUploadV2Backend.ts +4 -0
  105. package/src/uploadRecovery.ts +42 -8
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # Bota SDK for React Native
2
2
 
3
- Source candidate `2.0.0-beta.13` synchronizes the five-platform SDK family.
4
- It includes corrected recording-control decoding, dedicated reset authorization,
5
- Apple error metadata and React Native deferred upload-destination recovery.
6
- Beta.12 remains published until this candidate completes its release gates.
3
+ Source candidate `2.0.0-beta.15` synchronizes the five-platform SDK family.
4
+ It includes Apple single-ATT transfer bounds and local connection ownership,
5
+ awaitable React Native reconnect pause, and the reviewed main OTA, recording
6
+ marker, protected-status and BYO storage changes. Beta.14 remains the published
7
+ baseline until this candidate completes its gates.
7
8
  Native applications require a new binary.
9
+ The managed Apple backend also matches the strict session-create request without
10
+ an EndUser override; recording creation and native scope fences retain EndUser.
11
+ See [request correction](../../docs/parity/v2-managed-backend.md#october-12-apple-session-request-correction).
8
12
 
9
13
  Bluetooth device SDK for Bota Pin and Bota Note applications. The 2.x candidate
10
14
  preserves the public TypeScript API of `@bota.dev/react-native-sdk@0.0.65`
@@ -29,9 +33,9 @@ adopting this source API; published-package availability is a separate gate.
29
33
 
30
34
  ## Install
31
35
 
32
- Published beta.12 remains available. Use the beta.13 pin below only after
36
+ Published beta.14 remains available. Use the beta.15 pin below only after
33
37
  publication is verified; see the
34
- [preflight](../../release/evidence/2.0.0-beta.13-preflight.md).
38
+ [preflight](../../release/evidence/2.0.0-beta.15-preflight.md).
35
39
  Rebuild native applications and reconnect explicitly after connection loss.
36
40
  Physical completed-resume acceptance and wider GATT recovery remain separate.
37
41
  Remove `@bota.dev/react-native-sdk` before adding the replacement; do not
@@ -39,7 +43,7 @@ co-install both. Standalone maintenance ended September 29, 2026; follow the
39
43
  [retirement and migration notice](../../docs/migrations/react-native-sdk-sunset.md).
40
44
 
41
45
  ```bash
42
- npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.13
46
+ npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.15
43
47
  npx pod-install
44
48
  ```
45
49
 
@@ -84,6 +88,14 @@ await BotaClient.waitForBluetooth();
84
88
  The `BotaClient.devices`, `BotaClient.recordings`, and `BotaClient.ota`
85
89
  managers become available after configuration completes.
86
90
 
91
+ Source-only workspace coordination adds
92
+ `await BotaClient.devices.pauseAutoReconnect()`. Fence new reconnect work and
93
+ retry rearming first, then inspect fresh device activity and disconnect idle
94
+ connections before changing scope. This method waits for submitted reconnects;
95
+ it does not itself disconnect. Existing `disableAutoReconnect(): void` remains
96
+ unchanged. Published beta.14 does not include the addition; see
97
+ [verification and delivery limits](../../docs/parity/workspace-reconnect-quiescence.md).
98
+
87
99
  Discovery exposes the manufacturer-advertised `macAddress` as uppercase,
88
100
  colon-separated bytes (`AA:BB:CC:DD:EE:FF`), preserving the legacy React Native
89
101
  registration contract. Compact native addresses are formatted at the RN
@@ -183,6 +195,23 @@ separate physical-device and application rollout gates.
183
195
 
184
196
  ## Documentation
185
197
 
198
+ Unpublished source also exports `createProtectedStreamingStatusBackend` for
199
+ read-only protected STREAMING metadata. Configure a scoped HTTPS `baseUrl`,
200
+ optional `organizationId`, lifetime `signal` and fresh application
201
+ `getAccessToken`; call `getStatus({ recordingId, sessionId,
202
+ recordingGeneration, writerEpoch })`. The exact session identity is checked;
203
+ `revision` remains a decimal string and may be `"0"`. Cancel the lifetime and
204
+ call `dispose()` on workspace or account changes. A matching rebuilt native
205
+ binary is required; published beta.14 lacks these methods. `sealed` means
206
+ marker metadata only, never audio completion or permission to delete. This
207
+ reader does not start protected capture or relay signed documents.
208
+ See `docs/parity/protected-streaming-status.md` in the source repository.
209
+
210
+ 未发布源码新增上述只读状态接口,必须使用匹配的原生二进制;已发布 beta.14 不包含
211
+ 这些方法。查询校验精确会话身份,revision 保持字符串且允许 `"0"`;账号或工作空间
212
+ 变化时取消并释放。`sealed` 仅指标记元数据,不代表音频完成或允许删除;本接口不启动
213
+ protected 采集,也不转发签名文档。
214
+
186
215
  See [docs.bota.dev](https://docs.bota.dev) for pairing, provisioning,
187
216
  recording transfer, WiFi, OTA, and device-management guides.
188
217
 
@@ -211,3 +240,16 @@ fresh credentials, and completes the original backend recording before native
211
240
  cleanup. Supply the initial `recoveryScope` and configure the provider before
212
241
  starting batch sync. Do not call backend completion only after the sync generator
213
242
  returns: device confirmation has already happened then.
243
+
244
+ For legacy completion, preserve a pending response as numeric `status: 425`
245
+ with `data.error.code === 'upload_verification_pending'`. The current source
246
+ helper retries only that classification with 1–2 second jitter for two minutes;
247
+ the queue then retains completion for a later attempt without consuming its
248
+ upload-repair budget. The native journal's `completionPending` phase prevents
249
+ another PUT after restart while the backend remains pending. This phase never
250
+ permits cleanup without the exact completion ACK; only authenticated
251
+ `integrity_failure` permits a repair upload. Scope changes and cancellation
252
+ remain fenced. The host must honor request aborts and provide HTTP timeouts.
253
+ This unpublished correction requires rebuilt native adapters, not just updated
254
+ JavaScript. Timers require a live JS runtime; encrypted-v2 receipt recovery is
255
+ unchanged. See `docs/parity/upload-recovery.md` for evidence and limits.
@@ -236,7 +236,7 @@ internal class BotaDeviceSDKAndroidRecordingUploads(
236
236
 
237
237
  private fun metadata(serialized: String): JSONArray {
238
238
  val tasks = JSONArray(serialized)
239
- val fields = setOf("id", "recordingId", "deviceId", "recordingUuid", "localPath", "status", "retryCount", "createdAt", "updatedAt", "recoveryScope", "fileSizeBytes", "nextAttemptAt", "contentType", "contentSha256", "relayUpload")
239
+ val fields = setOf("id", "recordingId", "deviceId", "recordingUuid", "localPath", "status", "retryCount", "createdAt", "updatedAt", "recoveryScope", "fileSizeBytes", "nextAttemptAt", "contentType", "contentSha256", "relayUpload", "completionPending")
240
240
  val ids = mutableSetOf<String>()
241
241
  return JSONArray().apply {
242
242
  for (index in 0 until tasks.length()) {
@@ -247,7 +247,7 @@ internal class BotaDeviceSDKAndroidRecordingUploads(
247
247
  require(validInteger(task.get("retryCount")) && task.getString("status") in setOf("pending", "uploading", "completed", "failed")) { "invalid upload recovery journal" }
248
248
  for (key in listOf("recordingUuid", "recoveryScope", "contentType", "contentSha256")) if (task.has(key)) require(task.get(key) is String) { "invalid upload recovery journal" }
249
249
  for (key in listOf("fileSizeBytes", "nextAttemptAt")) if (task.has(key)) require(validInteger(task.get(key))) { "invalid upload recovery journal" }
250
- if (task.has("relayUpload")) require(task.get("relayUpload") is Boolean) { "invalid upload recovery journal" }
250
+ for (key in listOf("relayUpload", "completionPending")) if (task.has(key)) require(task.get(key) is Boolean) { "invalid upload recovery journal" }
251
251
  for (key in listOf("createdAt", "updatedAt")) require(runCatching { java.time.Instant.parse(task.getString(key)) }.isSuccess) { "invalid upload recovery journal" }
252
252
  put(JSONObject().apply {
253
253
  for (key in fields) if (task.has(key)) {
@@ -106,6 +106,7 @@ internal data class BotaEncryptedUploadV2Recording(
106
106
  val durationMs: String,
107
107
  val plaintextLength: String,
108
108
  val storageFormat: Int,
109
+ val markersRequired: Boolean = false,
109
110
  )
110
111
 
111
112
  internal data class BotaEncryptedUploadV2Capability(
@@ -225,6 +226,7 @@ internal fun BotaEncryptedUploadV2Recording.toWritableMap(): WritableMap = Argum
225
226
  putString("durationMs", durationMs)
226
227
  putString("plaintextLength", plaintextLength)
227
228
  putInt("storageFormat", storageFormat)
229
+ putBoolean("markersRequired", markersRequired)
228
230
  }
229
231
 
230
232
  private fun BotaEncryptedUploadV2Capability.toWritableMap(): WritableMap = Arguments.createMap().apply {
@@ -779,6 +781,7 @@ internal fun EncryptedUploadV2Recording.toBridgeValue(): BotaEncryptedUploadV2Re
779
781
  durationMs.toString(),
780
782
  plaintextLength.toString(),
781
783
  storageFormat.toInt(),
784
+ markersRequired,
782
785
  )
783
786
 
784
787
  private fun EncryptedUploadV2CapabilitySnapshot.toBridgeValue(): BotaEncryptedUploadV2Capability {
@@ -786,6 +786,7 @@ private fun ReadableMap.toEncryptedUploadV2Recording(): EncryptedUploadV2Recordi
786
786
  if (hasKey("storageFormat") && !isNull("storageFormat")) {
787
787
  getDouble("storageFormat").toUnsignedInt().also { require(it <= 255u) }.toUByte()
788
788
  } else 3u,
789
+ hasKey("markersRequired") && !isNull("markersRequired") && getBoolean("markersRequired"),
789
790
  )
790
791
  }
791
792
 
@@ -13,6 +13,8 @@ import kotlinx.coroutines.CoroutineScope
13
13
  import kotlinx.coroutines.Dispatchers
14
14
  import kotlinx.coroutines.SupervisorJob
15
15
  import kotlinx.coroutines.launch
16
+ import kotlinx.coroutines.Job
17
+ import kotlinx.coroutines.CoroutineStart
16
18
  import org.json.JSONObject
17
19
 
18
20
  @ReactModule(name = BotaUploadV2BackendModule.NAME)
@@ -24,6 +26,11 @@ internal class BotaUploadV2BackendModule(context: ReactApplicationContext) : Nat
24
26
  putString("requestId", requestId); putString("operationId", operationId)
25
27
  })
26
28
  }
29
+ private val byo = ByoBridgeDriver(File(context.noBackupFilesDir, "BotaSDKByo")) { requestId, operationId ->
30
+ emitOnCredentialsRequested(Arguments.createMap().apply {
31
+ putString("requestId", requestId); putString("operationId", operationId)
32
+ })
33
+ }
27
34
  private val upload = NativeUpload(
28
35
  UploadJournal(File(context.noBackupFilesDir, "BotaSDKUploadV2"), object : JournalDurability {
29
36
  override fun syncDirectory(directory: File) {
@@ -35,31 +42,58 @@ internal class BotaUploadV2BackendModule(context: ReactApplicationContext) : Nat
35
42
  }
36
43
  }), BackendUploadHttp(), credentials::request, BotaDeviceSDKEncryptedUploadV2Materials::readAuthNonce,
37
44
  )
45
+ private val statusReader = UploadStreamingStatus()
46
+ private val statusJobs = mutableMapOf<String, Job>()
38
47
  @Volatile private var destroyed = false
39
48
 
40
49
  private fun invoke(promise: Promise, block: suspend () -> Any?) {
41
50
  work.launch {
42
51
  try { demand(!destroyed, "CANCELLED"); promise.resolve(block()) }
43
52
  catch (error: Exception) {
44
- val code = (error as? UploadFailure)?.code ?: "BOTA_UPLOAD_NATIVE"
53
+ val code = (error as? dev.bota.sdk.ByoStorageException)?.message ?: (error as? UploadFailure)?.code ?: "BOTA_UPLOAD_NATIVE"
45
54
  promise.reject(code, code, null)
46
55
  }
47
56
  }
48
57
  }
58
+ override fun syncByo(inputJSON: String, promise: Promise) = invoke(promise) { byo.sync(inputJSON) }
59
+ override fun cancelByo(operationId: String, promise: Promise) = invoke(promise) { byo.cancel(operationId); null }
60
+ override fun readProtectedStreamingStatus(requestId: String, inputJSON: String, promise: Promise) {
61
+ val job = work.launch(start = CoroutineStart.LAZY) {
62
+ try { demand(!destroyed, "CANCELLED"); promise.resolve(statusReader.read(inputJSON)) }
63
+ catch (_: Exception) { promise.reject("BOTA_STREAM_STATUS_FAILED", "BOTA_STREAM_STATUS_FAILED", null) }
64
+ }
65
+ job.invokeOnCompletion { error ->
66
+ synchronized(statusJobs) { if (statusJobs[requestId] === job) statusJobs.remove(requestId) }
67
+ if (error != null) promise.reject("BOTA_STREAM_STATUS_FAILED", "BOTA_STREAM_STATUS_FAILED", null)
68
+ }
69
+ synchronized(statusJobs) {
70
+ if (destroyed || statusJobs.containsKey(requestId)) {
71
+ job.cancel(); return
72
+ }
73
+ statusJobs[requestId] = job
74
+ }
75
+ job.start()
76
+ }
77
+ override fun cancelProtectedStreamingStatus(requestId: String, promise: Promise) {
78
+ synchronized(statusJobs) { statusJobs[requestId]?.cancel() }
79
+ promise.resolve(null)
80
+ }
49
81
  override fun prepare(inputJSON: String, promise: Promise) = invoke(promise) {
50
82
  JSONObject(upload.prepare(inputJSON)).toString()
51
83
  }
52
84
  override fun cancel(operationId: String, promise: Promise) = invoke(promise) { upload.cancel(operationId); null }
53
85
  override fun complete(operationId: String, promise: Promise) = invoke(promise) { upload.complete(operationId); null }
54
86
  override fun resolveCredentials(requestId: String, token: String, promise: Promise) = invoke(promise) {
55
- credentials.resolve(requestId, token); null
87
+ credentials.resolve(requestId, token); byo.resolve(requestId, token); null
56
88
  }
57
89
  override fun rejectCredentials(requestId: String, promise: Promise) = invoke(promise) {
58
- credentials.reject(requestId); null
90
+ credentials.reject(requestId); byo.reject(requestId); null
59
91
  }
60
92
  override fun invalidate() {
61
93
  destroyed = true
62
94
  upload.cancelAll()
95
+ work.launch { byo.stop() }
96
+ synchronized(statusJobs) { statusJobs.values.toList().forEach { it.cancel() }; statusJobs.clear() }
63
97
  // Dispatched identity-creating requests must drain into their original journal.
64
98
  super.invalidate()
65
99
  }
@@ -0,0 +1,79 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import dev.bota.sdk.*
4
+ import dev.bota.sdk.model.*
5
+ import java.io.File
6
+ import java.util.UUID
7
+ import kotlinx.coroutines.*
8
+ import org.json.JSONObject
9
+
10
+ /** RN passes only identity metadata; all signed material and audio remain native. */
11
+ internal class ByoBridgeDriver(private val root: File,private val emit: (String,String)->Unit) {
12
+ private val lock=Any()
13
+ private val tasks=mutableMapOf<String,Job>()
14
+ private val cancelled=mutableSetOf<String>()
15
+ private var stopped=false
16
+ private data class Pending(val owner: String,val value: CompletableDeferred<String>)
17
+ private val pending=mutableMapOf<String,Pending>()
18
+ private fun check(id: String)=synchronized(lock) {demand(!stopped && id !in cancelled && tasks.containsKey(id),"CANCELLED")}
19
+ suspend fun sync(json: String): String=coroutineScope {
20
+ demand(json.toByteArray().size<=8192,"INVALID_INPUT")
21
+ val input=JSONObject(json);val id=input.getString("operationId")
22
+ val job=currentCoroutineContext()[Job]!!
23
+ synchronized(lock) {
24
+ demand(id.startsWith("byo-") && id.length<=64 && !stopped && id !in cancelled && !tasks.containsKey(id),"INVALID_INPUT")
25
+ tasks[id]=job
26
+ }
27
+ try {
28
+ val value=input.getJSONObject("device");val scope=input.getJSONObject("scope");val recording=input.optJSONObject("recording")
29
+ val type=when(value.getString("deviceType")) {"bota_pin"->DeviceType.BotaPin;"bota_pin_4g"->DeviceType.BotaPin4G;"bota_note"->DeviceType.BotaNote;else->fail("INVALID_INPUT")}
30
+ demand(value.getString("connectionState")=="connected" && value.getInt("mtu") in 23..517,"INVALID_INPUT")
31
+ val device=ConnectedDevice(value.getString("id"),value.getString("serialNumber"),type,value.getString("firmwareVersion"),
32
+ if(value.isNull("hardwareRevision")) null else value.optString("hardwareRevision"),
33
+ value.getBoolean("isProvisioned"),ConnectionState.Connected,value.getInt("mtu"))
34
+ val binding=scope.getLong("bindingGeneration");val generation=recording?.getLong("generation") ?: 0
35
+ demand(binding in 1..0xffffffffL && generation in 0..0xffffffffL,"INVALID_INPUT")
36
+ val options=ByoStorageOptions(scope.getString("baseUrl"),scope.getString("scopeKey"),scope.getString("organizationId"),
37
+ scope.getString("projectId"),scope.getString("deviceId"),scope.getString("environment"),binding.toUInt(),root,
38
+ {token(id)},{currentCoroutineContext().ensureActive();check(id)})
39
+ if(input.getString("mode")=="list") {
40
+ val values=BotaDeviceClient.shared.recordings.listByoRecordings(device,options);check(id)
41
+ org.json.JSONArray(values.map {JSONObject().put("uuid",it.uuid).put("generation",it.generation.toLong())}).toString()
42
+ } else {
43
+ demand(input.getString("mode")=="stream" || (input.getString("mode")=="sync" && recording!=null),"INVALID_INPUT")
44
+ val selected=recording?.let {ByoRecording(it.getString("uuid"),generation.toUInt())}
45
+ val result=if(input.getString("mode")=="stream") BotaDeviceClient.shared.recordings.streamByoRecording(device,selected,options)
46
+ else BotaDeviceClient.shared.recordings.syncByoRecording(device,requireNotNull(selected),options)
47
+ check(id)
48
+ JSONObject().put("recordingId",result.recordingId).put("uploadSessionId",result.uploadSessionId).toString()
49
+ }
50
+ } finally {
51
+ synchronized(lock) {tasks.remove(id);cancelled.remove(id)
52
+ pending.filterValues {it.owner==id}.keys.toList().forEach {pending.remove(it)?.value?.cancel()}}
53
+ }
54
+ }
55
+ private suspend fun token(id: String): String {
56
+ check(id);val request=UUID.randomUUID().toString();val value=CompletableDeferred<String>()
57
+ synchronized(lock) {check(id);pending[request]=Pending(id,value)}
58
+ try {emit(request,id);return withTimeout(30_000) {value.await()}.also {check(id)}}
59
+ finally {synchronized(lock) {pending.remove(request)}}
60
+ }
61
+ fun resolve(request: String,token: String) {
62
+ val entry=synchronized(lock) {pending.remove(request)} ?: return
63
+ if(token.isNotEmpty() && token.length<=16384 && token.all {it.code in 33..126}) entry.value.complete(token)
64
+ else entry.value.completeExceptionally(UploadFailure("BOTA_UPLOAD_CREDENTIALS"))
65
+ }
66
+ fun reject(request: String) {synchronized(lock) {pending.remove(request)}?.value?.completeExceptionally(UploadFailure("BOTA_UPLOAD_CREDENTIALS"))}
67
+ suspend fun cancel(id: String) {
68
+ val task=synchronized(lock) {
69
+ if(!id.startsWith("byo-") || id.length>64) return
70
+ if(cancelled.size>=128) stopped=true else cancelled.add(id)
71
+ tasks[id]
72
+ }
73
+ task?.cancelAndJoin()
74
+ }
75
+ suspend fun stop() {
76
+ val current=synchronized(lock) {stopped=true;tasks.values.toList()}
77
+ current.forEach {it.cancel()};current.forEach {it.join()}
78
+ }
79
+ }
@@ -257,13 +257,29 @@ internal class NativeUpload(
257
257
  else -> fail("INVALID_DOCUMENT")
258
258
  }
259
259
  timestamp(selected.string("expires_at"))
260
- val authorization = opaque(selected.string("authorization_base64"), 408, selected.string("authorization_sha256"))
260
+ var authorization = opaque(selected.string("authorization_base64"), 408, selected.string("authorization_sha256"))
261
+ if (input.recording.markersRequired) {
262
+ val path = sessionPath() + "/markers/authorization"
263
+ val admission = try {
264
+ retry { request(path + "?owner_revision=" + pointer!!.ownerRevision) }
265
+ } catch (error: UploadFailure) {
266
+ if (error.code != "BOTA_UPLOAD_HTTP_404" || selected.optString("state") !in setOf("", "created", "staging", "staged")) throw error
267
+ retry { request(path, JSONObject().put("owner_revision", pointer!!.ownerRevision)
268
+ .put("request_uuid", pointer!!.sessionId)) }
269
+ }
270
+ demand(admission.string("profile") == "recording-markers/1", "INVALID_DOCUMENT")
271
+ authorization += opaque(admission.string("context_base64"), 176)
272
+ authorization += opaque(admission.string("authorization_base64"), 280, admission.string("authorization_sha256"))
273
+ }
261
274
  val material = EncryptedUploadV2Material(
262
275
  materialId = UUID.randomUUID().toString(), recordingId = input.recording.uuid,
263
276
  uploadSessionId = UUID.fromString(pointer!!.sessionId), ownerRevision = pointer!!.ownerRevision!!.toUInt(),
264
277
  policy = nativePolicy, authorization = authorization,
265
278
  stagingRequest = { evidence -> callbacks.withLock { staging(evidence) } },
266
279
  submitManifest = { bytes, evidence -> callbacks.withLock { manifest(bytes, evidence) } },
280
+ submitMarkers = if (input.recording.markersRequired) { documents, evidence ->
281
+ callbacks.withLock { markers(documents, evidence) }
282
+ } else null,
267
283
  finalize = { evidence -> callbacks.withLock { finalize(evidence) } },
268
284
  completionReceipt = { evidence -> callbacks.withLock {
269
285
  verifyEvidence(evidence)
@@ -349,6 +365,15 @@ internal class NativeUpload(
349
365
  uploadCiphertext = false
350
366
  sendManifest = false
351
367
  }
368
+ private suspend fun markers(documents: List<ByteArray>, evidence: EncryptedUploadV2TransferEvidence) {
369
+ verifyEvidence(evidence)
370
+ demand(input.recording.markersRequired && documents.size in 2..4097 && documents[0].size == 200 &&
371
+ documents.drop(1).all { it.size in 216..402 }, "INVALID_DOCUMENT")
372
+ val pages = org.json.JSONArray()
373
+ documents.drop(1).forEach { pages.put(encoded(it)) }
374
+ retry { request(sessionPath() + "/markers/batch", JSONObject().put("owner_revision", pointer!!.ownerRevision)
375
+ .put("seal_base64", encoded(documents[0])).put("pages_base64", pages)) }
376
+ }
352
377
  private suspend fun finalize(evidence: EncryptedUploadV2TransferEvidence) {
353
378
  verifyEvidence(evidence)
354
379
  for (poll in 0 until 60) {
@@ -356,7 +381,10 @@ internal class NativeUpload(
356
381
  applyStatus(status)
357
382
  if (status.string("state") == "published") {
358
383
  demand(status.integer("plaintext_length") == input.recording.plaintextLength, "INVALID_DOCUMENT")
359
- receipt = opaque(status.string("completion_receipt_base64"), 336, status.string("completion_receipt_sha256"))
384
+ val audio = opaque(status.string("completion_receipt_base64"), 336, status.string("completion_receipt_sha256"))
385
+ receipt = if (input.recording.markersRequired) audio + opaque(
386
+ (status.opt("marker_completion_receipt_base64") as? String ?: fail("INVALID_DOCUMENT")), 296,
387
+ (status.opt("marker_completion_receipt_sha256") as? String ?: fail("INVALID_DOCUMENT"))) else audio
360
388
  return
361
389
  }
362
390
  wait(2000, operation)
@@ -78,15 +78,18 @@ internal data class UploadScope(
78
78
  internal data class UploadRecording(
79
79
  val uuid: String, val generation: Long, val ciphertextLength: Long, val ciphertextSha256: String,
80
80
  val startedAtMs: Long, val durationMs: Long, val plaintextLength: Long, val storageFormat: Long,
81
+ val markersRequired: Boolean = false,
81
82
  ) {
82
83
  fun identity(): List<Any> = listOf(uuid, generation, ciphertextLength.toString(), ciphertextSha256,
83
- startedAtMs.toString(), durationMs.toString(), plaintextLength.toString(), storageFormat)
84
+ startedAtMs.toString(), durationMs.toString(), plaintextLength.toString(), storageFormat) +
85
+ if (markersRequired) listOf(true) else emptyList()
84
86
 
85
87
  companion object {
86
88
  fun parse(json: JSONObject): UploadRecording = UploadRecording(uuid(json.string("uuid")),
87
89
  json.integer("generation", 1, 4294967295), json.decimal("ciphertextLength", 1),
88
90
  hashString(json.string("ciphertextSha256")), json.decimal("startedAtMs"), json.decimal("durationMs"),
89
- json.decimal("plaintextLength"), json.integer("storageFormat", 3, 3)).also {
91
+ json.decimal("plaintextLength"), json.integer("storageFormat", 3, 3),
92
+ if (json.has("markersRequired")) (json.get("markersRequired") as? Boolean ?: fail("INVALID_DOCUMENT")) else false).also {
90
93
  demand(it.startedAtMs + it.durationMs <= 8640000000000000L)
91
94
  }
92
95
  }
@@ -0,0 +1,67 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import java.io.IOException
4
+ import java.util.concurrent.TimeUnit
5
+ import kotlin.coroutines.resume
6
+ import kotlin.coroutines.resumeWithException
7
+ import kotlinx.coroutines.suspendCancellableCoroutine
8
+ import okhttp3.Call
9
+ import okhttp3.Callback
10
+ import okhttp3.OkHttpClient
11
+ import okhttp3.Request
12
+ import okhttp3.Response
13
+ import org.json.JSONObject
14
+
15
+ /** Read-only native HTTP boundary. Never sends opaque server fields over RN. */
16
+ internal class UploadStreamingStatus(private val client: OkHttpClient = OkHttpClient.Builder()
17
+ .followRedirects(false).followSslRedirects(false).retryOnConnectionFailure(false)
18
+ .connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).callTimeout(45, TimeUnit.SECONDS).build()) {
19
+ suspend fun read(input: String): String {
20
+ val json = sanitized("INVALID_INPUT") { JSONObject(input) }
21
+ val url = json.optString("url")
22
+ val token = json.optString("token")
23
+ val org = json.optString("organizationId")
24
+ demand(url.matches(Regex("https://[A-Za-z0-9.-]+(?::[0-9]+)?(?:/[A-Za-z0-9_-]+)+/recordings/rec_[A-Za-z0-9_-]+/streaming-status\\?session_id=[0-9a-f-]{36}")), "INVALID_INPUT")
25
+ demand(token.length in 1..16384 && token.all { it.code in 33..126 } &&
26
+ (org.isEmpty() || org.matches(Regex("[A-Za-z0-9_-]{1,128}"))), "INVALID_INPUT")
27
+ val request = Request.Builder().url(url).get().header("Authorization", "Bearer $token")
28
+ .header("Accept", "application/json").apply { if (org.isNotEmpty()) header("X-Organization-Id", org) }.build()
29
+ val call = client.newCall(request)
30
+ return suspendCancellableCoroutine { continuation ->
31
+ continuation.invokeOnCancellation { call.cancel() }
32
+ call.enqueue(object : Callback {
33
+ override fun onFailure(call: Call, e: IOException) {
34
+ if (continuation.isActive) continuation.resumeWithException(UploadFailure("BOTA_UPLOAD_TRANSPORT"))
35
+ }
36
+ override fun onResponse(call: Call, response: Response) {
37
+ val result = runCatching {
38
+ response.use {
39
+ demand(response.code == 200 && response.request.url == request.url, "HTTP_${response.code}")
40
+ val body = response.body ?: fail("INVALID_DOCUMENT")
41
+ demand(body.contentLength() <= 16384, "INVALID_DOCUMENT")
42
+ val source = body.source(); source.request(16385)
43
+ demand(source.buffer.size <= 16384, "INVALID_DOCUMENT")
44
+ scalarStatus(source.readUtf8())
45
+ }
46
+ }
47
+ if (continuation.isActive) result.fold({ continuation.resume(it) },
48
+ { continuation.resumeWithException(if (it is UploadFailure) it else UploadFailure("BOTA_UPLOAD_INVALID_DOCUMENT")) })
49
+ }
50
+ })
51
+ }
52
+ }
53
+
54
+ companion object {
55
+ private val stringFields = setOf("profile", "recording_id", "session_id", "writer_epoch", "revision", "state")
56
+ private val numberFields = setOf("recording_generation", "received_count", "contiguous_sequence")
57
+ fun scalarStatus(body: String): String {
58
+ val json = sanitized("INVALID_DOCUMENT") { JSONObject(body) }
59
+ demand(json.keys().asSequence().toSet() == stringFields + numberFields + setOf("expected_count", "authorization_expired"), "INVALID_DOCUMENT")
60
+ stringFields.forEach { demand(json.get(it) is String && (json.get(it) as String).length <= 160, "INVALID_DOCUMENT") }
61
+ numberFields.forEach { demand(json.get(it) is Number, "INVALID_DOCUMENT") }
62
+ demand(json.isNull("expected_count") || json.get("expected_count") is Number, "INVALID_DOCUMENT")
63
+ demand(json.get("authorization_expired") is Boolean, "INVALID_DOCUMENT")
64
+ return json.toString()
65
+ }
66
+ }
67
+ }
@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
8
8
  import okhttp3.Protocol
9
9
  import okhttp3.Response
10
10
  import okhttp3.ResponseBody.Companion.toResponseBody
11
+ import org.json.JSONArray
11
12
  import org.junit.Assert.assertEquals
12
13
  import org.junit.Assert.assertFalse
13
14
  import org.junit.Assert.assertTrue
@@ -96,6 +97,26 @@ class BotaDeviceSDKAndroidRecordingUploadsTest {
96
97
  assertEquals("failed", tasks.getJSONObject(0).getString("status"))
97
98
  }
98
99
 
100
+ @Test
101
+ fun completionPendingPersistsWithoutAuthorizingRelease() = runTest {
102
+ val fixture = UploadFixture()
103
+ var barriers = 0
104
+ val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile, syncDirectory = { barriers++ })
105
+ // This metadata test injects the directory barrier so it also runs on
106
+ // Windows; separate durability tests exercise the real POSIX barrier.
107
+ val journal = fixture.journal("pending").replace(fixture.recordingFile.path, fixture.recordingFile.path.replace("\\", "\\\\"))
108
+ uploads.saveQueue(journal.replace("\"status\":\"pending\"", "\"status\":\"pending\",\"completionPending\":true"))
109
+ val stored = JSONArray(uploads.loadQueue()).getJSONObject(0)
110
+ assertTrue(stored.getBoolean("completionPending"))
111
+ assertTrue(barriers > 0)
112
+ assertTrue(runCatching { uploads.release("task-1", fixture.recordingFile.path) }.isFailure)
113
+ assertTrue(fixture.recordingFile.exists())
114
+ for (invalid in listOf("\"true\"", "1")) {
115
+ assertTrue(runCatching { uploads.saveQueue(journal.replace("\"status\":\"pending\"", "\"status\":\"pending\",\"completionPending\":$invalid")) }.isFailure)
116
+ assertTrue(JSONArray(uploads.loadQueue()).getJSONObject(0).getBoolean("completionPending"))
117
+ }
118
+ }
119
+
99
120
  @Test
100
121
  fun releaseRequiresExactCompletedJournalAndIsIdempotent() = runTest {
101
122
  val fixture = UploadFixture()
@@ -140,6 +161,8 @@ class BotaDeviceSDKAndroidRecordingUploadsTest {
140
161
  "\"createdAt\":\"2026-09-24T00:00:00Z\"" to "\"createdAt\":\"invalid\"",
141
162
  "\"status\":\"completed\"" to "\"status\":\"completed\",\"fileSizeBytes\":\"4\"",
142
163
  "\"status\":\"completed\"" to "\"status\":\"completed\",\"recoveryScope\":123",
164
+ "\"status\":\"completed\"" to "\"status\":\"completed\",\"completionPending\":\"true\"",
165
+ "\"status\":\"completed\"" to "\"status\":\"completed\",\"completionPending\":1",
143
166
  )) {
144
167
  val fixture = UploadFixture()
145
168
  val contents = fixture.journal("completed").replace(old, invalid)