@bota.dev/react-native-app-sdk 2.0.0-beta.1 → 2.0.0-beta.11
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 +115 -4
- package/android/build.gradle +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -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 +86 -3
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
- 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 +751 -32
- package/ios/BotaDeviceSDK.mm +114 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
- package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
- 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/ios/BotaUploadV2Backend.mm +49 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
- package/ios/UploadBackend/UploadCoordinator.swift +84 -0
- package/ios/UploadBackend/UploadHTTP.swift +82 -0
- package/ios/UploadBackend/UploadJournal.swift +187 -0
- package/ios/UploadBackend/UploadModels.swift +273 -0
- package/ios/UploadBackend/UploadOperation.swift +391 -0
- package/lib/commonjs/BotaClient.js +16 -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 +183 -28
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/clientPresence.js +26 -0
- package/lib/commonjs/clientPresence.js.map +1 -0
- package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
- package/lib/commonjs/index.js +34 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +23 -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/sdkIdentity.js +10 -0
- package/lib/commonjs/sdkIdentity.js.map +1 -0
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/commonjs/uploadRecovery.js +68 -0
- package/lib/commonjs/uploadRecovery.js.map +1 -0
- package/lib/module/BotaClient.js +16 -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 +183 -28
- package/lib/module/client.js.map +1 -1
- package/lib/module/clientPresence.js +22 -0
- package/lib/module/clientPresence.js.map +1 -0
- package/lib/module/encryptedUploadV2Backend.js +196 -0
- package/lib/module/encryptedUploadV2Backend.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +23 -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/sdkIdentity.js +6 -0
- package/lib/module/sdkIdentity.js.map +1 -0
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/module/uploadRecovery.js +64 -0
- package/lib/module/uploadRecovery.js.map +1 -0
- package/lib/typescript/BotaClient.d.ts +2 -0
- 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 +24 -3
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/clientPresence.d.ts +26 -0
- package/lib/typescript/clientPresence.d.ts.map +1 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +13 -4
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +5 -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/sdkIdentity.d.ts +3 -0
- package/lib/typescript/sdkIdentity.d.ts.map +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/uploadRecovery.d.ts +27 -0
- package/lib/typescript/uploadRecovery.d.ts.map +1 -0
- package/package.json +1 -1
- package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
- package/src/BotaClient.ts +17 -3
- package/src/ble/constants.ts +5 -0
- package/src/ble/deviceDiagnostics.ts +238 -0
- package/src/client.ts +189 -18
- package/src/clientPresence.ts +33 -0
- package/src/encryptedUploadV2Backend.ts +171 -0
- package/src/index.ts +24 -0
- package/src/managers/DeviceManager.ts +23 -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/sdkIdentity.ts +3 -0
- package/src/specs/NativeBotaDeviceSDK.ts +66 -0
- package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
- package/src/uploadRecovery.ts +66 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { BotaDeviceSDKRecordingClient, BotaEncryptedUploadV2Recording,
|
|
2
|
+
BotaEncryptedUploadV2ProfileDecision, BotaEncryptedUploadV2Progress } from './client';
|
|
3
|
+
import type { ConnectedDevice } from './models/Device';
|
|
4
|
+
import type { Spec } from './specs/NativeBotaUploadV2Backend';
|
|
5
|
+
|
|
6
|
+
export interface EncryptedUploadV2BackendOptions {
|
|
7
|
+
/** Customer-authenticated HTTPS proxy exposing the v2 routes, e.g. https://api.example.com/v1. */
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
/** Stable, non-secret account/project/environment identity. */
|
|
10
|
+
scopeKey: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
endUserId: string;
|
|
14
|
+
environment: string;
|
|
15
|
+
/** Only needed by an organization-scoped backend bridge. */
|
|
16
|
+
organizationId?: string;
|
|
17
|
+
/** Abort permanently on logout or any account/project/binding change. */
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
/** Return an application access token, never a Bota secret API key. */
|
|
20
|
+
getAccessToken(signal: AbortSignal): Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EncryptedUploadV2SyncTarget {
|
|
24
|
+
deviceId: string;
|
|
25
|
+
bindingGeneration: number;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
onProgress?: (progress: BotaEncryptedUploadV2Progress) => void;
|
|
28
|
+
/** Optional import of an existing integration's exact cloud identity. */
|
|
29
|
+
priorUpload?: { recordingId: string; sessionId?: string; ownerRevision?: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EncryptedUploadV2Backend {
|
|
33
|
+
/** Reinvoking after restart/reconnect resumes the native journal for this exact recording. */
|
|
34
|
+
sync(device: ConnectedDevice, recording: BotaEncryptedUploadV2Recording,
|
|
35
|
+
target: EncryptedUploadV2SyncTarget): Promise<{ recordingId: string }>;
|
|
36
|
+
dispose(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal Dependency injection keeps the public facade and host tests on the same implementation. */
|
|
40
|
+
export function createManagedEncryptedUploadV2Backend(
|
|
41
|
+
native: Spec | null, recordings: BotaDeviceSDKRecordingClient,
|
|
42
|
+
options: EncryptedUploadV2BackendOptions,
|
|
43
|
+
): EncryptedUploadV2Backend {
|
|
44
|
+
// Capture identity once; mutating the caller's object cannot repoint an active request.
|
|
45
|
+
const config = { ...options };
|
|
46
|
+
const url = new URL(config.baseUrl);
|
|
47
|
+
const path = url.pathname.replace(/\/$/, '');
|
|
48
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
|
|
49
|
+
!/^(\/[A-Za-z0-9_-]+)+$/.test(path) || !config.scopeKey || !config.signal) {
|
|
50
|
+
throw new Error('Invalid encrypted upload backend configuration');
|
|
51
|
+
}
|
|
52
|
+
const apiOrigin = url.origin;
|
|
53
|
+
const attempts = new Set<{ cancel(): Promise<void>; settled: Promise<void> }>();
|
|
54
|
+
let disposed = false;
|
|
55
|
+
const backend: EncryptedUploadV2Backend = {
|
|
56
|
+
async sync(device, recording, target) {
|
|
57
|
+
device = { ...device };
|
|
58
|
+
recording = { ...recording };
|
|
59
|
+
target = { ...target, priorUpload: target.priorUpload && { ...target.priorUpload } };
|
|
60
|
+
if (!native) throw new Error('Encrypted upload backend requires the matching native SDK binary');
|
|
61
|
+
if (disposed || config.signal.aborted || target.signal?.aborted) throw new Error('Encrypted upload cancelled');
|
|
62
|
+
const signal = new AbortController();
|
|
63
|
+
let operationId: string | undefined;
|
|
64
|
+
let selected: (BotaEncryptedUploadV2ProfileDecision & { recordingId: string }) | undefined;
|
|
65
|
+
let cancelTask: Promise<void> | undefined;
|
|
66
|
+
let completed = false;
|
|
67
|
+
const check = () => {
|
|
68
|
+
if (disposed || signal.signal.aborted || config.signal.aborted || target.signal?.aborted) {
|
|
69
|
+
throw new Error('Encrypted upload cancelled');
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const cancel = () => {
|
|
73
|
+
signal.abort();
|
|
74
|
+
if (operationId) cancelTask ??= Promise.resolve().then(() => native.cancel(operationId!));
|
|
75
|
+
return cancelTask ?? Promise.resolve();
|
|
76
|
+
};
|
|
77
|
+
// Event callbacks must not create unhandled rejections; disposal awaits the same task.
|
|
78
|
+
const abort = () => { void cancel().catch(() => {}); };
|
|
79
|
+
let settle!: () => void;
|
|
80
|
+
const settled = new Promise<void>(resolve => { settle = resolve; });
|
|
81
|
+
const attempt = { cancel, settled };
|
|
82
|
+
attempts.add(attempt);
|
|
83
|
+
let subscription: { remove(): void } | undefined;
|
|
84
|
+
try {
|
|
85
|
+
config.signal.addEventListener('abort', abort, { once: true });
|
|
86
|
+
target.signal?.addEventListener('abort', abort, { once: true });
|
|
87
|
+
subscription = native.onCredentialsRequested(event => {
|
|
88
|
+
if (!operationId || event.operationId !== operationId) return;
|
|
89
|
+
void (async () => {
|
|
90
|
+
try {
|
|
91
|
+
check();
|
|
92
|
+
const token = await config.getAccessToken(signal.signal);
|
|
93
|
+
check();
|
|
94
|
+
await native.resolveCredentials(event.requestId, token);
|
|
95
|
+
} catch { await native.rejectCredentials(event.requestId).catch(() => {}); }
|
|
96
|
+
})();
|
|
97
|
+
});
|
|
98
|
+
check();
|
|
99
|
+
await recordings.syncEncryptedRecordingV2(device, recording, async context => {
|
|
100
|
+
check();
|
|
101
|
+
if (!context.operationId || (operationId && operationId !== context.operationId)) {
|
|
102
|
+
throw new Error('Encrypted upload operation changed');
|
|
103
|
+
}
|
|
104
|
+
operationId = context.operationId;
|
|
105
|
+
const value: unknown = JSON.parse(await native.prepare(JSON.stringify({
|
|
106
|
+
operationId,
|
|
107
|
+
scope: { apiOrigin, apiBasePath: path, scopeKey: config.scopeKey,
|
|
108
|
+
accountId: config.accountId, projectId: config.projectId, endUserId: config.endUserId,
|
|
109
|
+
organizationId: config.organizationId ?? '', environment: config.environment,
|
|
110
|
+
deviceId: target.deviceId, bindingGeneration: target.bindingGeneration,
|
|
111
|
+
serialNumber: device.serialNumber, nativeDeviceId: device.id },
|
|
112
|
+
recording: { uuid: context.recording.uuid, generation: context.recording.generation,
|
|
113
|
+
ciphertextLength: context.recording.ciphertextLength, ciphertextSha256: context.recording.ciphertextSha256,
|
|
114
|
+
startedAtMs: context.recording.startedAtMs, durationMs: context.recording.durationMs,
|
|
115
|
+
plaintextLength: context.recording.plaintextLength, storageFormat: context.recording.storageFormat },
|
|
116
|
+
capability: { rawValueHex: context.capability.rawValueHex, flags: context.capability.flags },
|
|
117
|
+
...(context.checkpoint === undefined ? {} : { checkpoint: {
|
|
118
|
+
version: context.checkpoint.version, uploadSessionId: context.checkpoint.uploadSessionId,
|
|
119
|
+
ownerRevision: context.checkpoint.ownerRevision, revision: context.checkpoint.revision,
|
|
120
|
+
nextCiphertextOffset: context.checkpoint.nextCiphertextOffset, prefixSha256: context.checkpoint.prefixSha256,
|
|
121
|
+
highestContiguousSequence: context.checkpoint.highestContiguousSequence,
|
|
122
|
+
transportSessionId: context.checkpoint.transportSessionId, sinkRegistrationId: context.checkpoint.sinkRegistrationId,
|
|
123
|
+
windowPackets: context.checkpoint.windowPackets, dataPayloadBytes: context.checkpoint.dataPayloadBytes,
|
|
124
|
+
} }),
|
|
125
|
+
...(target.priorUpload === undefined ? {} : { priorJournalEntry: {
|
|
126
|
+
recordingId: target.priorUpload.recordingId, sessionId: target.priorUpload.sessionId,
|
|
127
|
+
ownerRevision: target.priorUpload.ownerRevision } }),
|
|
128
|
+
})));
|
|
129
|
+
check();
|
|
130
|
+
const result = value as NonNullable<typeof selected>;
|
|
131
|
+
if (!result || result.profile !== 'encrypted_upload_v2' ||
|
|
132
|
+
!/^rec_[A-Za-z0-9_-]+$/.test(result.recordingId) ||
|
|
133
|
+
!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(result.uploadSessionId) ||
|
|
134
|
+
!Number.isSafeInteger(result.ownerRevision) || result.ownerRevision < 1 ||
|
|
135
|
+
!result.materialRegistrationId || !['legacy_allowed', 'v2_preferred', 'v2_required'].includes(result.securityPolicy)) {
|
|
136
|
+
throw new Error('Invalid encrypted upload backend response');
|
|
137
|
+
}
|
|
138
|
+
selected = result;
|
|
139
|
+
return { profile: result.profile, uploadSessionId: result.uploadSessionId,
|
|
140
|
+
ownerRevision: result.ownerRevision, securityPolicy: result.securityPolicy,
|
|
141
|
+
materialRegistrationId: result.materialRegistrationId };
|
|
142
|
+
}, progress => { if (!signal.signal.aborted) target.onProgress?.(progress); }, { signal: signal.signal });
|
|
143
|
+
check();
|
|
144
|
+
if (!operationId || !selected) throw new Error('Encrypted upload confirmation is missing');
|
|
145
|
+
// The native sync only resolves after signed receipt/CONFIRM. Never infer this from HTTP 2xx.
|
|
146
|
+
await native.complete(operationId);
|
|
147
|
+
completed = true;
|
|
148
|
+
return { recordingId: selected.recordingId };
|
|
149
|
+
} finally {
|
|
150
|
+
// Native complete already releases the operation. A later bridge cancellation
|
|
151
|
+
// failure must not turn confirmed device cleanup into a retryable sync failure.
|
|
152
|
+
try { if (completed) signal.abort(); else await cancel(); }
|
|
153
|
+
finally {
|
|
154
|
+
attempts.delete(attempt);
|
|
155
|
+
config.signal.removeEventListener('abort', abort);
|
|
156
|
+
target.signal?.removeEventListener('abort', abort);
|
|
157
|
+
subscription?.remove();
|
|
158
|
+
settle();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
async dispose() {
|
|
163
|
+
disposed = true;
|
|
164
|
+
const results = await Promise.allSettled([...attempts].map(async attempt => {
|
|
165
|
+
try { await attempt.cancel(); } finally { await attempt.settled; }
|
|
166
|
+
}));
|
|
167
|
+
if (results.some(result => result.status === 'rejected')) throw new Error('Encrypted upload cancellation failed');
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
return backend;
|
|
171
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import NativeBotaDeviceSDK from './specs/NativeBotaDeviceSDK';
|
|
2
2
|
import { createBotaDeviceSDK } from './client';
|
|
3
|
+
import NativeBotaUploadV2Backend from './specs/NativeBotaUploadV2Backend';
|
|
4
|
+
import { createManagedEncryptedUploadV2Backend } from './encryptedUploadV2Backend';
|
|
5
|
+
import type { EncryptedUploadV2BackendOptions } from './encryptedUploadV2Backend';
|
|
3
6
|
import { setDefaultCompatibilityClient } from './compatibility/runtime';
|
|
4
7
|
|
|
5
8
|
export {
|
|
@@ -7,6 +10,8 @@ export {
|
|
|
7
10
|
createBotaDeviceSDK,
|
|
8
11
|
} from './client';
|
|
9
12
|
export type {
|
|
13
|
+
BotaClientPresenceContext,
|
|
14
|
+
BotaClientPresence,
|
|
10
15
|
BotaAsyncEventSubscription,
|
|
11
16
|
BotaDeviceSDKCapabilities,
|
|
12
17
|
BotaDeviceSDKClient,
|
|
@@ -19,6 +24,8 @@ export type {
|
|
|
19
24
|
BotaEncryptedUploadV2ProgressPhase,
|
|
20
25
|
BotaEncryptedUploadV2ProviderContext,
|
|
21
26
|
BotaEncryptedUploadV2Recording,
|
|
27
|
+
BotaEncryptedUploadV2PendingRecording,
|
|
28
|
+
BotaEncryptedUploadV2SyncOptions,
|
|
22
29
|
BotaEncryptedUploadV2SecurityPolicy,
|
|
23
30
|
BotaDeprovisionResult,
|
|
24
31
|
BotaDeviceSDKDeviceClient,
|
|
@@ -48,9 +55,16 @@ export type {
|
|
|
48
55
|
} from './client';
|
|
49
56
|
|
|
50
57
|
export const BotaDeviceSDK = createBotaDeviceSDK(NativeBotaDeviceSDK);
|
|
58
|
+
export const createEncryptedUploadV2Backend = (options: EncryptedUploadV2BackendOptions) =>
|
|
59
|
+
createManagedEncryptedUploadV2Backend(NativeBotaUploadV2Backend, BotaDeviceSDK.recordings, options);
|
|
60
|
+
export type { EncryptedUploadV2BackendOptions, EncryptedUploadV2Backend, EncryptedUploadV2SyncTarget } from './encryptedUploadV2Backend';
|
|
51
61
|
setDefaultCompatibilityClient(BotaDeviceSDK);
|
|
52
62
|
|
|
53
63
|
export { BotaClient } from './BotaClient';
|
|
64
|
+
export type { ClientPresence, SdkClientContext } from './clientPresence';
|
|
65
|
+
export { readClientPresenceObservation } from './clientPresence';
|
|
66
|
+
export { createUploadRecoveryProvider } from './uploadRecovery';
|
|
67
|
+
export type { UploadRecoveryBackend } from './uploadRecovery';
|
|
54
68
|
|
|
55
69
|
export type {
|
|
56
70
|
BleFactoryResetResult,
|
|
@@ -62,6 +76,10 @@ export type {
|
|
|
62
76
|
DeviceConnectionSettings,
|
|
63
77
|
DeviceFlags,
|
|
64
78
|
DeviceLogEvent,
|
|
79
|
+
DeviceDiagnosticEvent,
|
|
80
|
+
DeviceDiagnosticEventType,
|
|
81
|
+
DeviceDiagnosticReasonCode,
|
|
82
|
+
DeviceDiagnosticsBatch,
|
|
65
83
|
DeviceState,
|
|
66
84
|
DeviceStatus,
|
|
67
85
|
DeviceType,
|
|
@@ -87,6 +105,9 @@ export type {
|
|
|
87
105
|
export type {
|
|
88
106
|
AudioCodec,
|
|
89
107
|
DeviceRecording,
|
|
108
|
+
RecordingDataStore,
|
|
109
|
+
UploadRecoveryContext,
|
|
110
|
+
UploadRecoveryProvider,
|
|
90
111
|
StreamingSessionEvents,
|
|
91
112
|
StreamingState,
|
|
92
113
|
StreamingSyncOptions,
|
|
@@ -118,13 +139,16 @@ export type {
|
|
|
118
139
|
FirmwareInfo,
|
|
119
140
|
OtaProgress,
|
|
120
141
|
OtaStage,
|
|
142
|
+
RecordingManagerOptions,
|
|
121
143
|
UploadInfoProvider,
|
|
122
144
|
} from './managers/types';
|
|
123
145
|
|
|
124
146
|
export { DeviceLogDecoder } from './ble/deviceLogs';
|
|
147
|
+
export { DeviceDiagnosticsDecoder, diagnosticEventIdCommand } from './ble/deviceDiagnostics';
|
|
125
148
|
export { DeviceManager } from './managers/DeviceManager';
|
|
126
149
|
export { OTAManager } from './managers/OTAManager';
|
|
127
150
|
export { RecordingManager } from './managers/RecordingManager';
|
|
151
|
+
export const UPLOAD_RECOVERY_VERSION = 1;
|
|
128
152
|
export { StreamingSession } from './managers/StreamingSession';
|
|
129
153
|
export {
|
|
130
154
|
BotaError,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import EventEmitter from 'eventemitter3';
|
|
2
|
+
import type { ClientPresence } from '../clientPresence';
|
|
2
3
|
|
|
3
4
|
import type {
|
|
4
5
|
BotaAsyncEventSubscription,
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
ConnectedDevice,
|
|
17
18
|
DeviceConnectionSettings,
|
|
18
19
|
DeviceLogEvent,
|
|
20
|
+
DeviceDiagnosticsBatch,
|
|
19
21
|
DeviceStatus,
|
|
20
22
|
DeviceWiFiScanResult,
|
|
21
23
|
DiscoveredDevice,
|
|
@@ -54,6 +56,17 @@ type CacheListener = (
|
|
|
54
56
|
) => void;
|
|
55
57
|
|
|
56
58
|
export class DeviceManager extends EventEmitter<DeviceManagerEvents> {
|
|
59
|
+
readonly clientPresence: ClientPresence = {
|
|
60
|
+
nextReport: async (deviceId) => {
|
|
61
|
+
if (this.destroyed) return null;
|
|
62
|
+
const report = await this.client.clientPresence.nextReport(deviceId);
|
|
63
|
+
if (this.destroyed || !report || (report.platform !== 'ios' && report.platform !== 'android')) return null;
|
|
64
|
+
return {
|
|
65
|
+
schema_version: 1, session_id: report.sessionId, sequence: report.sequence,
|
|
66
|
+
platform: report.platform, sdk_package: report.sdkPackage, sdk_version: report.sdkVersion,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
57
70
|
private readonly client: BotaDeviceSDKClient;
|
|
58
71
|
private readonly discoveredDevices = new Map<string, DiscoveredDevice>();
|
|
59
72
|
private readonly connectedDevices = new Map<string, ConnectedDevice>();
|
|
@@ -485,6 +498,16 @@ export class DeviceManager extends EventEmitter<DeviceManagerEvents> {
|
|
|
485
498
|
});
|
|
486
499
|
}
|
|
487
500
|
|
|
501
|
+
async readDiagnosticEvents(device: ConnectedDevice): Promise<DeviceDiagnosticsBatch> {
|
|
502
|
+
this.requireConnected(device.id);
|
|
503
|
+
return this.client.logs.readDiagnosticEvents(device);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async acknowledgeDiagnosticEvents(device: ConnectedDevice, acceptedEventIds: string[]): Promise<void> {
|
|
507
|
+
this.requireConnected(device.id);
|
|
508
|
+
await this.client.logs.acknowledgeDiagnosticEvents(device, acceptedEventIds);
|
|
509
|
+
}
|
|
510
|
+
|
|
488
511
|
async readConnectionSettings(
|
|
489
512
|
device: ConnectedDevice
|
|
490
513
|
): Promise<DeviceConnectionSettings> {
|
|
@@ -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
|
+
}
|