@bota.dev/react-native-app-sdk 2.0.0-beta.10 → 2.0.0-beta.12

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 (41) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  7. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  8. package/generated/codegen-contract.json +167 -8
  9. package/ios/BotaUploadV2Backend.mm +49 -0
  10. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  11. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  12. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  13. package/ios/UploadBackend/UploadJournal.swift +187 -0
  14. package/ios/UploadBackend/UploadModels.swift +273 -0
  15. package/ios/UploadBackend/UploadOperation.swift +391 -0
  16. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  17. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  18. package/lib/commonjs/index.js +5 -0
  19. package/lib/commonjs/index.js.map +1 -1
  20. package/lib/commonjs/sdkIdentity.js +1 -1
  21. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  22. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  23. package/lib/module/encryptedUploadV2Backend.js +196 -0
  24. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  25. package/lib/module/index.js +3 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/sdkIdentity.js +1 -1
  28. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  29. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  30. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  31. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  32. package/lib/typescript/index.d.ts +3 -0
  33. package/lib/typescript/index.d.ts.map +1 -1
  34. package/lib/typescript/sdkIdentity.d.ts +1 -1
  35. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  36. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  37. package/package.json +1 -1
  38. package/src/encryptedUploadV2Backend.ts +171 -0
  39. package/src/index.ts +6 -0
  40. package/src/sdkIdentity.ts +1 -1
  41. package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Bota SDK for React Native
2
2
 
3
+ Source candidate `2.0.0-beta.12` keeps the five-platform version synchronized.
4
+ Android repairs completed encrypted-v2 resume and failed connect/MTU cleanup;
5
+ Apple and Web runtime behavior is unchanged. Beta.11 remains the published
6
+ baseline until release gates pass. Native applications require a new binary.
7
+
3
8
  Bluetooth device SDK for Bota Pin and Bota Note applications. The 2.x candidate
4
9
  preserves the public TypeScript API of `@bota.dev/react-native-sdk@0.0.65`
5
10
  while moving Bluetooth workflows and recording, streaming, and firmware bytes
@@ -23,19 +28,17 @@ adopting this source API; published-package availability is a separate gate.
23
28
 
24
29
  ## Install
25
30
 
26
- Published beta.9 remains available. Install this beta.10 source candidate with
27
- the exact pin below only after publication is verified. Beta.10 retires the
28
- exact Android connection when explicit disconnect times out or is cancelled
29
- without a native callback, preserving replacement sessions. Reconnect explicitly.
30
- The [preflight](../../release/evidence/2.0.0-beta.10-preflight.md) separates
31
- automated cleanup regressions from publication and physical acceptance.
32
- The intermittent GATT 8/133 reconnect issue remains unverified.
31
+ Published beta.11 remains available. Use the beta.12 pin below only after
32
+ publication is verified; see the
33
+ [preflight](../../release/evidence/2.0.0-beta.12-preflight.md).
34
+ Rebuild native applications and reconnect explicitly after connection loss.
35
+ Physical completed-resume acceptance and wider GATT recovery remain separate.
33
36
  Remove `@bota.dev/react-native-sdk` before adding the replacement; do not
34
37
  co-install both. Standalone maintenance ended September 29, 2026; follow the
35
38
  [retirement and migration notice](../../docs/migrations/react-native-sdk-sunset.md).
36
39
 
37
40
  ```bash
38
- npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.10
41
+ npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.12
39
42
  npx pod-install
40
43
  ```
41
44
 
@@ -88,6 +91,13 @@ boundary; missing or malformed advertised values become `null`. The peripheral
88
91
 
89
92
  ## Encrypted upload v2
90
93
 
94
+ Current source includes `createEncryptedUploadV2Backend` for iOS/Android. Supply
95
+ your authenticated backend URL, fresh-token callback and scoped identity; the
96
+ SDK owns native session journaling, manifest replay, verification polling and
97
+ signed-receipt delivery. See the [managed integration](../../docs/parity/v2-managed-backend.md)
98
+ for the example, backend routes, restart trigger and release limits. Beta.10
99
+ does not contain this helper. The low-level provider below remains supported.
100
+
91
101
  The target encrypted-upload-v2 runtime is an additive `BotaDeviceSDK` API; it
92
102
  does not change the legacy `BotaClient` recording providers or events:
93
103
 
@@ -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
+ }
@@ -0,0 +1,400 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import dev.bota.sdk.EncryptedUploadV2ContextExchange
4
+ import dev.bota.sdk.EncryptedUploadV2Material
5
+ import dev.bota.sdk.EncryptedUploadV2SecurityPolicy
6
+ import dev.bota.sdk.EncryptedUploadV2TransferEvidence
7
+ import dev.bota.sdk.reactnative.BotaDeviceSDKEncryptedUploadV2Materials
8
+ import java.time.Instant
9
+ import java.util.UUID
10
+ import kotlinx.coroutines.NonCancellable
11
+ import kotlinx.coroutines.delay
12
+ import kotlinx.coroutines.sync.Mutex
13
+ import kotlinx.coroutines.sync.withLock
14
+ import kotlinx.coroutines.withContext
15
+ import okhttp3.HttpUrl.Companion.toHttpUrl
16
+ import okhttp3.Request
17
+ import okhttp3.RequestBody.Companion.toRequestBody
18
+ import org.json.JSONObject
19
+
20
+ internal class NativeUpload(
21
+ private val journal: UploadJournal,
22
+ private val http: UploadHttp,
23
+ private val credentials: suspend (UploadOperation) -> String,
24
+ private val readNonce: suspend (String) -> ByteArray,
25
+ private val now: () -> Long = System::currentTimeMillis,
26
+ private val wait: suspend (Long, UploadOperation) -> Unit = { milliseconds, op ->
27
+ op.check(); delay(milliseconds); op.check()
28
+ },
29
+ ) {
30
+ private val operations = mutableMapOf<String, Attempt>()
31
+ private val usedOperations = mutableSetOf<String>()
32
+ private val journalOwners = mutableSetOf<String>()
33
+
34
+ suspend fun prepare(text: String): Map<String, Any> {
35
+ val input = UploadInput.parse(text)
36
+ val attempt = synchronized(this) {
37
+ demand(input.operationId !in usedOperations && input.journalKey !in journalOwners, "BUSY")
38
+ usedOperations.add(input.operationId)
39
+ journalOwners.add(input.journalKey)
40
+ Attempt(UploadOperation(input)).also { operations[input.operationId] = it }
41
+ }
42
+ try {
43
+ return attempt.prepare()
44
+ } catch (error: Exception) {
45
+ cancel(input.operationId)
46
+ synchronized(this) { journalOwners.remove(input.journalKey); operations.remove(input.operationId) }
47
+ if (error is UploadFailure) throw error
48
+ fail("NATIVE")
49
+ } finally {
50
+ attempt.preparing = false
51
+ if (attempt.cancelled) synchronized(this) {
52
+ journalOwners.remove(input.journalKey)
53
+ operations.remove(input.operationId)
54
+ }
55
+ }
56
+ }
57
+
58
+ fun cancel(operationId: String) {
59
+ val attempt = synchronized(this) {
60
+ usedOperations.add(operationId)
61
+ operations[operationId]
62
+ } ?: return
63
+ attempt.cancelled = true
64
+ attempt.operation.cancel()
65
+ synchronized(attempt.operation) {
66
+ attempt.operation.registrationId?.let { BotaDeviceSDKEncryptedUploadV2Materials.remove(it) }
67
+ attempt.operation.registrationId = null
68
+ }
69
+ if (!attempt.preparing) synchronized(this) {
70
+ journalOwners.remove(attempt.input.journalKey)
71
+ operations.remove(operationId)
72
+ }
73
+ }
74
+
75
+ fun cancelAll() { synchronized(this) { operations.keys.toList() }.forEach(::cancel) }
76
+
77
+ suspend fun complete(operationId: String) {
78
+ val attempt = synchronized(this) { operations[operationId] } ?: fail("NOT_COMPLETE")
79
+ attempt.callbacks.withLock {
80
+ attempt.operation.active {
81
+ // Only the parent calls complete after the SDK's successful receipt/CONFIRM result.
82
+ demand(attempt.receiptDelivered && !attempt.preparing, "NOT_COMPLETE")
83
+ journal.remove(attempt.input, attempt.pointer ?: fail("NOT_COMPLETE"))
84
+ }
85
+ cancel(operationId)
86
+ }
87
+ }
88
+
89
+ private inner class Attempt(val operation: UploadOperation) {
90
+ val input = operation.input
91
+ val callbacks = Mutex()
92
+ @Volatile var preparing = true
93
+ @Volatile var cancelled = false
94
+ var pointer: UploadPointer? = null
95
+ var receiptDelivered = false
96
+ private var session: JSONObject? = null
97
+ private var uploadCiphertext = true
98
+ private var sendManifest = true
99
+ private var receipt: ByteArray? = null
100
+ private var importedPriorSha256: String? = null
101
+ private var authorizationNonceSha256: String? = null
102
+ private var recoveryNonceBase64: String? = null
103
+ private val base = input.scope.apiBasePath
104
+ private val contexts = "$base/devices/${input.scope.deviceId}/encrypted-upload-v2/contexts"
105
+ private fun sessions() = "$base/recordings/${pointer!!.recordingId}/encrypted-upload-v2/sessions"
106
+ private fun sessionPath() = "${sessions()}/${pointer!!.sessionId}"
107
+
108
+ private fun save(value: UploadPointer, nonceHash: String? = authorizationNonceSha256, recoveryNonce: String? = null) {
109
+ journal.save(input, JournalEntry(input.identitySha256, value, importedPriorSha256 = importedPriorSha256,
110
+ authorizationNonceSha256 = nonceHash, recoveryNonceBase64 = recoveryNonce))
111
+ pointer = value
112
+ authorizationNonceSha256 = nonceHash
113
+ recoveryNonceBase64 = recoveryNonce
114
+ }
115
+
116
+ private suspend fun request(path: String, body: JSONObject? = null, retain: Boolean = false,
117
+ beforeDispatch: () -> Unit = {}): JSONObject {
118
+ operation.check()
119
+ val token = credentials(operation)
120
+ operation.check()
121
+ val headers = mutableMapOf("Authorization" to "Bearer $token",
122
+ "Accept" to "application/json", "Content-Type" to "application/json")
123
+ if (input.scope.organizationId.isNotEmpty()) headers["X-Organization-Id"] = input.scope.organizationId
124
+ val result = http.execute(UploadRequest(input.scope.apiOrigin, path, if (body == null) "GET" else "POST", headers, body),
125
+ operation, retain, beforeDispatch)
126
+ if (!retain) operation.check()
127
+ return result
128
+ }
129
+
130
+ private suspend fun createRequest(path: String, body: JSONObject, phase: String): JSONObject {
131
+ val previous = pointer?.let { JournalEntry(input.identitySha256, it, importedPriorSha256 = importedPriorSha256,
132
+ authorizationNonceSha256 = authorizationNonceSha256) }
133
+ val marker = JournalEntry(input.identitySha256, pointer, phase, importedPriorSha256, authorizationNonceSha256)
134
+ var marked = false
135
+ try {
136
+ return request(path, body, true) {
137
+ demand(journal.load(input) == previous, "IDENTITY_CONFLICT")
138
+ journal.save(input, marker)
139
+ marked = true
140
+ }
141
+ } catch (error: UploadFailure) {
142
+ if (marked && error.code in setOf("BOTA_UPLOAD_HTTP_401", "BOTA_UPLOAD_HTTP_403")) {
143
+ // A retained rejection settles its originating journal even after cancellation.
144
+ synchronized(this@NativeUpload) {
145
+ demand(preparing && operations[input.operationId] === this && input.journalKey in journalOwners, "IDENTITY_CONFLICT")
146
+ journal.rollbackRejectedCreate(input, marker, previous)
147
+ }
148
+ }
149
+ operation.check()
150
+ throw error
151
+ }
152
+ }
153
+
154
+ private suspend fun <T> retry(block: suspend () -> T): T {
155
+ for (attempt in 0..2) {
156
+ operation.check()
157
+ try { return block().also { operation.check() } }
158
+ catch (error: UploadFailure) {
159
+ operation.check()
160
+ val status = error.code.removePrefix("BOTA_UPLOAD_HTTP_").toIntOrNull()
161
+ if (attempt == 2 || !(error.code == "BOTA_UPLOAD_TRANSPORT" || status == 429 || (status != null && status >= 500))) throw error
162
+ wait(250, operation)
163
+ }
164
+ }
165
+ fail("NATIVE")
166
+ }
167
+
168
+ private suspend fun nonce(): String {
169
+ operation.check()
170
+ val bytes = readNonce(input.operationId)
171
+ operation.check()
172
+ demand(bytes.size == 16, "INVALID_DOCUMENT")
173
+ return encoded(bytes)
174
+ }
175
+
176
+ suspend fun prepare(): Map<String, Any> {
177
+ operation.check()
178
+ val saved = journal.load(input)
179
+ if (saved != null) {
180
+ demand(saved.phase == "ready", "UNKNOWN_OUTCOME")
181
+ pointer = saved.pointer
182
+ importedPriorSha256 = saved.importedPriorSha256
183
+ authorizationNonceSha256 = saved.authorizationNonceSha256
184
+ recoveryNonceBase64 = saved.recoveryNonceBase64
185
+ } else if (input.prior != null) {
186
+ input.validateCheckpoint(input.prior)
187
+ importedPriorSha256 = input.prior.fingerprint()
188
+ operation.active { save(input.prior) }
189
+ }
190
+ input.validateCheckpoint(pointer)
191
+ if (pointer == null) {
192
+ val body = JSONObject().put("device_id", input.scope.deviceId).put("end_user_id", input.scope.endUserId)
193
+ .put("upload_method", "ble").put("streaming", false).put("metadata", JSONObject()
194
+ .put("started_at", Instant.ofEpochMilli(input.recording.startedAtMs).toString())
195
+ .put("ended_at", Instant.ofEpochMilli(input.recording.startedAtMs + input.recording.durationMs).toString()))
196
+ // No automatic retry: an unknown initial outcome must remain durable across process restart.
197
+ withContext(NonCancellable) {
198
+ val response = createRequest("$base/recordings", body, "recording_create_unknown")
199
+ val id = UploadPointer.parse(JSONObject().put("recordingId", response.string("id")))
200
+ save(id)
201
+ }
202
+ operation.check()
203
+ }
204
+ if (pointer!!.sessionId == null) {
205
+ val authNonce = nonce()
206
+ val body = JSONObject().put("device_id", input.scope.deviceId).put("binding_generation", input.scope.bindingGeneration)
207
+ .put("recording_uuid", input.recording.uuid).put("recording_generation", input.recording.generation)
208
+ .put("storage_format", "bota_enc_v2").put("channel", "ble").put("capabilities_base64", encoded(input.capability))
209
+ .put("auth_nonce_base64", authNonce).put("ciphertext_length", input.recording.ciphertextLength)
210
+ .put("ciphertext_sha256", input.recording.ciphertextSha256)
211
+ withContext(NonCancellable) {
212
+ val created = createRequest(sessions(), body, "session_create_unknown")
213
+ save(responsePointer(created), digest(opaque(authNonce, 16)))
214
+ operation.check()
215
+ session = created
216
+ }
217
+ } else {
218
+ val visited = mutableSetOf(pointer!!.sessionId!!)
219
+ var replacements = 0
220
+ while (true) {
221
+ val status = request(sessionPath())
222
+ validateIdentity(status)
223
+ val state = status.string("state")
224
+ val expiry = timestamp(status.string("expires_at"))
225
+ val expired = state == "expired" || (state in setOf("created", "staging", "staged") && expiry <= now())
226
+ val authNonce = recoveryNonceBase64 ?: if (expired || (authorizationNonceSha256 != null && state in setOf("created", "staging", "staged"))) nonce() else null
227
+ val nonceHash = authNonce?.let { digest(opaque(it, 16)) }
228
+ val nonceChanged = authorizationNonceSha256 != null && nonceHash != null && nonceHash != authorizationNonceSha256
229
+ if (recoveryNonceBase64 == null && !expired && !nonceChanged) { applyStatus(status); session = status; break }
230
+ demand(input.flags and 0x37f == 0x37fL, "EXPIRED")
231
+ demand(replacements < 3, "RECOVERY_LIMIT")
232
+ val parent = pointer!!
233
+ val path = sessionPath() + "/recover"
234
+ val body = JSONObject().put("owner_revision", parent.ownerRevision).put("auth_nonce_base64", authNonce)
235
+ .put("capabilities_base64", encoded(input.capability))
236
+ if (!expired) body.put("reason", "nonce_changed")
237
+ // Retrying a lost response must use the nonce of the original request.
238
+ save(parent, recoveryNonce = authNonce)
239
+ retry {
240
+ withContext(NonCancellable) {
241
+ val recovered = responsePointer(request(path, body, true))
242
+ demand(recovered.ownerRevision!! > parent.ownerRevision!! && recovered.sessionId !in visited, "IDENTITY_CONFLICT")
243
+ save(recovered, nonceHash)
244
+ visited.add(recovered.sessionId!!)
245
+ }
246
+ }
247
+ replacements++
248
+ }
249
+ }
250
+ operation.check()
251
+ val selected = session!!
252
+ val policy = selected.string("policy")
253
+ val nativePolicy = when (policy) {
254
+ "legacy_allowed" -> EncryptedUploadV2SecurityPolicy.LegacyAllowed
255
+ "v2_preferred" -> EncryptedUploadV2SecurityPolicy.V2Preferred
256
+ "v2_required" -> EncryptedUploadV2SecurityPolicy.V2Required
257
+ else -> fail("INVALID_DOCUMENT")
258
+ }
259
+ timestamp(selected.string("expires_at"))
260
+ val authorization = opaque(selected.string("authorization_base64"), 408, selected.string("authorization_sha256"))
261
+ val material = EncryptedUploadV2Material(
262
+ materialId = UUID.randomUUID().toString(), recordingId = input.recording.uuid,
263
+ uploadSessionId = UUID.fromString(pointer!!.sessionId), ownerRevision = pointer!!.ownerRevision!!.toUInt(),
264
+ policy = nativePolicy, authorization = authorization,
265
+ stagingRequest = { evidence -> callbacks.withLock { staging(evidence) } },
266
+ submitManifest = { bytes, evidence -> callbacks.withLock { manifest(bytes, evidence) } },
267
+ finalize = { evidence -> callbacks.withLock { finalize(evidence) } },
268
+ completionReceipt = { evidence -> callbacks.withLock {
269
+ verifyEvidence(evidence)
270
+ val bytes = receipt ?: fail("NOT_COMPLETE")
271
+ receiptDelivered = true
272
+ bytes.copyOf()
273
+ } },
274
+ cancel = { this@NativeUpload.cancel(input.operationId) },
275
+ uploadContext = { nonce -> context(nonce) },
276
+ shouldUploadCiphertext = { evidence -> callbacks.withLock { verifyEvidence(evidence); uploadCiphertext } },
277
+ reconcileStaging = { bytes, evidence -> callbacks.withLock { reconcileStaging(bytes, evidence) } },
278
+ )
279
+ return operation.active {
280
+ val registration = BotaDeviceSDKEncryptedUploadV2Materials.register(material)
281
+ operation.registrationId = registration
282
+ mapOf("profile" to "encrypted_upload_v2", "uploadSessionId" to pointer!!.sessionId!!,
283
+ "ownerRevision" to pointer!!.ownerRevision!!, "securityPolicy" to policy,
284
+ "materialRegistrationId" to registration, "recordingId" to pointer!!.recordingId)
285
+ }
286
+ }
287
+
288
+ private fun responsePointer(value: JSONObject): UploadPointer {
289
+ demand(value.string("profile") == "encrypted_upload_v2", "IDENTITY_CONFLICT")
290
+ return UploadPointer.parse(JSONObject().put("recordingId", pointer!!.recordingId)
291
+ .put("sessionId", value.string("session_id")).put("ownerRevision", value.integer("owner_revision", 1, Int.MAX_VALUE.toLong())))
292
+ }
293
+ private fun validateIdentity(value: JSONObject) {
294
+ demand(responsePointer(value) == pointer && value.string("channel") == "ble" &&
295
+ value.integer("ciphertext_length", 1) == input.recording.ciphertextLength &&
296
+ value.string("ciphertext_sha256") == input.recording.ciphertextSha256, "IDENTITY_CONFLICT")
297
+ }
298
+ private fun applyStatus(value: JSONObject) {
299
+ validateIdentity(value)
300
+ when (value.string("state")) {
301
+ "staging" -> { uploadCiphertext = true; sendManifest = true }
302
+ "staged" -> { uploadCiphertext = false; sendManifest = true }
303
+ "ready", "processing", "published" -> { uploadCiphertext = false; sendManifest = false }
304
+ else -> fail("STATE")
305
+ }
306
+ }
307
+ private fun verifyEvidence(evidence: EncryptedUploadV2TransferEvidence) {
308
+ operation.check()
309
+ demand(evidence.ciphertextLength == input.recording.ciphertextLength.toULong() &&
310
+ evidence.ciphertextSha256.contentEquals(hex(input.recording.ciphertextSha256)), "INVALID_DOCUMENT")
311
+ }
312
+ private suspend fun reconcileStaging(bytes: ByteArray, evidence: EncryptedUploadV2TransferEvidence): Boolean {
313
+ verifyEvidence(evidence)
314
+ if (!uploadCiphertext) return false
315
+ return try { retry { manifest(bytes, evidence) }; false }
316
+ catch (error: UploadFailure) {
317
+ if (error.code == "BOTA_UPLOAD_STAGING_MISSING") true else throw error
318
+ }
319
+ }
320
+ private suspend fun staging(evidence: EncryptedUploadV2TransferEvidence): Request {
321
+ verifyEvidence(evidence)
322
+ demand(uploadCiphertext, "STATE")
323
+ demand(timestamp(session!!.string("expires_at")) > now(), "EXPIRED")
324
+ val target = request(sessionPath() + "/staging-url", JSONObject().put("owner_revision", pointer!!.ownerRevision))
325
+ operation.check()
326
+ demand(timestamp(session!!.string("expires_at")) > now(), "EXPIRED")
327
+ return sanitized("INVALID_DOCUMENT") {
328
+ demand(target.string("method") == "PUT", "INVALID_DOCUMENT")
329
+ val url = target.string("url").toHttpUrl()
330
+ demand(url.isHttps && url.username.isEmpty() && url.password.isEmpty() && url.fragment == null, "INVALID_DOCUMENT")
331
+ val headers = target.getJSONObject("headers")
332
+ val builder = Request.Builder().url(url)
333
+ headers.keys().forEach { key ->
334
+ val value = headers.opt(key) as? String ?: fail("INVALID_DOCUMENT")
335
+ demand(!key.equals("Authorization", true) && !key.equals("Cookie", true), "INVALID_DOCUMENT")
336
+ builder.header(key, value)
337
+ }
338
+ // SDK replaces this bodyless template with its verified native ciphertext source.
339
+ builder.put(ByteArray(0).toRequestBody(null)).build()
340
+ }
341
+ }
342
+ private suspend fun manifest(bytes: ByteArray, evidence: EncryptedUploadV2TransferEvidence) {
343
+ verifyEvidence(evidence)
344
+ demand(bytes.size == 580 && evidence.manifestLength.toInt() == 580 &&
345
+ digest(bytes) == evidence.manifestSha256.joinToString("") { "%02x".format(it.toInt() and 255) }, "INVALID_DOCUMENT")
346
+ if (!sendManifest) return
347
+ request(sessionPath() + "/manifest", JSONObject().put("owner_revision", pointer!!.ownerRevision)
348
+ .put("manifest_base64", encoded(bytes)).put("manifest_sha256", digest(bytes)))
349
+ uploadCiphertext = false
350
+ sendManifest = false
351
+ }
352
+ private suspend fun finalize(evidence: EncryptedUploadV2TransferEvidence) {
353
+ verifyEvidence(evidence)
354
+ for (poll in 0 until 60) {
355
+ val status = retry { request(sessionPath()) }
356
+ applyStatus(status)
357
+ if (status.string("state") == "published") {
358
+ demand(status.integer("plaintext_length") == input.recording.plaintextLength, "INVALID_DOCUMENT")
359
+ receipt = opaque(status.string("completion_receipt_base64"), 336, status.string("completion_receipt_sha256"))
360
+ return
361
+ }
362
+ wait(2000, operation)
363
+ }
364
+ fail("PENDING")
365
+ }
366
+
367
+ private suspend fun context(nonce: ByteArray): EncryptedUploadV2ContextExchange {
368
+ operation.check()
369
+ demand(nonce.size == 16, "INVALID_DOCUMENT")
370
+ val body = JSONObject().put("nonce_base64", encoded(nonce))
371
+ val initial = retry { request(contexts, body) }
372
+ val id = uuid(initial.string("context_id"))
373
+ val expiry = initial.string("expires_at")
374
+ timestamp(expiry)
375
+ demand(initial.string("state") in setOf("challenge", "pending", "complete"), "INVALID_DOCUMENT")
376
+ val challengeBase64 = initial.string("challenge_base64")
377
+ val challenge = opaque(challengeBase64, 196)
378
+ return EncryptedUploadV2ContextExchange(challenge) { proof ->
379
+ operation.check()
380
+ demand(proof.size in 116..366, "INVALID_DOCUMENT")
381
+ val proofBody = JSONObject().put("proof_base64", encoded(proof))
382
+ var response = retry { request("$contexts/$id/proof", proofBody) }
383
+ for (poll in 0..100) {
384
+ operation.check()
385
+ demand(response.string("context_id") == id && response.string("challenge_base64") == challengeBase64 &&
386
+ response.string("expires_at") == expiry, "IDENTITY_CONFLICT")
387
+ when (response.string("state")) {
388
+ "complete" -> return@EncryptedUploadV2ContextExchange opaque(response.string("result_base64"), 264)
389
+ "pending" -> demand(response.has("result_base64") && response.isNull("result_base64"), "INVALID_DOCUMENT")
390
+ else -> fail("STATE")
391
+ }
392
+ if (poll == 100) break
393
+ wait(250, operation)
394
+ response = retry { request("$contexts/$id") }
395
+ }
396
+ fail("PENDING")
397
+ }
398
+ }
399
+ }
400
+ }