@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,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))
@@ -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 plaintextUploadUsesPutCompletesAndDeletesNativeFile() = runTest {
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
- assertFalse(fixture.recordingFile.exists())
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("[{\"id\":\"first\"}]")
91
- uploads.saveQueue("[{\"id\":\"second\"}]")
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
- assertEquals("[{\"id\":\"second\"}]", uploads.loadQueue())
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, "recording.bin").apply {
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,