@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
@@ -0,0 +1,330 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import type { BotaDeviceSDKRecordingClient, BotaRecordingTransferProgress } from '../client';
3
+ import type { UploadInfo, UploadRecoveryProvider, UploadTask } from '../models/Recording';
4
+ import type { RecordingManagerEvents } from '../models/Status';
5
+ import { persistedUploadQueue, restoreUploadQueue } from './uploadRecoveryMetadata';
6
+
7
+ const journalOperations = new WeakMap<object, Promise<unknown>>();
8
+ const journalOwners = new WeakMap<object, NativeUploadQueue>();
9
+ const retryDelays = [30_000, 120_000, 600_000, 3_600_000, 14_400_000];
10
+ const cancelled = () => new Error('Upload cancelled');
11
+ const dispose = (info: UploadInfo | null | undefined) => {
12
+ try { info?.dispose?.(); } catch { /* Cleanup cannot change an acknowledged result. */ }
13
+ };
14
+
15
+ type Attempt = {
16
+ controller: AbortController;
17
+ nativeId: string;
18
+ promise: Promise<void>;
19
+ };
20
+
21
+ /** Races application callbacks, not native teardown. Late credentials still release their lease. */
22
+ const abortable = <T>(promise: Promise<T>, signal: AbortSignal, late?: (value: T) => void): Promise<T> =>
23
+ new Promise((resolve, reject) => {
24
+ let settled = false;
25
+ const abort = () => { if (!settled) { settled = true; reject(cancelled()); } };
26
+ signal.addEventListener('abort', abort, { once: true });
27
+ if (signal.aborted) abort();
28
+ void promise.then(value => {
29
+ signal.removeEventListener('abort', abort);
30
+ if (settled) { late?.(value); return; }
31
+ settled = true;
32
+ resolve(value);
33
+ }, error => {
34
+ signal.removeEventListener('abort', abort);
35
+ if (!settled) { settled = true; reject(error); }
36
+ });
37
+ });
38
+
39
+ /** Only metadata crosses this owner. Native hosts retain and upload every byte. */
40
+ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
41
+ private tasks: UploadTask[] = [];
42
+ private readonly active = new Map<string, Attempt>();
43
+ private readonly credentials = new Map<string, UploadInfo>();
44
+ private readonly cancelledTasks = new Set<string>();
45
+ private paused = false;
46
+ private destroyed = false;
47
+ private initialized = false;
48
+ private initialization?: Promise<void>;
49
+ private timer?: ReturnType<typeof setTimeout>;
50
+
51
+ constructor(
52
+ private readonly client: BotaDeviceSDKRecordingClient,
53
+ private readonly provider?: UploadRecoveryProvider
54
+ ) { super(); }
55
+
56
+ initialize(): Promise<void> {
57
+ if (this.initialization) return this.initialization;
58
+ if (this.destroyed) return Promise.reject(cancelled());
59
+ const owner = journalOwners.get(this.client);
60
+ if (owner && owner !== this) return Promise.reject(new Error('Upload recovery journal is already owned by another RecordingManager'));
61
+ journalOwners.set(this.client, this);
62
+ this.initialization = this.serialize(async () => {
63
+ this.checkAlive();
64
+ const loaded = await this.client.loadUploadQueue();
65
+ this.checkAlive();
66
+ const restored = restoreUploadQueue(loaded);
67
+ if (restored.length > 0) await this.client.saveUploadQueue(restored);
68
+ this.checkAlive();
69
+ this.tasks = restored;
70
+ this.initialized = true;
71
+ }).then(() => {
72
+ for (const task of this.tasks.filter(value => value.status === 'completed')) {
73
+ void this.release(task).catch(() => undefined);
74
+ }
75
+ this.schedule();
76
+ }).catch(error => {
77
+ if (journalOwners.get(this.client) === this) journalOwners.delete(this.client);
78
+ throw error;
79
+ });
80
+ return this.initialization;
81
+ }
82
+
83
+ all(): UploadTask[] { return this.tasks.map(task => ({ ...task, createdAt: new Date(task.createdAt), updatedAt: new Date(task.updatedAt) })); }
84
+ get(id: string): UploadTask | undefined { return this.all().find(task => task.id === id); }
85
+
86
+ async add(task: UploadTask, info: UploadInfo): Promise<void> {
87
+ await this.mutate(tasks => [...tasks, ...persistedUploadQueue([task])]);
88
+ this.credentials.set(task.id, info);
89
+ }
90
+
91
+ offerCredentials(id: string, info: UploadInfo): boolean {
92
+ if (this.active.has(id) || this.get(id)?.status === 'completed' || this.paused) return false;
93
+ this.discardCredentials(id);
94
+ this.credentials.set(id, info);
95
+ return true;
96
+ }
97
+
98
+ async update(id: string, patch: Partial<UploadTask>, check?: () => void): Promise<void> {
99
+ await this.mutate(tasks => {
100
+ check?.();
101
+ if (!tasks.some(task => task.id === id) || this.cancelledTasks.has(id)) throw cancelled();
102
+ return tasks.map(task => task.id === id ? { ...task, ...patch, updatedAt: new Date() } : task);
103
+ });
104
+ const info = this.credentials.get(id);
105
+ if (info && patch.relayUpload === false) this.credentials.set(id, { ...info, relay: undefined });
106
+ }
107
+
108
+ run(id: string, onProgress?: (value: BotaRecordingTransferProgress) => void): Promise<void> {
109
+ if (this.active.has(id)) return this.active.get(id)!.promise;
110
+ if (this.get(id)?.status === 'completed') return Promise.resolve();
111
+ if (this.paused || this.destroyed || this.cancelledTasks.has(id)) return Promise.reject(cancelled());
112
+ const attempt: Attempt = {
113
+ controller: new AbortController(),
114
+ nativeId: `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`,
115
+ promise: Promise.resolve(),
116
+ };
117
+ this.active.set(id, attempt);
118
+ attempt.promise = this.perform(id, attempt, onProgress).finally(() => {
119
+ if (this.active.get(id) === attempt) this.active.delete(id);
120
+ this.schedule();
121
+ });
122
+ return attempt.promise;
123
+ }
124
+
125
+ private async perform(id: string, attempt: Attempt, onProgress?: (value: BotaRecordingTransferProgress) => void): Promise<void> {
126
+ const signal = attempt.controller.signal;
127
+ const check = () => {
128
+ if (this.destroyed || signal.aborted || this.cancelledTasks.has(id) || !this.get(id) || this.active.get(id) !== attempt) throw cancelled();
129
+ };
130
+ let info: UploadInfo | null | undefined;
131
+ let hostAbort: (() => void) | undefined;
132
+ let initialSignal: AbortSignal | undefined;
133
+ const initialAbort = () => attempt.controller.abort();
134
+ let nativeStarted = false;
135
+ let nativeCancellation: Promise<void> | undefined;
136
+ const cancelNative = () => {
137
+ if (nativeStarted && !nativeCancellation) {
138
+ nativeCancellation = this.client.cancelRecordingUpload(attempt.nativeId);
139
+ void nativeCancellation.catch(() => undefined);
140
+ }
141
+ };
142
+ signal.addEventListener('abort', cancelNative);
143
+ try {
144
+ check();
145
+ const task = this.get(id)!;
146
+ info = this.credentials.get(id);
147
+ this.credentials.delete(id);
148
+ initialSignal = info?.signal;
149
+ initialSignal?.addEventListener('abort', initialAbort, { once: true });
150
+ if (initialSignal?.aborted) initialAbort();
151
+ check();
152
+ if (info?.expiresAt && info.expiresAt.getTime() <= Date.now() && !info.alreadyUploaded && this.provider && !info.signal?.aborted) {
153
+ dispose(info);
154
+ info = undefined;
155
+ }
156
+ if (!info) {
157
+ if (!this.provider || !task.recordingUuid) throw new Error('Upload recovery requires a provider and recordingUuid');
158
+ info = await abortable(Promise.resolve().then(() => {
159
+ check();
160
+ return this.provider!({
161
+ taskId: id, recordingId: task.recordingId, deviceId: task.deviceId,
162
+ recordingUuid: task.recordingUuid!, recoveryScope: task.recoveryScope,
163
+ fileSizeBytes: task.fileSizeBytes, relayUpload: !!task.relayUpload,
164
+ contentType: task.contentType, contentSha256: task.contentSha256, signal,
165
+ });
166
+ }), signal, dispose);
167
+ }
168
+ check();
169
+ if (!info) {
170
+ await this.update(id, { status: 'pending', nextAttemptAt: Date.now() + 30_000 }, check);
171
+ throw new Error('Upload waiting for its original account');
172
+ }
173
+ if (info.recordingId !== task.recordingId || info.recoveryScope !== task.recoveryScope) {
174
+ throw new Error('Upload recovery identity changed');
175
+ }
176
+ if (!info.alreadyUploaded && !!info.relay !== !!task.relayUpload) throw new Error('Upload recovery route changed');
177
+ hostAbort = () => attempt.controller.abort();
178
+ info.signal?.addEventListener('abort', hostAbort, { once: true });
179
+ if (info.signal?.aborted) hostAbort();
180
+ check();
181
+ if (info.expiresAt && info.expiresAt.getTime() <= Date.now() && !info.alreadyUploaded) throw new Error('Upload credentials expired');
182
+ await this.update(id, { status: 'uploading', errorMessage: undefined, nextAttemptAt: undefined }, check);
183
+ check();
184
+ this.emit('uploadStarted', id);
185
+ check();
186
+ if (!info.alreadyUploaded) {
187
+ nativeStarted = true;
188
+ await this.client.uploadRecordingFile({
189
+ ...task, id: attempt.nativeId, uploadUrl: info.uploadUrl,
190
+ contentType: info.contentType ?? task.contentType,
191
+ uploadToken: info.complete ? undefined : info.uploadToken,
192
+ completeUrl: info.complete ? undefined : info.completeUrl,
193
+ relay: info.relay,
194
+ }, value => {
195
+ try { check(); } catch { return; }
196
+ onProgress?.(value);
197
+ this.emit('uploadProgress', id, value.totalBytes > 0 ? Math.min(value.completedBytes / value.totalBytes, 1) : 0);
198
+ });
199
+ check();
200
+ }
201
+ if (info.complete) {
202
+ if (task.fileSizeBytes === undefined) throw new Error('Upload completion requires known file size');
203
+ await abortable(Promise.resolve().then(() => {
204
+ check();
205
+ return info!.complete!({ fileSizeBytes: task.fileSizeBytes!, contentSha256: task.contentSha256, signal });
206
+ }), signal);
207
+ } else if (info.alreadyUploaded || (this.provider && !info.relay && !(info.completeUrl && info.uploadToken))) {
208
+ throw new Error('Recoverable upload requires durable host completion acknowledgement');
209
+ }
210
+ check();
211
+ await this.update(id, { status: 'completed', errorMessage: undefined, nextAttemptAt: undefined }, check);
212
+ check();
213
+ await this.release(this.get(id)!).catch(() => undefined);
214
+ check();
215
+ this.emit('uploadCompleted', id, task.recordingId);
216
+ } catch (error) {
217
+ if (!this.destroyed && !this.cancelledTasks.has(id) && this.get(id)?.status !== 'completed') {
218
+ const task = this.get(id)!;
219
+ const parked = info === null || signal.aborted;
220
+ const retry = !!this.provider && task.retryCount < 6 && Date.now() - task.createdAt.getTime() < 86_400_000;
221
+ await this.update(id, {
222
+ status: parked || retry ? 'pending' : 'failed',
223
+ retryCount: task.retryCount + (parked ? 0 : 1),
224
+ nextAttemptAt: parked ? Date.now() + 30_000 : retry ? Date.now() + retryDelays[Math.min(task.retryCount, 4)] : undefined,
225
+ errorMessage: parked ? 'Upload waiting for its original account' : 'Upload attempt failed',
226
+ }).catch(() => { this.pause(); });
227
+ if (!this.destroyed) this.emit('uploadFailed', id, new Error(parked ? 'Upload paused' : 'Upload attempt failed'));
228
+ }
229
+ throw error;
230
+ } finally {
231
+ signal.removeEventListener('abort', cancelNative);
232
+ initialSignal?.removeEventListener('abort', initialAbort);
233
+ if (hostAbort) info?.signal?.removeEventListener('abort', hostAbort);
234
+ // Credential-only legacy callers can explicitly retry during this process.
235
+ if (!this.provider && info && !info.dispose && !info.signal && !this.destroyed && !this.cancelledTasks.has(id) && this.get(id)?.status !== 'completed') this.credentials.set(id, info);
236
+ dispose(info);
237
+ await nativeCancellation?.catch(() => { this.pause(); });
238
+ }
239
+ }
240
+
241
+ private async release(task: UploadTask): Promise<void> {
242
+ this.checkAlive();
243
+ await this.client.releaseRecordingFile(task.id, task.localPath);
244
+ }
245
+
246
+ async cancel(id: string): Promise<void> {
247
+ this.cancelledTasks.add(id);
248
+ const active = this.active.get(id);
249
+ active?.controller.abort();
250
+ this.discardCredentials(id);
251
+ if (!active) await this.client.cancelRecordingUpload(id);
252
+ await this.mutate(tasks => tasks.filter(task => task.id !== id));
253
+ }
254
+
255
+ discardCredentials(id: string): void {
256
+ const credential = this.credentials.get(id);
257
+ this.credentials.delete(id);
258
+ dispose(credential);
259
+ }
260
+
261
+ async clearCompleted(): Promise<void> {
262
+ const snapshot = this.all().filter(value => value.status === 'completed');
263
+ for (const task of snapshot) await this.release(task);
264
+ await this.mutate(tasks => tasks.filter(task => !snapshot.some(released =>
265
+ task.status === 'completed' && task.id === released.id && task.localPath === released.localPath &&
266
+ task.recordingId === released.recordingId && task.deviceId === released.deviceId &&
267
+ task.recordingUuid === released.recordingUuid && task.recoveryScope === released.recoveryScope
268
+ )));
269
+ }
270
+
271
+ async clearAll(): Promise<void> { for (const task of this.all()) await this.cancel(task.id); }
272
+
273
+ async retryFailed(): Promise<void> {
274
+ const retry = this.all().filter(task => (task.status === 'failed' || (task.status === 'pending' && task.nextAttemptAt)) && !this.active.has(task.id) && !!task.localPath);
275
+ for (const task of retry) await this.update(task.id, { status: 'pending', retryCount: 0, nextAttemptAt: undefined, createdAt: new Date() });
276
+ if (!this.paused) for (const task of retry) await this.run(task.id).catch(() => undefined);
277
+ }
278
+
279
+ pause(): void { this.paused = true; if (this.timer) clearTimeout(this.timer); }
280
+ resume(): void { this.checkAlive(); this.paused = false; this.schedule(); }
281
+
282
+ destroy(): void {
283
+ if (this.destroyed) return;
284
+ this.destroyed = true;
285
+ if (journalOwners.get(this.client) === this) {
286
+ // New owners must read only after old persistence and native stop have settled.
287
+ const stopped = this.client.destroyCompatibilityOperations();
288
+ void stopped.catch(() => undefined);
289
+ void this.serialize(() => stopped).catch(() => undefined);
290
+ journalOwners.delete(this.client);
291
+ }
292
+ this.pause();
293
+ for (const attempt of this.active.values()) attempt.controller.abort();
294
+ for (const info of this.credentials.values()) dispose(info);
295
+ this.credentials.clear();
296
+ this.removeAllListeners();
297
+ }
298
+
299
+ private schedule(): void {
300
+ if (!this.initialized || this.destroyed || this.paused) return;
301
+ if (this.timer) clearTimeout(this.timer);
302
+ let wake = Infinity;
303
+ for (const task of this.tasks) {
304
+ if (task.status !== 'pending' || !task.localPath || this.active.has(task.id) || this.cancelledTasks.has(task.id) || (!this.provider && !this.credentials.has(task.id))) continue;
305
+ if ((task.nextAttemptAt ?? 0) > Date.now()) { wake = Math.min(wake, task.nextAttemptAt!); continue; }
306
+ if (this.active.size >= 2) continue;
307
+ void this.run(task.id).catch(() => undefined);
308
+ }
309
+ if (Number.isFinite(wake)) this.timer = setTimeout(() => this.schedule(), Math.max(1, wake - Date.now()));
310
+ }
311
+
312
+ private serialize<T>(operation: () => Promise<T>): Promise<T> {
313
+ const next = (journalOperations.get(this.client) ?? Promise.resolve()).catch(() => undefined).then(operation);
314
+ journalOperations.set(this.client, next.catch(() => undefined));
315
+ return next;
316
+ }
317
+
318
+ private mutate(change: (tasks: UploadTask[]) => UploadTask[]): Promise<void> {
319
+ return this.serialize(async () => {
320
+ this.checkAlive();
321
+ const next = change(this.tasks);
322
+ await this.client.saveUploadQueue(persistedUploadQueue(next));
323
+ this.checkAlive();
324
+ this.tasks = next;
325
+ this.emit('queueUpdated', this.all());
326
+ });
327
+ }
328
+
329
+ private checkAlive(): void { if (this.destroyed) throw cancelled(); }
330
+ }
@@ -16,7 +16,9 @@ import type {
16
16
  UploadTask,
17
17
  } from '../models/Recording';
18
18
  import type { RecordingManagerEvents } from '../models/Status';
19
- import type { UploadInfoProvider } from './types';
19
+ import type { RecordingManagerOptions, UploadInfoProvider } from './types';
20
+ import { NativeUploadQueue } from './NativeUploadQueue';
21
+ import { rejectRecordingDataStore } from './uploadRecoveryMetadata';
20
22
  import {
21
23
  StreamingSession,
22
24
  setStreamingSessionTerminalHandler,
@@ -85,26 +87,30 @@ const observeProgress = <T>(
85
87
 
86
88
  export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
87
89
  private readonly client: BotaDeviceSDKClient;
88
- private tasks: UploadTask[] = [];
90
+ private readonly queue: NativeUploadQueue;
89
91
  private initialized = false;
90
- private paused = false;
91
92
  private destroyed = false;
92
93
  private activeStreamingSession: StreamingSession | null = null;
93
94
 
94
- constructor() {
95
+ constructor();
96
+ constructor(options: RecordingManagerOptions);
97
+ constructor(options: RecordingManagerOptions = {}) {
95
98
  super();
99
+ rejectRecordingDataStore(options.recordingDataStore);
96
100
  this.client = getCompatibilityClient();
101
+ this.queue = new NativeUploadQueue(this.client.recordings, options.uploadRecoveryProvider);
102
+ this.queue.on('queueUpdated', tasks => this.emit('queueUpdated', tasks));
103
+ this.queue.on('uploadStarted', id => this.emit('uploadStarted', id));
104
+ this.queue.on('uploadProgress', (id, progress) => this.emit('uploadProgress', id, progress));
105
+ this.queue.on('uploadCompleted', (id, recordingId) => this.emit('uploadCompleted', id, recordingId));
106
+ this.queue.on('uploadFailed', (id, error) => this.emit('uploadFailed', id, error));
97
107
  }
98
108
 
99
109
  async initialize(): Promise<void> {
100
110
  if (this.initialized) return;
101
111
  this.assertAlive();
102
- this.tasks = (await this.client.recordings.loadUploadQueue()).map((task) => ({
103
- ...task,
104
- createdAt: new Date(task.createdAt),
105
- updatedAt: new Date(task.updatedAt),
106
- status: task.status === 'uploading' ? 'pending' : task.status,
107
- }));
112
+ await this.queue.initialize();
113
+ this.assertAlive();
108
114
  this.initialized = true;
109
115
  }
110
116
 
@@ -133,6 +139,10 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
133
139
  uploadInfo: UploadInfo
134
140
  ): AsyncGenerator<SyncProgress> {
135
141
  this.assertReady();
142
+ if (uploadInfo.signal?.aborted) {
143
+ uploadInfo.dispose?.();
144
+ throw new Error('Upload authorization context cancelled');
145
+ }
136
146
  this.emit('syncStarted', recording.uuid);
137
147
  const sinkId = createOpaqueId();
138
148
  const now = new Date();
@@ -140,6 +150,9 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
140
150
  id: createTaskId(),
141
151
  recordingId: uploadInfo.recordingId,
142
152
  deviceId: device.id,
153
+ recordingUuid: recording.uuid,
154
+ recoveryScope: uploadInfo.recoveryScope,
155
+ fileSizeBytes: recording.fileSizeBytes,
143
156
  localPath: '',
144
157
  uploadUrl: uploadInfo.uploadUrl,
145
158
  ...(uploadInfo.uploadToken ? { uploadToken: uploadInfo.uploadToken } : {}),
@@ -150,10 +163,33 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
150
163
  createdAt: now,
151
164
  updatedAt: now,
152
165
  };
153
- this.tasks.push(task);
154
- await this.persistTasks();
155
- this.emitQueueUpdated();
166
+ const recovered = uploadInfo.recoveryScope && this.queue.all().find(value =>
167
+ value.recoveryScope === uploadInfo.recoveryScope && value.recordingId === uploadInfo.recordingId &&
168
+ value.deviceId === device.id && value.recordingUuid === recording.uuid && !!value.localPath
169
+ );
170
+ if (recovered) {
171
+ const adopted = this.queue.offerCredentials(recovered.id, uploadInfo);
172
+ try {
173
+ await this.queue.run(recovered.id);
174
+ this.assertReady();
175
+ if (uploadInfo.signal?.aborted) throw new Error('Upload authorization context cancelled');
176
+ await this.client.recordings.confirmRecording(device, recording.uuid);
177
+ this.assertReady();
178
+ yield this.emitSyncProgress(recording.uuid, { stage: 'completed', progress: 1, recordingId: recovered.recordingId, contentSha256: recovered.contentSha256 });
179
+ this.emit('syncCompleted', recording.uuid, recovered.recordingId);
180
+ } finally {
181
+ if (!adopted) uploadInfo.dispose?.();
182
+ }
183
+ return;
184
+ }
185
+ try {
186
+ await this.queue.add(task, uploadInfo);
187
+ } catch (error) {
188
+ uploadInfo.dispose?.();
189
+ throw error;
190
+ }
156
191
 
192
+ let uploadAttempted = false;
157
193
  try {
158
194
  yield this.emitSyncProgress(recording.uuid, {
159
195
  stage: 'preparing',
@@ -193,10 +229,19 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
193
229
  'Device delivered encrypted recording data without an upload relay'
194
230
  );
195
231
  }
196
- await this.updateTask(task.id, {
232
+ this.assertReady();
233
+ if (uploadInfo.signal?.aborted || !this.queue.get(task.id)) throw new Error('Upload cancelled');
234
+ const nativeSize = 'fileSizeBytes' in transferResult && typeof transferResult.fileSizeBytes === 'number'
235
+ ? transferResult.fileSizeBytes : undefined;
236
+ if (nativeSize !== undefined && (!Number.isSafeInteger(nativeSize) || nativeSize < 0)) throw new Error('Invalid native recording file size');
237
+ if (nativeSize === undefined && uploadInfo.complete) throw new Error('Host completion requires actual native recording file size');
238
+ // Route selection comes from native transfer metadata, never list flags.
239
+ uploadInfo = { ...uploadInfo, relay: transferResult.e2eEncrypted ? uploadInfo.relay : undefined };
240
+ await this.queue.update(task.id, {
197
241
  localPath: transferResult.localPath,
242
+ fileSizeBytes: nativeSize ?? (transferResult.e2eEncrypted ? undefined : recording.fileSizeBytes),
198
243
  contentSha256: transferResult.contentSha256,
199
- relay: transferResult.e2eEncrypted ? uploadInfo.relay : undefined,
244
+ relayUpload: transferResult.e2eEncrypted,
200
245
  });
201
246
 
202
247
  yield this.emitSyncProgress(recording.uuid, {
@@ -206,8 +251,9 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
206
251
  totalBytes: recording.fileSizeBytes,
207
252
  contentSha256: transferResult.contentSha256,
208
253
  });
254
+ uploadAttempted = true;
209
255
  const upload = observeProgress((onProgress) =>
210
- this.runUpload(task.id, onProgress)
256
+ this.queue.run(task.id, onProgress)
211
257
  );
212
258
  while (true) {
213
259
  const value = await upload.next();
@@ -228,7 +274,10 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
228
274
  stage: 'completing',
229
275
  progress: 0.5,
230
276
  });
277
+ this.assertReady();
278
+ if (uploadInfo.signal?.aborted || !this.queue.get(task.id)) throw new Error('Upload cancelled');
231
279
  await this.client.recordings.confirmRecording(device, recording.uuid);
280
+ this.assertReady();
232
281
  const completed = this.emitSyncProgress(recording.uuid, {
233
282
  stage: 'completed',
234
283
  progress: 1,
@@ -239,12 +288,13 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
239
288
  this.emit('syncCompleted', recording.uuid, uploadInfo.recordingId);
240
289
  } catch (error) {
241
290
  const failure = error instanceof Error ? error : new Error(String(error));
242
- if (this.requireTask(task.id).status !== 'completed') {
243
- await this.updateTask(task.id, {
291
+ if (!this.destroyed && this.queue.get(task.id)?.status === 'pending' && !this.queue.get(task.id)?.localPath) {
292
+ await this.queue.update(task.id, {
244
293
  status: 'failed',
245
- errorMessage: failure.message,
294
+ errorMessage: 'Recording transfer failed',
246
295
  });
247
296
  }
297
+ if (this.destroyed) throw failure;
248
298
  yield this.emitSyncProgress(recording.uuid, {
249
299
  stage: 'failed',
250
300
  progress: 0,
@@ -252,6 +302,8 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
252
302
  });
253
303
  this.emit('syncFailed', recording.uuid, failure);
254
304
  throw failure;
305
+ } finally {
306
+ if (!uploadAttempted) this.queue.discardCredentials(task.id);
255
307
  }
256
308
  }
257
309
 
@@ -392,13 +444,13 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
392
444
  }
393
445
 
394
446
  getPendingUploads(): UploadTask[] {
395
- return this.tasks.filter(
447
+ return this.queue.all().filter(
396
448
  (task) => task.status === 'pending' || task.status === 'uploading'
397
449
  );
398
450
  }
399
451
 
400
452
  getAllUploads(): UploadTask[] {
401
- return this.tasks.map((task) => ({ ...task }));
453
+ return this.queue.all();
402
454
  }
403
455
 
404
456
  startStreamingSync(
@@ -435,99 +487,43 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
435
487
  }
436
488
 
437
489
  async cancelUpload(taskId: string): Promise<void> {
438
- await this.client.recordings.cancelRecordingUpload(taskId);
439
- this.tasks = this.tasks.filter((task) => task.id !== taskId);
440
- await this.persistTasks();
441
- this.emitQueueUpdated();
490
+ this.assertReady();
491
+ await this.queue.cancel(taskId);
442
492
  }
443
493
 
444
494
  async retryFailedUploads(): Promise<void> {
445
495
  this.assertReady();
446
- if (this.paused) return;
447
- for (const task of this.tasks.filter((value) => value.status === 'failed')) {
448
- await this.updateTask(task.id, {
449
- status: 'pending',
450
- retryCount: task.retryCount + 1,
451
- errorMessage: undefined,
452
- });
453
- try {
454
- await this.runUpload(task.id);
455
- } catch {
456
- // Keep failed tasks available for the next explicit retry.
457
- }
458
- }
496
+ await this.queue.retryFailed();
459
497
  }
460
498
 
461
499
  async clearCompletedUploads(): Promise<void> {
462
- this.tasks = this.tasks.filter((task) => task.status !== 'completed');
463
- await this.persistTasks();
464
- this.emitQueueUpdated();
500
+ this.assertReady();
501
+ await this.queue.clearCompleted();
465
502
  }
466
503
 
467
504
  async clearAllUploads(): Promise<void> {
468
- const ids = this.tasks.map((task) => task.id);
469
- await Promise.all(
470
- ids.map((taskId) =>
471
- this.client.recordings.cancelRecordingUpload(taskId).catch(() => undefined)
472
- )
473
- );
474
- this.tasks = [];
475
- await this.persistTasks();
476
- this.emitQueueUpdated();
505
+ this.assertReady();
506
+ await this.queue.clearAll();
477
507
  }
478
508
 
479
509
  pauseUploads(): void {
480
- this.paused = true;
510
+ this.queue.pause();
481
511
  }
482
512
 
483
513
  resumeUploads(): void {
484
- this.paused = false;
485
- void this.retryFailedUploads();
514
+ this.queue.resume();
486
515
  }
487
516
 
488
517
  destroy(): void {
489
518
  if (this.destroyed) return;
490
519
  this.destroyed = true;
520
+ this.queue.destroy();
491
521
  this.activeStreamingSession?.abort();
492
522
  this.activeStreamingSession = null;
493
- void this.client.recordings.destroyCompatibilityOperations().catch(() => undefined);
494
523
  this.removeAllListeners();
495
524
  this.initialized = false;
496
525
  }
497
526
 
498
- private async runUpload(
499
- taskId: string,
500
- onProgress?: (progress: BotaRecordingTransferProgress) => void
501
- ): Promise<void> {
502
- if (this.paused) throw new Error('Upload queue is paused');
503
- const task = this.requireTask(taskId);
504
- await this.updateTask(taskId, { status: 'uploading', errorMessage: undefined });
505
- this.emit('uploadStarted', taskId);
506
- try {
507
- await this.client.recordings.uploadRecordingFile(
508
- this.requireTask(taskId),
509
- (progress) => {
510
- onProgress?.(progress);
511
- const ratio =
512
- progress.totalBytes > 0
513
- ? Math.min(progress.completedBytes / progress.totalBytes, 1)
514
- : 0;
515
- this.emit('uploadProgress', taskId, ratio);
516
- }
517
- );
518
- await this.updateTask(taskId, { status: 'completed', errorMessage: undefined });
519
- this.emit('uploadCompleted', taskId, task.recordingId);
520
- } catch (error) {
521
- const failure = error instanceof Error ? error : new Error(String(error));
522
- await this.updateTask(taskId, {
523
- status: 'failed',
524
- errorMessage: failure.message,
525
- });
526
- this.emit('uploadFailed', taskId, failure);
527
- throw failure;
528
- }
529
- }
530
-
531
527
  private emitSyncProgress(
532
528
  recordingUuid: string,
533
529
  progress: SyncProgress
@@ -536,35 +532,6 @@ export class RecordingManager extends EventEmitter<RecordingManagerEvents> {
536
532
  return progress;
537
533
  }
538
534
 
539
- private requireTask(taskId: string): UploadTask {
540
- const task = this.tasks.find((value) => value.id === taskId);
541
- if (!task) throw new Error(`Upload task not found: ${taskId}`);
542
- return task;
543
- }
544
-
545
- private async updateTask(
546
- taskId: string,
547
- patch: Partial<UploadTask>
548
- ): Promise<void> {
549
- const index = this.tasks.findIndex((task) => task.id === taskId);
550
- if (index < 0) throw new Error(`Upload task not found: ${taskId}`);
551
- this.tasks[index] = {
552
- ...this.tasks[index],
553
- ...patch,
554
- updatedAt: new Date(),
555
- };
556
- await this.persistTasks();
557
- this.emitQueueUpdated();
558
- }
559
-
560
- private async persistTasks(): Promise<void> {
561
- await this.client.recordings.saveUploadQueue(this.tasks);
562
- }
563
-
564
- private emitQueueUpdated(): void {
565
- this.emit('queueUpdated', this.getAllUploads());
566
- }
567
-
568
535
  private assertReady(): void {
569
536
  this.assertAlive();
570
537
  if (!this.initialized) {
@@ -1,4 +1,10 @@
1
- import type { DeviceRecording, UploadInfo } from '../models/Recording';
1
+ import type { DeviceRecording, RecordingDataStore, UploadInfo, UploadRecoveryProvider } from '../models/Recording';
2
+
3
+ export interface RecordingManagerOptions {
4
+ uploadRecoveryProvider?: UploadRecoveryProvider;
5
+ /** Unsupported: recording bytes remain native-owned. Configuration throws. */
6
+ recordingDataStore?: RecordingDataStore;
7
+ }
2
8
 
3
9
  export type UploadInfoProvider = (
4
10
  recording: DeviceRecording