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

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 (100) hide show
  1. package/README.md +66 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +68 -3
  9. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  10. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  11. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  12. package/generated/codegen-contract.json +495 -8
  13. package/ios/BotaDeviceSDK.mm +102 -0
  14. package/ios/BotaDeviceSDKAppleBridge.swift +126 -4
  15. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  16. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  17. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  18. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  19. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  20. package/lib/commonjs/BotaClient.js +13 -2
  21. package/lib/commonjs/BotaClient.js.map +1 -1
  22. package/lib/commonjs/ble/constants.js +5 -1
  23. package/lib/commonjs/ble/constants.js.map +1 -1
  24. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  25. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  26. package/lib/commonjs/client.js +163 -28
  27. package/lib/commonjs/client.js.map +1 -1
  28. package/lib/commonjs/index.js +15 -0
  29. package/lib/commonjs/index.js.map +1 -1
  30. package/lib/commonjs/managers/DeviceManager.js +8 -0
  31. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  32. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  33. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  34. package/lib/commonjs/managers/RecordingManager.js +83 -102
  35. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  36. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  37. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  38. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  39. package/lib/module/BotaClient.js +13 -2
  40. package/lib/module/BotaClient.js.map +1 -1
  41. package/lib/module/ble/constants.js +4 -0
  42. package/lib/module/ble/constants.js.map +1 -1
  43. package/lib/module/ble/deviceDiagnostics.js +234 -0
  44. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  45. package/lib/module/client.js +163 -28
  46. package/lib/module/client.js.map +1 -1
  47. package/lib/module/index.js +2 -0
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/managers/DeviceManager.js +8 -0
  50. package/lib/module/managers/DeviceManager.js.map +1 -1
  51. package/lib/module/managers/NativeUploadQueue.js +380 -0
  52. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  53. package/lib/module/managers/RecordingManager.js +83 -102
  54. package/lib/module/managers/RecordingManager.js.map +1 -1
  55. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  56. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  57. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  58. package/lib/typescript/BotaClient.d.ts.map +1 -1
  59. package/lib/typescript/ble/constants.d.ts +4 -0
  60. package/lib/typescript/ble/constants.d.ts.map +1 -1
  61. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  62. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  63. package/lib/typescript/client.d.ts +18 -2
  64. package/lib/typescript/client.d.ts.map +1 -1
  65. package/lib/typescript/index.d.ts +6 -4
  66. package/lib/typescript/index.d.ts.map +1 -1
  67. package/lib/typescript/managers/DeviceManager.d.ts +3 -1
  68. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  69. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  70. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  71. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  72. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  73. package/lib/typescript/managers/types.d.ts +6 -1
  74. package/lib/typescript/managers/types.d.ts.map +1 -1
  75. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  76. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  77. package/lib/typescript/models/Device.d.ts +42 -0
  78. package/lib/typescript/models/Device.d.ts.map +1 -1
  79. package/lib/typescript/models/Recording.d.ts +47 -0
  80. package/lib/typescript/models/Recording.d.ts.map +1 -1
  81. package/lib/typescript/models/Status.d.ts +5 -1
  82. package/lib/typescript/models/Status.d.ts.map +1 -1
  83. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +61 -0
  84. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  87. package/src/BotaClient.ts +13 -3
  88. package/src/ble/constants.ts +5 -0
  89. package/src/ble/deviceDiagnostics.ts +238 -0
  90. package/src/client.ts +166 -18
  91. package/src/index.ts +12 -0
  92. package/src/managers/DeviceManager.ts +11 -0
  93. package/src/managers/NativeUploadQueue.ts +330 -0
  94. package/src/managers/RecordingManager.ts +83 -116
  95. package/src/managers/types.ts +7 -1
  96. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  97. package/src/models/Device.ts +43 -0
  98. package/src/models/Recording.ts +52 -0
  99. package/src/models/Status.ts +5 -1
  100. package/src/specs/NativeBotaDeviceSDK.ts +56 -0
package/README.md CHANGED
@@ -14,19 +14,25 @@ into the native Apple and Android SDKs.
14
14
 
15
15
  ## Install
16
16
 
17
- This source prepares synchronized `2.0.0-beta.1`; use the exact pin below after
18
- publication. Version `2.0.0-beta.0` remains the previous published release.
17
+ This source prepares synchronized `2.0.0-beta.3`; use the exact pin below after
18
+ publication. Version `2.0.0-beta.1` remains the previous published release.
19
19
  Remove `@bota.dev/react-native-sdk` before adding the replacement; do not
20
20
  co-install both. Production maintenance 0.0.x consumers need not migrate.
21
21
 
22
22
  ```bash
23
- npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.1
23
+ npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.3
24
24
  npx pod-install
25
25
  ```
26
26
 
27
27
  Rebuild the native iOS and Android applications after installation. An Expo Go
28
28
  runtime cannot load this native module; use a development or production build.
29
29
 
30
+ An application-native upload pod may depend on `BotaDeviceSDK`. With static
31
+ SwiftPM linkage, the SDK's CocoaPods workaround normalizes the module-map path
32
+ in both aggregate and dependent-pod cached build settings. This preserves the
33
+ path when Expo later writes macro flags. Do not work around missing module maps
34
+ by adding another copy of the Apple SDK.
35
+
30
36
  Expo prebuild and EAS projects must set the Android minimum SDK explicitly. Add
31
37
  the Expo-compatible `expo-build-properties` package and configure it in the app
32
38
  config:
@@ -59,6 +65,12 @@ await BotaClient.waitForBluetooth();
59
65
  The `BotaClient.devices`, `BotaClient.recordings`, and `BotaClient.ota`
60
66
  managers become available after configuration completes.
61
67
 
68
+ Discovery exposes the manufacturer-advertised `macAddress` as uppercase,
69
+ colon-separated bytes (`AA:BB:CC:DD:EE:FF`), preserving the legacy React Native
70
+ registration contract. Compact native addresses are formatted at the RN
71
+ boundary; missing or malformed advertised values become `null`. The peripheral
72
+ `id` is unchanged and is never substituted for an absent advertised MAC.
73
+
62
74
  ## Encrypted upload v2
63
75
 
64
76
  The target encrypted-upload-v2 runtime is an additive `BotaDeviceSDK` API; it
@@ -92,6 +104,57 @@ Its target-facade tests and the maintenance SDK `0.0.67` runtime tests are
92
104
  cross-referenced by the same canonical v2 workflow evidence in CI and tagged
93
105
  release verification. The frozen public compatibility surface remains `0.0.65`.
94
106
 
107
+ ### Unpublished Native App Integration
108
+
109
+ Current source adds `BotaDeviceSDK.recordings.listPendingRecordings(device)`.
110
+ It returns legacy recordings and v2 entries tagged by `storageFormat: 3`, with
111
+ the full UUID/generation, ciphertext length/hash and catalog metadata. Catalog
112
+ errors do not silently downgrade to legacy listing. These changes require
113
+ matching native source and a newly built application; beta.1 is insufficient.
114
+
115
+ The v2 sync method accepts a fifth argument `{ signal, operationId }`. The
116
+ optional operation ID is a fresh UUID shared with an application-native
117
+ adapter; cancellation stops and awaits that exact operation. Native provider
118
+ contexts expose an operation-scoped `readAuthNonce` callback. An RN application's
119
+ Swift/Kotlin adapter can call
120
+ `BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce(operationId)` while the
121
+ profile request is pending; it must not make a competing public BLE read.
122
+
123
+ Native material must supply `uploadContext`, which relays opaque challenge/proof
124
+ documents through the application backend. The SDK owns the bounded device
125
+ handshake before authorization and the first completion receipt. Ordinary v2
126
+ requires capability mask `0x17f`; expired-session replacement requires `0x37f`.
127
+ Neither the app nor SDK verifies backend signatures or trusts the phone clock
128
+ as a substitute for device verification. Cancellation discards late native
129
+ material via `cancelPreparation`; it does not delete recoverable recordings.
130
+ Historical checkpoints without ciphertext identity may resume the same owner
131
+ but cannot authorize replacement. See `docs/parity/v2-demo-*.md` for evidence
132
+ and limitations. Hardware and publication gates remain unchanged.
133
+
134
+ ## Unpublished Maintenance Additions
135
+
136
+ Current source follows the diagnostic and upload-recovery additions from
137
+ maintenance SDK commit `318974f925a573cf04b0d624978bee04784af09b`. These additions
138
+ are not part of the published `2.0.0-beta.1` package.
139
+
140
+ - `BotaClient.devices.readDiagnosticEvents(device)` returns a bounded batch.
141
+ Reading does not delete it. After the application durably accepts events,
142
+ call `acknowledgeDiagnosticEvents(device, acceptedEventIds)` with their exact
143
+ 16-character lowercase hexadecimal IDs. The native `BotaDeviceSDK.logs` API
144
+ exposes the same methods. Firmware must support the diagnostics service.
145
+ - Configure `uploadRecoveryProvider` to refresh upload credentials for the
146
+ original recording and account/project scope after restart. Only non-secret
147
+ queue metadata persists; recording bytes stay in native files. Completion
148
+ acknowledgement is required before releasing recoverable files.
149
+ - Legacy `RecordingDataStore` JavaScript byte callbacks are not supported.
150
+ Supplying one fails configuration explicitly. Use the native file store and
151
+ credential/completion callbacks instead; this is a migration adaptation,
152
+ not byte-store API equivalence.
153
+
154
+ See the repository's `docs/parity/` notes for recovery contracts and test
155
+ coverage. This does not enable encrypted-v2 device support or complete the
156
+ separate physical-device and application rollout gates.
157
+
95
158
  ## Documentation
96
159
 
97
160
  See [docs.bota.dev](https://docs.bota.dev) for pairing, provisioning,
@@ -72,5 +72,6 @@ dependencies {
72
72
  implementation "${androidMetadata.mavenCoordinate}:${packageVersion}"
73
73
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${androidMetadata.coroutinesVersion}"
74
74
  testImplementation "junit:junit:4.13.2"
75
+ testImplementation "org.json:json:20240303"
75
76
  testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${androidMetadata.coroutinesVersion}"
76
77
  }
@@ -0,0 +1,53 @@
1
+ package dev.bota.sdk.reactnative
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import dev.bota.sdk.model.DeviceDiagnosticsBatch
6
+ import dev.bota.sdk.model.DeviceDiagnosticEvent
7
+
8
+ internal fun DeviceDiagnosticsBatch.toWritableMap(): WritableMap = Arguments.createMap().apply {
9
+ putInt("schema_version", schemaVersion)
10
+ putArray("events", Arguments.createArray().apply { events.forEach { pushMap(it.toWritableMap()) } })
11
+ }
12
+
13
+ private fun DeviceDiagnosticEvent.toWritableMap(): WritableMap = Arguments.createMap().apply {
14
+ putString("event_id", eventId)
15
+ putString("event_type", eventType)
16
+ putString("reason_code", reasonCode)
17
+ putDouble("uptime_ms", uptimeMs.toDouble())
18
+ putString("signature", signature)
19
+ putString("firmware_build_id", firmwareBuildId)
20
+ putString("subsystem", subsystem)
21
+ putString("state_before_event", stateBeforeEvent)
22
+ report?.let { detail ->
23
+ putMap("report", Arguments.createMap().apply {
24
+ putMap("fault", Arguments.createMap().apply {
25
+ putInt("cpu_id", detail.fault.cpuId)
26
+ putString("cpu_emu", detail.fault.cpuEmu)
27
+ putString("core_emu", detail.fault.coreEmu)
28
+ putString("hsb_emu", detail.fault.hsbEmu)
29
+ putString("audio_emu", detail.fault.audioEmu)
30
+ putString("wireless_emu", detail.fault.wirelessEmu)
31
+ })
32
+ putMap("execution", Arguments.createMap().apply {
33
+ detail.execution.task?.let { putString("task", it) }
34
+ detail.execution.reti?.let { putString("reti", it) }
35
+ detail.execution.rets?.let { putString("rets", it) }
36
+ putArray("pc_trace", Arguments.createArray().apply { detail.execution.pcTrace.forEach { pushString(it) } })
37
+ })
38
+ detail.runtime?.let { runtime ->
39
+ putMap("runtime", Arguments.createMap().apply {
40
+ runtime.heapFreeBytes?.let { putDouble("heap_free_bytes", it.toDouble()) }
41
+ runtime.taskStackRemainingBytes?.let { putDouble("task_stack_remaining_bytes", it.toDouble()) }
42
+ })
43
+ }
44
+ detail.breadcrumbs?.let { breadcrumbs ->
45
+ putArray("breadcrumbs", Arguments.createArray().apply {
46
+ breadcrumbs.forEach { pushMap(Arguments.createMap().apply {
47
+ putInt("delta_ms", it.deltaMs); putString("code", it.code); putInt("arg0", it.arg0)
48
+ }) }
49
+ })
50
+ }
51
+ })
52
+ }
53
+ }
@@ -3,6 +3,7 @@ package dev.bota.sdk.reactnative
3
3
  import dev.bota.sdk.BotaDeviceClient
4
4
  import dev.bota.sdk.model.ConnectedDevice
5
5
  import dev.bota.sdk.model.DeviceLogLine
6
+ import dev.bota.sdk.model.DeviceDiagnosticsBatch
6
7
  import kotlinx.coroutines.CancellationException
7
8
  import kotlinx.coroutines.CoroutineScope
8
9
  import kotlinx.coroutines.CoroutineStart
@@ -15,6 +16,8 @@ import kotlinx.coroutines.sync.Mutex
15
16
  import kotlinx.coroutines.sync.withLock
16
17
 
17
18
  internal interface BotaDeviceSDKAndroidLogClient {
19
+ suspend fun readDiagnosticEvents(device: ConnectedDevice): DeviceDiagnosticsBatch
20
+ suspend fun acknowledgeDiagnosticEvents(device: ConnectedDevice, acceptedEventIds: List<String>)
18
21
  fun streamLogs(device: ConnectedDevice): Flow<DeviceLogLine>
19
22
 
20
23
  suspend fun stop()
@@ -29,12 +32,25 @@ internal class BotaDeviceSDKSharedAndroidLogClient(
29
32
  override suspend fun stop() {
30
33
  client.logs.stop()
31
34
  }
35
+
36
+ override suspend fun readDiagnosticEvents(device: ConnectedDevice): DeviceDiagnosticsBatch =
37
+ client.logs.readDiagnosticEvents(device)
38
+
39
+ override suspend fun acknowledgeDiagnosticEvents(device: ConnectedDevice, acceptedEventIds: List<String>) {
40
+ client.logs.acknowledgeDiagnosticEvents(device, acceptedEventIds)
41
+ }
32
42
  }
33
43
 
34
44
  internal class BotaDeviceSDKAndroidLogs(
35
45
  private val client: BotaDeviceSDKAndroidLogClient,
36
46
  private val scope: CoroutineScope,
37
47
  ) {
48
+ suspend fun readDiagnosticEvents(device: ConnectedDevice): DeviceDiagnosticsBatch =
49
+ client.readDiagnosticEvents(device)
50
+
51
+ suspend fun acknowledgeDiagnosticEvents(device: ConnectedDevice, acceptedEventIds: List<String>) {
52
+ client.acknowledgeDiagnosticEvents(device, acceptedEventIds)
53
+ }
38
54
  private val operations = Mutex()
39
55
  private val streamLock = Any()
40
56
  private var activeStream: Job? = null
@@ -5,11 +5,18 @@ import com.facebook.react.bridge.Arguments
5
5
  import com.facebook.react.bridge.ReadableMap
6
6
  import com.facebook.react.bridge.WritableMap
7
7
  import java.io.File
8
+ import java.io.FileOutputStream
8
9
  import java.nio.file.AtomicMoveNotSupportedException
9
10
  import java.nio.file.Files
10
11
  import java.nio.file.StandardCopyOption
12
+ import java.nio.channels.FileChannel
13
+ import java.nio.file.StandardOpenOption
11
14
  import java.util.concurrent.ConcurrentHashMap
12
15
  import kotlinx.coroutines.Dispatchers
16
+ import kotlinx.coroutines.currentCoroutineContext
17
+ import kotlinx.coroutines.ensureActive
18
+ import kotlinx.coroutines.sync.Mutex
19
+ import kotlinx.coroutines.sync.withLock
13
20
  import kotlinx.coroutines.withContext
14
21
  import okhttp3.Call
15
22
  import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -17,6 +24,8 @@ import okhttp3.OkHttpClient
17
24
  import okhttp3.Request
18
25
  import okhttp3.RequestBody.Companion.asRequestBody
19
26
  import okhttp3.RequestBody.Companion.toRequestBody
27
+ import org.json.JSONArray
28
+ import org.json.JSONObject
20
29
 
21
30
  internal data class BotaRecordingUploadRequest(
22
31
  val taskId: String,
@@ -29,6 +38,7 @@ internal data class BotaRecordingUploadRequest(
29
38
  val contentSha256: String?,
30
39
  val relayUrl: String?,
31
40
  val relayBearerToken: String?,
41
+ val fileSizeBytes: Long? = null,
32
42
  )
33
43
 
34
44
  internal data class BotaRecordingUploadProgress(
@@ -40,21 +50,51 @@ internal data class BotaRecordingUploadProgress(
40
50
  internal class BotaDeviceSDKAndroidRecordingUploads(
41
51
  private val queueFile: File,
42
52
  private val httpClient: OkHttpClient = OkHttpClient(),
53
+ private val defaultRecordingDirectory: File = File(queueFile.parentFile, "recordings"),
54
+ private val syncDirectory: (File) -> Unit = { directory ->
55
+ FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { it.force(true) }
56
+ },
43
57
  ) {
44
58
  constructor(
45
59
  context: Context,
46
60
  httpClient: OkHttpClient = OkHttpClient(),
47
- ) : this(File(context.filesDir, "bota-sdk/compat-upload-queue.json"), httpClient)
61
+ ) : this(File(context.filesDir, "bota-sdk/compat-upload-queue.json"), httpClient,
62
+ File(context.noBackupFilesDir, "bota-app-sdk/recordings"))
48
63
 
49
- private val calls = ConcurrentHashMap<String, Call>()
64
+ @Volatile private var recordingDirectory = defaultRecordingDirectory
65
+
66
+ fun configure(storageDirectory: File?) {
67
+ recordingDirectory = storageDirectory?.let { File(it, "recordings") } ?: defaultRecordingDirectory
68
+ }
69
+
70
+ private class Operation {
71
+ private var cancelled = false
72
+ private var call: Call? = null
73
+
74
+ @Synchronized fun execute(next: Call) {
75
+ check(!cancelled) { "recording upload cancelled" }
76
+ call = next
77
+ }
78
+
79
+ @Synchronized fun cancel() { cancelled = true; call?.cancel() }
80
+ @Synchronized fun checkActive() { check(!cancelled) { "recording upload cancelled" } }
81
+ }
82
+
83
+ private val operations = ConcurrentHashMap<String, Operation>()
84
+ private val cancelledAttempts = mutableSetOf<String>()
85
+ private val journalMutex = Mutex()
86
+ private var journalWriteUncertain = false
50
87
 
51
88
  suspend fun upload(
52
89
  upload: BotaRecordingUploadRequest,
53
90
  onProgress: (BotaRecordingUploadProgress) -> Unit,
54
91
  ): Unit = withContext(Dispatchers.IO) {
55
92
  val file = File(upload.localPath)
93
+ requireOwnedFile(upload.localPath)
56
94
  require(file.isFile) { "recording file does not exist: ${upload.localPath}" }
57
95
  val total = file.length()
96
+ require(upload.fileSizeBytes == null || upload.fileSizeBytes == total) { "stored recording length changed" }
97
+ currentCoroutineContext().ensureActive()
58
98
  onProgress(BotaRecordingUploadProgress(upload.taskId, 0, total))
59
99
  val relay = upload.relayUrl != null
60
100
  val request = Request.Builder()
@@ -69,15 +109,22 @@ internal class BotaDeviceSDKAndroidRecordingUploads(
69
109
  }
70
110
  .build()
71
111
  val call = httpClient.newCall(request)
72
- check(calls.putIfAbsent(upload.taskId, call) == null) {
73
- "recording upload is already active: ${upload.taskId}"
112
+ val operation = Operation()
113
+ synchronized(operations) {
114
+ check(upload.taskId !in cancelledAttempts) { "recording upload cancelled" }
115
+ check(operations.putIfAbsent(upload.taskId, operation) == null) {
116
+ "recording upload is already active: ${upload.taskId}"
117
+ }
74
118
  }
75
119
  try {
120
+ operation.execute(call)
76
121
  call.execute().use { response ->
77
122
  check(response.isSuccessful) {
78
123
  "recording upload failed with HTTP ${response.code}"
79
124
  }
80
125
  }
126
+ currentCoroutineContext().ensureActive()
127
+ operation.checkActive()
81
128
  if (!relay && upload.completeUrl != null && upload.uploadToken != null) {
82
129
  val checksum = upload.contentSha256?.let {
83
130
  ",\"content_sha256\":\"${it.jsonEscaped()}\""
@@ -89,39 +136,86 @@ internal class BotaDeviceSDKAndroidRecordingUploads(
89
136
  .post(body.toRequestBody("application/json".toMediaTypeOrNull()))
90
137
  .build()
91
138
  val completionCall = httpClient.newCall(complete)
92
- calls[upload.taskId] = completionCall
139
+ operation.execute(completionCall)
93
140
  completionCall.execute().use { response ->
94
141
  check(response.isSuccessful) {
95
142
  "recording completion failed with HTTP ${response.code}"
96
143
  }
97
144
  }
98
145
  }
146
+ currentCoroutineContext().ensureActive()
147
+ operation.checkActive()
99
148
  onProgress(BotaRecordingUploadProgress(upload.taskId, total, total))
100
- check(file.delete() || !file.exists()) {
101
- "uploaded recording file could not be removed"
102
- }
103
149
  } finally {
104
- calls.remove(upload.taskId)
150
+ operations.remove(upload.taskId, operation)
105
151
  }
106
152
  }
107
153
 
108
154
  suspend fun cancel(taskId: String) = withContext(Dispatchers.IO) {
109
- calls.remove(taskId)?.cancel()
155
+ synchronized(operations) {
156
+ cancelledAttempts.add(taskId)
157
+ operations[taskId]?.cancel()
158
+ }
110
159
  }
111
160
 
112
161
  suspend fun cancelAll() = withContext(Dispatchers.IO) {
113
- calls.values.forEach(Call::cancel)
114
- calls.clear()
162
+ synchronized(operations) {
163
+ cancelledAttempts.addAll(operations.keys)
164
+ operations.values.forEach(Operation::cancel)
165
+ }
115
166
  }
116
167
 
117
168
  suspend fun loadQueue(): String = withContext(Dispatchers.IO) {
118
- if (queueFile.isFile) queueFile.readText() else "[]"
169
+ journalMutex.withLock {
170
+ if (!queueFile.isFile) return@withLock "[]"
171
+ queueFile.readText()
172
+ }
119
173
  }
120
174
 
121
175
  suspend fun saveQueue(serializedTasks: String): Unit = withContext(Dispatchers.IO) {
176
+ journalMutex.withLock { writeQueue(metadata(serializedTasks).toString()) }
177
+ }
178
+
179
+ suspend fun release(taskId: String, localPath: String): Unit = withContext(Dispatchers.IO) {
180
+ journalMutex.withLock {
181
+ check(!journalWriteUncertain) { "recording release requires durable journal completion" }
182
+ val tasks = metadata(queueFile.readText())
183
+ check((0 until tasks.length()).any { index ->
184
+ val task = tasks.getJSONObject(index)
185
+ task.optString("id") == taskId && task.optString("localPath") == localPath &&
186
+ task.optString("status") == "completed"
187
+ }) { "recording release requires exact durable completion" }
188
+ removeCompletedFile(localPath)
189
+ }
190
+ }
191
+
192
+ private fun removeCompletedFile(path: String) {
193
+ requireOwnedFile(path)
194
+ val file = File(path)
195
+ require(!file.exists() || (file.isFile && !Files.isSymbolicLink(file.toPath()))) { "recording release requires a regular file" }
196
+ check(file.delete() || !file.exists()) { "completed recording cleanup deferred" }
197
+ // A missing file may be an earlier unlink whose directory sync failed.
198
+ syncDirectory(requireNotNull(file.parentFile))
199
+ }
200
+
201
+ private fun requireOwnedFile(path: String) {
202
+ val file = File(path)
203
+ require(file.isAbsolute && file.extension == "recording" &&
204
+ runCatching { java.util.UUID.fromString(file.nameWithoutExtension) }.isSuccess &&
205
+ file.canonicalFile.parentFile == recordingDirectory.canonicalFile &&
206
+ !Files.isSymbolicLink(file.toPath()) && (!file.exists() || file.isFile)
207
+ ) { "recording file is not owned by the native recording store" }
208
+ }
209
+
210
+ private fun writeQueue(serializedTasks: String) {
211
+ journalWriteUncertain = true
122
212
  queueFile.parentFile?.mkdirs()
213
+ queueFile.parentFile?.parentFile?.let(syncDirectory)
123
214
  val temporary = File(queueFile.parentFile, "${queueFile.name}.tmp")
124
- temporary.writeText(serializedTasks)
215
+ FileOutputStream(temporary).use { stream ->
216
+ stream.write(serializedTasks.toByteArray(Charsets.UTF_8))
217
+ stream.fd.sync()
218
+ }
125
219
  try {
126
220
  Files.move(
127
221
  temporary.toPath(),
@@ -136,6 +230,41 @@ internal class BotaDeviceSDKAndroidRecordingUploads(
136
230
  StandardCopyOption.REPLACE_EXISTING,
137
231
  )
138
232
  }
233
+ queueFile.parentFile?.let(syncDirectory)
234
+ journalWriteUncertain = false
235
+ }
236
+
237
+ private fun metadata(serialized: String): JSONArray {
238
+ val tasks = JSONArray(serialized)
239
+ val fields = setOf("id", "recordingId", "deviceId", "recordingUuid", "localPath", "status", "retryCount", "createdAt", "updatedAt", "recoveryScope", "fileSizeBytes", "nextAttemptAt", "contentType", "contentSha256", "relayUpload")
240
+ val ids = mutableSetOf<String>()
241
+ return JSONArray().apply {
242
+ for (index in 0 until tasks.length()) {
243
+ val task = tasks.getJSONObject(index)
244
+ require(task.get("id") is String && task.getString("id").isNotEmpty() && ids.add(task.getString("id"))) { "invalid upload recovery journal" }
245
+ for (key in listOf("recordingId", "deviceId", "localPath", "createdAt", "updatedAt")) require(task.get(key) is String) { "invalid upload recovery journal" }
246
+ require(task.getString("recordingId").isNotEmpty() && task.getString("deviceId").isNotEmpty()) { "invalid upload recovery journal" }
247
+ require(validInteger(task.get("retryCount")) && task.getString("status") in setOf("pending", "uploading", "completed", "failed")) { "invalid upload recovery journal" }
248
+ for (key in listOf("recordingUuid", "recoveryScope", "contentType", "contentSha256")) if (task.has(key)) require(task.get(key) is String) { "invalid upload recovery journal" }
249
+ for (key in listOf("fileSizeBytes", "nextAttemptAt")) if (task.has(key)) require(validInteger(task.get(key))) { "invalid upload recovery journal" }
250
+ if (task.has("relayUpload")) require(task.get("relayUpload") is Boolean) { "invalid upload recovery journal" }
251
+ for (key in listOf("createdAt", "updatedAt")) require(runCatching { java.time.Instant.parse(task.getString(key)) }.isSuccess) { "invalid upload recovery journal" }
252
+ put(JSONObject().apply {
253
+ for (key in fields) if (task.has(key)) {
254
+ val value = task.get(key)
255
+ require(value is String || value is Number || value is Boolean) { "invalid upload recovery journal" }
256
+ put(key, value)
257
+ }
258
+ put("relayUpload", if (task.has("relayUpload")) task.getBoolean("relayUpload") else task.optJSONObject("relay") != null)
259
+ })
260
+ }
261
+ }
262
+ }
263
+
264
+ private fun validInteger(value: Any): Boolean {
265
+ if (value !is Number) return false
266
+ val number = value.toDouble()
267
+ return number.isFinite() && number >= 0 && number <= 9_007_199_254_740_991.0 && number % 1.0 == 0.0
139
268
  }
140
269
  }
141
270
 
@@ -151,6 +280,7 @@ internal fun ReadableMap.toRecordingUploadRequest(): BotaRecordingUploadRequest
151
280
  contentSha256 = optionalString("contentSha256"),
152
281
  relayUrl = optionalString("relayUrl"),
153
282
  relayBearerToken = optionalString("relayBearerToken"),
283
+ fileSizeBytes = if (hasKey("fileSizeBytes") && !isNull("fileSizeBytes")) getDouble("fileSizeBytes").toLong() else null,
154
284
  )
155
285
 
156
286
  internal fun BotaRecordingUploadProgress.toWritableMap(): WritableMap =