@bota.dev/react-native-app-sdk 2.0.0-beta.0 → 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.
- package/README.md +66 -2
- package/android/build.gradle +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +68 -3
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
- package/generated/codegen-contract.json +495 -8
- package/ios/BotaDeviceSDK.mm +102 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +126 -4
- package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
- package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
- package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
- package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
- package/lib/commonjs/BotaClient.js +13 -2
- package/lib/commonjs/BotaClient.js.map +1 -1
- package/lib/commonjs/ble/constants.js +5 -1
- package/lib/commonjs/ble/constants.js.map +1 -1
- package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
- package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
- package/lib/commonjs/client.js +163 -28
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/index.js +15 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +8 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
- package/lib/commonjs/managers/RecordingManager.js +83 -102
- package/lib/commonjs/managers/RecordingManager.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/BotaClient.js +13 -2
- package/lib/module/BotaClient.js.map +1 -1
- package/lib/module/ble/constants.js +4 -0
- package/lib/module/ble/constants.js.map +1 -1
- package/lib/module/ble/deviceDiagnostics.js +234 -0
- package/lib/module/ble/deviceDiagnostics.js.map +1 -0
- package/lib/module/client.js +163 -28
- package/lib/module/client.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +8 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +380 -0
- package/lib/module/managers/NativeUploadQueue.js.map +1 -0
- package/lib/module/managers/RecordingManager.js +83 -102
- package/lib/module/managers/RecordingManager.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/typescript/BotaClient.d.ts.map +1 -1
- package/lib/typescript/ble/constants.d.ts +4 -0
- package/lib/typescript/ble/constants.d.ts.map +1 -1
- package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
- package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
- package/lib/typescript/client.d.ts +18 -2
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +6 -4
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +3 -1
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
- package/lib/typescript/managers/RecordingManager.d.ts +3 -8
- package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
- package/lib/typescript/managers/types.d.ts +6 -1
- package/lib/typescript/managers/types.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
- package/lib/typescript/models/Device.d.ts +42 -0
- package/lib/typescript/models/Device.d.ts.map +1 -1
- package/lib/typescript/models/Recording.d.ts +47 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/models/Status.d.ts +5 -1
- package/lib/typescript/models/Status.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +61 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
- package/src/BotaClient.ts +13 -3
- package/src/ble/constants.ts +5 -0
- package/src/ble/deviceDiagnostics.ts +238 -0
- package/src/client.ts +166 -18
- package/src/index.ts +12 -0
- package/src/managers/DeviceManager.ts +11 -0
- package/src/managers/NativeUploadQueue.ts +330 -0
- package/src/managers/RecordingManager.ts +83 -116
- package/src/managers/types.ts +7 -1
- package/src/managers/uploadRecoveryMetadata.ts +59 -0
- package/src/models/Device.ts +43 -0
- package/src/models/Recording.ts +52 -0
- package/src/models/Status.ts +5 -1
- package/src/specs/NativeBotaDeviceSDK.ts +56 -0
package/README.md
CHANGED
|
@@ -14,18 +14,25 @@ into the native Apple and Android SDKs.
|
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
|
-
|
|
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.
|
|
18
19
|
Remove `@bota.dev/react-native-sdk` before adding the replacement; do not
|
|
19
20
|
co-install both. Production maintenance 0.0.x consumers need not migrate.
|
|
20
21
|
|
|
21
22
|
```bash
|
|
22
|
-
npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.
|
|
23
|
+
npm install --save-exact @bota.dev/react-native-app-sdk@2.0.0-beta.3
|
|
23
24
|
npx pod-install
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Rebuild the native iOS and Android applications after installation. An Expo Go
|
|
27
28
|
runtime cannot load this native module; use a development or production build.
|
|
28
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
|
+
|
|
29
36
|
Expo prebuild and EAS projects must set the Android minimum SDK explicitly. Add
|
|
30
37
|
the Expo-compatible `expo-build-properties` package and configure it in the app
|
|
31
38
|
config:
|
|
@@ -58,6 +65,12 @@ await BotaClient.waitForBluetooth();
|
|
|
58
65
|
The `BotaClient.devices`, `BotaClient.recordings`, and `BotaClient.ota`
|
|
59
66
|
managers become available after configuration completes.
|
|
60
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
|
+
|
|
61
74
|
## Encrypted upload v2
|
|
62
75
|
|
|
63
76
|
The target encrypted-upload-v2 runtime is an additive `BotaDeviceSDK` API; it
|
|
@@ -91,6 +104,57 @@ Its target-facade tests and the maintenance SDK `0.0.67` runtime tests are
|
|
|
91
104
|
cross-referenced by the same canonical v2 workflow evidence in CI and tagged
|
|
92
105
|
release verification. The frozen public compatibility surface remains `0.0.65`.
|
|
93
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
|
+
|
|
94
158
|
## Documentation
|
|
95
159
|
|
|
96
160
|
See [docs.bota.dev](https://docs.bota.dev) for pairing, provisioning,
|
package/android/build.gradle
CHANGED
|
@@ -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
|
package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt
CHANGED
|
@@ -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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
operations.remove(upload.taskId, operation)
|
|
105
151
|
}
|
|
106
152
|
}
|
|
107
153
|
|
|
108
154
|
suspend fun cancel(taskId: String) = withContext(Dispatchers.IO) {
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|