@bota.dev/react-native-app-sdk 2.0.0-beta.0 → 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 -3
  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
package/README.md CHANGED
@@ -7,6 +7,15 @@ into the native Apple and Android SDKs.
7
7
 
8
8
  ## Requirements
9
9
 
10
+ App SDK beta.6 and later expose `BotaDeviceSDK.clientPresence.nextReport(deviceId)`.
11
+ It returns `BotaClientPresenceContext | null` from the native verified connection,
12
+ preserving native session/sequence while identifying this React Native package
13
+ and its generated version. It makes no HTTP call or device command. Destroy
14
+ discards late metadata results. The host explicitly relays a report alongside
15
+ fresh status; see the root README. The frozen compatibility `BotaClient` and
16
+ compatibility `DeviceManager` APIs are unchanged. Rebuild native binaries when
17
+ adopting this source API; published-package availability is a separate gate.
18
+
10
19
  - React Native `0.86.3` or newer with the New Architecture enabled
11
20
  - iOS `15.1` or newer
12
21
  - Android API `26` or newer
@@ -14,18 +23,31 @@ into the native Apple and Android SDKs.
14
23
 
15
24
  ## Install
16
25
 
17
- Prepared instructions for `2.0.0-beta.0`; registry publication is pending.
26
+ Published beta.9 remains available. Install this beta.10 source candidate with
27
+ the exact pin below only after publication is verified. Beta.10 retires the
28
+ exact Android connection when explicit disconnect times out or is cancelled
29
+ without a native callback, preserving replacement sessions. Reconnect explicitly.
30
+ The [preflight](../../release/evidence/2.0.0-beta.10-preflight.md) separates
31
+ automated cleanup regressions from publication and physical acceptance.
32
+ The intermittent GATT 8/133 reconnect issue remains unverified.
18
33
  Remove `@bota.dev/react-native-sdk` before adding the replacement; do not
19
- co-install both. Production maintenance 0.0.x consumers need not migrate.
34
+ co-install both. Standalone maintenance ended September 29, 2026; follow the
35
+ [retirement and migration notice](../../docs/migrations/react-native-sdk-sunset.md).
20
36
 
21
37
  ```bash
22
- npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.0
38
+ npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.10
23
39
  npx pod-install
24
40
  ```
25
41
 
26
42
  Rebuild the native iOS and Android applications after installation. An Expo Go
27
43
  runtime cannot load this native module; use a development or production build.
28
44
 
45
+ An application-native upload pod may depend on `BotaDeviceSDK`. With static
46
+ SwiftPM linkage, the SDK's CocoaPods workaround normalizes the module-map path
47
+ in both aggregate and dependent-pod cached build settings. This preserves the
48
+ path when Expo later writes macro flags. Do not work around missing module maps
49
+ by adding another copy of the Apple SDK.
50
+
29
51
  Expo prebuild and EAS projects must set the Android minimum SDK explicitly. Add
30
52
  the Expo-compatible `expo-build-properties` package and configure it in the app
31
53
  config:
@@ -58,6 +80,12 @@ await BotaClient.waitForBluetooth();
58
80
  The `BotaClient.devices`, `BotaClient.recordings`, and `BotaClient.ota`
59
81
  managers become available after configuration completes.
60
82
 
83
+ Discovery exposes the manufacturer-advertised `macAddress` as uppercase,
84
+ colon-separated bytes (`AA:BB:CC:DD:EE:FF`), preserving the legacy React Native
85
+ registration contract. Compact native addresses are formatted at the RN
86
+ boundary; missing or malformed advertised values become `null`. The peripheral
87
+ `id` is unchanged and is never substituted for an absent advertised MAC.
88
+
61
89
  ## Encrypted upload v2
62
90
 
63
91
  The target encrypted-upload-v2 runtime is an additive `BotaDeviceSDK` API; it
@@ -91,6 +119,57 @@ Its target-facade tests and the maintenance SDK `0.0.67` runtime tests are
91
119
  cross-referenced by the same canonical v2 workflow evidence in CI and tagged
92
120
  release verification. The frozen public compatibility surface remains `0.0.65`.
93
121
 
122
+ ### Unpublished Native App Integration
123
+
124
+ Current source adds `BotaDeviceSDK.recordings.listPendingRecordings(device)`.
125
+ It returns legacy recordings and v2 entries tagged by `storageFormat: 3`, with
126
+ the full UUID/generation, ciphertext length/hash and catalog metadata. Catalog
127
+ errors do not silently downgrade to legacy listing. These changes require
128
+ matching native source and a newly built application; beta.1 is insufficient.
129
+
130
+ The v2 sync method accepts a fifth argument `{ signal, operationId }`. The
131
+ optional operation ID is a fresh UUID shared with an application-native
132
+ adapter; cancellation stops and awaits that exact operation. Native provider
133
+ contexts expose an operation-scoped `readAuthNonce` callback. An RN application's
134
+ Swift/Kotlin adapter can call
135
+ `BotaDeviceSDKEncryptedUploadV2Materials.readAuthNonce(operationId)` while the
136
+ profile request is pending; it must not make a competing public BLE read.
137
+
138
+ Native material must supply `uploadContext`, which relays opaque challenge/proof
139
+ documents through the application backend. The SDK owns the bounded device
140
+ handshake before authorization and the first completion receipt. Ordinary v2
141
+ requires capability mask `0x17f`; expired-session replacement requires `0x37f`.
142
+ Neither the app nor SDK verifies backend signatures or trusts the phone clock
143
+ as a substitute for device verification. Cancellation discards late native
144
+ material via `cancelPreparation`; it does not delete recoverable recordings.
145
+ Historical checkpoints without ciphertext identity may resume the same owner
146
+ but cannot authorize replacement. See `docs/parity/v2-demo-*.md` for evidence
147
+ and limitations. Hardware and publication gates remain unchanged.
148
+
149
+ ## Unpublished Maintenance Additions
150
+
151
+ Current source follows the diagnostic and upload-recovery additions from
152
+ maintenance SDK commit `318974f925a573cf04b0d624978bee04784af09b`. These additions
153
+ are not part of the published `2.0.0-beta.1` package.
154
+
155
+ - `BotaClient.devices.readDiagnosticEvents(device)` returns a bounded batch.
156
+ Reading does not delete it. After the application durably accepts events,
157
+ call `acknowledgeDiagnosticEvents(device, acceptedEventIds)` with their exact
158
+ 16-character lowercase hexadecimal IDs. The native `BotaDeviceSDK.logs` API
159
+ exposes the same methods. Firmware must support the diagnostics service.
160
+ - Configure `uploadRecoveryProvider` to refresh upload credentials for the
161
+ original recording and account/project scope after restart. Only non-secret
162
+ queue metadata persists; recording bytes stay in native files. Completion
163
+ acknowledgement is required before releasing recoverable files.
164
+ - Legacy `RecordingDataStore` JavaScript byte callbacks are not supported.
165
+ Supplying one fails configuration explicitly. Use the native file store and
166
+ credential/completion callbacks instead; this is a migration adaptation,
167
+ not byte-store API equivalence.
168
+
169
+ See the repository's `docs/parity/` notes for recovery contracts and test
170
+ coverage. This does not enable encrypted-v2 device support or complete the
171
+ separate physical-device and application rollout gates.
172
+
94
173
  ## Documentation
95
174
 
96
175
  See [docs.bota.dev](https://docs.bota.dev) for pairing, provisioning,
@@ -99,3 +178,25 @@ recording transfer, WiFi, OTA, and device-management guides.
99
178
  ## License
100
179
 
101
180
  MIT
181
+
182
+ ## Standalone migration additions (beta.6)
183
+
184
+ `BotaClient.clientPresence.nextReport(deviceId)` and
185
+ `BotaClient.devices.clientPresence.nextReport(deviceId)` return the standalone
186
+ `SdkClientContext` shape (`schema_version`, `session_id`, `sequence`, `platform`,
187
+ `sdk_package`, `sdk_version`) or null. This is passive diagnostic metadata.
188
+ The existing modern facade still returns camelCase fields, now typed as
189
+ `BotaClientPresenceContext`; callers that imported its former `SdkClientContext`
190
+ type should rename that type import. No native wire fields changed.
191
+
192
+ Use `readClientPresenceObservation` around a fresh status read and an exact host
193
+ scope guard, then attach the report and the authorized `binding_generation` to
194
+ the existing authenticated BLE heartbeat. Skip during competing work; do not
195
+ add timers, replay offline reports, or treat metadata as device authority.
196
+
197
+ Use `createUploadRecoveryProvider` with an authenticated, disposable
198
+ `UploadRecoveryBackend` lease. The helper checks identity and evidence, obtains
199
+ fresh credentials, and completes the original backend recording before native
200
+ cleanup. Supply the initial `recoveryScope` and configure the provider before
201
+ starting batch sync. Do not call backend completion only after the sync generator
202
+ returns: device confirmation has already happened then.
@@ -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
  }
@@ -28,8 +28,10 @@ internal interface BotaDeviceSDKAndroidDeviceClient {
28
28
  suspend fun disconnect()
29
29
 
30
30
  suspend fun readStatus(): DeviceStatus
31
+ suspend fun nextClientPresence(deviceId: String): dev.bota.sdk.SDKClientContext?
31
32
 
32
33
  suspend fun statusUpdates(): Flow<DeviceStatus>
34
+ fun connectionUpdates(): Flow<ConnectedDevice?>
33
35
  }
34
36
 
35
37
  internal class BotaDeviceSDKSharedAndroidDeviceClient(
@@ -56,19 +58,37 @@ internal class BotaDeviceSDKSharedAndroidDeviceClient(
56
58
  }
57
59
 
58
60
  override suspend fun readStatus(): DeviceStatus = client.devices.readStatus()
61
+ override suspend fun nextClientPresence(deviceId: String): dev.bota.sdk.SDKClientContext? =
62
+ client.clientPresence.nextReport(deviceId)
59
63
 
60
64
  override suspend fun statusUpdates(): Flow<DeviceStatus> = client.devices.statusUpdates()
65
+ override fun connectionUpdates(): Flow<ConnectedDevice?> = client.devices.connectionUpdates()
61
66
  }
62
67
 
63
68
  internal class BotaDeviceSDKAndroidDevices(
64
69
  private val client: BotaDeviceSDKAndroidDeviceClient,
65
70
  private val scope: CoroutineScope,
66
71
  ) {
72
+ suspend fun nextClientPresence(deviceId: String): dev.bota.sdk.SDKClientContext? = client.nextClientPresence(deviceId)
67
73
  private val operations = Mutex()
68
74
  private val scanLock = Any()
69
75
  private val statusLock = Any()
70
76
  private var activeScan: Job? = null
71
77
  private var activeStatusUpdates: Job? = null
78
+ private var activeConnectionUpdates: Job? = null
79
+
80
+ suspend fun startConnectionUpdates(onDisconnected: () -> Unit) = operations.withLock {
81
+ activeConnectionUpdates?.cancelAndJoin()
82
+ val stream = client.connectionUpdates()
83
+ activeConnectionUpdates = scope.launch(start = CoroutineStart.UNDISPATCHED) {
84
+ var connected = false
85
+ stream.collect { device ->
86
+ val lost = connected && device == null
87
+ connected = device != null
88
+ if (lost) onDisconnected()
89
+ }
90
+ }
91
+ }
72
92
 
73
93
  suspend fun startScan(
74
94
  timeoutMilliseconds: ULong,
@@ -151,6 +171,8 @@ internal class BotaDeviceSDKAndroidDevices(
151
171
  }
152
172
 
153
173
  suspend fun stopAll() = operations.withLock {
174
+ activeConnectionUpdates?.cancelAndJoin()
175
+ activeConnectionUpdates = null
154
176
  stopScanOwned()
155
177
  stopStatusUpdatesOwned()
156
178
  }
@@ -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 =