rn-file-toolkit 1.0.1
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/FileToolkit.podspec +22 -0
- package/LICENSE +20 -0
- package/README.md +522 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +13 -0
- package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +1204 -0
- package/android/src/main/java/com/filetoolkit/FileToolkitPackage.kt +31 -0
- package/android/src/main/res/xml/file_provider_paths.xml +14 -0
- package/app.plugin.js +49 -0
- package/ios/FileToolkit.h +6 -0
- package/ios/FileToolkit.mm +1468 -0
- package/lib/commonjs/NativeFileToolkit.js +9 -0
- package/lib/commonjs/NativeFileToolkit.js.map +1 -0
- package/lib/commonjs/index.js +941 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/module/NativeFileToolkit.js +5 -0
- package/lib/module/NativeFileToolkit.js.map +1 -0
- package/lib/module/index.js +905 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts +29 -0
- package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +635 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/NativeFileToolkit.d.ts +29 -0
- package/lib/typescript/module/src/NativeFileToolkit.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +635 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/package.json +232 -0
- package/src/NativeFileToolkit.ts +29 -0
- package/src/index.tsx +1293 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { NativeEventEmitter, NativeModules } from 'react-native';
|
|
3
|
+
import FileToolkitSpec from './NativeFileToolkit';
|
|
4
|
+
|
|
5
|
+
const FileToolkitModule = NativeModules.FileToolkit || FileToolkitSpec;
|
|
6
|
+
const eventEmitter = new NativeEventEmitter(FileToolkitModule);
|
|
7
|
+
|
|
8
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rich progress information emitted during a download.
|
|
12
|
+
* Replaces the plain `number` percent from earlier versions.
|
|
13
|
+
*/
|
|
14
|
+
export interface ProgressInfo {
|
|
15
|
+
/** Download progress as a percentage (0–100) */
|
|
16
|
+
percent: number;
|
|
17
|
+
/** Number of bytes downloaded so far */
|
|
18
|
+
bytesDownloaded: number;
|
|
19
|
+
/** Total file size in bytes (0 if unknown) */
|
|
20
|
+
totalBytes: number;
|
|
21
|
+
/** Current download speed in bytes per second */
|
|
22
|
+
speedBps: number;
|
|
23
|
+
/** Estimated seconds remaining (0 if unknown) */
|
|
24
|
+
etaSeconds: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DownloadOptions {
|
|
28
|
+
/** Remote URL to download from */
|
|
29
|
+
url: string;
|
|
30
|
+
/** Optional file name. Auto-detected from URL if not provided */
|
|
31
|
+
fileName?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Run as a background download.
|
|
34
|
+
* - iOS: uses NSURLSession background configuration
|
|
35
|
+
* - Android: uses system DownloadManager
|
|
36
|
+
* Background downloads survive app suspension. Listen to `onDownloadComplete`
|
|
37
|
+
* and `onDownloadError` events instead of awaiting the promise.
|
|
38
|
+
*/
|
|
39
|
+
background?: boolean;
|
|
40
|
+
/** Custom request headers (e.g. Authorization) */
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
/**
|
|
43
|
+
* Destination directory for the downloaded file.
|
|
44
|
+
* - 'downloads': Public Downloads folder (default)
|
|
45
|
+
* - 'cache': App-private cache directory (cleared by OS when space is low)
|
|
46
|
+
* - 'documents': App-private documents directory (persisted)
|
|
47
|
+
*/
|
|
48
|
+
destination?: 'downloads' | 'cache' | 'documents';
|
|
49
|
+
/** Android only: custom title for the system download notification */
|
|
50
|
+
notificationTitle?: string;
|
|
51
|
+
/** Android only: custom description for the system download notification */
|
|
52
|
+
notificationDescription?: string;
|
|
53
|
+
/** Optional checksum verification after download completes */
|
|
54
|
+
checksum?: {
|
|
55
|
+
hash: string;
|
|
56
|
+
algorithm: 'md5' | 'sha1' | 'sha256';
|
|
57
|
+
};
|
|
58
|
+
/** Called with rich progress info during foreground downloads */
|
|
59
|
+
onProgress?: (info: ProgressInfo) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Join the managed download queue instead of starting immediately.
|
|
62
|
+
* Respects the `maxConcurrent` limit set via `setQueueOptions()`.
|
|
63
|
+
* Defaults to `false`.
|
|
64
|
+
*/
|
|
65
|
+
queue?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Priority inside the queue.
|
|
68
|
+
* - `'high'`: inserted at the front of the pending queue.
|
|
69
|
+
* - `'normal'` (default): appended to the back.
|
|
70
|
+
* Has no effect when `queue` is `false`.
|
|
71
|
+
*/
|
|
72
|
+
priority?: 'high' | 'normal';
|
|
73
|
+
/**
|
|
74
|
+
* Auto-retry on network failure with exponential backoff.
|
|
75
|
+
* Only retries on network errors (timeouts, connection drops).
|
|
76
|
+
* Server errors (4xx/5xx) and checksum mismatches are NOT retried.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* download({
|
|
81
|
+
* url: '...',
|
|
82
|
+
* retry: {
|
|
83
|
+
* attempts: 3,
|
|
84
|
+
* delay: 1000, // 1s → 2s → 4s (doubles each time, capped at 30s)
|
|
85
|
+
* onRetry: (attempt, error) => console.log(`Retry #${attempt}: ${error}`),
|
|
86
|
+
* }
|
|
87
|
+
* })
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
retry?: {
|
|
91
|
+
/** Maximum number of retry attempts (default: 0 = no retry) */
|
|
92
|
+
attempts: number;
|
|
93
|
+
/** Base delay in ms between retries. Doubles each attempt (default: 1000) */
|
|
94
|
+
delay?: number;
|
|
95
|
+
/** Called just before each retry attempt */
|
|
96
|
+
onRetry?: (attempt: number, error: string) => void;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UploadOptions {
|
|
101
|
+
/** Remote URL to upload to */
|
|
102
|
+
url: string;
|
|
103
|
+
/** Absolute local path of the file to upload */
|
|
104
|
+
filePath: string;
|
|
105
|
+
/** Multi-part field name for the file (default: 'file') */
|
|
106
|
+
fieldName?: string;
|
|
107
|
+
/** Custom request headers */
|
|
108
|
+
headers?: Record<string, string>;
|
|
109
|
+
/** Additional text parameters for the multi-part request */
|
|
110
|
+
parameters?: Record<string, string>;
|
|
111
|
+
/** Called with progress 0–100 during upload */
|
|
112
|
+
onProgress?: (percent: number) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface DownloadResult {
|
|
116
|
+
success: boolean;
|
|
117
|
+
/** Local path of the saved file */
|
|
118
|
+
filePath?: string;
|
|
119
|
+
/** Unique ID for this download — use with pause/resume/cancel */
|
|
120
|
+
downloadId?: string;
|
|
121
|
+
/** Error message if success is false */
|
|
122
|
+
error?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface UploadResult {
|
|
126
|
+
success: boolean;
|
|
127
|
+
/** HTTP response status code */
|
|
128
|
+
status?: number;
|
|
129
|
+
/** HTTP response body as string (if any) */
|
|
130
|
+
data?: string;
|
|
131
|
+
/** Error message if success is false */
|
|
132
|
+
error?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ActionResult {
|
|
136
|
+
success: boolean;
|
|
137
|
+
error?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface CachedFile {
|
|
141
|
+
fileName: string;
|
|
142
|
+
filePath: string;
|
|
143
|
+
/** File size in bytes */
|
|
144
|
+
size: number;
|
|
145
|
+
/** Last-modified timestamp in milliseconds */
|
|
146
|
+
modifiedAt: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface CacheResult {
|
|
150
|
+
success: boolean;
|
|
151
|
+
files?: CachedFile[];
|
|
152
|
+
error?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface SaveBase64Options {
|
|
156
|
+
/**
|
|
157
|
+
* Base64 string or data URI (e.g., "data:image/png;base64,iVBORw0...")
|
|
158
|
+
* If a data URI is provided, the base64 portion will be extracted automatically.
|
|
159
|
+
*/
|
|
160
|
+
base64Data: string;
|
|
161
|
+
/** Optional file name. Auto-generated if not provided */
|
|
162
|
+
fileName?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Destination directory for the saved file.
|
|
165
|
+
* - 'downloads': Public Downloads folder (default)
|
|
166
|
+
* - 'cache': App-private cache directory
|
|
167
|
+
* - 'documents': App-private documents directory
|
|
168
|
+
*/
|
|
169
|
+
destination?: 'downloads' | 'cache' | 'documents';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface SaveBase64Result {
|
|
173
|
+
success: boolean;
|
|
174
|
+
/** Local path of the saved file */
|
|
175
|
+
filePath?: string;
|
|
176
|
+
/** Error message if success is false */
|
|
177
|
+
error?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface UrlToBase64Options {
|
|
181
|
+
/** URL of the file to convert to base64 (image, video, gif, etc.) */
|
|
182
|
+
url: string;
|
|
183
|
+
/** Optional custom headers (e.g., Authorization) */
|
|
184
|
+
headers?: Record<string, string>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface UrlToBase64Result {
|
|
188
|
+
success: boolean;
|
|
189
|
+
/** Base64-encoded string */
|
|
190
|
+
base64?: string;
|
|
191
|
+
/** MIME type detected from response (e.g., 'image/png') */
|
|
192
|
+
mimeType?: string;
|
|
193
|
+
/** Complete data URI (e.g., 'data:image/png;base64,...') */
|
|
194
|
+
dataUri?: string;
|
|
195
|
+
/** Error message if success is false */
|
|
196
|
+
error?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface ShareFileOptions {
|
|
200
|
+
/** Absolute path to the file to share */
|
|
201
|
+
filePath: string;
|
|
202
|
+
/** Optional title for the share dialog (Android only) */
|
|
203
|
+
title?: string;
|
|
204
|
+
/** Optional subject for email sharing (Android only) */
|
|
205
|
+
subject?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface OpenFileOptions {
|
|
209
|
+
/** Absolute path to the file to open */
|
|
210
|
+
filePath: string;
|
|
211
|
+
/** MIME type of the file (e.g., 'application/pdf', 'image/jpeg'). Auto-detected if not provided. */
|
|
212
|
+
mimeType?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface ShareFileResult {
|
|
216
|
+
success: boolean;
|
|
217
|
+
/** Whether user completed the share action (iOS only) */
|
|
218
|
+
completed?: boolean;
|
|
219
|
+
/** Error message if success is false */
|
|
220
|
+
error?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface OpenFileResult {
|
|
224
|
+
success: boolean;
|
|
225
|
+
/** Error message if success is false */
|
|
226
|
+
error?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type FsEncoding = 'utf8' | 'base64';
|
|
230
|
+
|
|
231
|
+
export interface FsStat {
|
|
232
|
+
/** Absolute path */
|
|
233
|
+
path: string;
|
|
234
|
+
/** Basename */
|
|
235
|
+
name: string;
|
|
236
|
+
/** Size in bytes (0 for directories) */
|
|
237
|
+
size: number;
|
|
238
|
+
/** Last modified timestamp in milliseconds */
|
|
239
|
+
modified: number;
|
|
240
|
+
/** Whether path is a directory */
|
|
241
|
+
isDir: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface FsApi {
|
|
245
|
+
exists: (filePath: string) => Promise<boolean>;
|
|
246
|
+
stat: (filePath: string) => Promise<FsStat>;
|
|
247
|
+
readFile: (filePath: string, encoding?: FsEncoding) => Promise<string>;
|
|
248
|
+
writeFile: (
|
|
249
|
+
filePath: string,
|
|
250
|
+
data: string,
|
|
251
|
+
encoding?: FsEncoding
|
|
252
|
+
) => Promise<void>;
|
|
253
|
+
copyFile: (fromPath: string, toPath: string) => Promise<void>;
|
|
254
|
+
moveFile: (fromPath: string, toPath: string) => Promise<void>;
|
|
255
|
+
deleteFile: (filePath: string) => Promise<void>;
|
|
256
|
+
mkdir: (dirPath: string) => Promise<void>;
|
|
257
|
+
ls: (dirPath: string) => Promise<string[]>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Queue ───────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export interface QueueOptions {
|
|
263
|
+
/**
|
|
264
|
+
* Maximum number of simultaneous downloads when using the managed queue.
|
|
265
|
+
* Defaults to `3`.
|
|
266
|
+
*/
|
|
267
|
+
maxConcurrent?: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface QueueStatus {
|
|
271
|
+
/** Number of downloads actively running right now */
|
|
272
|
+
active: number;
|
|
273
|
+
/** Number of downloads waiting in the queue */
|
|
274
|
+
pending: number;
|
|
275
|
+
/** Current `maxConcurrent` setting */
|
|
276
|
+
maxConcurrent: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface QueueItem {
|
|
280
|
+
options: DownloadOptions;
|
|
281
|
+
resolve: (result: DownloadResult) => void;
|
|
282
|
+
reject: (reason?: any) => void;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
class DownloadQueue {
|
|
286
|
+
private _maxConcurrent: number = 3;
|
|
287
|
+
private _active: number = 0;
|
|
288
|
+
private _queue: QueueItem[] = [];
|
|
289
|
+
|
|
290
|
+
setOptions(opts: QueueOptions): void {
|
|
291
|
+
if (opts.maxConcurrent != null && opts.maxConcurrent > 0) {
|
|
292
|
+
this._maxConcurrent = opts.maxConcurrent;
|
|
293
|
+
// Kick off any slots that just opened.
|
|
294
|
+
this._flush();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
getStatus(): QueueStatus {
|
|
299
|
+
return {
|
|
300
|
+
active: this._active,
|
|
301
|
+
pending: this._queue.length,
|
|
302
|
+
maxConcurrent: this._maxConcurrent,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
enqueue(options: DownloadOptions): Promise<DownloadResult> {
|
|
307
|
+
return new Promise<DownloadResult>((resolve, reject) => {
|
|
308
|
+
const item: QueueItem = { options, resolve, reject };
|
|
309
|
+
if (options.priority === 'high') {
|
|
310
|
+
this._queue.unshift(item);
|
|
311
|
+
} else {
|
|
312
|
+
this._queue.push(item);
|
|
313
|
+
}
|
|
314
|
+
this._flush();
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private _flush(): void {
|
|
319
|
+
while (this._active < this._maxConcurrent && this._queue.length > 0) {
|
|
320
|
+
const item = this._queue.shift()!;
|
|
321
|
+
this._active++;
|
|
322
|
+
// Strip queue-specific fields before passing to the native layer.
|
|
323
|
+
const nativeOptions = { ...item.options };
|
|
324
|
+
delete nativeOptions.queue;
|
|
325
|
+
delete nativeOptions.priority;
|
|
326
|
+
_executeDownload(nativeOptions)
|
|
327
|
+
.then((result) => {
|
|
328
|
+
item.resolve(result);
|
|
329
|
+
})
|
|
330
|
+
.catch((err) => {
|
|
331
|
+
item.reject(err);
|
|
332
|
+
})
|
|
333
|
+
.finally(() => {
|
|
334
|
+
this._active--;
|
|
335
|
+
this._flush();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const _globalQueue = new DownloadQueue();
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Configure the global managed download queue.
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* setQueueOptions({ maxConcurrent: 3 });
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
export function setQueueOptions(options: QueueOptions): void {
|
|
352
|
+
_globalQueue.setOptions(options);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get the current state of the managed download queue.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* ```ts
|
|
360
|
+
* const { active, pending, maxConcurrent } = getQueueStatus();
|
|
361
|
+
* ```
|
|
362
|
+
*/
|
|
363
|
+
export function getQueueStatus(): QueueStatus {
|
|
364
|
+
return _globalQueue.getStatus();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── Core download ────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Core native download executor — used internally by `download()` and the queue.
|
|
371
|
+
* Never routes through the queue.
|
|
372
|
+
*/
|
|
373
|
+
async function _executeDownload(
|
|
374
|
+
options: DownloadOptions
|
|
375
|
+
): Promise<DownloadResult> {
|
|
376
|
+
let progressSubscription: ReturnType<typeof eventEmitter.addListener> | null =
|
|
377
|
+
null;
|
|
378
|
+
let retrySubscription: ReturnType<typeof eventEmitter.addListener> | null =
|
|
379
|
+
null;
|
|
380
|
+
|
|
381
|
+
// downloadId is resolved immediately on Android foreground, allowing
|
|
382
|
+
// ID-based event filtering. On iOS it only arrives on completion, so we
|
|
383
|
+
// fall back to URL-based filtering in that case.
|
|
384
|
+
let knownDownloadId: string | null = null;
|
|
385
|
+
|
|
386
|
+
// Speed / ETA tracking (JS-side, works regardless of native platform)
|
|
387
|
+
let _lastProgressTs: number | null = null;
|
|
388
|
+
let _lastProgressBytes: number = 0;
|
|
389
|
+
// Smoothed speed using exponential moving average (α = 0.3)
|
|
390
|
+
let _smoothedSpeedBps: number = 0;
|
|
391
|
+
|
|
392
|
+
if (options.onProgress) {
|
|
393
|
+
progressSubscription = eventEmitter.addListener(
|
|
394
|
+
'onDownloadProgress',
|
|
395
|
+
(event: any) => {
|
|
396
|
+
const matchesId =
|
|
397
|
+
knownDownloadId && event.downloadId === knownDownloadId;
|
|
398
|
+
const matchesUrl = !knownDownloadId && event.url === options.url;
|
|
399
|
+
if ((matchesId || matchesUrl) && options.onProgress) {
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
const percent: number = event.progress ?? 0;
|
|
402
|
+
const bytesDownloaded: number = event.bytesDownloaded ?? 0;
|
|
403
|
+
const totalBytes: number = event.totalBytes ?? 0;
|
|
404
|
+
|
|
405
|
+
let speedBps = 0;
|
|
406
|
+
let etaSeconds = 0;
|
|
407
|
+
|
|
408
|
+
if (_lastProgressTs !== null) {
|
|
409
|
+
const dtSec = (now - _lastProgressTs) / 1000;
|
|
410
|
+
if (dtSec > 0) {
|
|
411
|
+
const bytesDelta = bytesDownloaded - _lastProgressBytes;
|
|
412
|
+
// Only update speed if we have byte-level data from native
|
|
413
|
+
if (bytesDelta > 0 && totalBytes > 0) {
|
|
414
|
+
const instantSpeed = bytesDelta / dtSec;
|
|
415
|
+
// Exponential moving average for smoother readings
|
|
416
|
+
_smoothedSpeedBps =
|
|
417
|
+
_smoothedSpeedBps === 0
|
|
418
|
+
? instantSpeed
|
|
419
|
+
: 0.3 * instantSpeed + 0.7 * _smoothedSpeedBps;
|
|
420
|
+
speedBps = _smoothedSpeedBps;
|
|
421
|
+
const remaining = totalBytes - bytesDownloaded;
|
|
422
|
+
etaSeconds = speedBps > 0 ? remaining / speedBps : 0;
|
|
423
|
+
} else if (percent > 0 && percent < 100) {
|
|
424
|
+
// Fallback: estimate from percent when bytes aren't available
|
|
425
|
+
const estimatedTotalBytes =
|
|
426
|
+
totalBytes > 0
|
|
427
|
+
? totalBytes
|
|
428
|
+
: _lastProgressBytes / (percent / 100);
|
|
429
|
+
const percentDelta =
|
|
430
|
+
percent - (_lastProgressBytes / estimatedTotalBytes) * 100;
|
|
431
|
+
if (percentDelta > 0) {
|
|
432
|
+
const estimatedByteDelta =
|
|
433
|
+
(percentDelta / 100) * estimatedTotalBytes;
|
|
434
|
+
const instantSpeed = estimatedByteDelta / dtSec;
|
|
435
|
+
_smoothedSpeedBps =
|
|
436
|
+
_smoothedSpeedBps === 0
|
|
437
|
+
? instantSpeed
|
|
438
|
+
: 0.3 * instantSpeed + 0.7 * _smoothedSpeedBps;
|
|
439
|
+
speedBps = _smoothedSpeedBps;
|
|
440
|
+
const remainingPct = 100 - percent;
|
|
441
|
+
const estimatedRemaining =
|
|
442
|
+
(remainingPct / 100) * estimatedTotalBytes;
|
|
443
|
+
etaSeconds = speedBps > 0 ? estimatedRemaining / speedBps : 0;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_lastProgressTs = now;
|
|
450
|
+
_lastProgressBytes = bytesDownloaded;
|
|
451
|
+
|
|
452
|
+
const info: ProgressInfo = {
|
|
453
|
+
percent,
|
|
454
|
+
bytesDownloaded,
|
|
455
|
+
totalBytes,
|
|
456
|
+
speedBps,
|
|
457
|
+
etaSeconds,
|
|
458
|
+
};
|
|
459
|
+
options.onProgress(info);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (options.retry?.onRetry) {
|
|
466
|
+
retrySubscription = eventEmitter.addListener(
|
|
467
|
+
'onDownloadRetry',
|
|
468
|
+
(event: any) => {
|
|
469
|
+
const matchesId =
|
|
470
|
+
knownDownloadId && event.downloadId === knownDownloadId;
|
|
471
|
+
const matchesUrl = !knownDownloadId && event.url === options.url;
|
|
472
|
+
if ((matchesId || matchesUrl) && options.retry?.onRetry) {
|
|
473
|
+
options.retry.onRetry(event.attempt, event.error ?? '');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const cleanup = () => {
|
|
480
|
+
progressSubscription?.remove();
|
|
481
|
+
retrySubscription?.remove();
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const resultPromise = (FileToolkitSpec as any).download({
|
|
486
|
+
url: options.url,
|
|
487
|
+
fileName: options.fileName,
|
|
488
|
+
background: options.background ?? false,
|
|
489
|
+
headers: options.headers ?? {},
|
|
490
|
+
destination: options.destination ?? 'downloads',
|
|
491
|
+
notificationTitle: options.notificationTitle,
|
|
492
|
+
notificationDescription: options.notificationDescription,
|
|
493
|
+
checksum: options.checksum,
|
|
494
|
+
retry: options.retry
|
|
495
|
+
? {
|
|
496
|
+
attempts: options.retry.attempts,
|
|
497
|
+
delay: options.retry.delay ?? 1000,
|
|
498
|
+
}
|
|
499
|
+
: undefined,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// On Android foreground the promise resolves immediately with downloadId,
|
|
503
|
+
// giving us a precise ID to filter events by before download completes.
|
|
504
|
+
// On iOS it only resolves on completion, so knownDownloadId stays null
|
|
505
|
+
// and URL-based filtering is used as the fallback.
|
|
506
|
+
resultPromise.then?.((partial: any) => {
|
|
507
|
+
if (partial?.downloadId && !partial?.filePath) {
|
|
508
|
+
// Android early-resolve: has downloadId but no filePath yet
|
|
509
|
+
knownDownloadId = partial.downloadId;
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const result = await resultPromise;
|
|
514
|
+
cleanup();
|
|
515
|
+
return result as DownloadResult;
|
|
516
|
+
} catch (error: any) {
|
|
517
|
+
cleanup();
|
|
518
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Download a file.
|
|
524
|
+
*
|
|
525
|
+
* For **foreground** downloads the promise resolves when the file is saved.
|
|
526
|
+
* For **background** downloads the promise resolves immediately with a
|
|
527
|
+
* `downloadId`; listen to the `onDownloadComplete` / `onDownloadError`
|
|
528
|
+
* events for the final result.
|
|
529
|
+
*
|
|
530
|
+
* Pass `queue: true` to join the managed queue and respect the `maxConcurrent`
|
|
531
|
+
* limit configured via `setQueueOptions()`. Use `priority: 'high'` to jump
|
|
532
|
+
* ahead of other pending items in the queue.
|
|
533
|
+
*
|
|
534
|
+
* @example
|
|
535
|
+
* ```ts
|
|
536
|
+
* // Direct (unqueued) download
|
|
537
|
+
* const result = await download({ url: 'https://...' });
|
|
538
|
+
*
|
|
539
|
+
* // Queued download with high priority
|
|
540
|
+
* setQueueOptions({ maxConcurrent: 3 });
|
|
541
|
+
* const result = await download({
|
|
542
|
+
* url: 'https://...',
|
|
543
|
+
* queue: true,
|
|
544
|
+
* priority: 'high',
|
|
545
|
+
* });
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
export function download(options: DownloadOptions): Promise<DownloadResult> {
|
|
549
|
+
if (options.queue) {
|
|
550
|
+
return _globalQueue.enqueue(options);
|
|
551
|
+
}
|
|
552
|
+
return _executeDownload(options);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── Core upload ───────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Upload a file using multipart/form-data.
|
|
559
|
+
*/
|
|
560
|
+
export async function upload(options: UploadOptions): Promise<UploadResult> {
|
|
561
|
+
let progressSubscription: ReturnType<typeof eventEmitter.addListener> | null =
|
|
562
|
+
null;
|
|
563
|
+
|
|
564
|
+
if (options.onProgress) {
|
|
565
|
+
progressSubscription = eventEmitter.addListener(
|
|
566
|
+
'onUploadProgress',
|
|
567
|
+
(event: any) => {
|
|
568
|
+
if (event.url === options.url && options.onProgress) {
|
|
569
|
+
options.onProgress(event.progress);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
const result = await (FileToolkitSpec as any).upload({
|
|
577
|
+
url: options.url,
|
|
578
|
+
filePath: options.filePath,
|
|
579
|
+
fieldName: options.fieldName ?? 'file',
|
|
580
|
+
headers: options.headers ?? {},
|
|
581
|
+
parameters: options.parameters ?? {},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
if (progressSubscription) {
|
|
585
|
+
progressSubscription.remove();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return result as UploadResult;
|
|
589
|
+
} catch (error: any) {
|
|
590
|
+
if (progressSubscription) {
|
|
591
|
+
progressSubscription.remove();
|
|
592
|
+
}
|
|
593
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ─── Pause / Resume / Cancel ──────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Pause an active foreground download.
|
|
601
|
+
* On iOS the partial data is stored so it can be resumed.
|
|
602
|
+
* On Android the download thread is suspended.
|
|
603
|
+
*/
|
|
604
|
+
export async function pauseDownload(downloadId: string): Promise<ActionResult> {
|
|
605
|
+
try {
|
|
606
|
+
return (await (FileToolkitSpec as any).pauseDownload(
|
|
607
|
+
downloadId
|
|
608
|
+
)) as ActionResult;
|
|
609
|
+
} catch (error: any) {
|
|
610
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Resume a previously paused download.
|
|
616
|
+
* On iOS resumes from partial data (HTTP Range). On Android unblocks the thread.
|
|
617
|
+
*/
|
|
618
|
+
export async function resumeDownload(
|
|
619
|
+
downloadId: string
|
|
620
|
+
): Promise<ActionResult> {
|
|
621
|
+
try {
|
|
622
|
+
return (await (FileToolkitSpec as any).resumeDownload(
|
|
623
|
+
downloadId
|
|
624
|
+
)) as ActionResult;
|
|
625
|
+
} catch (error: any) {
|
|
626
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Cancel and discard a download (foreground or background).
|
|
632
|
+
* Any partially-downloaded file is deleted.
|
|
633
|
+
*/
|
|
634
|
+
export async function cancelDownload(
|
|
635
|
+
downloadId: string
|
|
636
|
+
): Promise<ActionResult> {
|
|
637
|
+
try {
|
|
638
|
+
return (await (FileToolkitSpec as any).cancelDownload(
|
|
639
|
+
downloadId
|
|
640
|
+
)) as ActionResult;
|
|
641
|
+
} catch (error: any) {
|
|
642
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─── Cache management ─────────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* List all files in the app's cache directory.
|
|
650
|
+
*/
|
|
651
|
+
export async function getCachedFiles(): Promise<CacheResult> {
|
|
652
|
+
try {
|
|
653
|
+
return (await (FileToolkitSpec as any).getCachedFiles()) as CacheResult;
|
|
654
|
+
} catch (error: any) {
|
|
655
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Delete a single file by its absolute path.
|
|
661
|
+
*/
|
|
662
|
+
export async function deleteFile(filePath: string): Promise<ActionResult> {
|
|
663
|
+
try {
|
|
664
|
+
return (await (FileToolkitSpec as any).deleteFile(
|
|
665
|
+
filePath
|
|
666
|
+
)) as ActionResult;
|
|
667
|
+
} catch (error: any) {
|
|
668
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Delete all files in the app's cache directory.
|
|
674
|
+
*/
|
|
675
|
+
export async function clearCache(): Promise<ActionResult> {
|
|
676
|
+
try {
|
|
677
|
+
return (await (FileToolkitSpec as any).clearCache()) as ActionResult;
|
|
678
|
+
} catch (error: any) {
|
|
679
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Get all active background downloads.
|
|
685
|
+
* Use this after app restart to "re-attach" to ongoing downloads.
|
|
686
|
+
*/
|
|
687
|
+
export async function getBackgroundDownloads(): Promise<{
|
|
688
|
+
success: boolean;
|
|
689
|
+
downloads?: Array<{
|
|
690
|
+
downloadId: string;
|
|
691
|
+
url: string;
|
|
692
|
+
status: number;
|
|
693
|
+
progress: number;
|
|
694
|
+
}>;
|
|
695
|
+
error?: string;
|
|
696
|
+
}> {
|
|
697
|
+
try {
|
|
698
|
+
return (await (FileToolkitSpec as any).getBackgroundDownloads()) as any;
|
|
699
|
+
} catch (error: any) {
|
|
700
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─── File system helpers ─────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
function _ensureFsSuccess(result: any, fallback: string): void {
|
|
707
|
+
if (!result?.success) {
|
|
708
|
+
throw new Error(result?.error || fallback);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Check whether a file or directory exists. */
|
|
713
|
+
export async function exists(filePath: string): Promise<boolean> {
|
|
714
|
+
const result = await (FileToolkitSpec as any).exists(filePath);
|
|
715
|
+
_ensureFsSuccess(result, 'EXISTS_ERROR');
|
|
716
|
+
return !!result.exists;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Get file or directory metadata. */
|
|
720
|
+
export async function stat(filePath: string): Promise<FsStat> {
|
|
721
|
+
const result = await (FileToolkitSpec as any).stat(filePath);
|
|
722
|
+
_ensureFsSuccess(result, 'STAT_ERROR');
|
|
723
|
+
return result.stat as FsStat;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Read a file as utf8 (default) or base64 string. */
|
|
727
|
+
export async function readFile(
|
|
728
|
+
filePath: string,
|
|
729
|
+
encoding: FsEncoding = 'utf8'
|
|
730
|
+
): Promise<string> {
|
|
731
|
+
const result = await (FileToolkitSpec as any).readFile(filePath, encoding);
|
|
732
|
+
_ensureFsSuccess(result, 'READ_FILE_ERROR');
|
|
733
|
+
return result.data ?? '';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Write utf8 (default) or base64 data to a file. */
|
|
737
|
+
export async function writeFile(
|
|
738
|
+
filePath: string,
|
|
739
|
+
data: string,
|
|
740
|
+
encoding: FsEncoding = 'utf8'
|
|
741
|
+
): Promise<void> {
|
|
742
|
+
const result = await (FileToolkitSpec as any).writeFile(
|
|
743
|
+
filePath,
|
|
744
|
+
data,
|
|
745
|
+
encoding
|
|
746
|
+
);
|
|
747
|
+
_ensureFsSuccess(result, 'WRITE_FILE_ERROR');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Copy a file from source to destination. */
|
|
751
|
+
export async function copyFile(
|
|
752
|
+
fromPath: string,
|
|
753
|
+
toPath: string
|
|
754
|
+
): Promise<void> {
|
|
755
|
+
const result = await (FileToolkitSpec as any).copyFile(fromPath, toPath);
|
|
756
|
+
_ensureFsSuccess(result, 'COPY_FILE_ERROR');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Move a file from source to destination. */
|
|
760
|
+
export async function moveFile(
|
|
761
|
+
fromPath: string,
|
|
762
|
+
toPath: string
|
|
763
|
+
): Promise<void> {
|
|
764
|
+
const result = await (FileToolkitSpec as any).moveFile(fromPath, toPath);
|
|
765
|
+
_ensureFsSuccess(result, 'MOVE_FILE_ERROR');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Create a directory recursively. */
|
|
769
|
+
export async function mkdir(dirPath: string): Promise<void> {
|
|
770
|
+
const result = await (FileToolkitSpec as any).mkdir(dirPath);
|
|
771
|
+
_ensureFsSuccess(result, 'MKDIR_ERROR');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** List direct entries (names) in a directory. */
|
|
775
|
+
export async function ls(dirPath: string): Promise<string[]> {
|
|
776
|
+
const result = await (FileToolkitSpec as any).ls(dirPath);
|
|
777
|
+
_ensureFsSuccess(result, 'LS_ERROR');
|
|
778
|
+
return (result.entries || []) as string[];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* File system API namespace.
|
|
783
|
+
*
|
|
784
|
+
* @example
|
|
785
|
+
* ```ts
|
|
786
|
+
* import { fs } from 'rn-file-toolkit';
|
|
787
|
+
*
|
|
788
|
+
* const ok = await fs.exists('/path/to/file.pdf');
|
|
789
|
+
* const meta = await fs.stat('/path/to/file.pdf');
|
|
790
|
+
* const text = await fs.readFile('/path/to/file.txt');
|
|
791
|
+
* ```
|
|
792
|
+
*/
|
|
793
|
+
export const fs: FsApi = {
|
|
794
|
+
exists,
|
|
795
|
+
stat,
|
|
796
|
+
readFile,
|
|
797
|
+
writeFile,
|
|
798
|
+
copyFile,
|
|
799
|
+
moveFile,
|
|
800
|
+
deleteFile: async (filePath: string) => {
|
|
801
|
+
const result = await deleteFile(filePath);
|
|
802
|
+
_ensureFsSuccess(result, 'DELETE_FILE_ERROR');
|
|
803
|
+
},
|
|
804
|
+
mkdir,
|
|
805
|
+
ls,
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// ─── Event helpers ────────────────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Subscribe to background download completion events.
|
|
812
|
+
* Returns an unsubscribe function.
|
|
813
|
+
*/
|
|
814
|
+
export function onDownloadComplete(
|
|
815
|
+
callback: (result: DownloadResult) => void
|
|
816
|
+
): () => void {
|
|
817
|
+
const sub = eventEmitter.addListener('onDownloadComplete', callback as any);
|
|
818
|
+
return () => sub.remove();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Subscribe to background download error events.
|
|
823
|
+
* Returns an unsubscribe function.
|
|
824
|
+
*/
|
|
825
|
+
export function onDownloadError(
|
|
826
|
+
callback: (result: DownloadResult) => void
|
|
827
|
+
): () => void {
|
|
828
|
+
const sub = eventEmitter.addListener('onDownloadError', callback as any);
|
|
829
|
+
return () => sub.remove();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Subscribe to upload progress events.
|
|
834
|
+
* Returns an unsubscribe function.
|
|
835
|
+
*/
|
|
836
|
+
export function onUploadProgress(
|
|
837
|
+
callback: (result: { url: string; progress: number }) => void
|
|
838
|
+
): () => void {
|
|
839
|
+
const sub = eventEmitter.addListener('onUploadProgress', callback as any);
|
|
840
|
+
return () => sub.remove();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Subscribe to download retry events (fired before each retry attempt).
|
|
845
|
+
* Returns an unsubscribe function.
|
|
846
|
+
*/
|
|
847
|
+
export function onDownloadRetry(
|
|
848
|
+
callback: (result: {
|
|
849
|
+
downloadId: string;
|
|
850
|
+
url: string;
|
|
851
|
+
attempt: number;
|
|
852
|
+
}) => void
|
|
853
|
+
): () => void {
|
|
854
|
+
const sub = eventEmitter.addListener('onDownloadRetry', callback as any);
|
|
855
|
+
return () => sub.remove();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ─── saveBase64AsFile ─────────────────────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Save a base64 string or data URI as a file.
|
|
862
|
+
* Supports data URIs (e.g., "data:image/png;base64,iVBORw0...") and plain base64 strings.
|
|
863
|
+
*
|
|
864
|
+
* @example
|
|
865
|
+
* ```typescript
|
|
866
|
+
* // From data URI
|
|
867
|
+
* const result = await saveBase64AsFile({
|
|
868
|
+
* base64Data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...',
|
|
869
|
+
* fileName: 'photo.png',
|
|
870
|
+
* destination: 'documents'
|
|
871
|
+
* });
|
|
872
|
+
*
|
|
873
|
+
* // From plain base64
|
|
874
|
+
* const result = await saveBase64AsFile({
|
|
875
|
+
* base64Data: 'SGVsbG8gV29ybGQ=',
|
|
876
|
+
* fileName: 'hello.txt',
|
|
877
|
+
* destination: 'cache'
|
|
878
|
+
* });
|
|
879
|
+
* ```
|
|
880
|
+
*/
|
|
881
|
+
export async function saveBase64AsFile(
|
|
882
|
+
options: SaveBase64Options
|
|
883
|
+
): Promise<SaveBase64Result> {
|
|
884
|
+
try {
|
|
885
|
+
let { base64Data, fileName, destination } = options;
|
|
886
|
+
|
|
887
|
+
// Parse data URI if present (e.g., "data:image/png;base64,iVBORw0...")
|
|
888
|
+
if (base64Data.startsWith('data:')) {
|
|
889
|
+
const matches = base64Data.match(/^data:([^;]+);base64,(.+)$/);
|
|
890
|
+
if (matches && matches[2]) {
|
|
891
|
+
base64Data = matches[2];
|
|
892
|
+
|
|
893
|
+
// Auto-detect file extension from MIME type if fileName not provided
|
|
894
|
+
if (!fileName && matches[1]) {
|
|
895
|
+
const mimeType = matches[1];
|
|
896
|
+
const ext = mimeType.split('/')[1] || 'bin';
|
|
897
|
+
fileName = `file_${Date.now()}.${ext}`;
|
|
898
|
+
}
|
|
899
|
+
} else {
|
|
900
|
+
return {
|
|
901
|
+
success: false,
|
|
902
|
+
error:
|
|
903
|
+
'Invalid data URI format. Expected: data:<mimetype>;base64,<data>',
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const result = await FileToolkitModule.saveBase64AsFile({
|
|
909
|
+
base64Data,
|
|
910
|
+
fileName,
|
|
911
|
+
destination,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
return result as SaveBase64Result;
|
|
915
|
+
} catch (error: any) {
|
|
916
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ─── urlToBase64 ──────────────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Convert a web URL (image, video, gif, etc.) to base64 string.
|
|
924
|
+
* Downloads the file and returns both the base64 string and data URI format.
|
|
925
|
+
*
|
|
926
|
+
* @example
|
|
927
|
+
* ```typescript
|
|
928
|
+
* // Convert image to base64
|
|
929
|
+
* const result = await urlToBase64({
|
|
930
|
+
* url: 'https://example.com/photo.jpg',
|
|
931
|
+
* headers: { Authorization: 'Bearer token' }
|
|
932
|
+
* });
|
|
933
|
+
*
|
|
934
|
+
* if (result.success) {
|
|
935
|
+
* console.log('Base64:', result.base64);
|
|
936
|
+
* console.log('Data URI:', result.dataUri);
|
|
937
|
+
* console.log('MIME Type:', result.mimeType);
|
|
938
|
+
* }
|
|
939
|
+
* ```
|
|
940
|
+
*/
|
|
941
|
+
export async function urlToBase64(
|
|
942
|
+
options: UrlToBase64Options
|
|
943
|
+
): Promise<UrlToBase64Result> {
|
|
944
|
+
try {
|
|
945
|
+
const result = await FileToolkitModule.urlToBase64({
|
|
946
|
+
url: options.url,
|
|
947
|
+
headers: options.headers,
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
return result as UrlToBase64Result;
|
|
951
|
+
} catch (error: any) {
|
|
952
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ─── shareFile ────────────────────────────────────────────────────────────────
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Share a file with other apps using the native share dialog.
|
|
960
|
+
* Opens the system share sheet on iOS and share chooser on Android.
|
|
961
|
+
*
|
|
962
|
+
* @example
|
|
963
|
+
* ```typescript
|
|
964
|
+
* const result = await shareFile({
|
|
965
|
+
* filePath: '/path/to/document.pdf',
|
|
966
|
+
* title: 'Share Document',
|
|
967
|
+
* subject: 'Check out this file'
|
|
968
|
+
* });
|
|
969
|
+
*
|
|
970
|
+
* if (result.success) {
|
|
971
|
+
* console.log('File shared successfully');
|
|
972
|
+
* }
|
|
973
|
+
* ```
|
|
974
|
+
*/
|
|
975
|
+
export async function shareFile(
|
|
976
|
+
options: ShareFileOptions
|
|
977
|
+
): Promise<ShareFileResult> {
|
|
978
|
+
try {
|
|
979
|
+
const result = await FileToolkitModule.shareFile(options.filePath, {
|
|
980
|
+
title: options.title,
|
|
981
|
+
subject: options.subject,
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
return result as ShareFileResult;
|
|
985
|
+
} catch (error: any) {
|
|
986
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ─── openFile ─────────────────────────────────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Open a file with the default app or app chooser.
|
|
994
|
+
* On iOS, displays a preview or shows apps that can handle the file.
|
|
995
|
+
* On Android, opens with the default app or shows app chooser.
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* ```typescript
|
|
999
|
+
* const result = await openFile({
|
|
1000
|
+
* filePath: '/path/to/document.pdf',
|
|
1001
|
+
* mimeType: 'application/pdf' // optional, auto-detected if not provided
|
|
1002
|
+
* });
|
|
1003
|
+
*
|
|
1004
|
+
* if (result.success) {
|
|
1005
|
+
* console.log('File opened successfully');
|
|
1006
|
+
* }
|
|
1007
|
+
* ```
|
|
1008
|
+
*/
|
|
1009
|
+
export async function openFile(
|
|
1010
|
+
options: OpenFileOptions
|
|
1011
|
+
): Promise<OpenFileResult> {
|
|
1012
|
+
try {
|
|
1013
|
+
const result = await FileToolkitModule.openFile(
|
|
1014
|
+
options.filePath,
|
|
1015
|
+
options.mimeType || ''
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
return result as OpenFileResult;
|
|
1019
|
+
} catch (error: any) {
|
|
1020
|
+
return { success: false, error: error?.message || 'UNKNOWN_ERROR' };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ─── Zip / Unzip ─────────────────────────────────────────────────────────────
|
|
1025
|
+
|
|
1026
|
+
export interface UnzipResult {
|
|
1027
|
+
success: boolean;
|
|
1028
|
+
/** Absolute path of the destination directory */
|
|
1029
|
+
destDir?: string;
|
|
1030
|
+
/** List of absolute paths of all extracted files */
|
|
1031
|
+
files?: string[];
|
|
1032
|
+
/** Error message if success is false */
|
|
1033
|
+
error?: string;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export interface ZipResult {
|
|
1037
|
+
success: boolean;
|
|
1038
|
+
/** Absolute path of the created zip archive */
|
|
1039
|
+
zipPath?: string;
|
|
1040
|
+
/** Error message if success is false */
|
|
1041
|
+
error?: string;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Extract a ZIP archive to a destination directory.
|
|
1046
|
+
*
|
|
1047
|
+
* Uses `java.util.zip` on Android and zlib (system framework) on iOS.
|
|
1048
|
+
* No third-party dependency required.
|
|
1049
|
+
*
|
|
1050
|
+
* @param sourcePath Absolute path to the `.zip` file
|
|
1051
|
+
* @param destDir Absolute path to the directory where files will be extracted.
|
|
1052
|
+
* The directory is created automatically if it does not exist.
|
|
1053
|
+
*
|
|
1054
|
+
* @example
|
|
1055
|
+
* ```ts
|
|
1056
|
+
* const result = await unzip(
|
|
1057
|
+
* '/path/to/archive.zip',
|
|
1058
|
+
* '/path/to/output-folder'
|
|
1059
|
+
* );
|
|
1060
|
+
*
|
|
1061
|
+
* if (result.success) {
|
|
1062
|
+
* console.log('Extracted files:', result.files);
|
|
1063
|
+
* }
|
|
1064
|
+
* ```
|
|
1065
|
+
*/
|
|
1066
|
+
export async function unzip(
|
|
1067
|
+
sourcePath: string,
|
|
1068
|
+
destDir: string
|
|
1069
|
+
): Promise<UnzipResult> {
|
|
1070
|
+
try {
|
|
1071
|
+
const result = await (FileToolkitSpec as any).unzip(sourcePath, destDir);
|
|
1072
|
+
return result as UnzipResult;
|
|
1073
|
+
} catch (error: any) {
|
|
1074
|
+
return { success: false, error: error?.message || 'UNZIP_ERROR' };
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Create a ZIP archive from a file or directory.
|
|
1080
|
+
*
|
|
1081
|
+
* Uses `java.util.zip` on Android and zlib (system framework) on iOS.
|
|
1082
|
+
* No third-party dependency required.
|
|
1083
|
+
*
|
|
1084
|
+
* @param sourcePath Absolute path to the file or directory to compress
|
|
1085
|
+
* @param destPath Absolute path for the output `.zip` file.
|
|
1086
|
+
* Parent directory is created automatically if needed.
|
|
1087
|
+
*
|
|
1088
|
+
* @example
|
|
1089
|
+
* ```ts
|
|
1090
|
+
* // Zip a single file
|
|
1091
|
+
* const result = await zip(
|
|
1092
|
+
* '/path/to/document.pdf',
|
|
1093
|
+
* '/path/to/document.zip'
|
|
1094
|
+
* );
|
|
1095
|
+
*
|
|
1096
|
+
* // Zip an entire directory
|
|
1097
|
+
* const result = await zip(
|
|
1098
|
+
* '/path/to/my-folder',
|
|
1099
|
+
* '/path/to/my-folder.zip'
|
|
1100
|
+
* );
|
|
1101
|
+
*
|
|
1102
|
+
* if (result.success) {
|
|
1103
|
+
* console.log('Archive created at:', result.zipPath);
|
|
1104
|
+
* }
|
|
1105
|
+
* ```
|
|
1106
|
+
*/
|
|
1107
|
+
export async function zip(
|
|
1108
|
+
sourcePath: string,
|
|
1109
|
+
destPath: string
|
|
1110
|
+
): Promise<ZipResult> {
|
|
1111
|
+
try {
|
|
1112
|
+
const result = await (FileToolkitSpec as any).zip(sourcePath, destPath);
|
|
1113
|
+
return result as ZipResult;
|
|
1114
|
+
} catch (error: any) {
|
|
1115
|
+
return { success: false, error: error?.message || 'ZIP_ERROR' };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ─── useDownload hook ─────────────────────────────────────────────────────────
|
|
1120
|
+
|
|
1121
|
+
export type DownloadStatus =
|
|
1122
|
+
| 'idle'
|
|
1123
|
+
| 'downloading'
|
|
1124
|
+
| 'paused'
|
|
1125
|
+
| 'done'
|
|
1126
|
+
| 'error';
|
|
1127
|
+
|
|
1128
|
+
export interface UseDownloadReturn {
|
|
1129
|
+
/** Start a download. Resolves with the final result. */
|
|
1130
|
+
start: (options: DownloadOptions) => Promise<DownloadResult>;
|
|
1131
|
+
/** Pause the current download */
|
|
1132
|
+
pause: () => Promise<void>;
|
|
1133
|
+
/** Resume the current download */
|
|
1134
|
+
resume: () => Promise<void>;
|
|
1135
|
+
/** Cancel the current download */
|
|
1136
|
+
cancel: () => Promise<void>;
|
|
1137
|
+
/** Current status of the download */
|
|
1138
|
+
status: DownloadStatus;
|
|
1139
|
+
/** Rich progress information (null until first progress event) */
|
|
1140
|
+
progress: ProgressInfo | null;
|
|
1141
|
+
/** Final result once download completes or fails */
|
|
1142
|
+
result: DownloadResult | null;
|
|
1143
|
+
/** The active download ID (available after download starts) */
|
|
1144
|
+
downloadId: string | null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* React hook for managing a single download with built-in state.
|
|
1149
|
+
*
|
|
1150
|
+
* Tracks status, rich progress (percent, speed, ETA), and the final result.
|
|
1151
|
+
* Exposes `pause`, `resume`, and `cancel` controls tied to the active download.
|
|
1152
|
+
*
|
|
1153
|
+
* @example
|
|
1154
|
+
* ```tsx
|
|
1155
|
+
* function DownloadButton() {
|
|
1156
|
+
* const { start, pause, resume, cancel, status, progress, result } = useDownload();
|
|
1157
|
+
*
|
|
1158
|
+
* return (
|
|
1159
|
+
* <View>
|
|
1160
|
+
* <Button
|
|
1161
|
+
* title="Download"
|
|
1162
|
+
* onPress={() => start({ url: 'https://example.com/file.zip' })}
|
|
1163
|
+
* />
|
|
1164
|
+
*
|
|
1165
|
+
* {status === 'downloading' && progress && (
|
|
1166
|
+
* <View>
|
|
1167
|
+
* <Text>{progress.percent.toFixed(1)}%</Text>
|
|
1168
|
+
* <Text>Speed: {(progress.speedBps / 1024).toFixed(1)} KB/s</Text>
|
|
1169
|
+
* <Text>ETA: {progress.etaSeconds.toFixed(0)}s</Text>
|
|
1170
|
+
* <ProgressBar value={progress.percent / 100} />
|
|
1171
|
+
* <Button title="Pause" onPress={pause} />
|
|
1172
|
+
* </View>
|
|
1173
|
+
* )}
|
|
1174
|
+
*
|
|
1175
|
+
* {status === 'paused' && (
|
|
1176
|
+
* <Button title="Resume" onPress={resume} />
|
|
1177
|
+
* )}
|
|
1178
|
+
*
|
|
1179
|
+
* {status === 'done' && result?.success && (
|
|
1180
|
+
* <Text>Saved to: {result.filePath}</Text>
|
|
1181
|
+
* )}
|
|
1182
|
+
*
|
|
1183
|
+
* {status === 'error' && (
|
|
1184
|
+
* <Text>Error: {result?.error}</Text>
|
|
1185
|
+
* )}
|
|
1186
|
+
* </View>
|
|
1187
|
+
* );
|
|
1188
|
+
* }
|
|
1189
|
+
* ```
|
|
1190
|
+
*/
|
|
1191
|
+
export function useDownload(): UseDownloadReturn {
|
|
1192
|
+
const [status, setStatus] = useState<DownloadStatus>('idle');
|
|
1193
|
+
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
|
1194
|
+
const [result, setResult] = useState<DownloadResult | null>(null);
|
|
1195
|
+
const [downloadId, setDownloadId] = useState<string | null>(null);
|
|
1196
|
+
const downloadIdRef = useRef<string | null>(null);
|
|
1197
|
+
|
|
1198
|
+
const start = useCallback(
|
|
1199
|
+
async (options: DownloadOptions): Promise<DownloadResult> => {
|
|
1200
|
+
setStatus('downloading');
|
|
1201
|
+
setProgress(null);
|
|
1202
|
+
setResult(null);
|
|
1203
|
+
setDownloadId(null);
|
|
1204
|
+
downloadIdRef.current = null;
|
|
1205
|
+
|
|
1206
|
+
const res = await download({
|
|
1207
|
+
...options,
|
|
1208
|
+
onProgress: (info: ProgressInfo) => {
|
|
1209
|
+
setProgress(info);
|
|
1210
|
+
options.onProgress?.(info);
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
if (res.downloadId) {
|
|
1215
|
+
downloadIdRef.current = res.downloadId;
|
|
1216
|
+
setDownloadId(res.downloadId);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
setResult(res);
|
|
1220
|
+
const isBackgroundPending =
|
|
1221
|
+
!!options.background &&
|
|
1222
|
+
!!res.success &&
|
|
1223
|
+
!!res.downloadId &&
|
|
1224
|
+
!res.filePath;
|
|
1225
|
+
setStatus(
|
|
1226
|
+
isBackgroundPending ? 'downloading' : res.success ? 'done' : 'error'
|
|
1227
|
+
);
|
|
1228
|
+
return res;
|
|
1229
|
+
},
|
|
1230
|
+
[]
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
const pause = useCallback(async (): Promise<void> => {
|
|
1234
|
+
if (downloadIdRef.current) {
|
|
1235
|
+
await pauseDownload(downloadIdRef.current);
|
|
1236
|
+
setStatus('paused');
|
|
1237
|
+
}
|
|
1238
|
+
}, []);
|
|
1239
|
+
|
|
1240
|
+
const resume = useCallback(async (): Promise<void> => {
|
|
1241
|
+
if (downloadIdRef.current) {
|
|
1242
|
+
await resumeDownload(downloadIdRef.current);
|
|
1243
|
+
setStatus('downloading');
|
|
1244
|
+
}
|
|
1245
|
+
}, []);
|
|
1246
|
+
|
|
1247
|
+
const cancel = useCallback(async (): Promise<void> => {
|
|
1248
|
+
if (downloadIdRef.current) {
|
|
1249
|
+
await cancelDownload(downloadIdRef.current);
|
|
1250
|
+
setStatus('idle');
|
|
1251
|
+
setProgress(null);
|
|
1252
|
+
setResult(null);
|
|
1253
|
+
downloadIdRef.current = null;
|
|
1254
|
+
setDownloadId(null);
|
|
1255
|
+
}
|
|
1256
|
+
}, []);
|
|
1257
|
+
|
|
1258
|
+
return { start, pause, resume, cancel, status, progress, result, downloadId };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export default {
|
|
1262
|
+
download,
|
|
1263
|
+
upload,
|
|
1264
|
+
pauseDownload,
|
|
1265
|
+
resumeDownload,
|
|
1266
|
+
cancelDownload,
|
|
1267
|
+
getCachedFiles,
|
|
1268
|
+
deleteFile,
|
|
1269
|
+
clearCache,
|
|
1270
|
+
getBackgroundDownloads,
|
|
1271
|
+
saveBase64AsFile,
|
|
1272
|
+
urlToBase64,
|
|
1273
|
+
shareFile,
|
|
1274
|
+
openFile,
|
|
1275
|
+
onDownloadComplete,
|
|
1276
|
+
onDownloadError,
|
|
1277
|
+
onUploadProgress,
|
|
1278
|
+
onDownloadRetry,
|
|
1279
|
+
setQueueOptions,
|
|
1280
|
+
getQueueStatus,
|
|
1281
|
+
exists,
|
|
1282
|
+
stat,
|
|
1283
|
+
readFile,
|
|
1284
|
+
writeFile,
|
|
1285
|
+
copyFile,
|
|
1286
|
+
moveFile,
|
|
1287
|
+
mkdir,
|
|
1288
|
+
ls,
|
|
1289
|
+
fs,
|
|
1290
|
+
useDownload,
|
|
1291
|
+
unzip,
|
|
1292
|
+
zip,
|
|
1293
|
+
};
|