@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.
- package/README.md +115 -4
- package/android/build.gradle +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
- 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/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
- package/generated/codegen-contract.json +751 -32
- package/ios/BotaDeviceSDK.mm +114 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
- package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
- package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
- package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
- package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
- package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
- 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/BotaClient.js +16 -2
- package/lib/commonjs/BotaClient.js.map +1 -1
- package/lib/commonjs/ble/constants.js +5 -1
- package/lib/commonjs/ble/constants.js.map +1 -1
- package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
- package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
- package/lib/commonjs/client.js +183 -28
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/clientPresence.js +26 -0
- package/lib/commonjs/clientPresence.js.map +1 -0
- package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
- package/lib/commonjs/index.js +34 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +23 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
- package/lib/commonjs/managers/RecordingManager.js +83 -102
- package/lib/commonjs/managers/RecordingManager.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +10 -0
- package/lib/commonjs/sdkIdentity.js.map +1 -0
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/commonjs/uploadRecovery.js +68 -0
- package/lib/commonjs/uploadRecovery.js.map +1 -0
- package/lib/module/BotaClient.js +16 -2
- package/lib/module/BotaClient.js.map +1 -1
- package/lib/module/ble/constants.js +4 -0
- package/lib/module/ble/constants.js.map +1 -1
- package/lib/module/ble/deviceDiagnostics.js +234 -0
- package/lib/module/ble/deviceDiagnostics.js.map +1 -0
- package/lib/module/client.js +183 -28
- package/lib/module/client.js.map +1 -1
- package/lib/module/clientPresence.js +22 -0
- package/lib/module/clientPresence.js.map +1 -0
- package/lib/module/encryptedUploadV2Backend.js +196 -0
- package/lib/module/encryptedUploadV2Backend.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +23 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +380 -0
- package/lib/module/managers/NativeUploadQueue.js.map +1 -0
- package/lib/module/managers/RecordingManager.js +83 -102
- package/lib/module/managers/RecordingManager.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/module/sdkIdentity.js +6 -0
- package/lib/module/sdkIdentity.js.map +1 -0
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/module/uploadRecovery.js +64 -0
- package/lib/module/uploadRecovery.js.map +1 -0
- package/lib/typescript/BotaClient.d.ts +2 -0
- package/lib/typescript/BotaClient.d.ts.map +1 -1
- package/lib/typescript/ble/constants.d.ts +4 -0
- package/lib/typescript/ble/constants.d.ts.map +1 -1
- package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
- package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
- package/lib/typescript/client.d.ts +24 -3
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/clientPresence.d.ts +26 -0
- package/lib/typescript/clientPresence.d.ts.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 +13 -4
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +5 -1
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
- package/lib/typescript/managers/RecordingManager.d.ts +3 -8
- package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
- package/lib/typescript/managers/types.d.ts +6 -1
- package/lib/typescript/managers/types.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
- package/lib/typescript/models/Device.d.ts +42 -0
- package/lib/typescript/models/Device.d.ts.map +1 -1
- package/lib/typescript/models/Recording.d.ts +47 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/models/Status.d.ts +5 -1
- package/lib/typescript/models/Status.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +3 -0
- package/lib/typescript/sdkIdentity.d.ts.map +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/uploadRecovery.d.ts +27 -0
- package/lib/typescript/uploadRecovery.d.ts.map +1 -0
- package/package.json +1 -1
- package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
- package/src/BotaClient.ts +17 -3
- package/src/ble/constants.ts +5 -0
- package/src/ble/deviceDiagnostics.ts +238 -0
- package/src/client.ts +189 -18
- package/src/clientPresence.ts +33 -0
- package/src/encryptedUploadV2Backend.ts +171 -0
- package/src/index.ts +24 -0
- package/src/managers/DeviceManager.ts +23 -0
- package/src/managers/NativeUploadQueue.ts +330 -0
- package/src/managers/RecordingManager.ts +83 -116
- package/src/managers/types.ts +7 -1
- package/src/managers/uploadRecoveryMetadata.ts +59 -0
- package/src/models/Device.ts +43 -0
- package/src/models/Recording.ts +52 -0
- package/src/models/Status.ts +5 -1
- package/src/sdkIdentity.ts +3 -0
- package/src/specs/NativeBotaDeviceSDK.ts +66 -0
- package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
- package/src/uploadRecovery.ts +66 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import java.net.URI
|
|
4
|
+
import java.security.MessageDigest
|
|
5
|
+
import java.time.Instant
|
|
6
|
+
import java.util.Base64
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
internal class UploadFailure(val code: String) : Exception(code)
|
|
11
|
+
internal fun fail(code: String): Nothing = throw UploadFailure("BOTA_UPLOAD_$code")
|
|
12
|
+
internal fun demand(value: Boolean, code: String = "INVALID_INPUT") { if (!value) fail(code) }
|
|
13
|
+
internal inline fun <T> sanitized(code: String, block: () -> T): T = try { block() }
|
|
14
|
+
catch (error: UploadFailure) { throw error }
|
|
15
|
+
catch (_: Exception) { fail(code) }
|
|
16
|
+
internal fun digest(value: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(value)
|
|
17
|
+
.joinToString("") { "%02x".format(it.toInt() and 255) }
|
|
18
|
+
internal fun hex(value: String): ByteArray {
|
|
19
|
+
demand(value.length % 2 == 0 && value.matches(Regex("[0-9a-fA-F]*")))
|
|
20
|
+
return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
21
|
+
}
|
|
22
|
+
internal fun encoded(value: ByteArray): String = Base64.getEncoder().encodeToString(value)
|
|
23
|
+
internal fun opaque(value: String, length: Int, hash: String? = null): ByteArray = sanitized("INVALID_DOCUMENT") {
|
|
24
|
+
val bytes = Base64.getDecoder().decode(value)
|
|
25
|
+
demand(bytes.size == length && encoded(bytes) == value && (hash == null || digest(bytes) == hash), "INVALID_DOCUMENT")
|
|
26
|
+
bytes
|
|
27
|
+
}
|
|
28
|
+
internal fun JSONObject.string(key: String): String = (opt(key) as? String)?.also {
|
|
29
|
+
demand(it.isNotEmpty() && it.length <= 8192)
|
|
30
|
+
} ?: fail("INVALID_INPUT")
|
|
31
|
+
internal fun JSONObject.integer(key: String, minimum: Long = 0, maximum: Long = 9007199254740991L): Long {
|
|
32
|
+
val raw = opt(key) as? Number ?: fail("INVALID_INPUT")
|
|
33
|
+
val text = raw.toString()
|
|
34
|
+
demand(text.matches(Regex("0|[1-9][0-9]*")))
|
|
35
|
+
val number = text.toLongOrNull() ?: fail("INVALID_INPUT")
|
|
36
|
+
demand(number in minimum..maximum)
|
|
37
|
+
return number
|
|
38
|
+
}
|
|
39
|
+
internal fun JSONObject.decimal(key: String, minimum: Long = 0): Long {
|
|
40
|
+
val value = string(key)
|
|
41
|
+
demand(value.matches(Regex("0|[1-9][0-9]*")))
|
|
42
|
+
return (value.toLongOrNull() ?: fail("INVALID_INPUT")).also { demand(it in minimum..9007199254740991L) }
|
|
43
|
+
}
|
|
44
|
+
internal fun uuid(value: String): String = value.also {
|
|
45
|
+
demand(it.matches(Regex("[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")))
|
|
46
|
+
}
|
|
47
|
+
internal fun hashString(value: String): String = value.also { demand(it.matches(Regex("[0-9a-f]{64}"))) }
|
|
48
|
+
internal fun JSONObject.optionalObject(key: String): JSONObject? {
|
|
49
|
+
if (!has(key) || isNull(key)) return null
|
|
50
|
+
return opt(key) as? JSONObject ?: fail("INVALID_INPUT")
|
|
51
|
+
}
|
|
52
|
+
internal fun timestamp(value: String): Long = sanitized("INVALID_DOCUMENT") { Instant.parse(value).toEpochMilli() }
|
|
53
|
+
|
|
54
|
+
internal data class UploadScope(
|
|
55
|
+
val apiOrigin: String, val accountId: String, val projectId: String, val organizationId: String,
|
|
56
|
+
val environment: String, val endUserId: String, val deviceId: String,
|
|
57
|
+
val bindingGeneration: Long, val serialNumber: String, val nativeDeviceId: String, val scopeKey: String,
|
|
58
|
+
val apiBasePath: String = "/v1",
|
|
59
|
+
) {
|
|
60
|
+
fun identity(includeTransport: Boolean = false): List<Any> = listOf(apiOrigin, accountId, projectId, organizationId, environment, endUserId,
|
|
61
|
+
deviceId, bindingGeneration, serialNumber, if (includeTransport) nativeDeviceId else "", scopeKey, apiBasePath)
|
|
62
|
+
|
|
63
|
+
companion object {
|
|
64
|
+
fun parse(json: JSONObject): UploadScope {
|
|
65
|
+
val origin = json.string("apiOrigin")
|
|
66
|
+
val url = URI(origin)
|
|
67
|
+
demand(url.scheme == "https" && !url.host.isNullOrEmpty() && url.rawUserInfo == null &&
|
|
68
|
+
url.rawQuery == null && url.rawFragment == null && (url.rawPath.isNullOrEmpty() || url.rawPath == "/"))
|
|
69
|
+
fun identifier(key: String) = json.string(key).also { demand(it.matches(Regex("[A-Za-z0-9_-]{1,256}"))) }
|
|
70
|
+
return UploadScope(origin.trimEnd('/'), identifier("accountId"), identifier("projectId"), json.optString("organizationId", "").also { demand(it.isEmpty() || it.matches(Regex("[A-Za-z0-9_-]{1,256}"))) },
|
|
71
|
+
identifier("environment"), identifier("endUserId"), identifier("deviceId"), json.integer("bindingGeneration"),
|
|
72
|
+
json.string("serialNumber"), json.string("nativeDeviceId"), json.string("scopeKey"),
|
|
73
|
+
json.optString("apiBasePath", "/v1").also { demand(it.matches(Regex("(/[A-Za-z0-9_-]+)+"))) })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
internal data class UploadRecording(
|
|
79
|
+
val uuid: String, val generation: Long, val ciphertextLength: Long, val ciphertextSha256: String,
|
|
80
|
+
val startedAtMs: Long, val durationMs: Long, val plaintextLength: Long, val storageFormat: Long,
|
|
81
|
+
) {
|
|
82
|
+
fun identity(): List<Any> = listOf(uuid, generation, ciphertextLength.toString(), ciphertextSha256,
|
|
83
|
+
startedAtMs.toString(), durationMs.toString(), plaintextLength.toString(), storageFormat)
|
|
84
|
+
|
|
85
|
+
companion object {
|
|
86
|
+
fun parse(json: JSONObject): UploadRecording = UploadRecording(uuid(json.string("uuid")),
|
|
87
|
+
json.integer("generation", 1, 4294967295), json.decimal("ciphertextLength", 1),
|
|
88
|
+
hashString(json.string("ciphertextSha256")), json.decimal("startedAtMs"), json.decimal("durationMs"),
|
|
89
|
+
json.decimal("plaintextLength"), json.integer("storageFormat", 3, 3)).also {
|
|
90
|
+
demand(it.startedAtMs + it.durationMs <= 8640000000000000L)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
internal data class UploadPointer(val recordingId: String, val sessionId: String? = null, val ownerRevision: Long? = null) {
|
|
96
|
+
fun fingerprint(): String = digest(JSONArray(listOf(recordingId, sessionId ?: JSONObject.NULL,
|
|
97
|
+
ownerRevision ?: JSONObject.NULL)).toString().toByteArray(Charsets.UTF_8))
|
|
98
|
+
fun json(): JSONObject = JSONObject().put("recordingId", recordingId).apply {
|
|
99
|
+
if (sessionId != null) put("sessionId", sessionId).put("ownerRevision", ownerRevision)
|
|
100
|
+
}
|
|
101
|
+
companion object {
|
|
102
|
+
fun parse(json: JSONObject): UploadPointer {
|
|
103
|
+
val id = json.string("recordingId")
|
|
104
|
+
demand(id.matches(Regex("rec_[A-Za-z0-9_-]+")))
|
|
105
|
+
val hasSession = json.has("sessionId") && !json.isNull("sessionId")
|
|
106
|
+
val hasRevision = json.has("ownerRevision") && !json.isNull("ownerRevision")
|
|
107
|
+
demand(hasSession == hasRevision)
|
|
108
|
+
return UploadPointer(id, if (hasSession) uuid(json.string("sessionId")) else null,
|
|
109
|
+
if (hasRevision) json.integer("ownerRevision", 1, Int.MAX_VALUE.toLong()) else null)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
internal data class UploadInput(
|
|
115
|
+
val operationId: String, val scope: UploadScope, val recording: UploadRecording,
|
|
116
|
+
val capability: ByteArray, val flags: Long, val journalKey: String, val identitySha256: String,
|
|
117
|
+
val prior: UploadPointer?, val checkpoint: JSONObject?,
|
|
118
|
+
) {
|
|
119
|
+
val legacyIdentitySha256: String get() = digest(JSONArray(scope.identity(includeTransport = true) +
|
|
120
|
+
recording.identity()).toString().toByteArray(Charsets.UTF_8))
|
|
121
|
+
|
|
122
|
+
fun validateCheckpoint(pointer: UploadPointer?) {
|
|
123
|
+
val cp = checkpoint ?: return
|
|
124
|
+
sanitized("IDENTITY_CONFLICT") {
|
|
125
|
+
demand(pointer?.sessionId != null && pointer.ownerRevision != null, "IDENTITY_CONFLICT")
|
|
126
|
+
val session = uuid(cp.string("uploadSessionId"))
|
|
127
|
+
cp.integer("version", 1, 1)
|
|
128
|
+
val revision = cp.integer("ownerRevision", 1, Int.MAX_VALUE.toLong())
|
|
129
|
+
demand(revision <= pointer!!.ownerRevision!! &&
|
|
130
|
+
((revision == pointer.ownerRevision) == (session == pointer.sessionId)), "IDENTITY_CONFLICT")
|
|
131
|
+
demand(cp.decimal("nextCiphertextOffset") <= recording.ciphertextLength, "IDENTITY_CONFLICT")
|
|
132
|
+
hashString(cp.string("prefixSha256"))
|
|
133
|
+
cp.integer("revision", 1, 4294967295)
|
|
134
|
+
val transport = cp.string("transportSessionId")
|
|
135
|
+
demand(transport.matches(Regex("[1-9][0-9]*")) && transport.toULongOrNull() != null)
|
|
136
|
+
cp.string("sinkRegistrationId")
|
|
137
|
+
cp.integer("windowPackets", 1, 65535)
|
|
138
|
+
cp.integer("dataPayloadBytes", 1, 65535)
|
|
139
|
+
if (cp.has("highestContiguousSequence") && !cp.isNull("highestContiguousSequence"))
|
|
140
|
+
cp.integer("highestContiguousSequence", 0, 4294967295)
|
|
141
|
+
// Legacy identity fields, if supplied during migration, cannot contradict this recording.
|
|
142
|
+
if (cp.has("recordingUuid")) demand(cp.string("recordingUuid") == recording.uuid, "IDENTITY_CONFLICT")
|
|
143
|
+
if (cp.has("recordingGeneration")) demand(cp.integer("recordingGeneration") == recording.generation, "IDENTITY_CONFLICT")
|
|
144
|
+
if (cp.has("ciphertextLength")) demand(cp.decimal("ciphertextLength") == recording.ciphertextLength, "IDENTITY_CONFLICT")
|
|
145
|
+
if (cp.has("ciphertextSha256")) demand(cp.string("ciphertextSha256") == recording.ciphertextSha256, "IDENTITY_CONFLICT")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
companion object {
|
|
150
|
+
fun parse(text: String): UploadInput = sanitized("INVALID_INPUT") {
|
|
151
|
+
demand(text.length <= 32768)
|
|
152
|
+
val json = JSONObject(text)
|
|
153
|
+
val operation = json.string("operationId").also { demand(it.length <= 256) }
|
|
154
|
+
val scope = UploadScope.parse(json.getJSONObject("scope"))
|
|
155
|
+
val recording = UploadRecording.parse(json.getJSONObject("recording"))
|
|
156
|
+
val capability = json.getJSONObject("capability")
|
|
157
|
+
val bytes = hex(capability.string("rawValueHex"))
|
|
158
|
+
demand(bytes.size == 24)
|
|
159
|
+
|
|
160
|
+
val keyFields = listOf(scope.scopeKey, scope.deviceId, scope.bindingGeneration, recording.uuid,
|
|
161
|
+
recording.generation, recording.ciphertextLength.toString(), recording.ciphertextSha256)
|
|
162
|
+
// Android org.json escapes slashes; the existing JS journal key uses JSON.stringify.
|
|
163
|
+
val keyJson = JSONArray(keyFields).toString().replace("\\/", "/")
|
|
164
|
+
val key = digest(keyJson.toByteArray(Charsets.UTF_8))
|
|
165
|
+
if (json.has("journalKey")) demand(hashString(json.string("journalKey")) == key, "IDENTITY_CONFLICT")
|
|
166
|
+
val identity = digest(JSONArray(scope.identity() + recording.identity()).toString().toByteArray(Charsets.UTF_8))
|
|
167
|
+
UploadInput(operation, scope, recording, bytes, capability.integer("flags", 0, 4294967295), key, identity,
|
|
168
|
+
json.optionalObject("priorJournalEntry")?.let(UploadPointer::parse), json.optionalObject("checkpoint"))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -22,6 +22,38 @@ import org.junit.Assert.assertEquals
|
|
|
22
22
|
import org.junit.Test
|
|
23
23
|
|
|
24
24
|
class BotaDeviceSDKAndroidDevicesTest {
|
|
25
|
+
@Test
|
|
26
|
+
fun transportLossIsForwardedWithoutAStatusSubscription() = runTest {
|
|
27
|
+
val verified = ConnectedDevice(
|
|
28
|
+
id = "selected", serialNumber = "EVFXXW67KP", deviceType = DeviceType.BotaPin,
|
|
29
|
+
firmwareVersion = "1.0.19", isProvisioned = false,
|
|
30
|
+
connectionState = ConnectionState.Connected, mtu = 247,
|
|
31
|
+
)
|
|
32
|
+
val stopped = CompletableDeferred<Unit>()
|
|
33
|
+
val client = FakeDeviceClient(
|
|
34
|
+
DiscoveredDevice("selected", rssi = -30), verified,
|
|
35
|
+
connections = flow {
|
|
36
|
+
try {
|
|
37
|
+
emit(null)
|
|
38
|
+
emit(verified)
|
|
39
|
+
emit(null)
|
|
40
|
+
emit(null)
|
|
41
|
+
awaitCancellation()
|
|
42
|
+
} finally {
|
|
43
|
+
stopped.complete(Unit)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
val devices = BotaDeviceSDKAndroidDevices(client, backgroundScope)
|
|
48
|
+
var losses = 0
|
|
49
|
+
devices.startConnectionUpdates { losses += 1 }
|
|
50
|
+
assertEquals(1, losses)
|
|
51
|
+
assertEquals(0, client.statusReadCount)
|
|
52
|
+
devices.stopAll()
|
|
53
|
+
stopped.await()
|
|
54
|
+
assertEquals(1, losses)
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
@Test
|
|
26
58
|
fun unknownPairingStateUsesTheFrozenUnpairedFallback() {
|
|
27
59
|
assertEquals("unpaired", PairingState.Unknown(0xFFu).toBridgeValue())
|
|
@@ -134,7 +166,10 @@ class BotaDeviceSDKAndroidDevicesTest {
|
|
|
134
166
|
private val connected: ConnectedDevice,
|
|
135
167
|
private val scanFailure: Throwable? = null,
|
|
136
168
|
private val status: DeviceStatus = testDeviceStatus(),
|
|
169
|
+
private val connections: Flow<ConnectedDevice?> = kotlinx.coroutines.flow.emptyFlow(),
|
|
137
170
|
) : BotaDeviceSDKAndroidDeviceClient {
|
|
171
|
+
override fun connectionUpdates(): Flow<ConnectedDevice?> = connections
|
|
172
|
+
override suspend fun nextClientPresence(deviceId: String): dev.bota.sdk.SDKClientContext? = null
|
|
138
173
|
val scanOptions = mutableListOf<Pair<ULong, Boolean>>()
|
|
139
174
|
val selectedIds = mutableListOf<String>()
|
|
140
175
|
val reconnectSerials = mutableListOf<String>()
|
|
@@ -4,6 +4,8 @@ import dev.bota.sdk.model.ConnectedDevice
|
|
|
4
4
|
import dev.bota.sdk.model.ConnectionState
|
|
5
5
|
import dev.bota.sdk.model.DeviceLogLine
|
|
6
6
|
import dev.bota.sdk.model.DeviceType
|
|
7
|
+
import dev.bota.sdk.model.DeviceDiagnosticEvent
|
|
8
|
+
import dev.bota.sdk.model.DeviceDiagnosticsBatch
|
|
7
9
|
import kotlinx.coroutines.CoroutineScope
|
|
8
10
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
9
11
|
import kotlinx.coroutines.awaitCancellation
|
|
@@ -16,6 +18,23 @@ import org.junit.Assert.assertTrue
|
|
|
16
18
|
import org.junit.Test
|
|
17
19
|
|
|
18
20
|
class BotaDeviceSDKAndroidLogsTest {
|
|
21
|
+
@Test
|
|
22
|
+
fun diagnosticReadDoesNotAcknowledgeAndPreservesEventIDs() = runTest {
|
|
23
|
+
val client = TestAndroidLogClient()
|
|
24
|
+
val logs = BotaDeviceSDKAndroidLogs(client, CoroutineScope(coroutineContext))
|
|
25
|
+
val device = ConnectedDevice(
|
|
26
|
+
id = "selected", serialNumber = "EVFXXW67KP", deviceType = DeviceType.BotaPin,
|
|
27
|
+
firmwareVersion = "1.0.11", isProvisioned = true,
|
|
28
|
+
connectionState = ConnectionState.Connected, mtu = 247,
|
|
29
|
+
)
|
|
30
|
+
val batch = logs.readDiagnosticEvents(device)
|
|
31
|
+
assertTrue(client.acceptedIDs.isEmpty())
|
|
32
|
+
assertEquals("ffffffffffffffff", batch.events.single().eventId)
|
|
33
|
+
assertEquals(UInt.MAX_VALUE, batch.events.single().uptimeMs)
|
|
34
|
+
logs.acknowledgeDiagnosticEvents(device, listOf("ffffffffffffffff"))
|
|
35
|
+
assertEquals(listOf("ffffffffffffffff"), client.acceptedIDs)
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
@OptIn(ExperimentalCoroutinesApi::class)
|
|
20
39
|
@Test
|
|
21
40
|
fun logStreamEmitsSanitizedLinesAndOwnsStop() = runTest {
|
|
@@ -44,6 +63,20 @@ class BotaDeviceSDKAndroidLogsTest {
|
|
|
44
63
|
|
|
45
64
|
private class TestAndroidLogClient : BotaDeviceSDKAndroidLogClient {
|
|
46
65
|
var stopped = false
|
|
66
|
+
val acceptedIDs = mutableListOf<String>()
|
|
67
|
+
|
|
68
|
+
override suspend fun readDiagnosticEvents(device: ConnectedDevice) = DeviceDiagnosticsBatch(
|
|
69
|
+
schemaVersion = 1,
|
|
70
|
+
events = listOf(DeviceDiagnosticEvent(
|
|
71
|
+
eventId = "ffffffffffffffff", eventType = "crash", reasonCode = "exception",
|
|
72
|
+
uptimeMs = UInt.MAX_VALUE, signature = "0123456789abcdef", firmwareBuildId = "1.0.11",
|
|
73
|
+
subsystem = "system", stateBeforeEvent = "idle",
|
|
74
|
+
)),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
override suspend fun acknowledgeDiagnosticEvents(device: ConnectedDevice, acceptedEventIds: List<String>) {
|
|
78
|
+
acceptedIDs.addAll(acceptedEventIds)
|
|
79
|
+
}
|
|
47
80
|
|
|
48
81
|
override fun streamLogs(device: ConnectedDevice): Flow<DeviceLogLine> = flow {
|
|
49
82
|
emit(DeviceLogLine("boot pass", isBacklog = true))
|
package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt
CHANGED
|
@@ -10,11 +10,12 @@ import okhttp3.Response
|
|
|
10
10
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
11
11
|
import org.junit.Assert.assertEquals
|
|
12
12
|
import org.junit.Assert.assertFalse
|
|
13
|
+
import org.junit.Assert.assertTrue
|
|
13
14
|
import org.junit.Test
|
|
14
15
|
|
|
15
16
|
class BotaDeviceSDKAndroidRecordingUploadsTest {
|
|
16
17
|
@Test
|
|
17
|
-
fun
|
|
18
|
+
fun plaintextUploadRetainsNativeFileUntilDurableHostCompletion() = runTest {
|
|
18
19
|
val fixture = UploadFixture()
|
|
19
20
|
val requests = Collections.synchronizedList(mutableListOf<CapturedRequest>())
|
|
20
21
|
val uploads = BotaDeviceSDKAndroidRecordingUploads(
|
|
@@ -32,7 +33,7 @@ class BotaDeviceSDKAndroidRecordingUploadsTest {
|
|
|
32
33
|
progress::add,
|
|
33
34
|
)
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
assertTrue(fixture.recordingFile.exists())
|
|
36
37
|
assertEquals(
|
|
37
38
|
listOf(
|
|
38
39
|
CapturedRequest("PUT", "https://s3.example/recording", null),
|
|
@@ -87,10 +88,134 @@ class BotaDeviceSDKAndroidRecordingUploadsTest {
|
|
|
87
88
|
stubbedClient(mutableListOf()),
|
|
88
89
|
)
|
|
89
90
|
|
|
90
|
-
uploads.saveQueue("
|
|
91
|
-
uploads.saveQueue("
|
|
91
|
+
uploads.saveQueue(fixture.journal("pending"))
|
|
92
|
+
uploads.saveQueue(fixture.journal("failed"))
|
|
93
|
+
|
|
94
|
+
val tasks = org.json.JSONArray(uploads.loadQueue())
|
|
95
|
+
assertEquals(1, tasks.length())
|
|
96
|
+
assertEquals("failed", tasks.getJSONObject(0).getString("status"))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test
|
|
100
|
+
fun releaseRequiresExactCompletedJournalAndIsIdempotent() = runTest {
|
|
101
|
+
val fixture = UploadFixture()
|
|
102
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
103
|
+
uploads.saveQueue(fixture.journal("uploading"))
|
|
104
|
+
assertTrue(runCatching { uploads.release("task-1", fixture.recordingFile.path) }.isFailure)
|
|
105
|
+
assertTrue(fixture.recordingFile.exists())
|
|
106
|
+
uploads.saveQueue(fixture.journal("completed"))
|
|
107
|
+
assertTrue(runCatching { uploads.release("other-task", fixture.recordingFile.path) }.isFailure)
|
|
108
|
+
uploads.release("task-1", fixture.recordingFile.path)
|
|
109
|
+
uploads.release("task-1", fixture.recordingFile.path)
|
|
110
|
+
assertFalse(fixture.recordingFile.exists())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
fun loadIsReadOnlyUntilValidatedSaveAndRelease() = runTest {
|
|
115
|
+
val fixture = UploadFixture()
|
|
116
|
+
fixture.queueFile.writeText(fixture.journal("completed"))
|
|
117
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
118
|
+
val loaded = uploads.loadQueue()
|
|
119
|
+
assertEquals(fixture.journal("completed"), loaded)
|
|
120
|
+
assertTrue(fixture.recordingFile.exists())
|
|
121
|
+
uploads.saveQueue(loaded)
|
|
122
|
+
uploads.release("task-1", fixture.recordingFile.path)
|
|
123
|
+
assertFalse(fixture.recordingFile.exists())
|
|
124
|
+
assertFalse(fixture.queueFile.readText().contains("secret"))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@Test
|
|
128
|
+
fun corruptJournalIsNotOverwritten() = runTest {
|
|
129
|
+
val fixture = UploadFixture()
|
|
130
|
+
fixture.queueFile.writeText("{broken")
|
|
131
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
132
|
+
assertEquals("{broken", uploads.loadQueue())
|
|
133
|
+
assertEquals("{broken", fixture.queueFile.readText())
|
|
134
|
+
}
|
|
92
135
|
|
|
93
|
-
|
|
136
|
+
@Test
|
|
137
|
+
fun parseableInvalidJournalNeverMutatesOrDeletesFiles() = runTest {
|
|
138
|
+
for ((old, invalid) in listOf(
|
|
139
|
+
"\"retryCount\":0" to "\"retryCount\":-1",
|
|
140
|
+
"\"createdAt\":\"2026-09-24T00:00:00Z\"" to "\"createdAt\":\"invalid\"",
|
|
141
|
+
"\"status\":\"completed\"" to "\"status\":\"completed\",\"fileSizeBytes\":\"4\"",
|
|
142
|
+
"\"status\":\"completed\"" to "\"status\":\"completed\",\"recoveryScope\":123",
|
|
143
|
+
)) {
|
|
144
|
+
val fixture = UploadFixture()
|
|
145
|
+
val contents = fixture.journal("completed").replace(old, invalid)
|
|
146
|
+
fixture.queueFile.writeText(contents)
|
|
147
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
148
|
+
assertEquals(contents, uploads.loadQueue())
|
|
149
|
+
assertTrue(runCatching { uploads.saveQueue(contents) }.isFailure)
|
|
150
|
+
assertEquals(contents, fixture.queueFile.readText())
|
|
151
|
+
assertTrue(fixture.recordingFile.exists())
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
fun directorySyncFailurePreventsRelease() = runTest {
|
|
157
|
+
val fixture = UploadFixture()
|
|
158
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile, syncDirectory = { directory ->
|
|
159
|
+
if (directory == fixture.queueFile.parentFile) error("directory sync failed")
|
|
160
|
+
})
|
|
161
|
+
assertTrue(runCatching { uploads.saveQueue(fixture.journal("completed")) }.isFailure)
|
|
162
|
+
assertTrue(runCatching { uploads.release("task-1", fixture.recordingFile.path) }.isFailure)
|
|
163
|
+
assertTrue(fixture.recordingFile.exists())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@Test
|
|
167
|
+
fun cleanupDirectorySyncFailureRetainsJournalAndRetriesMissingFile() = runTest {
|
|
168
|
+
val fixture = UploadFixture()
|
|
169
|
+
val failure = java.io.IOException("cleanup directory sync failed")
|
|
170
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile, syncDirectory = { directory ->
|
|
171
|
+
if (directory == fixture.recordingFile.parentFile) throw failure
|
|
172
|
+
})
|
|
173
|
+
uploads.saveQueue(fixture.journal("completed"))
|
|
174
|
+
val journal = fixture.queueFile.readText()
|
|
175
|
+
|
|
176
|
+
repeat(2) {
|
|
177
|
+
val result = runCatching { uploads.release("task-1", fixture.recordingFile.path) }
|
|
178
|
+
assertTrue(result.exceptionOrNull() is java.io.IOException)
|
|
179
|
+
assertEquals(failure.message, result.exceptionOrNull()?.message)
|
|
180
|
+
assertFalse(fixture.recordingFile.exists())
|
|
181
|
+
assertEquals(journal, fixture.queueFile.readText())
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
val restarted = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
185
|
+
restarted.release("task-1", fixture.recordingFile.path)
|
|
186
|
+
assertFalse(fixture.recordingFile.exists())
|
|
187
|
+
assertEquals(journal, fixture.queueFile.readText())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@Test
|
|
191
|
+
fun cancelledAttemptCannotStartLate() = runTest {
|
|
192
|
+
val fixture = UploadFixture()
|
|
193
|
+
val requests = Collections.synchronizedList(mutableListOf<CapturedRequest>())
|
|
194
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile, stubbedClient(requests))
|
|
195
|
+
uploads.cancel("cancelled-attempt")
|
|
196
|
+
assertTrue(runCatching { uploads.upload(fixture.request("cancelled-attempt")) {} }.isFailure)
|
|
197
|
+
assertTrue(requests.isEmpty())
|
|
198
|
+
assertTrue(fixture.recordingFile.exists())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test
|
|
202
|
+
fun releaseNeverDeletesADirectory() = runTest {
|
|
203
|
+
val fixture = UploadFixture()
|
|
204
|
+
val child = File(fixture.recordingFile.parentFile, "must-retain").apply { mkdir() }
|
|
205
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
206
|
+
uploads.saveQueue(fixture.journal("completed").replace(fixture.recordingFile.path, child.path))
|
|
207
|
+
assertTrue(runCatching { uploads.release("task-1", child.path) }.isFailure)
|
|
208
|
+
assertTrue(child.isDirectory)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@Test
|
|
212
|
+
fun completedJournalCannotReleaseForeignFile() = runTest {
|
|
213
|
+
val fixture = UploadFixture()
|
|
214
|
+
val foreign = File(fixture.queueFile.parentFile, "${java.util.UUID.randomUUID()}.recording").apply { writeText("keep") }
|
|
215
|
+
val uploads = BotaDeviceSDKAndroidRecordingUploads(fixture.queueFile)
|
|
216
|
+
uploads.saveQueue(fixture.journal("completed").replace(fixture.recordingFile.path, foreign.path))
|
|
217
|
+
assertTrue(runCatching { uploads.release("task-1", foreign.path) }.isFailure)
|
|
218
|
+
assertTrue(foreign.exists())
|
|
94
219
|
}
|
|
95
220
|
|
|
96
221
|
private fun stubbedClient(requests: MutableList<CapturedRequest>): OkHttpClient =
|
|
@@ -121,11 +246,15 @@ private data class CapturedRequest(
|
|
|
121
246
|
|
|
122
247
|
private class UploadFixture {
|
|
123
248
|
private val directory = Files.createTempDirectory("bota-recording-upload").toFile()
|
|
124
|
-
val recordingFile = File(directory, "
|
|
249
|
+
val recordingFile = File(File(directory, "recordings").apply { mkdir() }, "${java.util.UUID.randomUUID()}.recording").apply {
|
|
125
250
|
writeBytes(byteArrayOf(1, 2, 3, 4))
|
|
126
251
|
}
|
|
127
252
|
val queueFile = File(directory, "queue.json")
|
|
128
253
|
|
|
254
|
+
fun journal(status: String): String = """
|
|
255
|
+
[{"id":"task-1","recordingId":"rec-1","deviceId":"device-1","localPath":"${recordingFile.path}","status":"$status","retryCount":0,"createdAt":"2026-09-24T00:00:00Z","updatedAt":"2026-09-24T00:00:00Z","uploadUrl":"https://secret","uploadToken":"secret","completeUrl":"https://secret","errorMessage":"secret","relay":{"url":"https://secret","bearerToken":"secret"}}]
|
|
256
|
+
""".trimIndent()
|
|
257
|
+
|
|
129
258
|
fun request(
|
|
130
259
|
taskId: String,
|
|
131
260
|
completeUrl: String? = null,
|