@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.
- package/README.md +18 -8
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
- package/generated/codegen-contract.json +167 -8
- package/ios/BotaUploadV2Backend.mm +49 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
- package/ios/UploadBackend/UploadCoordinator.swift +84 -0
- package/ios/UploadBackend/UploadHTTP.swift +82 -0
- package/ios/UploadBackend/UploadJournal.swift +187 -0
- package/ios/UploadBackend/UploadModels.swift +273 -0
- package/ios/UploadBackend/UploadOperation.swift +391 -0
- package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
- package/lib/commonjs/index.js +5 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/module/encryptedUploadV2Backend.js +196 -0
- package/lib/module/encryptedUploadV2Backend.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +3 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/encryptedUploadV2Backend.ts +171 -0
- package/src/index.ts +6 -0
- package/src/sdkIdentity.ts +1 -1
- 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.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
+
}
|