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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +12 -1
  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,9 @@
1
1
  # Bota SDK for React Native
2
2
 
3
+ Source candidate `2.0.0-beta.11` keeps the five-platform version synchronized.
4
+ The managed HTTP helper is React Native only; other facade behavior is unchanged.
5
+ Beta.10 remains the published baseline until release gates pass.
6
+
3
7
  Bluetooth device SDK for Bota Pin and Bota Note applications. The 2.x candidate
4
8
  preserves the public TypeScript API of `@bota.dev/react-native-sdk@0.0.65`
5
9
  while moving Bluetooth workflows and recording, streaming, and firmware bytes
@@ -35,7 +39,7 @@ co-install both. Standalone maintenance ended September 29, 2026; follow the
35
39
  [retirement and migration notice](../../docs/migrations/react-native-sdk-sunset.md).
36
40
 
37
41
  ```bash
38
- npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.10
42
+ npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.11
39
43
  npx pod-install
40
44
  ```
41
45
 
@@ -88,6 +92,13 @@ boundary; missing or malformed advertised values become `null`. The peripheral
88
92
 
89
93
  ## Encrypted upload v2
90
94
 
95
+ Current source includes `createEncryptedUploadV2Backend` for iOS/Android. Supply
96
+ your authenticated backend URL, fresh-token callback and scoped identity; the
97
+ SDK owns native session journaling, manifest replay, verification polling and
98
+ signed-receipt delivery. See the [managed integration](../../docs/parity/v2-managed-backend.md)
99
+ for the example, backend routes, restart trigger and release limits. Beta.10
100
+ does not contain this helper. The low-level provider below remains supported.
101
+
91
102
  The target encrypted-upload-v2 runtime is an additive `BotaDeviceSDK` API; it
92
103
  does not change the legacy `BotaClient` recording providers or events:
93
104
 
@@ -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
+ }