@symbiote-native/file-system 0.1.0

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/build/angular/index.d.ts +1 -0
  4. package/build/angular/index.js +1 -0
  5. package/build/core/file-system.d.ts +169 -0
  6. package/build/core/file-system.js +404 -0
  7. package/build/core/index.d.ts +2 -0
  8. package/build/core/index.js +2 -0
  9. package/build/core/native-module.d.ts +32 -0
  10. package/build/core/native-module.js +2 -0
  11. package/build/core/types.d.ts +104 -0
  12. package/build/core/types.js +15 -0
  13. package/build/next/directory.d.ts +36 -0
  14. package/build/next/directory.js +59 -0
  15. package/build/next/file.d.ts +71 -0
  16. package/build/next/file.js +207 -0
  17. package/build/next/index.d.ts +5 -0
  18. package/build/next/index.js +5 -0
  19. package/build/next/native-module.d.ts +109 -0
  20. package/build/next/native-module.js +2 -0
  21. package/build/next/network-tasks.d.ts +118 -0
  22. package/build/next/network-tasks.js +333 -0
  23. package/build/next/node-path.d.ts +16 -0
  24. package/build/next/node-path.js +353 -0
  25. package/build/next/path-utilities.d.ts +42 -0
  26. package/build/next/path-utilities.js +110 -0
  27. package/build/next/paths.d.ts +22 -0
  28. package/build/next/paths.js +43 -0
  29. package/build/next/streams.d.ts +25 -0
  30. package/build/next/streams.js +55 -0
  31. package/build/next/types.d.ts +152 -0
  32. package/build/next/types.js +19 -0
  33. package/build/next/url-utilities.d.ts +3 -0
  34. package/build/next/url-utilities.js +62 -0
  35. package/build/next/watcher.d.ts +19 -0
  36. package/build/next/watcher.js +64 -0
  37. package/build-ngc/angular/index.d.ts +1 -0
  38. package/build-ngc/angular/index.js +2 -0
  39. package/build-ngc/angular/index.js.map +1 -0
  40. package/build-ngc/next/directory.d.ts +36 -0
  41. package/build-ngc/next/directory.js +60 -0
  42. package/build-ngc/next/directory.js.map +1 -0
  43. package/build-ngc/next/file.d.ts +71 -0
  44. package/build-ngc/next/file.js +208 -0
  45. package/build-ngc/next/file.js.map +1 -0
  46. package/build-ngc/next/index.d.ts +5 -0
  47. package/build-ngc/next/index.js +6 -0
  48. package/build-ngc/next/index.js.map +1 -0
  49. package/build-ngc/next/native-module.d.ts +109 -0
  50. package/build-ngc/next/native-module.js +3 -0
  51. package/build-ngc/next/native-module.js.map +1 -0
  52. package/build-ngc/next/network-tasks.d.ts +118 -0
  53. package/build-ngc/next/network-tasks.js +334 -0
  54. package/build-ngc/next/network-tasks.js.map +1 -0
  55. package/build-ngc/next/node-path.d.ts +16 -0
  56. package/build-ngc/next/node-path.js +354 -0
  57. package/build-ngc/next/node-path.js.map +1 -0
  58. package/build-ngc/next/path-utilities.d.ts +42 -0
  59. package/build-ngc/next/path-utilities.js +111 -0
  60. package/build-ngc/next/path-utilities.js.map +1 -0
  61. package/build-ngc/next/paths.d.ts +22 -0
  62. package/build-ngc/next/paths.js +44 -0
  63. package/build-ngc/next/paths.js.map +1 -0
  64. package/build-ngc/next/streams.d.ts +25 -0
  65. package/build-ngc/next/streams.js +56 -0
  66. package/build-ngc/next/streams.js.map +1 -0
  67. package/build-ngc/next/types.d.ts +152 -0
  68. package/build-ngc/next/types.js +20 -0
  69. package/build-ngc/next/types.js.map +1 -0
  70. package/build-ngc/next/url-utilities.d.ts +3 -0
  71. package/build-ngc/next/url-utilities.js +63 -0
  72. package/build-ngc/next/url-utilities.js.map +1 -0
  73. package/build-ngc/next/watcher.d.ts +19 -0
  74. package/build-ngc/next/watcher.js +65 -0
  75. package/build-ngc/next/watcher.js.map +1 -0
  76. package/native-link.json +17 -0
  77. package/package.json +154 -0
  78. package/src/angular/index.ts +1 -0
  79. package/src/core/file-system.ts +634 -0
  80. package/src/core/index.ts +2 -0
  81. package/src/core/native-module.ts +99 -0
  82. package/src/core/types.ts +135 -0
  83. package/src/next/directory.ts +83 -0
  84. package/src/next/file.ts +318 -0
  85. package/src/next/index.ts +5 -0
  86. package/src/next/native-module.ts +188 -0
  87. package/src/next/network-tasks.ts +456 -0
  88. package/src/next/node-path.ts +362 -0
  89. package/src/next/path-utilities.ts +144 -0
  90. package/src/next/paths.ts +52 -0
  91. package/src/next/streams-ambient.d.ts +34 -0
  92. package/src/next/streams.ts +81 -0
  93. package/src/next/types.ts +194 -0
  94. package/src/next/url-utilities.ts +70 -0
  95. package/src/next/watcher.ts +106 -0
@@ -0,0 +1,188 @@
1
+ import { requireNativeModule, type EventSubscription } from 'expo-modules-core';
2
+
3
+ import type { Directory } from './directory';
4
+ import type { File } from './file';
5
+ import type {
6
+ IDirectoryCreateOptions,
7
+ IDirectoryInfo,
8
+ IFileCreateOptions,
9
+ IFileInfo,
10
+ IFileSystemDownloadOptions,
11
+ IFileSystemDownloadProgress,
12
+ IFileSystemHandle,
13
+ IFileSystemInfoOptions,
14
+ IFileSystemUploadProgress,
15
+ IFileSystemUploadResult,
16
+ IFileSystemWatchEvent,
17
+ IFileSystemWatchOptions,
18
+ IFileWriteOptions,
19
+ IPathInfo,
20
+ IPickMultipleFilesOptions,
21
+ IPickSingleFileOptions,
22
+ IRelocationOptions,
23
+ FileMode,
24
+ } from './types';
25
+
26
+ // Mirrors upstream's own native-module declaration shape (ExpoFileSystem.ts / internal/
27
+ // NativeFileSystem.types.ts): `declare class`, not a plain object type — a class declaration
28
+ // gives TypeScript both a proper instance type AND a proper `typeof X` constructor type, which
29
+ // is what `class File extends expoFileSystemNext.FileSystemFile {}` needs to resolve overrides
30
+ // correctly. An object type with a self-referential `new(...): T` signature does NOT behave the
31
+ // same way and produced spurious `override` errors — the base classes below are handed to JS by
32
+ // native code through the JSI SharedObject mechanism, so this declaration exists purely for the
33
+ // type checker; there is no runtime implementation here.
34
+ //
35
+ // requireOptionalNativeModule's soft-null fallback (used everywhere else in this repo) cannot
36
+ // work here either: `extends null` throws immediately, so a hard requireNativeModule — matching
37
+ // upstream's own choice — is the correct behavior, not a convention break.
38
+
39
+ export declare class NativeFileSystemDirectory {
40
+ constructor(...uris: (string | File | Directory)[]);
41
+ readonly uri: string;
42
+ validatePath(): void;
43
+ delete(): void;
44
+ exists: boolean;
45
+ create(options?: IDirectoryCreateOptions): void;
46
+ createFile(name: string, mimeType: string | null): File;
47
+ createDirectory(name: string): Directory;
48
+ copy(
49
+ destination: Directory | File,
50
+ options?: IRelocationOptions,
51
+ ): Promise<void>;
52
+ copySync(destination: Directory | File, options?: IRelocationOptions): void;
53
+ move(
54
+ destination: Directory | File,
55
+ options?: IRelocationOptions,
56
+ ): Promise<void>;
57
+ moveSync(destination: Directory | File, options?: IRelocationOptions): void;
58
+ rename(newName: string): void;
59
+ listAsRecords(): { isDirectory: boolean; uri: string }[];
60
+ list(): (Directory | File)[];
61
+ info(): IDirectoryInfo;
62
+ size: number | null;
63
+ }
64
+
65
+ export declare class NativeFileSystemFile {
66
+ constructor(...uris: (string | File | Directory)[]);
67
+ get uri(): string;
68
+ validatePath(): void;
69
+ text(): Promise<string>;
70
+ textSync(): string;
71
+ base64(): Promise<string>;
72
+ base64Sync(): string;
73
+ bytes(): Promise<Uint8Array<ArrayBuffer>>;
74
+ bytesSync(): Uint8Array;
75
+ write(content: string | Uint8Array, options?: IFileWriteOptions): void;
76
+ delete(): void;
77
+ info(options?: IFileSystemInfoOptions): IFileInfo;
78
+ exists: boolean;
79
+ create(options?: IFileCreateOptions): void;
80
+ copy(
81
+ destination: Directory | File,
82
+ options?: IRelocationOptions,
83
+ ): Promise<void>;
84
+ copySync(destination: Directory | File, options?: IRelocationOptions): void;
85
+ move(
86
+ destination: Directory | File,
87
+ options?: IRelocationOptions,
88
+ ): Promise<void>;
89
+ moveSync(destination: Directory | File, options?: IRelocationOptions): void;
90
+ rename(newName: string): void;
91
+ open(mode?: FileMode): IFileSystemHandle;
92
+ size: number;
93
+ md5: string | null;
94
+ modificationTime: number | null;
95
+ lastModified: number | null;
96
+ creationTime: number | null;
97
+ type: string;
98
+ contentUri: string;
99
+ }
100
+
101
+ export declare class NativeFileSystemUploadTask {
102
+ start(
103
+ url: string,
104
+ file: File,
105
+ options: Record<string, unknown>,
106
+ ): Promise<IFileSystemUploadResult>;
107
+ cancel(): void;
108
+ release(): void;
109
+ addListener(
110
+ eventName: 'progress',
111
+ listener: (data: IFileSystemUploadProgress) => void,
112
+ ): EventSubscription;
113
+ }
114
+
115
+ export declare class NativeFileSystemDownloadTask {
116
+ start(
117
+ url: string,
118
+ to: File | Directory,
119
+ options?: Record<string, unknown>,
120
+ ): Promise<string | null>;
121
+ pause(): Promise<{ resumeData?: string } | undefined>;
122
+ resume(
123
+ url: string,
124
+ to: File | Directory,
125
+ resumeData: string,
126
+ options?: Record<string, unknown>,
127
+ ): Promise<string | null>;
128
+ cancel(): void;
129
+ release(): void;
130
+ addListener(
131
+ eventName: 'progress',
132
+ listener: (data: IFileSystemDownloadProgress) => void,
133
+ ): EventSubscription;
134
+ }
135
+
136
+ export type INativeFileSystemWatcherEvent = {
137
+ type: IFileSystemWatchEvent<File | Directory>['type'];
138
+ path: string;
139
+ isDirectory: boolean;
140
+ nativeEventFlags?: number;
141
+ newPath?: string;
142
+ newPathIsDirectory?: boolean;
143
+ };
144
+
145
+ export declare class NativeFileSystemWatcher {
146
+ constructor(path: string, options?: IFileSystemWatchOptions);
147
+ start(): void;
148
+ stop(): void;
149
+ addListener(
150
+ eventName: 'change',
151
+ listener: (event: INativeFileSystemWatcherEvent) => void,
152
+ ): EventSubscription;
153
+ }
154
+
155
+ export type INativeFileSystemNextModule = {
156
+ FileSystemDirectory: typeof NativeFileSystemDirectory;
157
+ FileSystemFile: typeof NativeFileSystemFile;
158
+ FileSystemUploadTask: typeof NativeFileSystemUploadTask;
159
+ FileSystemDownloadTask: typeof NativeFileSystemDownloadTask;
160
+ FileSystemWatcher: typeof NativeFileSystemWatcher;
161
+ downloadFileAsync(
162
+ url: string,
163
+ destination: File | Directory,
164
+ options?: IFileSystemDownloadOptions,
165
+ uuid?: string,
166
+ ): Promise<string>;
167
+ cancelDownloadAsync(uuid: string): void;
168
+ pickDirectoryAsync(initialUri?: string): Promise<Directory>;
169
+ pickFileAsync(options: IPickSingleFileOptions): Promise<File>;
170
+ pickFileAsync(options: IPickMultipleFilesOptions): Promise<File[]>;
171
+ info(uri: string): IPathInfo;
172
+ totalDiskSpace: number;
173
+ availableDiskSpace: number;
174
+ documentDirectory: string;
175
+ cacheDirectory: string;
176
+ bundleDirectory: string;
177
+ appleSharedContainers?: Record<string, string>;
178
+ addListener(
179
+ eventName: 'downloadProgress',
180
+ listener: (data: {
181
+ uuid: string;
182
+ data: IFileSystemDownloadProgress;
183
+ }) => void,
184
+ ): EventSubscription;
185
+ };
186
+
187
+ export const expoFileSystemNext =
188
+ requireNativeModule<INativeFileSystemNextModule>('FileSystem');
@@ -0,0 +1,456 @@
1
+ import type { EventSubscription } from 'expo-modules-core';
2
+
3
+ import { Directory } from './directory';
4
+ import { File } from './file';
5
+ import { expoFileSystemNext } from './native-module';
6
+ import {
7
+ FileSystemUploadType,
8
+ type IFileSystemDownloadPauseState,
9
+ type IFileSystemDownloadProgress,
10
+ type IFileSystemDownloadTaskOptions,
11
+ type IFileSystemDownloadTaskState,
12
+ type IFileSystemUploadOptions,
13
+ type IFileSystemUploadProgress,
14
+ type IFileSystemUploadResult,
15
+ type IFileSystemUploadTaskState,
16
+ } from './types';
17
+
18
+ type ITaskState = IFileSystemUploadTaskState | IFileSystemDownloadTaskState;
19
+
20
+ type INetworkTaskOptions<TProgress> = {
21
+ signal?: AbortSignal;
22
+ onProgress?: (progress: TProgress) => void;
23
+ };
24
+
25
+ function createAbortError(reason?: unknown): Error {
26
+ const message =
27
+ typeof reason === 'string' ? reason : 'The operation was aborted.';
28
+ const error = new Error(message);
29
+ error.name = 'AbortError';
30
+ return error;
31
+ }
32
+
33
+ function assertNetworkTaskState<TState extends ITaskState>(
34
+ state: TState,
35
+ allowedStates: readonly TState[],
36
+ methodName: string,
37
+ ) {
38
+ if (!allowedStates.includes(state)) {
39
+ throw new Error(`Cannot call ${methodName}() in state "${state}"`);
40
+ }
41
+ }
42
+
43
+ function wireNetworkTaskAbortSignal(
44
+ signal: INetworkTaskOptions<never>['signal'],
45
+ cancel: () => void,
46
+ ) {
47
+ if (signal?.aborted) {
48
+ throw createAbortError();
49
+ }
50
+ if (signal) {
51
+ const abortHandler = () => cancel();
52
+ signal.addEventListener('abort', abortHandler, { once: true });
53
+ return abortHandler;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function wireNetworkTaskProgress<TProgress>(
59
+ onProgress: INetworkTaskOptions<TProgress>['onProgress'],
60
+ addListener: (listener: (progress: TProgress) => void) => EventSubscription,
61
+ ) {
62
+ if (onProgress) {
63
+ return addListener(onProgress);
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ function cleanupNetworkTask(
69
+ signal: INetworkTaskOptions<never>['signal'],
70
+ subscription: EventSubscription | undefined,
71
+ abortHandler: (() => void) | undefined,
72
+ ) {
73
+ subscription?.remove();
74
+ if (abortHandler && signal) {
75
+ signal.removeEventListener('abort', abortHandler);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Represents an upload task with progress tracking and cancellation support.
81
+ *
82
+ * Upload tasks start in the `idle` state. Calling `uploadAsync()` moves the task to `active`,
83
+ * then to `completed`, `cancelled`, or `error`.
84
+ */
85
+ export class UploadTask {
86
+ private _state: IFileSystemUploadTaskState = 'idle';
87
+ private _file: File;
88
+ private _url: string;
89
+ private _options?: IFileSystemUploadOptions;
90
+ private _subscription?: EventSubscription;
91
+ private _abortHandler?: () => void;
92
+ private readonly _nativeTask: InstanceType<
93
+ typeof expoFileSystemNext.FileSystemUploadTask
94
+ >;
95
+
96
+ /**
97
+ * Creates an upload task. The task does not start automatically — call `uploadAsync()` to
98
+ * begin uploading.
99
+ */
100
+ constructor(file: File, url: string, options?: IFileSystemUploadOptions) {
101
+ this._nativeTask = new expoFileSystemNext.FileSystemUploadTask();
102
+ this._file = file;
103
+ this._url = url;
104
+ this._options = options;
105
+ }
106
+
107
+ /** The current state of the upload task. */
108
+ get state(): IFileSystemUploadTaskState {
109
+ return this._state;
110
+ }
111
+
112
+ /**
113
+ * Starts the upload operation. Can only be called once, while the task is `idle`. The promise
114
+ * resolves with response metadata and body for completed HTTP responses, including non-2xx
115
+ * status codes. It is rejected when the file cannot be read, the request fails, or the task is
116
+ * cancelled. If `options.signal` is aborted, the promise is rejected with an `AbortError`.
117
+ */
118
+ async uploadAsync(): Promise<IFileSystemUploadResult> {
119
+ assertNetworkTaskState(this._state, ['idle'], 'uploadAsync');
120
+ this._state = 'active';
121
+ try {
122
+ this._abortHandler = wireNetworkTaskAbortSignal(
123
+ this._options?.signal,
124
+ () => this.cancel(),
125
+ );
126
+ this._subscription = wireNetworkTaskProgress(
127
+ this._options?.onProgress,
128
+ listener => this.addListener('progress', listener),
129
+ );
130
+
131
+ const nativeOpts = {
132
+ httpMethod: this._options?.httpMethod || 'POST',
133
+ uploadType:
134
+ this._options?.uploadType ?? FileSystemUploadType.BINARY_CONTENT,
135
+ headers: this._options?.headers,
136
+ fieldName: this._options?.fieldName,
137
+ mimeType: this._options?.mimeType,
138
+ parameters: this._options?.parameters,
139
+ sessionType: this._options?.sessionType,
140
+ };
141
+
142
+ const result = await this._nativeTask.start(
143
+ this._url,
144
+ this._file,
145
+ nativeOpts,
146
+ );
147
+ this._state = 'completed';
148
+
149
+ // Emit a synthetic final progress to guarantee 100% is reported. Native progress events
150
+ // may not fire for small files, and even when they do, the event can race with promise
151
+ // resolution (listener removed before delivery).
152
+ if (this._options?.onProgress && this._file.exists) {
153
+ const size = this._file.size ?? 0;
154
+ if (size > 0) {
155
+ this._options.onProgress({ bytesSent: size, totalBytes: size });
156
+ }
157
+ }
158
+
159
+ return result;
160
+ } catch (error) {
161
+ if (this._options?.signal?.aborted) {
162
+ this._state = 'cancelled';
163
+ throw createAbortError();
164
+ }
165
+ if (this.state === 'cancelled') {
166
+ throw error;
167
+ }
168
+ this._state = 'error';
169
+ throw error;
170
+ } finally {
171
+ cleanupNetworkTask(
172
+ this._options?.signal,
173
+ this._subscription,
174
+ this._abortHandler,
175
+ );
176
+ this._subscription = undefined;
177
+ this._abortHandler = undefined;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Adds a listener for upload progress events. Prefer the `onProgress` option unless manual
183
+ * subscription control is needed.
184
+ */
185
+ addListener(
186
+ eventName: 'progress',
187
+ listener: (data: IFileSystemUploadProgress) => void,
188
+ ): EventSubscription {
189
+ return this._nativeTask.addListener(eventName, listener);
190
+ }
191
+
192
+ /** Releases the native task handle manually. */
193
+ release(): void {
194
+ this._nativeTask.release();
195
+ }
196
+
197
+ /**
198
+ * Cancels the upload operation. If `uploadAsync()` is pending, its promise is rejected after
199
+ * the native request is cancelled. Has no effect once the task reaches a terminal state.
200
+ */
201
+ cancel(): void {
202
+ if (['completed', 'cancelled', 'error'].includes(this._state)) return;
203
+ this._state = 'cancelled';
204
+ this._nativeTask.cancel();
205
+ cleanupNetworkTask(
206
+ this._options?.signal,
207
+ this._subscription,
208
+ this._abortHandler,
209
+ );
210
+ this._subscription = undefined;
211
+ this._abortHandler = undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Represents a download task with pause/resume support and progress tracking.
217
+ *
218
+ * Download tasks start in the `idle` state. Calling `downloadAsync()` moves the task to
219
+ * `active`; pausing moves it to `paused`, and a completed, cancelled, or failed transfer moves
220
+ * it to the corresponding terminal state.
221
+ */
222
+ export class DownloadTask {
223
+ private _state: IFileSystemDownloadTaskState = 'idle';
224
+ private _url: string;
225
+ private _destination: File | Directory;
226
+ private _options?: IFileSystemDownloadTaskOptions;
227
+ private _resumeData?: string;
228
+ private _subscription?: EventSubscription;
229
+ private _abortHandler?: () => void;
230
+ private _inFlightOperation?: Promise<File | null>;
231
+ private _pauseRequest?: Promise<void>;
232
+ private readonly _nativeTask: InstanceType<
233
+ typeof expoFileSystemNext.FileSystemDownloadTask
234
+ >;
235
+
236
+ /**
237
+ * Creates a download task. The task does not start automatically — call `downloadAsync()` to
238
+ * begin downloading.
239
+ */
240
+ constructor(
241
+ url: string,
242
+ destination: File | Directory,
243
+ options?: IFileSystemDownloadTaskOptions,
244
+ ) {
245
+ this._nativeTask = new expoFileSystemNext.FileSystemDownloadTask();
246
+ this._url = url;
247
+ this._destination = destination;
248
+ this._options = options;
249
+ }
250
+
251
+ /** The current state of the download task. */
252
+ get state(): IFileSystemDownloadTaskState {
253
+ return this._state;
254
+ }
255
+
256
+ /**
257
+ * Starts the download operation. Can only be called once, while the task is `idle`. Resolves
258
+ * with the downloaded file when the transfer completes, or with `null` if the task is paused
259
+ * before completion.
260
+ */
261
+ async downloadAsync(): Promise<File | null> {
262
+ assertNetworkTaskState(this._state, ['idle'], 'downloadAsync');
263
+ this._state = 'active';
264
+ this._pauseRequest = undefined;
265
+ const operation = this._runDownloadOperation(() =>
266
+ this._nativeTask.start(this._url, this._destination, {
267
+ headers: this._options?.headers,
268
+ sessionType: this._options?.sessionType,
269
+ }),
270
+ );
271
+ this._inFlightOperation = operation;
272
+ return operation;
273
+ }
274
+
275
+ /**
276
+ * Requests pausing the active download operation. The pending `downloadAsync()`/`resumeAsync()`
277
+ * promise resolves with `null` once native code produces resume data. Use `pauseAsync()` to
278
+ * wait for that to happen.
279
+ */
280
+ pause(): void {
281
+ assertNetworkTaskState(this._state, ['active'], 'pause');
282
+ this._pauseRequest = Promise.resolve(this._nativeTask.pause()).then(
283
+ result => {
284
+ this._resumeData = result?.resumeData ?? undefined;
285
+ },
286
+ );
287
+ }
288
+
289
+ /**
290
+ * Requests pausing the active download operation and waits until the task reaches the
291
+ * `paused` state.
292
+ */
293
+ async pauseAsync(): Promise<void> {
294
+ this.pause();
295
+ await this._pauseRequest;
296
+ await this._inFlightOperation;
297
+ }
298
+
299
+ /**
300
+ * Resumes a paused download operation. Resolves with the downloaded file when the transfer
301
+ * completes, or with `null` if paused again before completion.
302
+ */
303
+ async resumeAsync(): Promise<File | null> {
304
+ assertNetworkTaskState(this._state, ['paused'], 'resumeAsync');
305
+ if (!this._resumeData) {
306
+ throw new Error(
307
+ 'No resume data available. Was the download paused before any data was received?',
308
+ );
309
+ }
310
+ this._state = 'active';
311
+ this._pauseRequest = undefined;
312
+ const operation = this._runDownloadOperation(() =>
313
+ this._nativeTask.resume(this._url, this._destination, this._resumeData!, {
314
+ headers: this._options?.headers,
315
+ sessionType: this._options?.sessionType,
316
+ }),
317
+ );
318
+ this._inFlightOperation = operation;
319
+ return operation;
320
+ }
321
+
322
+ /**
323
+ * Adds a listener for download progress events. Prefer the `onProgress` option unless manual
324
+ * subscription control is needed.
325
+ */
326
+ addListener(
327
+ eventName: 'progress',
328
+ listener: (data: IFileSystemDownloadProgress) => void,
329
+ ): EventSubscription {
330
+ return this._nativeTask.addListener(eventName, listener);
331
+ }
332
+
333
+ /** Releases the native task handle manually. */
334
+ release(): void {
335
+ this._nativeTask.release();
336
+ }
337
+
338
+ /**
339
+ * Cancels the download operation. If `downloadAsync()`/`resumeAsync()` is pending, its promise
340
+ * is rejected after the native request is cancelled. Has no effect once the task reaches a
341
+ * terminal state.
342
+ */
343
+ cancel(): void {
344
+ if (['completed', 'cancelled', 'error'].includes(this._state)) return;
345
+ this._state = 'cancelled';
346
+ this._pauseRequest = undefined;
347
+ this._nativeTask.cancel();
348
+ cleanupNetworkTask(
349
+ this._options?.signal,
350
+ this._subscription,
351
+ this._abortHandler,
352
+ );
353
+ this._subscription = undefined;
354
+ this._abortHandler = undefined;
355
+ }
356
+
357
+ /**
358
+ * Returns the paused task state that can be persisted and restored later. Can only be called
359
+ * while the task is `paused`.
360
+ */
361
+ savable(): IFileSystemDownloadPauseState {
362
+ assertNetworkTaskState(this._state, ['paused'], 'savable');
363
+ return {
364
+ url: this._url,
365
+ fileUri: this._destination.uri,
366
+ isDirectory: this._destination instanceof Directory,
367
+ headers: this._options?.headers,
368
+ resumeData: this._resumeData,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Creates a paused download task from saved state, to continue a download after persisting the
374
+ * value returned by `savable()`.
375
+ */
376
+ static fromSavable(
377
+ state: IFileSystemDownloadPauseState,
378
+ options?: IFileSystemDownloadTaskOptions,
379
+ ): DownloadTask {
380
+ if (!state.resumeData) {
381
+ throw new Error(
382
+ 'Cannot restore task: DownloadPauseState has no resumeData',
383
+ );
384
+ }
385
+ const dest = state.isDirectory
386
+ ? new Directory(state.fileUri)
387
+ : new File(state.fileUri);
388
+ const mergedOptions =
389
+ options || state.headers
390
+ ? { ...options, headers: { ...state.headers, ...options?.headers } }
391
+ : undefined;
392
+ const task = new DownloadTask(state.url, dest, mergedOptions);
393
+ task._resumeData = state.resumeData;
394
+ task._state = 'paused';
395
+ return task;
396
+ }
397
+
398
+ private async _runDownloadOperation(
399
+ operation: () => Promise<string | null>,
400
+ ): Promise<File | null> {
401
+ try {
402
+ this._abortHandler = wireNetworkTaskAbortSignal(
403
+ this._options?.signal,
404
+ () => this.cancel(),
405
+ );
406
+ this._subscription = wireNetworkTaskProgress(
407
+ this._options?.onProgress,
408
+ listener => this.addListener('progress', listener),
409
+ );
410
+
411
+ const result = await operation();
412
+ if (result) {
413
+ this._state = 'completed';
414
+ this._resumeData = undefined;
415
+ const file = new File(result);
416
+ this._emitFinalProgressEvent(file.size);
417
+ return file;
418
+ }
419
+
420
+ await this._pauseRequest;
421
+ this._state = 'paused';
422
+ return null;
423
+ } catch (error) {
424
+ if (this._options?.signal?.aborted) {
425
+ this._state = 'cancelled';
426
+ throw createAbortError();
427
+ }
428
+ if (this.state === 'cancelled') {
429
+ throw error;
430
+ }
431
+ this._state = 'error';
432
+ throw error;
433
+ } finally {
434
+ cleanupNetworkTask(
435
+ this._options?.signal,
436
+ this._subscription,
437
+ this._abortHandler,
438
+ );
439
+ this._subscription = undefined;
440
+ this._abortHandler = undefined;
441
+ this._inFlightOperation = undefined;
442
+ }
443
+ }
444
+
445
+ private _emitFinalProgressEvent(fileSize: number) {
446
+ // Emit a synthetic final progress to guarantee 100% is reported. Native progress events may
447
+ // not fire for small files, and even when they do, the event can race with promise
448
+ // resolution (listener removed before delivery).
449
+ if (this._options?.onProgress && fileSize > 0) {
450
+ this._options.onProgress({
451
+ bytesWritten: fileSize,
452
+ totalBytes: fileSize,
453
+ });
454
+ }
455
+ }
456
+ }