@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.
- package/README.md +49 -7
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +2 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +3 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +37 -3
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/ByoBridgeDriver.kt +79 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +30 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +5 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadStreamingStatus.kt +67 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +23 -0
- package/generated/codegen-contract.json +110 -8
- package/ios/BotaDeviceSDK.mm +1 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +4 -2
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +3 -3
- package/ios/BotaDeviceSDKAppleRecordings.swift +1 -0
- package/ios/BotaUploadV2Backend.mm +21 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +52 -2
- package/ios/UploadBackend/ByoBridgeDriver.swift +94 -0
- package/ios/UploadBackend/UploadModels.swift +6 -1
- package/ios/UploadBackend/UploadOperation.swift +41 -3
- package/ios/UploadBackend/UploadStreamingStatus.swift +43 -0
- package/lib/commonjs/byoStorageBackend.js +144 -0
- package/lib/commonjs/byoStorageBackend.js.map +1 -0
- package/lib/commonjs/client.js +2 -1
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/encryptedUploadV2Backend.js +4 -1
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -1
- package/lib/commonjs/index.js +7 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +6 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +22 -8
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/commonjs/managers/uploadVerificationPending.js +20 -0
- package/lib/commonjs/managers/uploadVerificationPending.js.map +1 -0
- package/lib/commonjs/protectedStreamingStatus.js +120 -0
- package/lib/commonjs/protectedStreamingStatus.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -1
- package/lib/commonjs/uploadRecovery.js +50 -7
- package/lib/commonjs/uploadRecovery.js.map +1 -1
- package/lib/module/byoStorageBackend.js +140 -0
- package/lib/module/byoStorageBackend.js.map +1 -0
- package/lib/module/client.js +2 -1
- package/lib/module/client.js.map +1 -1
- package/lib/module/encryptedUploadV2Backend.js +4 -1
- package/lib/module/encryptedUploadV2Backend.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +6 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +22 -8
- package/lib/module/managers/NativeUploadQueue.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/module/managers/uploadVerificationPending.js +14 -0
- package/lib/module/managers/uploadVerificationPending.js.map +1 -0
- package/lib/module/protectedStreamingStatus.js +116 -0
- package/lib/module/protectedStreamingStatus.js.map +1 -0
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -1
- package/lib/module/uploadRecovery.js +50 -7
- package/lib/module/uploadRecovery.js.map +1 -1
- package/lib/typescript/byoStorageBackend.d.ts +36 -0
- package/lib/typescript/byoStorageBackend.d.ts.map +1 -0
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +2 -0
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -1
- package/lib/typescript/managers/uploadVerificationPending.d.ts +6 -0
- package/lib/typescript/managers/uploadVerificationPending.d.ts.map +1 -0
- package/lib/typescript/models/Recording.d.ts +6 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/protectedStreamingStatus.d.ts +37 -0
- package/lib/typescript/protectedStreamingStatus.d.ts.map +1 -0
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +4 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -1
- package/lib/typescript/uploadRecovery.d.ts +1 -1
- package/lib/typescript/uploadRecovery.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/byoStorageBackend.ts +104 -0
- package/src/client.ts +2 -0
- package/src/encryptedUploadV2Backend.ts +2 -1
- package/src/index.ts +11 -0
- package/src/managers/DeviceManager.ts +6 -0
- package/src/managers/NativeUploadQueue.ts +18 -9
- package/src/managers/uploadRecoveryMetadata.ts +2 -0
- package/src/managers/uploadVerificationPending.ts +10 -0
- package/src/models/Recording.ts +6 -0
- package/src/protectedStreamingStatus.ts +137 -0
- package/src/sdkIdentity.ts +1 -1
- package/src/specs/NativeBotaDeviceSDK.ts +1 -0
- package/src/specs/NativeBotaUploadV2Backend.ts +4 -0
- 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.
|
|
4
|
-
It includes
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|
package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt
CHANGED
|
@@ -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)
|