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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +115 -4
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
  9. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
  10. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  11. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  12. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  13. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  14. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  15. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  16. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
  17. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  18. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  19. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  20. package/generated/codegen-contract.json +751 -32
  21. package/ios/BotaDeviceSDK.mm +114 -0
  22. package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
  23. package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
  24. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  25. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  26. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  27. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  28. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  29. package/ios/BotaUploadV2Backend.mm +49 -0
  30. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  31. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  32. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  33. package/ios/UploadBackend/UploadJournal.swift +187 -0
  34. package/ios/UploadBackend/UploadModels.swift +273 -0
  35. package/ios/UploadBackend/UploadOperation.swift +391 -0
  36. package/lib/commonjs/BotaClient.js +16 -2
  37. package/lib/commonjs/BotaClient.js.map +1 -1
  38. package/lib/commonjs/ble/constants.js +5 -1
  39. package/lib/commonjs/ble/constants.js.map +1 -1
  40. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  41. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  42. package/lib/commonjs/client.js +183 -28
  43. package/lib/commonjs/client.js.map +1 -1
  44. package/lib/commonjs/clientPresence.js +26 -0
  45. package/lib/commonjs/clientPresence.js.map +1 -0
  46. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  47. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  48. package/lib/commonjs/index.js +34 -0
  49. package/lib/commonjs/index.js.map +1 -1
  50. package/lib/commonjs/managers/DeviceManager.js +23 -0
  51. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  52. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  53. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  54. package/lib/commonjs/managers/RecordingManager.js +83 -102
  55. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  56. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  57. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  58. package/lib/commonjs/sdkIdentity.js +10 -0
  59. package/lib/commonjs/sdkIdentity.js.map +1 -0
  60. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  61. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  62. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  63. package/lib/commonjs/uploadRecovery.js +68 -0
  64. package/lib/commonjs/uploadRecovery.js.map +1 -0
  65. package/lib/module/BotaClient.js +16 -2
  66. package/lib/module/BotaClient.js.map +1 -1
  67. package/lib/module/ble/constants.js +4 -0
  68. package/lib/module/ble/constants.js.map +1 -1
  69. package/lib/module/ble/deviceDiagnostics.js +234 -0
  70. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  71. package/lib/module/client.js +183 -28
  72. package/lib/module/client.js.map +1 -1
  73. package/lib/module/clientPresence.js +22 -0
  74. package/lib/module/clientPresence.js.map +1 -0
  75. package/lib/module/encryptedUploadV2Backend.js +196 -0
  76. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  77. package/lib/module/index.js +7 -0
  78. package/lib/module/index.js.map +1 -1
  79. package/lib/module/managers/DeviceManager.js +23 -0
  80. package/lib/module/managers/DeviceManager.js.map +1 -1
  81. package/lib/module/managers/NativeUploadQueue.js +380 -0
  82. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  83. package/lib/module/managers/RecordingManager.js +83 -102
  84. package/lib/module/managers/RecordingManager.js.map +1 -1
  85. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  86. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  87. package/lib/module/sdkIdentity.js +6 -0
  88. package/lib/module/sdkIdentity.js.map +1 -0
  89. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  90. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  91. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  92. package/lib/module/uploadRecovery.js +64 -0
  93. package/lib/module/uploadRecovery.js.map +1 -0
  94. package/lib/typescript/BotaClient.d.ts +2 -0
  95. package/lib/typescript/BotaClient.d.ts.map +1 -1
  96. package/lib/typescript/ble/constants.d.ts +4 -0
  97. package/lib/typescript/ble/constants.d.ts.map +1 -1
  98. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  99. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  100. package/lib/typescript/client.d.ts +24 -3
  101. package/lib/typescript/client.d.ts.map +1 -1
  102. package/lib/typescript/clientPresence.d.ts +26 -0
  103. package/lib/typescript/clientPresence.d.ts.map +1 -0
  104. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  105. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  106. package/lib/typescript/index.d.ts +13 -4
  107. package/lib/typescript/index.d.ts.map +1 -1
  108. package/lib/typescript/managers/DeviceManager.d.ts +5 -1
  109. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  110. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  111. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  112. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  113. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  114. package/lib/typescript/managers/types.d.ts +6 -1
  115. package/lib/typescript/managers/types.d.ts.map +1 -1
  116. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  117. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  118. package/lib/typescript/models/Device.d.ts +42 -0
  119. package/lib/typescript/models/Device.d.ts.map +1 -1
  120. package/lib/typescript/models/Recording.d.ts +47 -0
  121. package/lib/typescript/models/Recording.d.ts.map +1 -1
  122. package/lib/typescript/models/Status.d.ts +5 -1
  123. package/lib/typescript/models/Status.d.ts.map +1 -1
  124. package/lib/typescript/sdkIdentity.d.ts +3 -0
  125. package/lib/typescript/sdkIdentity.d.ts.map +1 -0
  126. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
  127. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  128. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  129. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  130. package/lib/typescript/uploadRecovery.d.ts +27 -0
  131. package/lib/typescript/uploadRecovery.d.ts.map +1 -0
  132. package/package.json +1 -1
  133. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  134. package/src/BotaClient.ts +17 -3
  135. package/src/ble/constants.ts +5 -0
  136. package/src/ble/deviceDiagnostics.ts +238 -0
  137. package/src/client.ts +189 -18
  138. package/src/clientPresence.ts +33 -0
  139. package/src/encryptedUploadV2Backend.ts +171 -0
  140. package/src/index.ts +24 -0
  141. package/src/managers/DeviceManager.ts +23 -0
  142. package/src/managers/NativeUploadQueue.ts +330 -0
  143. package/src/managers/RecordingManager.ts +83 -116
  144. package/src/managers/types.ts +7 -1
  145. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  146. package/src/models/Device.ts +43 -0
  147. package/src/models/Recording.ts +52 -0
  148. package/src/models/Status.ts +5 -1
  149. package/src/sdkIdentity.ts +3 -0
  150. package/src/specs/NativeBotaDeviceSDK.ts +66 -0
  151. package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
  152. package/src/uploadRecovery.ts +66 -0
@@ -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
+ }
@@ -0,0 +1,158 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import java.io.IOException
4
+ import java.util.UUID
5
+ import java.util.concurrent.TimeUnit
6
+ import kotlin.coroutines.resume
7
+ import kotlin.coroutines.resumeWithException
8
+ import kotlinx.coroutines.CompletableDeferred
9
+ import kotlinx.coroutines.NonCancellable
10
+ import kotlinx.coroutines.suspendCancellableCoroutine
11
+ import kotlinx.coroutines.withContext
12
+ import kotlinx.coroutines.withTimeoutOrNull
13
+ import okhttp3.Call
14
+ import okhttp3.Callback
15
+ import okhttp3.MediaType.Companion.toMediaType
16
+ import okhttp3.OkHttpClient
17
+ import okhttp3.Request
18
+ import okhttp3.RequestBody.Companion.toRequestBody
19
+ import okhttp3.Response
20
+ import org.json.JSONObject
21
+
22
+ internal class UploadOperation(val input: UploadInput) {
23
+ private var cancelled = false
24
+ private val cancellations = mutableSetOf<() -> Unit>()
25
+ var registrationId: String? = null
26
+
27
+ @Synchronized fun check() { if (cancelled) fail("CANCELLED") }
28
+ @Synchronized fun <T> active(block: () -> T): T { check(); return block() }
29
+ @Synchronized fun onCancel(callback: () -> Unit): () -> Unit {
30
+ check()
31
+ cancellations.add(callback)
32
+ return { synchronized(this) { cancellations.remove(callback) }; Unit }
33
+ }
34
+ fun cancel() {
35
+ val callbacks = synchronized(this) {
36
+ if (cancelled) return
37
+ cancelled = true
38
+ cancellations.toList().also { cancellations.clear() }
39
+ }
40
+ callbacks.forEach { it() }
41
+ }
42
+ }
43
+
44
+ internal class CredentialBroker(private val emit: (String, String) -> Unit) {
45
+ private data class Pending(val operation: UploadOperation, val result: CompletableDeferred<String>)
46
+ private val pending = mutableMapOf<String, Pending>()
47
+
48
+ suspend fun request(operation: UploadOperation): String {
49
+ operation.check()
50
+ val requestId = UUID.randomUUID().toString()
51
+ val result = CompletableDeferred<String>()
52
+ val unregister = operation.onCancel { reject(requestId) }
53
+ try {
54
+ operation.active {
55
+ synchronized(this) { pending[requestId] = Pending(operation, result) }
56
+ emit(requestId, operation.input.operationId)
57
+ }
58
+ val token = withTimeoutOrNull(30_000) { result.await() } ?: fail("CREDENTIALS")
59
+ operation.check()
60
+ return token
61
+ } catch (error: Exception) {
62
+ operation.check()
63
+ throw error
64
+ } finally {
65
+ unregister()
66
+ synchronized(this) { pending.remove(requestId) }
67
+ }
68
+ }
69
+
70
+ fun resolve(requestId: String, token: String) {
71
+ val request = synchronized(this) { pending.remove(requestId) } ?: return
72
+ try {
73
+ request.operation.active {
74
+ demand(token.length in 1..32768 && token.all { it.code in 33..126 }, "CREDENTIALS")
75
+ request.result.complete(token)
76
+ }
77
+ } catch (error: UploadFailure) { request.result.completeExceptionally(error) }
78
+ }
79
+
80
+ fun reject(requestId: String) {
81
+ val request = synchronized(this) { pending.remove(requestId) } ?: return
82
+ request.result.completeExceptionally(UploadFailure("BOTA_UPLOAD_CREDENTIALS"))
83
+ }
84
+ }
85
+
86
+ internal class UploadRequest(
87
+ val origin: String, val path: String, val method: String, val headers: Map<String, String>, val body: JSONObject?,
88
+ ) {
89
+ override fun toString(): String = "UploadRequest(<redacted>)"
90
+ }
91
+
92
+ internal interface UploadHttp {
93
+ suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
94
+ beforeDispatch: () -> Unit = {}): JSONObject
95
+ }
96
+
97
+ /** Dedicated client: no app network recorder, auth interceptor, redirects, or automatic POST retry. */
98
+ internal class BackendUploadHttp(private val client: OkHttpClient = OkHttpClient.Builder()
99
+ .followRedirects(false).followSslRedirects(false).retryOnConnectionFailure(false)
100
+ .connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).callTimeout(45, TimeUnit.SECONDS).build()) : UploadHttp {
101
+ override suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
102
+ beforeDispatch: () -> Unit): JSONObject = withContext(if (retain) NonCancellable else kotlin.coroutines.EmptyCoroutineContext) {
103
+ try {
104
+ val nativeRequest = Request.Builder().url(request.origin + request.path).apply {
105
+ request.headers.forEach { (key, value) -> header(key, value) }
106
+ method(request.method, request.body?.toString()?.toRequestBody("application/json".toMediaType()))
107
+ }.build()
108
+ val call = client.newCall(nativeRequest)
109
+ suspendCancellableCoroutine { continuation ->
110
+ var unregister: (() -> Unit)? = null
111
+ continuation.invokeOnCancellation { call.cancel() }
112
+ try {
113
+ operation.active {
114
+ if (!retain) unregister = operation.onCancel { call.cancel() }
115
+ beforeDispatch()
116
+ call.enqueue(object : Callback {
117
+ override fun onFailure(call: Call, e: IOException) {
118
+ unregister?.invoke()
119
+ if (continuation.isActive) continuation.resumeWithException(UploadFailure("BOTA_UPLOAD_TRANSPORT"))
120
+ }
121
+ override fun onResponse(call: Call, response: Response) {
122
+ val value = runCatching {
123
+ response.use {
124
+ if (!response.isSuccessful && response.code != 401 && response.code != 403 && response.code != 409) fail("HTTP_${response.code}")
125
+ val body = response.body ?: fail("INVALID_DOCUMENT")
126
+ demand(body.contentLength() <= 65536, "INVALID_DOCUMENT")
127
+ // Read a bounded body here, before completing the retained request.
128
+ val source = body.source()
129
+ source.request(65537)
130
+ demand(source.buffer.size <= 65536, "INVALID_DOCUMENT")
131
+ val text = source.readUtf8()
132
+ if (response.code == 409 && runCatching {
133
+ JSONObject(text).optJSONObject("error")?.optString("code") == "encrypted_upload_v2_staging_missing"
134
+ }.getOrDefault(false)) fail("STAGING_MISSING")
135
+ if (!response.isSuccessful) fail("HTTP_${response.code}")
136
+ if (text.isEmpty()) JSONObject() else sanitized("INVALID_DOCUMENT") { JSONObject(text) }
137
+ }
138
+ }
139
+ unregister?.invoke()
140
+ if (continuation.isActive) value.fold({ continuation.resume(it) }, {
141
+ continuation.resumeWithException(if (it is UploadFailure) it else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
142
+ })
143
+ }
144
+ })
145
+ }
146
+ } catch (error: Exception) {
147
+ unregister?.invoke()
148
+ if (continuation.isActive) continuation.resumeWithException(
149
+ if (error is UploadFailure) error else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
150
+ }
151
+ }
152
+ } catch (error: Exception) {
153
+ if (!retain) operation.check()
154
+ if (error is UploadFailure) throw error
155
+ fail("TRANSPORT")
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,112 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.nio.channels.FileChannel
6
+ import java.nio.file.Files
7
+ import java.nio.file.LinkOption.NOFOLLOW_LINKS
8
+ import java.nio.file.StandardCopyOption.ATOMIC_MOVE
9
+ import java.nio.file.StandardCopyOption.REPLACE_EXISTING
10
+ import java.nio.file.StandardOpenOption.READ
11
+ import java.util.UUID
12
+ import org.json.JSONObject
13
+
14
+ internal data class JournalEntry(val identitySha256: String, val pointer: UploadPointer?, val phase: String = "ready",
15
+ val importedPriorSha256: String? = null, val authorizationNonceSha256: String? = null,
16
+ val recoveryNonceBase64: String? = null) {
17
+ fun json(): JSONObject = (pointer?.json() ?: JSONObject()).put("version", 2)
18
+ .put("identitySha256", identitySha256).put("phase", phase).apply {
19
+ if (importedPriorSha256 != null) put("importedPriorSha256", importedPriorSha256)
20
+ if (authorizationNonceSha256 != null) put("authorizationNonceSha256", authorizationNonceSha256)
21
+ if (recoveryNonceBase64 != null) put("recoveryNonceBase64", recoveryNonceBase64)
22
+ }
23
+ }
24
+
25
+ internal interface JournalDurability {
26
+ fun syncFile(stream: FileOutputStream) { stream.fd.sync() }
27
+ fun replace(source: File, target: File) { Files.move(source.toPath(), target.toPath(), ATOMIC_MOVE, REPLACE_EXISTING) }
28
+ fun syncDirectory(directory: File) { FileChannel.open(directory.toPath(), READ).use { it.force(true) } }
29
+ }
30
+
31
+ internal class UploadJournal(private val root: File, private val durability: JournalDurability = object : JournalDurability {}) {
32
+ private fun directory() {
33
+ val path = root.toPath()
34
+ if (!Files.exists(path, NOFOLLOW_LINKS)) {
35
+ demand(root.mkdir(), "JOURNAL")
36
+ durability.syncDirectory(root.parentFile!!)
37
+ }
38
+ demand(Files.isDirectory(path, NOFOLLOW_LINKS) && !Files.isSymbolicLink(path), "JOURNAL")
39
+ }
40
+ private fun file(key: String): File = File(root, "${hashString(key)}.json")
41
+
42
+ @Synchronized fun load(input: UploadInput): JournalEntry? = sanitized("JOURNAL") {
43
+ directory()
44
+ val file = file(input.journalKey)
45
+ if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return@sanitized null
46
+ demand(Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS) && file.length() <= 4096, "JOURNAL")
47
+ val json = JSONObject(file.readText(Charsets.UTF_8))
48
+ val allowed = setOf("version", "identitySha256", "phase", "recordingId", "sessionId", "ownerRevision", "importedPriorSha256", "authorizationNonceSha256", "recoveryNonceBase64")
49
+ demand(json.keys().asSequence().all { it in allowed }, "JOURNAL")
50
+ val version = json.integer("version", 1, 2)
51
+ val identity = hashString(json.string("identitySha256"))
52
+ demand(identity == (if (version == 1L) input.legacyIdentitySha256 else input.identitySha256), "IDENTITY_CONFLICT")
53
+ val phase = json.string("phase")
54
+ demand(phase in setOf("ready", "recording_create_unknown", "session_create_unknown"), "JOURNAL")
55
+ val pointer = if (json.has("recordingId")) UploadPointer.parse(json) else null
56
+ val imported = if (json.has("importedPriorSha256")) hashString(json.string("importedPriorSha256")) else null
57
+ val nonceHash = if (json.has("authorizationNonceSha256")) hashString(json.string("authorizationNonceSha256")) else null
58
+ demand(nonceHash == null || pointer?.sessionId != null, "JOURNAL")
59
+ val recoveryNonce = if (json.has("recoveryNonceBase64")) json.string("recoveryNonceBase64").also { opaque(it, 16) } else null
60
+ demand(recoveryNonce == null || (phase == "ready" && pointer?.sessionId != null), "JOURNAL")
61
+ demand(if (phase == "recording_create_unknown") pointer == null else pointer != null, "JOURNAL")
62
+ demand(phase != "session_create_unknown" || pointer?.sessionId == null, "JOURNAL")
63
+ if (input.prior != null && pointer != null) {
64
+ demand(input.prior.recordingId == pointer.recordingId &&
65
+ (input.prior.sessionId == null || input.prior.fingerprint() == imported ||
66
+ (input.prior.sessionId == pointer.sessionId && input.prior.ownerRevision == pointer.ownerRevision)),
67
+ "IDENTITY_CONFLICT")
68
+ }
69
+ JournalEntry(input.identitySha256, pointer, phase, imported, nonceHash, recoveryNonce).also {
70
+ if (version == 1L) save(input, it)
71
+ }
72
+ }
73
+
74
+ @Synchronized fun save(input: UploadInput, entry: JournalEntry) = sanitized("JOURNAL") {
75
+ directory()
76
+ demand(entry.identitySha256 == input.identitySha256, "IDENTITY_CONFLICT")
77
+ val target = file(input.journalKey)
78
+ demand(!Files.isSymbolicLink(target.toPath()), "JOURNAL")
79
+ val temporary = File(root, ".${input.journalKey}.${UUID.randomUUID()}.tmp")
80
+ try {
81
+ demand(temporary.createNewFile(), "JOURNAL")
82
+ FileOutputStream(temporary).use { stream ->
83
+ stream.write(entry.json().toString().toByteArray(Charsets.UTF_8))
84
+ durability.syncFile(stream)
85
+ }
86
+ durability.replace(temporary, target)
87
+ durability.syncDirectory(root)
88
+ } finally { if (temporary.exists()) temporary.delete() }
89
+ }
90
+
91
+ @Synchronized fun remove(input: UploadInput, expected: UploadPointer) = sanitized("JOURNAL") {
92
+ val current = load(input) ?: fail("IDENTITY_CONFLICT")
93
+ demand(current.phase == "ready" && current.pointer == expected, "IDENTITY_CONFLICT")
94
+ Files.delete(file(input.journalKey).toPath())
95
+ durability.syncDirectory(root)
96
+ }
97
+
98
+ @Synchronized fun rollbackRejectedCreate(input: UploadInput, marker: JournalEntry, previous: JournalEntry?) = sanitized("JOURNAL") {
99
+ demand(load(input) == marker, "IDENTITY_CONFLICT")
100
+ when (marker.phase) {
101
+ "recording_create_unknown" -> demand(previous == null && marker.pointer == null, "IDENTITY_CONFLICT")
102
+ "session_create_unknown" -> demand(previous?.phase == "ready" && previous.copy(phase = marker.phase) == marker, "IDENTITY_CONFLICT")
103
+ else -> fail("IDENTITY_CONFLICT")
104
+ }
105
+ if (previous == null) {
106
+ Files.delete(file(input.journalKey).toPath())
107
+ durability.syncDirectory(root)
108
+ } else {
109
+ save(input, previous)
110
+ }
111
+ }
112
+ }