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

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 (125) hide show
  1. package/README.md +104 -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/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
  11. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  12. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  13. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  14. package/generated/codegen-contract.json +568 -8
  15. package/ios/BotaDeviceSDK.mm +114 -0
  16. package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
  17. package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
  18. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  19. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  20. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  21. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  22. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  23. package/lib/commonjs/BotaClient.js +16 -2
  24. package/lib/commonjs/BotaClient.js.map +1 -1
  25. package/lib/commonjs/ble/constants.js +5 -1
  26. package/lib/commonjs/ble/constants.js.map +1 -1
  27. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  28. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  29. package/lib/commonjs/client.js +183 -28
  30. package/lib/commonjs/client.js.map +1 -1
  31. package/lib/commonjs/clientPresence.js +26 -0
  32. package/lib/commonjs/clientPresence.js.map +1 -0
  33. package/lib/commonjs/index.js +29 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/managers/DeviceManager.js +23 -0
  36. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  37. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  38. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  39. package/lib/commonjs/managers/RecordingManager.js +83 -102
  40. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  41. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  42. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  43. package/lib/commonjs/sdkIdentity.js +10 -0
  44. package/lib/commonjs/sdkIdentity.js.map +1 -0
  45. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  46. package/lib/commonjs/uploadRecovery.js +68 -0
  47. package/lib/commonjs/uploadRecovery.js.map +1 -0
  48. package/lib/module/BotaClient.js +16 -2
  49. package/lib/module/BotaClient.js.map +1 -1
  50. package/lib/module/ble/constants.js +4 -0
  51. package/lib/module/ble/constants.js.map +1 -1
  52. package/lib/module/ble/deviceDiagnostics.js +234 -0
  53. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  54. package/lib/module/client.js +183 -28
  55. package/lib/module/client.js.map +1 -1
  56. package/lib/module/clientPresence.js +22 -0
  57. package/lib/module/clientPresence.js.map +1 -0
  58. package/lib/module/index.js +4 -0
  59. package/lib/module/index.js.map +1 -1
  60. package/lib/module/managers/DeviceManager.js +23 -0
  61. package/lib/module/managers/DeviceManager.js.map +1 -1
  62. package/lib/module/managers/NativeUploadQueue.js +380 -0
  63. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  64. package/lib/module/managers/RecordingManager.js +83 -102
  65. package/lib/module/managers/RecordingManager.js.map +1 -1
  66. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  67. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  68. package/lib/module/sdkIdentity.js +6 -0
  69. package/lib/module/sdkIdentity.js.map +1 -0
  70. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  71. package/lib/module/uploadRecovery.js +64 -0
  72. package/lib/module/uploadRecovery.js.map +1 -0
  73. package/lib/typescript/BotaClient.d.ts +2 -0
  74. package/lib/typescript/BotaClient.d.ts.map +1 -1
  75. package/lib/typescript/ble/constants.d.ts +4 -0
  76. package/lib/typescript/ble/constants.d.ts.map +1 -1
  77. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  78. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  79. package/lib/typescript/client.d.ts +24 -3
  80. package/lib/typescript/client.d.ts.map +1 -1
  81. package/lib/typescript/clientPresence.d.ts +26 -0
  82. package/lib/typescript/clientPresence.d.ts.map +1 -0
  83. package/lib/typescript/index.d.ts +10 -4
  84. package/lib/typescript/index.d.ts.map +1 -1
  85. package/lib/typescript/managers/DeviceManager.d.ts +5 -1
  86. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  87. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  88. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  89. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  90. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  91. package/lib/typescript/managers/types.d.ts +6 -1
  92. package/lib/typescript/managers/types.d.ts.map +1 -1
  93. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  94. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  95. package/lib/typescript/models/Device.d.ts +42 -0
  96. package/lib/typescript/models/Device.d.ts.map +1 -1
  97. package/lib/typescript/models/Recording.d.ts +47 -0
  98. package/lib/typescript/models/Recording.d.ts.map +1 -1
  99. package/lib/typescript/models/Status.d.ts +5 -1
  100. package/lib/typescript/models/Status.d.ts.map +1 -1
  101. package/lib/typescript/sdkIdentity.d.ts +3 -0
  102. package/lib/typescript/sdkIdentity.d.ts.map +1 -0
  103. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
  104. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  105. package/lib/typescript/uploadRecovery.d.ts +27 -0
  106. package/lib/typescript/uploadRecovery.d.ts.map +1 -0
  107. package/package.json +1 -1
  108. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  109. package/src/BotaClient.ts +17 -3
  110. package/src/ble/constants.ts +5 -0
  111. package/src/ble/deviceDiagnostics.ts +238 -0
  112. package/src/client.ts +189 -18
  113. package/src/clientPresence.ts +33 -0
  114. package/src/index.ts +18 -0
  115. package/src/managers/DeviceManager.ts +23 -0
  116. package/src/managers/NativeUploadQueue.ts +330 -0
  117. package/src/managers/RecordingManager.ts +83 -116
  118. package/src/managers/types.ts +7 -1
  119. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  120. package/src/models/Device.ts +43 -0
  121. package/src/models/Recording.ts +52 -0
  122. package/src/models/Status.ts +5 -1
  123. package/src/sdkIdentity.ts +3 -0
  124. package/src/specs/NativeBotaDeviceSDK.ts +66 -0
  125. package/src/uploadRecovery.ts +66 -0
@@ -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,
@@ -33,13 +33,199 @@ import kotlinx.coroutines.flow.Flow
33
33
  import kotlinx.coroutines.flow.flowOf
34
34
  import kotlinx.coroutines.flow.flow
35
35
  import kotlinx.coroutines.test.runTest
36
+ import kotlinx.coroutines.CompletableDeferred
37
+ import kotlinx.coroutines.CancellationException
38
+ import kotlinx.coroutines.NonCancellable
39
+ import kotlinx.coroutines.async
40
+ import kotlinx.coroutines.launch
41
+ import kotlinx.coroutines.withContext
42
+ import kotlinx.coroutines.withTimeout
43
+ import kotlinx.coroutines.yield
36
44
  import okhttp3.Request
37
45
  import org.junit.Assert.assertEquals
38
46
  import org.junit.Assert.assertNull
39
47
  import org.junit.Assert.assertTrue
48
+ import org.junit.Assert.assertFalse
49
+ import org.junit.Assert.assertArrayEquals
40
50
  import org.junit.Test
41
51
 
42
52
  class BotaDeviceSDKAndroidRecordingsTest {
53
+ @Test
54
+ fun pendingCatalogPreservesMixedProfilesAndExactDecimalMetadata() = runTest {
55
+ val encrypted = encryptedRecording()
56
+ val client = TestAndroidRecordingClient(recording(), context(encrypted))
57
+ val recordings = BotaDeviceSDKAndroidRecordings(client)
58
+ val entries = recordings.listPendingRecordings(connectedDevice())
59
+ assertEquals(2, entries.size)
60
+ assertEquals(recording(), (entries[0] as dev.bota.sdk.PendingRecording.Legacy).recording)
61
+ val metadata = (entries[1] as dev.bota.sdk.PendingRecording.EncryptedV2).recording.toBridgeValue()
62
+ assertEquals(encrypted.uuid, metadata.uuid)
63
+ assertEquals("18446744073709551615", metadata.ciphertextLength)
64
+ assertEquals("9007199254740993", metadata.startedAtMs)
65
+ assertEquals("9007199254740994", metadata.durationMs)
66
+ assertEquals("9007199254740995", metadata.plaintextLength)
67
+ assertEquals(3, metadata.storageFormat)
68
+ assertEquals("5a".repeat(32), metadata.ciphertextSha256)
69
+ assertTrue(metadata.javaClass.declaredFields.none { it.type == ByteArray::class.java })
70
+ }
71
+
72
+ @Test
73
+ fun preCancelledEncryptedOperationNeverEntersNativeSdk() = runTest {
74
+ val client = TestAndroidRecordingClient(recording(), context())
75
+ val recordings = BotaDeviceSDKAndroidRecordings(client)
76
+ recordings.cancelEncryptedRecordingV2("pre-cancelled")
77
+ val outcome = runCatching {
78
+ recordings.syncEncryptedRecordingV2(connectedDevice(), encryptedRecording(), "pre-cancelled", {}, {})
79
+ }
80
+ assertTrue(outcome.exceptionOrNull() is CancellationException)
81
+ assertTrue(client.encryptedOperationIds.isEmpty())
82
+ assertTrue(client.cancelledEncryptedIds.isEmpty())
83
+ }
84
+
85
+ @Test
86
+ fun cancellationWhileProfilePendingJoinsExactNativeCleanup() = runTest {
87
+ checkPendingCancellation(bulk = false)
88
+ }
89
+
90
+ @Test
91
+ fun bulkCancellationWhileProfilePendingJoinsExactNativeCleanup() = runTest {
92
+ checkPendingCancellation(bulk = true)
93
+ }
94
+
95
+ private suspend fun kotlinx.coroutines.test.TestScope.checkPendingCancellation(bulk: Boolean) {
96
+ val client = TestAndroidRecordingClient(recording(), context())
97
+ val recordings = BotaDeviceSDKAndroidRecordings(client)
98
+ val requested = CompletableDeferred<BotaEncryptedUploadV2ProfileRequest>()
99
+ val cleanupEntered = CompletableDeferred<Unit>()
100
+ val cleanupReleased = CompletableDeferred<Unit>()
101
+ client.encryptedFinally = {
102
+ withContext(NonCancellable) { cleanupEntered.complete(Unit); cleanupReleased.await() }
103
+ }
104
+ val sync = launch {
105
+ runCatching { recordings.syncEncryptedRecordingV2(connectedDevice(), encryptedRecording(), "pending",
106
+ { requested.complete(it) }, {}) }
107
+ }
108
+ withTimeout(5_000) { requested.await() }
109
+ val cancel = async {
110
+ if (bulk) recordings.cancelAll() else recordings.cancelEncryptedRecordingV2("pending")
111
+ }
112
+ withTimeout(5_000) { cleanupEntered.await() }
113
+ assertFalse(cancel.isCompleted)
114
+ cleanupReleased.complete(Unit)
115
+ withTimeout(5_000) { cancel.await() }
116
+ assertTrue(sync.isCompleted)
117
+ assertEquals(client.encryptedOperationIds, client.cancelledEncryptedIds)
118
+ assertTrue(runCatching { BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce("pending") }.isFailure)
119
+ }
120
+
121
+ @Test
122
+ fun selfCancellationCompletesNativeCleanupWithoutJoiningItsOwnJob() = runTest {
123
+ val client = TestAndroidRecordingClient(recording(), context())
124
+ val recordings = BotaDeviceSDKAndroidRecordings(client)
125
+ var cleanupFinished = false
126
+ client.cancelEncrypted = { yield(); cleanupFinished = true }
127
+ client.beforeEncrypted = { recordings.cancelEncryptedRecordingV2("self") }
128
+ val sync = launch {
129
+ runCatching { recordings.syncEncryptedRecordingV2(connectedDevice(), encryptedRecording(), "self", {}, {}) }
130
+ }
131
+ withTimeout(5_000) { sync.join() }
132
+ assertTrue(cleanupFinished)
133
+ assertEquals(client.encryptedOperationIds, client.cancelledEncryptedIds)
134
+ }
135
+
136
+ @Test
137
+ fun resolvedMaterialIsReleasedWhenCancellationWinsBeforeProviderDelivery() = runTest {
138
+ var releases = 0
139
+ val selected = material { releases++ }
140
+ val registration = BotaDeviceSDKEncryptedUploadV2Materials.register(selected)
141
+ val client = TestAndroidRecordingClient(recording(), context())
142
+ val recordings = BotaDeviceSDKAndroidRecordings(client)
143
+ val requested = CompletableDeferred<BotaEncryptedUploadV2ProfileRequest>()
144
+ val sync = launch {
145
+ runCatching { recordings.syncEncryptedRecordingV2(connectedDevice(), encryptedRecording(), "delivery",
146
+ { requested.complete(it) }, {}) }
147
+ }
148
+ val request = withTimeout(5_000) { requested.await() }
149
+ recordings.resolveEncryptedUploadV2Profile(request.requestId, "encrypted_upload_v2",
150
+ selected.uploadSessionId.toString(), selected.ownerRevision, "v2_required", registration)
151
+ sync.cancel()
152
+ withTimeout(5_000) { sync.join() }
153
+ assertEquals(1, releases)
154
+ assertNull(client.encryptedMaterialId)
155
+ assertNull(BotaDeviceSDKEncryptedUploadV2Materials.remove(registration))
156
+ }
157
+
158
+ @Test
159
+ fun lateProfileResolutionAndReleaseAreIdempotentAndDoNotRetainMaterial() = runTest {
160
+ var releases = 0
161
+ val selected = material { releases++ }
162
+ val registration = BotaDeviceSDKEncryptedUploadV2Materials.register(selected)
163
+ val recordings = BotaDeviceSDKAndroidRecordings(TestAndroidRecordingClient(recording(), context()))
164
+ val outcome = runCatching { recordings.resolveEncryptedUploadV2Profile("obsolete", "encrypted_upload_v2",
165
+ selected.uploadSessionId.toString(), selected.ownerRevision, "v2_required", registration) }
166
+ assertTrue(outcome.isFailure)
167
+ assertEquals(1, releases)
168
+ BotaDeviceSDKEncryptedUploadV2Materials.release(registration)
169
+ BotaDeviceSDKEncryptedUploadV2Materials.release(registration)
170
+ assertEquals(1, releases)
171
+ assertNull(BotaDeviceSDKEncryptedUploadV2Materials.remove(registration))
172
+ }
173
+
174
+ @Test
175
+ fun nonceLeaseEndsOnRejectOrResolveAndRejectsAnInFlightStaleRead() = runTest {
176
+ for (resolve in listOf(false, true)) {
177
+ val reading = CompletableDeferred<Unit>()
178
+ val nonceReady = CompletableDeferred<ByteArray>()
179
+ val client = TestAndroidRecordingClient(recording(), context(readNonce = {
180
+ reading.complete(Unit); nonceReady.await()
181
+ }))
182
+ val recordings = BotaDeviceSDKAndroidRecordings(client)
183
+ val requested = CompletableDeferred<BotaEncryptedUploadV2ProfileRequest>()
184
+ val operationId = "nonce-$resolve"
185
+ val sync = launch {
186
+ runCatching { recordings.syncEncryptedRecordingV2(connectedDevice(), encryptedRecording(), operationId,
187
+ { requested.complete(it) }, {}) }
188
+ }
189
+ val request = withTimeout(5_000) { requested.await() }
190
+ val read = async { runCatching { BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce(operationId) } }
191
+ withTimeout(5_000) { reading.await() }
192
+ if (resolve) {
193
+ val selected = material()
194
+ recordings.resolveEncryptedUploadV2Profile(request.requestId, "encrypted_upload_v2",
195
+ selected.uploadSessionId.toString(), selected.ownerRevision, "v2_required",
196
+ BotaDeviceSDKEncryptedUploadV2Materials.register(selected))
197
+ } else recordings.rejectEncryptedUploadV2Profile(request.requestId, "application_material_rejected")
198
+ nonceReady.complete(ByteArray(16))
199
+ assertTrue(withTimeout(5_000) { read.await() }.isFailure)
200
+ assertTrue(runCatching { BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce(operationId) }.isFailure)
201
+ withTimeout(5_000) { sync.join() }
202
+ }
203
+ }
204
+
205
+ @Test
206
+ fun registryNonceIsCopiedAndCannotSurviveAnOperationLeaseReplacement() = runTest {
207
+ val nonce = ByteArray(16) { 5 }
208
+ BotaDeviceSDKEncryptedUploadV2Materials.registerContext("lease", context(readNonce = { nonce }))
209
+ try {
210
+ val value = BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce("lease")
211
+ value.fill(1)
212
+ assertArrayEquals(ByteArray(16) { 5 }, nonce)
213
+ val readStarted = CompletableDeferred<Unit>()
214
+ val finishRead = CompletableDeferred<Unit>()
215
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext("lease")
216
+ BotaDeviceSDKEncryptedUploadV2Materials.registerContext("lease", context(readNonce = {
217
+ readStarted.complete(Unit); finishRead.await(); nonce
218
+ }))
219
+ val read = async { runCatching { BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce("lease") } }
220
+ withTimeout(5_000) { readStarted.await() }
221
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext("lease")
222
+ BotaDeviceSDKEncryptedUploadV2Materials.registerContext("lease", context(readNonce = { ByteArray(16) { 7 } }))
223
+ finishRead.complete(Unit)
224
+ assertTrue(withTimeout(5_000) { read.await() }.isFailure)
225
+ assertArrayEquals(ByteArray(16) { 7 }, BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce("lease"))
226
+ } finally { BotaDeviceSDKEncryptedUploadV2Materials.removeContext("lease") }
227
+ }
228
+
43
229
  @Test
44
230
  fun encryptedUploadV2UsesOneShotNativeMaterialAndSafeBridgeValues() = runTest {
45
231
  val recordingUuid = "00112233-4455-6677-8899-aabbccddeeff"
@@ -98,14 +284,16 @@ class BotaDeviceSDKAndroidRecordingsTest {
98
284
  "operation-1",
99
285
  onProfileRequest = { request ->
100
286
  requests += request
101
- recordings.resolveEncryptedUploadV2Profile(
102
- request.requestId,
103
- "encrypted_upload_v2",
104
- uploadSessionId.toString(),
105
- 2u,
106
- "v2_required",
107
- registrationId,
108
- )
287
+ launch {
288
+ recordings.resolveEncryptedUploadV2Profile(
289
+ request.requestId,
290
+ "encrypted_upload_v2",
291
+ uploadSessionId.toString(),
292
+ 2u,
293
+ "v2_required",
294
+ registrationId,
295
+ )
296
+ }
109
297
  },
110
298
  onProgress = progress::add,
111
299
  )
@@ -176,7 +364,7 @@ class BotaDeviceSDKAndroidRecordingsTest {
176
364
  isEncrypted = true,
177
365
  )
178
366
  val client = TestAndroidRecordingClient(recording)
179
- val recordings = BotaDeviceSDKAndroidRecordings(client)
367
+ val recordings = BotaDeviceSDKAndroidRecordings(client, fileSize = { 48_036L })
180
368
  val progress = mutableListOf<RecordingTransferProgress>()
181
369
 
182
370
  assertEquals(listOf(recording), recordings.listRecordings(connected))
@@ -185,6 +373,7 @@ class BotaDeviceSDKAndroidRecordingsTest {
185
373
  "/tmp/bota-recordings/recording-1.ogg",
186
374
  true,
187
375
  "5a".repeat(32),
376
+ 48_036L,
188
377
  ),
189
378
  recordings.syncRecording(connected, recording, "sink-1", progress::add),
190
379
  )
@@ -280,6 +469,23 @@ class BotaDeviceSDKAndroidRecordingsTest {
280
469
  isEncrypted = true,
281
470
  )
282
471
 
472
+ private fun encryptedRecording() = EncryptedUploadV2Recording(
473
+ "00112233-4455-6677-8899-aabbccddeeff", 4u, ULong.MAX_VALUE, ByteArray(32) { 0x5a },
474
+ 9007199254740993u, 9007199254740994u, 9007199254740995u, 3u,
475
+ )
476
+
477
+ private fun context(recording: EncryptedUploadV2Recording = encryptedRecording(),
478
+ readNonce: suspend () -> ByteArray = { ByteArray(16) { 6 } },
479
+ ) = EncryptedUploadV2ProviderContext(recording,
480
+ EncryptedUploadV2CapabilitySnapshot(ByteArray(24), ByteArray(32),
481
+ EncryptedUploadV2Capabilities(0x37fu, 580u, 580u, 128u, 8u, 1u, 8u)), null, readNonce)
482
+
483
+ private fun material(cancel: suspend () -> Unit = {}) = EncryptedUploadV2Material(
484
+ "native-material", encryptedRecording().uuid, UUID.fromString("10213243-5465-7687-98a9-bacbdcedfe0f"),
485
+ 2u, EncryptedUploadV2SecurityPolicy.V2Required, ByteArray(408),
486
+ { Request.Builder().url("https://native.invalid/stage").build() }, { _, _ -> }, {}, { ByteArray(336) }, cancel,
487
+ )
488
+
283
489
  private class TestAndroidRecordingClient(
284
490
  private val recording: DeviceRecording,
285
491
  private val encryptedUploadV2Context: EncryptedUploadV2ProviderContext? = null,
@@ -288,10 +494,19 @@ class BotaDeviceSDKAndroidRecordingsTest {
288
494
  var cancelled = false
289
495
  val sinkIds = mutableListOf<String>()
290
496
  var encryptedMaterialId: String? = null
497
+ val encryptedOperationIds = mutableListOf<UUID>()
498
+ val cancelledEncryptedIds = mutableListOf<UUID>()
499
+ var beforeEncrypted: suspend () -> Unit = {}
500
+ var encryptedFinally: suspend () -> Unit = {}
501
+ var cancelEncrypted: suspend () -> Unit = {}
291
502
 
292
503
  override suspend fun listRecordings(device: ConnectedDevice): List<DeviceRecording> =
293
504
  listOf(recording)
294
505
 
506
+ override suspend fun listPendingRecordings(device: ConnectedDevice): List<dev.bota.sdk.PendingRecording> =
507
+ listOfNotNull(dev.bota.sdk.PendingRecording.Legacy(recording),
508
+ encryptedUploadV2Context?.let { dev.bota.sdk.PendingRecording.EncryptedV2(it.recording) })
509
+
295
510
  override fun syncRecording(
296
511
  device: ConnectedDevice,
297
512
  recording: DeviceRecording,
@@ -307,11 +522,22 @@ class BotaDeviceSDKAndroidRecordingsTest {
307
522
  override suspend fun syncEncryptedRecordingV2(
308
523
  device: ConnectedDevice,
309
524
  recording: EncryptedUploadV2Recording,
525
+ operationId: UUID,
310
526
  provider: EncryptedUploadV2ProfileProvider,
311
527
  ) {
528
+ encryptedOperationIds += operationId
312
529
  encryptedUploadV2Error?.let { throw it }
313
530
  val context = requireNotNull(encryptedUploadV2Context)
314
- encryptedMaterialId = provider.select(context).materialId
531
+ try {
532
+ beforeEncrypted()
533
+ encryptedMaterialId = provider.select(context).materialId
534
+ } finally { encryptedFinally() }
535
+ }
536
+
537
+ override suspend fun cancelEncryptedRecordingV2(operationId: UUID) {
538
+ cancelledEncryptedIds += operationId
539
+ cancelEncrypted()
540
+ cancelled = true
315
541
  }
316
542
 
317
543
  override suspend fun confirmRecording(device: ConnectedDevice, recordingUuid: String) = Unit