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.
Files changed (34) hide show
  1. package/FileToolkit.podspec +22 -0
  2. package/LICENSE +20 -0
  3. package/README.md +522 -0
  4. package/android/build.gradle +61 -0
  5. package/android/src/main/AndroidManifest.xml +13 -0
  6. package/android/src/main/java/com/filetoolkit/FileToolkitModule.kt +1204 -0
  7. package/android/src/main/java/com/filetoolkit/FileToolkitPackage.kt +31 -0
  8. package/android/src/main/res/xml/file_provider_paths.xml +14 -0
  9. package/app.plugin.js +49 -0
  10. package/ios/FileToolkit.h +6 -0
  11. package/ios/FileToolkit.mm +1468 -0
  12. package/lib/commonjs/NativeFileToolkit.js +9 -0
  13. package/lib/commonjs/NativeFileToolkit.js.map +1 -0
  14. package/lib/commonjs/index.js +941 -0
  15. package/lib/commonjs/index.js.map +1 -0
  16. package/lib/commonjs/package.json +1 -0
  17. package/lib/module/NativeFileToolkit.js +5 -0
  18. package/lib/module/NativeFileToolkit.js.map +1 -0
  19. package/lib/module/index.js +905 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/package.json +1 -0
  22. package/lib/typescript/commonjs/package.json +1 -0
  23. package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts +29 -0
  24. package/lib/typescript/commonjs/src/NativeFileToolkit.d.ts.map +1 -0
  25. package/lib/typescript/commonjs/src/index.d.ts +635 -0
  26. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  27. package/lib/typescript/module/package.json +1 -0
  28. package/lib/typescript/module/src/NativeFileToolkit.d.ts +29 -0
  29. package/lib/typescript/module/src/NativeFileToolkit.d.ts.map +1 -0
  30. package/lib/typescript/module/src/index.d.ts +635 -0
  31. package/lib/typescript/module/src/index.d.ts.map +1 -0
  32. package/package.json +232 -0
  33. package/src/NativeFileToolkit.ts +29 -0
  34. package/src/index.tsx +1293 -0
@@ -0,0 +1,1468 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <React/RCTLog.h>
3
+ #import <CommonCrypto/CommonDigest.h>
4
+ #import <UIKit/UIKit.h>
5
+ #import "FileToolkit.h"
6
+ #include <zlib.h>
7
+
8
+ // ─── Foreground session delegate ──────────────────────────────────────────────
9
+ @interface FileToolkit () <NSURLSessionDownloadDelegate, NSURLSessionDataDelegate, UIDocumentInteractionControllerDelegate>
10
+ @property (nonatomic, strong) NSURLSession *fgSession; // foreground
11
+ @property (nonatomic, strong) NSURLSession *bgSession; // background
12
+ // downloadId → resolve/reject blocks
13
+ @property (nonatomic, strong) NSMutableDictionary *activePromises;
14
+ // downloadId → original options dict
15
+ @property (nonatomic, strong) NSMutableDictionary *downloadOptions;
16
+ // downloadId → NSURLSessionDownloadTask
17
+ @property (nonatomic, strong) NSMutableDictionary *activeTasks;
18
+ // downloadId → NSData (resume data for paused tasks)
19
+ @property (nonatomic, strong) NSMutableDictionary *resumeDataStore;
20
+ // NSURLSessionTask identifier (int) → downloadId (string)
21
+ @property (nonatomic, strong) NSMutableDictionary *taskIdMap;
22
+ // downloadId → current retry attempt count (NSNumber)
23
+ @property (nonatomic, strong) NSMutableDictionary *retryAttempts;
24
+ // Upload tracking
25
+ @property (nonatomic, strong) NSMutableDictionary *uploadPromises; // uploadId → {resolve, reject}
26
+ @property (nonatomic, strong) NSMutableDictionary *uploadUrls; // uploadId → URL string
27
+ @property (nonatomic, strong) NSMutableDictionary *uploadResponseData; // uploadId → NSMutableData
28
+ @property (nonatomic, strong) NSMutableDictionary *uploadTaskIdMap; // taskIdentifier → uploadId
29
+ // Strong ref to prevent ARC deallocation during preview
30
+ @property (nonatomic, strong) UIDocumentInteractionController *documentController;
31
+ @end
32
+
33
+ @implementation FileToolkit
34
+
35
+ RCT_EXPORT_MODULE()
36
+
37
+ - (instancetype)init {
38
+ if (self = [super init]) {
39
+ self.activePromises = [NSMutableDictionary new];
40
+ self.downloadOptions = [NSMutableDictionary new];
41
+ self.activeTasks = [NSMutableDictionary new];
42
+ self.resumeDataStore = [NSMutableDictionary new];
43
+ self.taskIdMap = [NSMutableDictionary new];
44
+ self.retryAttempts = [NSMutableDictionary new];
45
+ self.uploadPromises = [NSMutableDictionary new];
46
+ self.uploadUrls = [NSMutableDictionary new];
47
+ self.uploadResponseData = [NSMutableDictionary new];
48
+ self.uploadTaskIdMap = [NSMutableDictionary new];
49
+
50
+ // Foreground session
51
+ NSURLSessionConfiguration *fgConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
52
+ self.fgSession = [NSURLSession sessionWithConfiguration:fgConfig delegate:self delegateQueue:nil];
53
+
54
+ // Background session (survives app suspension)
55
+ NSURLSessionConfiguration *bgConfig =
56
+ [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.filetoolkit.background"];
57
+ bgConfig.discretionary = NO;
58
+ bgConfig.sessionSendsLaunchEvents = YES;
59
+ self.bgSession = [NSURLSession sessionWithConfiguration:bgConfig delegate:self delegateQueue:nil];
60
+ }
61
+ return self;
62
+ }
63
+
64
+ - (NSArray<NSString *> *)supportedEvents {
65
+ return @[@"onDownloadProgress", @"onDownloadComplete", @"onDownloadError", @"onUploadProgress", @"onDownloadRetry"];
66
+ }
67
+
68
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
69
+
70
+ - (NSString *)generateDownloadId {
71
+ return [[NSUUID UUID] UUIDString];
72
+ }
73
+
74
+ - (NSURL *)destURLForFileName:(NSString *)fileName destination:(NSString *)destType {
75
+ NSSearchPathDirectory dirType = NSDownloadsDirectory;
76
+ if ([destType isEqualToString:@"cache"]) {
77
+ dirType = NSCachesDirectory;
78
+ } else if ([destType isEqualToString:@"documents"]) {
79
+ dirType = NSDocumentDirectory;
80
+ }
81
+
82
+ NSURL *dirURL = [[NSFileManager defaultManager]
83
+ URLsForDirectory:dirType inDomains:NSUserDomainMask].firstObject;
84
+
85
+ // For iOS < 16 some directories might not exist or need subfolders
86
+ if ([destType isEqualToString:@"downloads"] && !dirURL) {
87
+ NSURL *docsDir = [[NSFileManager defaultManager]
88
+ URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
89
+ dirURL = [docsDir URLByAppendingPathComponent:@"Downloads"];
90
+ [[NSFileManager defaultManager] createDirectoryAtURL:dirURL withIntermediateDirectories:YES attributes:nil error:nil];
91
+ }
92
+
93
+ return [dirURL URLByAppendingPathComponent:fileName];
94
+ }
95
+
96
+ - (NSString *)calculateChecksumForPath:(NSString *)path algorithm:(NSString *)algo {
97
+ NSData *data = [NSData dataWithContentsOfFile:path];
98
+ if (!data) return nil;
99
+
100
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
101
+ if ([algo isEqualToString:@"MD5"]) {
102
+ CC_MD5(data.bytes, (CC_LONG)data.length, digest);
103
+ NSMutableString *output = [NSMutableString stringWithCapacity:CC_MD5_DIGEST_LENGTH * 2];
104
+ for (int i = 0; i < CC_MD5_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
105
+ return output;
106
+ } else if ([algo isEqualToString:@"SHA1"]) {
107
+ CC_SHA1(data.bytes, (CC_LONG)data.length, digest);
108
+ NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2];
109
+ for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
110
+ return output;
111
+ } else {
112
+ CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
113
+ NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
114
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
115
+ return output;
116
+ }
117
+ }
118
+
119
+ - (NSString *)fileNameFromOptions:(NSDictionary *)options task:(NSURLSessionDownloadTask *)task {
120
+ NSString *name = options[@"fileName"];
121
+ if (!name || [name isEqualToString:@""]) {
122
+ name = task.originalRequest.URL.lastPathComponent;
123
+ }
124
+ if (!name || [name isEqualToString:@""]) {
125
+ name = @"downloaded_file";
126
+ }
127
+ return name;
128
+ }
129
+
130
+ // ─── download ─────────────────────────────────────────────────────────────────
131
+
132
+ - (void)download:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
133
+ NSString *urlString = options[@"url"];
134
+ if (!urlString) {
135
+ resolve(@{@"success": @NO, @"error": @"URL is missing"});
136
+ return;
137
+ }
138
+
139
+ BOOL isBackground = [options[@"background"] boolValue];
140
+ NSString *downloadId = [self generateDownloadId];
141
+ NSURL *url = [NSURL URLWithString:urlString];
142
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
143
+
144
+ // Add custom headers
145
+ NSDictionary *headers = options[@"headers"];
146
+ if (headers) {
147
+ for (NSString *key in headers) {
148
+ [request setValue:headers[key] forHTTPHeaderField:key];
149
+ }
150
+ }
151
+
152
+ NSURLSession *session = isBackground ? self.bgSession : self.fgSession;
153
+ NSURLSessionDownloadTask *task = [session downloadTaskWithRequest:request];
154
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
155
+
156
+ self.taskIdMap[taskKey] = downloadId;
157
+ self.activeTasks[downloadId] = task;
158
+ self.downloadOptions[downloadId] = options;
159
+ task.taskDescription = downloadId;
160
+
161
+ if (isBackground) {
162
+ // Resolve immediately with the downloadId — result comes via event
163
+ resolve(@{@"success": @YES, @"downloadId": downloadId});
164
+ } else {
165
+ self.activePromises[downloadId] = @{@"resolve": resolve, @"reject": reject};
166
+ }
167
+
168
+ [task resume];
169
+ }
170
+
171
+ // ─── pauseDownload ────────────────────────────────────────────────────────────
172
+
173
+ - (void)pauseDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
174
+ NSURLSessionDownloadTask *task = self.activeTasks[downloadId];
175
+ if (!task) {
176
+ resolve(@{@"success": @NO, @"error": @"Download not found"});
177
+ return;
178
+ }
179
+
180
+ [task cancelByProducingResumeData:^(NSData *resumeData) {
181
+ if (resumeData) {
182
+ self.resumeDataStore[downloadId] = resumeData;
183
+ }
184
+ [self.activeTasks removeObjectForKey:downloadId];
185
+ resolve(@{@"success": @YES});
186
+ }];
187
+ }
188
+
189
+ // ─── resumeDownload ───────────────────────────────────────────────────────────
190
+
191
+ - (void)resumeDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
192
+ NSData *resumeData = self.resumeDataStore[downloadId];
193
+ if (!resumeData) {
194
+ resolve(@{@"success": @NO, @"error": @"No resume data — download was not paused or was cancelled"});
195
+ return;
196
+ }
197
+
198
+ NSDictionary *options = self.downloadOptions[downloadId];
199
+ BOOL isBackground = [options[@"background"] boolValue];
200
+ NSURLSession *session = isBackground ? self.bgSession : self.fgSession;
201
+
202
+ NSURLSessionDownloadTask *task = [session downloadTaskWithResumeData:resumeData];
203
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
204
+
205
+ self.taskIdMap[taskKey] = downloadId;
206
+ self.activeTasks[downloadId] = task;
207
+ [self.resumeDataStore removeObjectForKey:downloadId];
208
+
209
+ [task resume];
210
+ resolve(@{@"success": @YES});
211
+ }
212
+
213
+ // ─── cancelDownload ───────────────────────────────────────────────────────────
214
+
215
+ - (void)cancelDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
216
+ NSURLSessionDownloadTask *task = self.activeTasks[downloadId];
217
+ if (task) {
218
+ [task cancel];
219
+ [self.activeTasks removeObjectForKey:downloadId];
220
+ }
221
+ [self.resumeDataStore removeObjectForKey:downloadId];
222
+ [self.activePromises removeObjectForKey:downloadId];
223
+ [self.downloadOptions removeObjectForKey:downloadId];
224
+ [self.retryAttempts removeObjectForKey:downloadId];
225
+ resolve(@{@"success": @YES});
226
+ }
227
+
228
+ // ─── getCachedFiles ───────────────────────────────────────────────────────────
229
+
230
+ - (void)getCachedFiles:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
231
+ NSMutableArray *result = [NSMutableArray new];
232
+
233
+ // Scan all three directories: Downloads, Caches, Documents
234
+ NSURL *downloadsDir = [[NSFileManager defaultManager]
235
+ URLsForDirectory:NSDownloadsDirectory inDomains:NSUserDomainMask].firstObject;
236
+ if (!downloadsDir) {
237
+ NSURL *fallbackDocs = [[NSFileManager defaultManager]
238
+ URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
239
+ downloadsDir = [fallbackDocs URLByAppendingPathComponent:@"Downloads"];
240
+ }
241
+ NSURL *cacheDir = [[NSFileManager defaultManager]
242
+ URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject;
243
+ NSURL *docsDir = [[NSFileManager defaultManager]
244
+ URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
245
+
246
+ NSMutableArray<NSURL *> *dirs = [NSMutableArray new];
247
+ if (downloadsDir) [dirs addObject:downloadsDir];
248
+ if (cacheDir) [dirs addObject:cacheDir];
249
+ if (docsDir) [dirs addObject:docsDir];
250
+
251
+ for (NSURL *dirURL in dirs) {
252
+ NSArray<NSURL *> *files = [[NSFileManager defaultManager]
253
+ contentsOfDirectoryAtURL:dirURL
254
+ includingPropertiesForKeys:@[NSURLFileSizeKey, NSURLContentModificationDateKey, NSURLIsDirectoryKey]
255
+ options:NSDirectoryEnumerationSkipsHiddenFiles
256
+ error:nil];
257
+
258
+ for (NSURL *fileURL in files) {
259
+ NSNumber *isDir;
260
+ [fileURL getResourceValue:&isDir forKey:NSURLIsDirectoryKey error:nil];
261
+ if ([isDir boolValue]) continue; // skip directories
262
+
263
+ NSNumber *size;
264
+ NSDate *modDate;
265
+ [fileURL getResourceValue:&size forKey:NSURLFileSizeKey error:nil];
266
+ [fileURL getResourceValue:&modDate forKey:NSURLContentModificationDateKey error:nil];
267
+ [result addObject:@{
268
+ @"fileName": fileURL.lastPathComponent,
269
+ @"filePath": fileURL.path,
270
+ @"size": size ?: @0,
271
+ @"modifiedAt": @((long long)([modDate timeIntervalSince1970] * 1000))
272
+ }];
273
+ }
274
+ }
275
+
276
+ resolve(@{@"success": @YES, @"files": result});
277
+ }
278
+
279
+ // ─── deleteFile ───────────────────────────────────────────────────────────────
280
+
281
+ - (void)deleteFile:(NSString *)filePath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
282
+ NSError *error;
283
+ [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error];
284
+ if (error) {
285
+ resolve(@{@"success": @NO, @"error": error.localizedDescription});
286
+ } else {
287
+ resolve(@{@"success": @YES});
288
+ }
289
+ }
290
+
291
+ // ─── clearCache ───────────────────────────────────────────────────────────────
292
+
293
+ - (void)clearCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
294
+ // Only clear app-private directories — never touch Downloads
295
+ NSURL *cacheDir = [[NSFileManager defaultManager]
296
+ URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject;
297
+ NSURL *docsDir = [[NSFileManager defaultManager]
298
+ URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
299
+
300
+ NSMutableArray<NSURL *> *dirs = [NSMutableArray new];
301
+ if (cacheDir) [dirs addObject:cacheDir];
302
+ if (docsDir) [dirs addObject:docsDir];
303
+
304
+ for (NSURL *dirURL in dirs) {
305
+ NSArray<NSURL *> *files = [[NSFileManager defaultManager]
306
+ contentsOfDirectoryAtURL:dirURL
307
+ includingPropertiesForKeys:nil
308
+ options:NSDirectoryEnumerationSkipsHiddenFiles
309
+ error:nil];
310
+
311
+ for (NSURL *fileURL in files) {
312
+ [[NSFileManager defaultManager] removeItemAtURL:fileURL error:nil];
313
+ }
314
+ }
315
+ resolve(@{@"success": @YES});
316
+ }
317
+
318
+ // ─── exists ──────────────────────────────────────────────────────────────────
319
+
320
+ - (void)exists:(NSString *)filePath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
321
+ @try {
322
+ BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:filePath];
323
+ resolve(@{
324
+ @"success": @YES,
325
+ @"exists": @(exists)
326
+ });
327
+ } @catch (NSException *exception) {
328
+ resolve(@{
329
+ @"success": @NO,
330
+ @"error": exception.reason ?: @"EXISTS_ERROR"
331
+ });
332
+ }
333
+ }
334
+
335
+ // ─── stat ────────────────────────────────────────────────────────────────────
336
+
337
+ - (void)stat:(NSString *)filePath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
338
+ @try {
339
+ NSFileManager *fm = [NSFileManager defaultManager];
340
+ BOOL isDir = NO;
341
+ BOOL exists = [fm fileExistsAtPath:filePath isDirectory:&isDir];
342
+ if (!exists) {
343
+ resolve(@{@"success": @NO, @"error": @"Path does not exist"});
344
+ return;
345
+ }
346
+
347
+ NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
348
+ NSNumber *size = attrs[NSFileSize] ?: @0;
349
+ NSDate *modDate = attrs[NSFileModificationDate] ?: [NSDate dateWithTimeIntervalSince1970:0];
350
+
351
+ resolve(@{
352
+ @"success": @YES,
353
+ @"stat": @{
354
+ @"path": filePath,
355
+ @"name": [filePath lastPathComponent] ?: @"",
356
+ @"isDir": @(isDir),
357
+ @"size": isDir ? @0 : size,
358
+ @"modified": @((long long)([modDate timeIntervalSince1970] * 1000))
359
+ }
360
+ });
361
+ } @catch (NSException *exception) {
362
+ resolve(@{
363
+ @"success": @NO,
364
+ @"error": exception.reason ?: @"STAT_ERROR"
365
+ });
366
+ }
367
+ }
368
+
369
+ // ─── readFile ────────────────────────────────────────────────────────────────
370
+
371
+ - (void)readFile:(NSString *)filePath encoding:(NSString *)encoding resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
372
+ NSFileManager *fm = [NSFileManager defaultManager];
373
+ BOOL isDir = NO;
374
+ if (![fm fileExistsAtPath:filePath isDirectory:&isDir] || isDir) {
375
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"File not found: %@", filePath]});
376
+ return;
377
+ }
378
+
379
+ NSError *readError = nil;
380
+ NSData *raw = [NSData dataWithContentsOfFile:filePath options:0 error:&readError];
381
+ if (!raw || readError) {
382
+ resolve(@{@"success": @NO, @"error": readError.localizedDescription ?: @"READ_FILE_ERROR"});
383
+ return;
384
+ }
385
+
386
+ NSString *dataString = nil;
387
+ if ([[encoding lowercaseString] isEqualToString:@"base64"]) {
388
+ dataString = [raw base64EncodedStringWithOptions:0];
389
+ } else {
390
+ dataString = [[NSString alloc] initWithData:raw encoding:NSUTF8StringEncoding];
391
+ if (!dataString) {
392
+ resolve(@{@"success": @NO, @"error": @"File is not valid UTF-8. Try base64 encoding."});
393
+ return;
394
+ }
395
+ }
396
+
397
+ resolve(@{
398
+ @"success": @YES,
399
+ @"data": dataString ?: @""
400
+ });
401
+ }
402
+
403
+ // ─── writeFile ───────────────────────────────────────────────────────────────
404
+
405
+ - (void)writeFile:(NSString *)filePath data:(NSString *)data encoding:(NSString *)encoding resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
406
+ NSFileManager *fm = [NSFileManager defaultManager];
407
+ NSString *parent = [filePath stringByDeletingLastPathComponent];
408
+ if (parent.length > 0) {
409
+ [fm createDirectoryAtPath:parent withIntermediateDirectories:YES attributes:nil error:nil];
410
+ }
411
+
412
+ NSError *writeError = nil;
413
+ BOOL success = NO;
414
+
415
+ if ([[encoding lowercaseString] isEqualToString:@"base64"]) {
416
+ NSData *decoded = [[NSData alloc] initWithBase64EncodedString:data options:NSDataBase64DecodingIgnoreUnknownCharacters];
417
+ if (!decoded) {
418
+ resolve(@{@"success": @NO, @"error": @"Invalid base64 string"});
419
+ return;
420
+ }
421
+ success = [decoded writeToFile:filePath options:NSDataWritingAtomic error:&writeError];
422
+ } else {
423
+ success = [data writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:&writeError];
424
+ }
425
+
426
+ if (!success || writeError) {
427
+ resolve(@{@"success": @NO, @"error": writeError.localizedDescription ?: @"WRITE_FILE_ERROR"});
428
+ return;
429
+ }
430
+
431
+ resolve(@{@"success": @YES});
432
+ }
433
+
434
+ // ─── copyFile ────────────────────────────────────────────────────────────────
435
+
436
+ - (void)copyFile:(NSString *)fromPath toPath:(NSString *)toPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
437
+ NSFileManager *fm = [NSFileManager defaultManager];
438
+ BOOL isDir = NO;
439
+ if (![fm fileExistsAtPath:fromPath isDirectory:&isDir] || isDir) {
440
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"Source file not found: %@", fromPath]});
441
+ return;
442
+ }
443
+
444
+ NSString *parent = [toPath stringByDeletingLastPathComponent];
445
+ if (parent.length > 0) {
446
+ [fm createDirectoryAtPath:parent withIntermediateDirectories:YES attributes:nil error:nil];
447
+ }
448
+
449
+ [fm removeItemAtPath:toPath error:nil];
450
+ NSError *copyError = nil;
451
+ BOOL success = [fm copyItemAtPath:fromPath toPath:toPath error:&copyError];
452
+
453
+ if (!success || copyError) {
454
+ resolve(@{@"success": @NO, @"error": copyError.localizedDescription ?: @"COPY_FILE_ERROR"});
455
+ return;
456
+ }
457
+
458
+ resolve(@{@"success": @YES});
459
+ }
460
+
461
+ // ─── moveFile ────────────────────────────────────────────────────────────────
462
+
463
+ - (void)moveFile:(NSString *)fromPath toPath:(NSString *)toPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
464
+ NSFileManager *fm = [NSFileManager defaultManager];
465
+ if (![fm fileExistsAtPath:fromPath]) {
466
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"Source path not found: %@", fromPath]});
467
+ return;
468
+ }
469
+
470
+ NSString *parent = [toPath stringByDeletingLastPathComponent];
471
+ if (parent.length > 0) {
472
+ [fm createDirectoryAtPath:parent withIntermediateDirectories:YES attributes:nil error:nil];
473
+ }
474
+
475
+ [fm removeItemAtPath:toPath error:nil];
476
+ NSError *moveError = nil;
477
+ BOOL success = [fm moveItemAtPath:fromPath toPath:toPath error:&moveError];
478
+
479
+ if (!success || moveError) {
480
+ resolve(@{@"success": @NO, @"error": moveError.localizedDescription ?: @"MOVE_FILE_ERROR"});
481
+ return;
482
+ }
483
+
484
+ resolve(@{@"success": @YES});
485
+ }
486
+
487
+ // ─── mkdir ───────────────────────────────────────────────────────────────────
488
+
489
+ - (void)mkdir:(NSString *)dirPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
490
+ NSFileManager *fm = [NSFileManager defaultManager];
491
+ BOOL isDir = NO;
492
+ BOOL exists = [fm fileExistsAtPath:dirPath isDirectory:&isDir];
493
+ if (exists && isDir) {
494
+ resolve(@{@"success": @YES});
495
+ return;
496
+ }
497
+
498
+ NSError *mkdirError = nil;
499
+ BOOL success = [fm createDirectoryAtPath:dirPath withIntermediateDirectories:YES attributes:nil error:&mkdirError];
500
+ if (!success || mkdirError) {
501
+ resolve(@{@"success": @NO, @"error": mkdirError.localizedDescription ?: @"MKDIR_ERROR"});
502
+ return;
503
+ }
504
+
505
+ resolve(@{@"success": @YES});
506
+ }
507
+
508
+ // ─── ls ──────────────────────────────────────────────────────────────────────
509
+
510
+ - (void)ls:(NSString *)dirPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
511
+ NSFileManager *fm = [NSFileManager defaultManager];
512
+ BOOL isDir = NO;
513
+ if (![fm fileExistsAtPath:dirPath isDirectory:&isDir] || !isDir) {
514
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"Directory not found: %@", dirPath]});
515
+ return;
516
+ }
517
+
518
+ NSError *lsError = nil;
519
+ NSArray<NSString *> *entries = [fm contentsOfDirectoryAtPath:dirPath error:&lsError];
520
+ if (lsError) {
521
+ resolve(@{@"success": @NO, @"error": lsError.localizedDescription ?: @"LS_ERROR"});
522
+ return;
523
+ }
524
+
525
+ resolve(@{
526
+ @"success": @YES,
527
+ @"entries": entries ?: @[]
528
+ });
529
+ }
530
+
531
+ // ─── getBackgroundDownloads ───────────────────────────────────────────────────
532
+
533
+ - (void)getBackgroundDownloads:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
534
+ [self.bgSession getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
535
+ NSMutableArray *results = [NSMutableArray new];
536
+ for (NSURLSessionDownloadTask *task in downloadTasks) {
537
+ NSString *downloadId = task.taskDescription ?: @"";
538
+ NSString *url = task.originalRequest.URL.absoluteString ?: @"";
539
+
540
+ int progress = 0;
541
+ if (task.countOfBytesExpectedToReceive > 0) {
542
+ progress = (int)((task.countOfBytesReceived * 100) / task.countOfBytesExpectedToReceive);
543
+ }
544
+
545
+ [results addObject:@{
546
+ @"downloadId": downloadId,
547
+ @"url": url,
548
+ @"status": @(task.state), // 0=Running, 1=Suspended, 2=Canceling, 3=Completed
549
+ @"progress": @(progress)
550
+ }];
551
+ }
552
+ resolve(@{@"success": @YES, @"downloads": results});
553
+ }];
554
+ }
555
+
556
+ // ─── NSURLSession delegates ───────────────────────────────────────────────────
557
+
558
+ - (void)URLSession:(NSURLSession *)session
559
+ downloadTask:(NSURLSessionDownloadTask *)downloadTask
560
+ didWriteData:(int64_t)bytesWritten
561
+ totalBytesWritten:(int64_t)totalBytesWritten
562
+ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
563
+ if (totalBytesExpectedToWrite > 0) {
564
+ int progress = (int)((totalBytesWritten * 100) / totalBytesExpectedToWrite);
565
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)downloadTask.taskIdentifier];
566
+ NSString *downloadId = self.taskIdMap[taskKey] ?: @"";
567
+ NSString *url = downloadTask.originalRequest.URL.absoluteString ?: @"";
568
+
569
+ [self sendEventWithName:@"onDownloadProgress"
570
+ body:@{
571
+ @"url": url,
572
+ @"downloadId": downloadId,
573
+ @"progress": @(progress),
574
+ @"bytesDownloaded": @(totalBytesWritten),
575
+ @"totalBytes": @(totalBytesExpectedToWrite)
576
+ }];
577
+ }
578
+ }
579
+
580
+ - (void)URLSession:(NSURLSession *)session
581
+ downloadTask:(NSURLSessionDownloadTask *)downloadTask
582
+ didFinishDownloadingToURL:(NSURL *)location {
583
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)downloadTask.taskIdentifier];
584
+ NSString *downloadId = self.taskIdMap[taskKey];
585
+ if (!downloadId) return;
586
+
587
+ NSDictionary *options = self.downloadOptions[downloadId];
588
+ NSString *fileName = [self fileNameFromOptions:options task:downloadTask];
589
+ NSString *destType = options[@"destination"] ?: @"downloads";
590
+ NSURL *destURL = [self destURLForFileName:fileName destination:destType];
591
+
592
+ NSError *error;
593
+ [[NSFileManager defaultManager] removeItemAtURL:destURL error:nil];
594
+ [[NSFileManager defaultManager] moveItemAtURL:location toURL:destURL error:&error];
595
+
596
+ NSDictionary *resultDict;
597
+ BOOL isError = NO;
598
+ if (error) {
599
+ isError = YES;
600
+ resultDict = @{@"success": @NO, @"downloadId": downloadId, @"error": error.localizedDescription};
601
+ } else {
602
+ // Checksum verification
603
+ NSDictionary *checksum = options[@"checksum"];
604
+ if (checksum) {
605
+ NSString *expectedHash = checksum[@"hash"];
606
+ NSString *algo = checksum[@"algorithm"] ?: @"MD5";
607
+ NSString *actualHash = [self calculateChecksumForPath:destURL.path algorithm:algo.uppercaseString];
608
+ if (![actualHash.lowercaseString isEqualToString:expectedHash.lowercaseString]) {
609
+ [[NSFileManager defaultManager] removeItemAtURL:destURL error:nil];
610
+ isError = YES;
611
+ resultDict = @{
612
+ @"success": @NO,
613
+ @"downloadId": downloadId,
614
+ @"error": [NSString stringWithFormat:@"CHECKSUM_MISMATCH: expected %@, got %@", expectedHash, actualHash]
615
+ };
616
+ } else {
617
+ resultDict = @{@"success": @YES, @"downloadId": downloadId, @"filePath": destURL.path};
618
+ }
619
+ } else {
620
+ resultDict = @{@"success": @YES, @"downloadId": downloadId, @"filePath": destURL.path};
621
+ }
622
+ }
623
+
624
+ NSDictionary *funcs = self.activePromises[downloadId];
625
+ BOOL isBackground = [options[@"background"] boolValue];
626
+
627
+ if (funcs && !isBackground) {
628
+ // Foreground: resolve the promise
629
+ RCTPromiseResolveBlock resolve = funcs[@"resolve"];
630
+ resolve(resultDict);
631
+ } else {
632
+ // Background: fire the correct event based on isError flag (Bug #1 fix)
633
+ NSString *event = isError ? @"onDownloadError" : @"onDownloadComplete";
634
+ [self sendEventWithName:event body:resultDict];
635
+ }
636
+
637
+ [self.activePromises removeObjectForKey:downloadId];
638
+ [self.downloadOptions removeObjectForKey:downloadId];
639
+ [self.activeTasks removeObjectForKey:downloadId];
640
+ [self.taskIdMap removeObjectForKey:taskKey];
641
+ [self.retryAttempts removeObjectForKey:downloadId];
642
+ }
643
+
644
+ - (void)URLSession:(NSURLSession *)session
645
+ task:(NSURLSessionTask *)task
646
+ didCompleteWithError:(NSError *)error {
647
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
648
+
649
+ // ── Upload task completion ─────────────────────────────────────────────
650
+ NSString *uploadId = self.uploadTaskIdMap[taskKey];
651
+ if (uploadId) {
652
+ NSDictionary *funcs = self.uploadPromises[uploadId];
653
+ RCTPromiseResolveBlock uploadResolve = funcs[@"resolve"];
654
+
655
+ if (error) {
656
+ if (uploadResolve) uploadResolve(@{@"success": @NO, @"error": error.localizedDescription});
657
+ } else {
658
+ NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response;
659
+ NSData *responseData = self.uploadResponseData[uploadId] ?: [NSData data];
660
+ NSString *respString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] ?: @"";
661
+
662
+ if (uploadResolve) uploadResolve(@{
663
+ @"success": @(httpResponse.statusCode >= 200 && httpResponse.statusCode < 300),
664
+ @"status": @(httpResponse.statusCode),
665
+ @"data": respString
666
+ });
667
+ }
668
+
669
+ [self.uploadPromises removeObjectForKey:uploadId];
670
+ [self.uploadUrls removeObjectForKey:uploadId];
671
+ [self.uploadResponseData removeObjectForKey:uploadId];
672
+ [self.uploadTaskIdMap removeObjectForKey:taskKey];
673
+ return;
674
+ }
675
+
676
+ // ── Download task error handling ───────────────────────────────────────
677
+ if (!error) return;
678
+ // Ignore cancellation
679
+ if (error.code == NSURLErrorCancelled) return;
680
+
681
+ NSString *downloadId = self.taskIdMap[taskKey];
682
+ if (!downloadId) return;
683
+
684
+ NSDictionary *options = self.downloadOptions[downloadId];
685
+ BOOL isBackground = [options[@"background"] boolValue];
686
+
687
+ // ── Retry logic ────────────────────────────────────────────────────────
688
+ NSDictionary *retryConfig = options[@"retry"];
689
+ NSInteger maxAttempts = retryConfig ? [retryConfig[@"attempts"] integerValue] : 0;
690
+ NSInteger baseDelay = retryConfig ? ([retryConfig[@"delay"] integerValue] ?: 1000) : 1000;
691
+ NSInteger currentAttempt = [self.retryAttempts[downloadId] integerValue];
692
+
693
+ // Remove old task mapping — will be replaced on retry
694
+ [self.taskIdMap removeObjectForKey:taskKey];
695
+ [self.activeTasks removeObjectForKey:downloadId];
696
+
697
+ if (currentAttempt < maxAttempts) {
698
+ // Schedule a retry
699
+ NSInteger nextAttempt = currentAttempt + 1;
700
+ self.retryAttempts[downloadId] = @(nextAttempt);
701
+
702
+ // Bug #4 fix: avoid integer overflow — cap shift operand, then apply MIN
703
+ NSInteger shiftBits = currentAttempt < 15 ? currentAttempt : 15;
704
+ NSInteger delayMs = MIN(baseDelay * (1 << shiftBits), (NSInteger)30000);
705
+
706
+ // Emit retry event so JS onRetry callback is called
707
+ // Bug #3 fix: include the error description that triggered this retry
708
+ [self sendEventWithName:@"onDownloadRetry" body:@{
709
+ @"downloadId": downloadId,
710
+ @"url": options[@"url"] ?: @"",
711
+ @"attempt": @(nextAttempt),
712
+ @"error": error.localizedDescription ?: @""
713
+ }];
714
+
715
+ // Bug #2 fix: use __weak self to avoid retain cycle and crash on dealloc during delay
716
+ __weak typeof(self) weakSelf = self;
717
+ dispatch_after(
718
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayMs * NSEC_PER_MSEC)),
719
+ dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
720
+ ^{
721
+ __strong typeof(weakSelf) strongSelf = weakSelf;
722
+ if (!strongSelf) return; // module deallocated during delay — bail safely
723
+
724
+ // Recreate task with same options & downloadId
725
+ NSString *urlString = options[@"url"];
726
+ if (!urlString) return;
727
+ NSURL *url = [NSURL URLWithString:urlString];
728
+ if (!url) return;
729
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
730
+ NSDictionary *headers = options[@"headers"];
731
+ if (headers) {
732
+ for (NSString *key in headers) {
733
+ [request setValue:headers[key] forHTTPHeaderField:key];
734
+ }
735
+ }
736
+ NSURLSession *sess = isBackground ? strongSelf.bgSession : strongSelf.fgSession;
737
+ NSURLSessionDownloadTask *newTask = [sess downloadTaskWithRequest:request];
738
+ NSString *newTaskKey = [NSString stringWithFormat:@"%lu",
739
+ (unsigned long)newTask.taskIdentifier];
740
+ strongSelf.taskIdMap[newTaskKey] = downloadId;
741
+ strongSelf.activeTasks[downloadId] = newTask;
742
+ newTask.taskDescription = downloadId;
743
+ [newTask resume];
744
+ }
745
+ );
746
+ return; // Don't resolve promise yet — retry is in flight
747
+ }
748
+
749
+ // ── No more retries — normal error path ────────────────────────────────
750
+ [self.retryAttempts removeObjectForKey:downloadId];
751
+
752
+ NSDictionary *errDict = @{@"success": @NO, @"downloadId": downloadId, @"error": error.localizedDescription};
753
+
754
+ NSDictionary *funcs = self.activePromises[downloadId];
755
+ if (funcs && !isBackground) {
756
+ RCTPromiseResolveBlock resolve = funcs[@"resolve"];
757
+ resolve(errDict);
758
+ } else {
759
+ [self sendEventWithName:@"onDownloadError" body:errDict];
760
+ }
761
+
762
+ [self.activePromises removeObjectForKey:downloadId];
763
+ [self.downloadOptions removeObjectForKey:downloadId];
764
+ }
765
+
766
+ // ─── Upload progress delegate ────────────────────────────────────────────────
767
+
768
+ - (void)URLSession:(NSURLSession *)session
769
+ task:(NSURLSessionTask *)task
770
+ didSendBodyData:(int64_t)bytesSent
771
+ totalBytesSent:(int64_t)totalBytesSent
772
+ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
773
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
774
+ NSString *uploadId = self.uploadTaskIdMap[taskKey];
775
+ if (!uploadId) return;
776
+
777
+ NSString *url = self.uploadUrls[uploadId] ?: @"";
778
+ if (totalBytesExpectedToSend > 0) {
779
+ int progress = (int)((totalBytesSent * 100) / totalBytesExpectedToSend);
780
+ [self sendEventWithName:@"onUploadProgress"
781
+ body:@{@"url": url, @"progress": @(progress)}];
782
+ }
783
+ }
784
+
785
+ // ─── Upload response data accumulation ───────────────────────────────────────
786
+
787
+ - (void)URLSession:(NSURLSession *)session
788
+ dataTask:(NSURLSessionDataTask *)dataTask
789
+ didReceiveData:(NSData *)data {
790
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)dataTask.taskIdentifier];
791
+ NSString *uploadId = self.uploadTaskIdMap[taskKey];
792
+ if (!uploadId) return;
793
+
794
+ NSMutableData *responseData = self.uploadResponseData[uploadId];
795
+ if (!responseData) {
796
+ responseData = [NSMutableData new];
797
+ self.uploadResponseData[uploadId] = responseData;
798
+ }
799
+ [responseData appendData:data];
800
+ }
801
+
802
+ // ─── TurboModule ──────────────────────────────────────────────────────────────
803
+
804
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
805
+ (const facebook::react::ObjCTurboModule::InitParams &)params
806
+ {
807
+ return std::make_shared<facebook::react::NativeFileToolkitSpecJSI>(params);
808
+ }
809
+
810
+ + (NSString *)moduleName
811
+ {
812
+ return @"FileToolkit";
813
+ }
814
+
815
+ // ─── upload ───────────────────────────────────────────────────────────────────
816
+
817
+ - (void)upload:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
818
+ NSString *urlString = options[@"url"];
819
+ NSString *filePath = options[@"filePath"];
820
+ if (!urlString || !filePath) {
821
+ resolve(@{@"success": @NO, @"error": @"URL or filePath is missing"});
822
+ return;
823
+ }
824
+
825
+ NSString *fieldName = options[@"fieldName"] ?: @"file";
826
+ NSDictionary *headers = options[@"headers"];
827
+ NSDictionary *params = options[@"parameters"];
828
+
829
+ NSString *boundary = [NSString stringWithFormat:@"Boundary-%@", [[NSUUID UUID] UUIDString]];
830
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
831
+ [request setHTTPMethod:@"POST"];
832
+ [request setValue:[NSString stringWithFormat:@"multipart/form-data; boundary=%@", boundary] forHTTPHeaderField:@"Content-Type"];
833
+
834
+ if (headers) {
835
+ for (NSString *key in headers) {
836
+ [request setValue:headers[key] forHTTPHeaderField:key];
837
+ }
838
+ }
839
+
840
+ NSMutableData *body = [NSMutableData data];
841
+ [params enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
842
+ [body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
843
+ [body appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n", key] dataUsingEncoding:NSUTF8StringEncoding]];
844
+ [body appendData:[[NSString stringWithFormat:@"%@\r\n", value] dataUsingEncoding:NSUTF8StringEncoding]];
845
+ }];
846
+
847
+ NSString *fileName = [filePath lastPathComponent];
848
+ [body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
849
+ [body appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; filename=\"%@\"\r\n", fieldName, fileName] dataUsingEncoding:NSUTF8StringEncoding]];
850
+ [body appendData:[@"Content-Type: application/octet-stream\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
851
+ [body appendData:[NSData dataWithContentsOfFile:filePath]];
852
+ [body appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
853
+ [body appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
854
+
855
+ // Use delegate-based session for upload progress support
856
+ NSString *uploadId = [self generateDownloadId];
857
+ NSURLSessionUploadTask *task = [self.fgSession uploadTaskWithRequest:request fromData:body];
858
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
859
+
860
+ self.uploadTaskIdMap[taskKey] = uploadId;
861
+ self.uploadPromises[uploadId] = @{@"resolve": resolve, @"reject": reject};
862
+ self.uploadUrls[uploadId] = urlString;
863
+
864
+ [task resume];
865
+ }
866
+
867
+ // ─── saveBase64AsFile ─────────────────────────────────────────────────────────
868
+
869
+ RCT_REMAP_METHOD(saveBase64AsFile,
870
+ base64Options:(NSDictionary *)options
871
+ saveResolver:(RCTPromiseResolveBlock)resolve
872
+ saveRejecter:(RCTPromiseRejectBlock)reject)
873
+ {
874
+ NSString *base64String = options[@"base64Data"];
875
+ if (!base64String || base64String.length == 0) {
876
+ resolve(@{@"success": @NO, @"error": @"base64Data is required"});
877
+ return;
878
+ }
879
+
880
+ NSString *fileName = options[@"fileName"];
881
+ if (!fileName || fileName.length == 0) {
882
+ fileName = [NSString stringWithFormat:@"base64_file_%lld", (long long)([[NSDate date] timeIntervalSince1970] * 1000)];
883
+ }
884
+
885
+ NSString *destination = options[@"destination"] ?: @"downloads";
886
+
887
+ // Decode base64
888
+ NSData *decodedData = [[NSData alloc] initWithBase64EncodedString:base64String options:NSDataBase64DecodingIgnoreUnknownCharacters];
889
+ if (!decodedData) {
890
+ resolve(@{@"success": @NO, @"error": @"Invalid base64 string"});
891
+ return;
892
+ }
893
+
894
+ NSURL *destURL = [self destURLForFileName:fileName destination:destination];
895
+ NSError *writeError = nil;
896
+ BOOL success = [decodedData writeToURL:destURL options:NSDataWritingAtomic error:&writeError];
897
+
898
+ if (!success || writeError) {
899
+ resolve(@{@"success": @NO, @"error": writeError ? writeError.localizedDescription : @"Failed to write file"});
900
+ return;
901
+ }
902
+
903
+ resolve(@{
904
+ @"success": @YES,
905
+ @"filePath": destURL.path
906
+ });
907
+ }
908
+
909
+ // ─── urlToBase64 ──────────────────────────────────────────────────────────────
910
+
911
+ RCT_REMAP_METHOD(urlToBase64,
912
+ urlOptions:(NSDictionary *)options
913
+ urlResolver:(RCTPromiseResolveBlock)resolve
914
+ urlRejecter:(RCTPromiseRejectBlock)reject)
915
+ {
916
+ NSString *urlString = options[@"url"];
917
+ if (!urlString || urlString.length == 0) {
918
+ resolve(@{@"success": @NO, @"error": @"URL is required"});
919
+ return;
920
+ }
921
+
922
+ NSURL *url = [NSURL URLWithString:urlString];
923
+ if (!url) {
924
+ resolve(@{@"success": @NO, @"error": @"Invalid URL"});
925
+ return;
926
+ }
927
+
928
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
929
+ request.timeoutInterval = 30.0;
930
+
931
+ // Add custom headers if provided
932
+ NSDictionary *headers = options[@"headers"];
933
+ if (headers) {
934
+ for (NSString *key in headers) {
935
+ [request setValue:headers[key] forHTTPHeaderField:key];
936
+ }
937
+ }
938
+
939
+ NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
940
+ if (error) {
941
+ resolve(@{@"success": @NO, @"error": error.localizedDescription});
942
+ return;
943
+ }
944
+
945
+ NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
946
+ if (httpResponse.statusCode < 200 || httpResponse.statusCode >= 300) {
947
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"HTTP %ld", (long)httpResponse.statusCode]});
948
+ return;
949
+ }
950
+
951
+ if (!data || data.length == 0) {
952
+ resolve(@{@"success": @NO, @"error": @"No data received"});
953
+ return;
954
+ }
955
+
956
+ // Get MIME type from response
957
+ NSString *mimeType = httpResponse.MIMEType ?: @"application/octet-stream";
958
+
959
+ // Encode to base64
960
+ NSString *base64String = [data base64EncodedStringWithOptions:0];
961
+ NSString *dataUri = [NSString stringWithFormat:@"data:%@;base64,%@", mimeType, base64String];
962
+
963
+ resolve(@{
964
+ @"success": @YES,
965
+ @"base64": base64String,
966
+ @"mimeType": mimeType,
967
+ @"dataUri": dataUri
968
+ });
969
+ }];
970
+
971
+ [task resume];
972
+ }
973
+
974
+ // ─── shareFile ────────────────────────────────────────────────────────────────
975
+
976
+ RCT_REMAP_METHOD(shareFile,
977
+ shareFilePath:(NSString *)filePath
978
+ shareOptions:(NSDictionary *)options
979
+ shareResolver:(RCTPromiseResolveBlock)resolve
980
+ shareRejecter:(RCTPromiseRejectBlock)reject)
981
+ {
982
+ if (!filePath || filePath.length == 0) {
983
+ resolve(@{@"success": @NO, @"error": @"File path is required"});
984
+ return;
985
+ }
986
+
987
+ if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
988
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"File not found: %@", filePath]});
989
+ return;
990
+ }
991
+
992
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
993
+
994
+ dispatch_async(dispatch_get_main_queue(), ^{
995
+ UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
996
+
997
+ // Find the topmost view controller
998
+ while (rootViewController.presentedViewController) {
999
+ rootViewController = rootViewController.presentedViewController;
1000
+ }
1001
+
1002
+ NSArray *itemsToShare = @[fileURL];
1003
+ UIActivityViewController *activityVC = [[UIActivityViewController alloc] initWithActivityItems:itemsToShare applicationActivities:nil];
1004
+
1005
+ // For iPad, set the popover presentation controller
1006
+ if (activityVC.popoverPresentationController) {
1007
+ activityVC.popoverPresentationController.sourceView = rootViewController.view;
1008
+ activityVC.popoverPresentationController.sourceRect = CGRectMake(rootViewController.view.bounds.size.width / 2,
1009
+ rootViewController.view.bounds.size.height / 2,
1010
+ 0, 0);
1011
+ activityVC.popoverPresentationController.permittedArrowDirections = 0;
1012
+ }
1013
+
1014
+ activityVC.completionWithItemsHandler = ^(UIActivityType activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) {
1015
+ if (activityError) {
1016
+ resolve(@{@"success": @NO, @"error": activityError.localizedDescription});
1017
+ } else {
1018
+ resolve(@{@"success": @YES, @"completed": @(completed)});
1019
+ }
1020
+ };
1021
+
1022
+ [rootViewController presentViewController:activityVC animated:YES completion:nil];
1023
+ });
1024
+ }
1025
+
1026
+ // ─── openFile ─────────────────────────────────────────────────────────────────
1027
+
1028
+ RCT_REMAP_METHOD(openFile,
1029
+ openFilePath:(NSString *)filePath
1030
+ mimeType:(NSString *)mimeType
1031
+ openResolver:(RCTPromiseResolveBlock)resolve
1032
+ openRejecter:(RCTPromiseRejectBlock)reject)
1033
+ {
1034
+ if (!filePath || filePath.length == 0) {
1035
+ resolve(@{@"success": @NO, @"error": @"File path is required"});
1036
+ return;
1037
+ }
1038
+
1039
+ if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
1040
+ resolve(@{@"success": @NO, @"error": [NSString stringWithFormat:@"File not found: %@", filePath]});
1041
+ return;
1042
+ }
1043
+
1044
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1045
+
1046
+ dispatch_async(dispatch_get_main_queue(), ^{
1047
+ UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1048
+
1049
+ // Find the topmost view controller
1050
+ while (rootViewController.presentedViewController) {
1051
+ rootViewController = rootViewController.presentedViewController;
1052
+ }
1053
+
1054
+ // Use UIDocumentInteractionController for opening files
1055
+ self.documentController = [UIDocumentInteractionController interactionControllerWithURL:fileURL];
1056
+ self.documentController.delegate = (id<UIDocumentInteractionControllerDelegate>)self;
1057
+
1058
+ BOOL canOpen = [self.documentController presentPreviewAnimated:YES];
1059
+
1060
+ if (!canOpen) {
1061
+ // Fallback: Try to open with options menu
1062
+ canOpen = [self.documentController presentOptionsMenuFromRect:CGRectMake(rootViewController.view.bounds.size.width / 2,
1063
+ rootViewController.view.bounds.size.height / 2,
1064
+ 0, 0)
1065
+ inView:rootViewController.view
1066
+ animated:YES];
1067
+ }
1068
+
1069
+ if (canOpen) {
1070
+ resolve(@{@"success": @YES});
1071
+ } else {
1072
+ resolve(@{@"success": @NO, @"error": @"No app found to open this file"});
1073
+ }
1074
+ });
1075
+ }
1076
+
1077
+ // UIDocumentInteractionControllerDelegate method
1078
+ - (UIViewController *)documentInteractionControllerViewControllerForPreview:(UIDocumentInteractionController *)controller {
1079
+ UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1080
+ while (rootViewController.presentedViewController) {
1081
+ rootViewController = rootViewController.presentedViewController;
1082
+ }
1083
+ return rootViewController;
1084
+ }
1085
+
1086
+ // ─── Unzip ────────────────────────────────────────────────────────────────────
1087
+
1088
+ RCT_EXPORT_METHOD(unzip:(NSString *)sourcePath
1089
+ destDir:(NSString *)destDir
1090
+ resolve:(RCTPromiseResolveBlock)resolve
1091
+ reject:(RCTPromiseRejectBlock)reject)
1092
+ {
1093
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
1094
+ NSFileManager *fm = [NSFileManager defaultManager];
1095
+ NSURL *sourceURL = [NSURL fileURLWithPath:sourcePath];
1096
+ NSURL *destURL = [NSURL fileURLWithPath:destDir];
1097
+
1098
+ // Ensure destination directory exists
1099
+ NSError *mkdirErr = nil;
1100
+ [fm createDirectoryAtURL:destURL withIntermediateDirectories:YES attributes:nil error:&mkdirErr];
1101
+
1102
+ // Use NSData + manual zip parsing via Foundation's built-in zip support
1103
+ // (available via -[NSFileManager createDirectoryAtPath] + zlib read loop)
1104
+ // We use the C-level zlib (linked via s.libraries = 'z') through minizip-style reading.
1105
+ // Simpler approach: use the Objective-C Archive API available on all iOS versions.
1106
+
1107
+ // Primary path: try SSZipArchive-style pure-C zlib approach
1108
+ // Since we only have zlib (no minizip headers), we'll use NSData + the public
1109
+ // Archive Utility API: ziparchive is not available, but we CAN use:
1110
+ // -[NSFileWrapper] or the Archive framework (iOS 16+).
1111
+ // Most reliable zero-dependency path: pipe through /usr/bin/unzip subprocess.
1112
+ // On iOS that binary doesn't exist. So we use the ZipFoundation-compatible
1113
+ // pure-Foundation approach using NSInputStream with a known zip local-file header parser.
1114
+
1115
+ // ── Pure-Foundation zip reader (no third-party, no subprocess) ──────────
1116
+ NSData *zipData = [NSData dataWithContentsOfFile:sourcePath];
1117
+ if (!zipData) {
1118
+ resolve(@{@"success": @NO, @"error": @"Cannot read zip file"});
1119
+ return;
1120
+ }
1121
+
1122
+ NSMutableArray<NSString *> *extractedFiles = [NSMutableArray new];
1123
+ NSError *extractError = nil;
1124
+ BOOL ok = [self extractZipData:zipData toDirectory:destDir extractedFiles:extractedFiles error:&extractError];
1125
+
1126
+ if (ok) {
1127
+ resolve(@{@"success": @YES, @"destDir": destDir, @"files": extractedFiles});
1128
+ } else {
1129
+ resolve(@{@"success": @NO, @"error": extractError.localizedDescription ?: @"UNZIP_ERROR"});
1130
+ }
1131
+ });
1132
+ }
1133
+
1134
+ /**
1135
+ * Pure-Foundation ZIP extractor.
1136
+ * Parses the ZIP local file headers (signature 0x04034b50) sequentially.
1137
+ * Uses zlib inflate (deflate method) and store (method 0) — the two methods
1138
+ * used by virtually every ZIP file in the wild.
1139
+ * No third-party library required; zlib is a system framework (s.libraries = 'z').
1140
+ */
1141
+ - (BOOL)extractZipData:(NSData *)data
1142
+ toDirectory:(NSString *)destDir
1143
+ extractedFiles:(NSMutableArray<NSString *> *)extractedFiles
1144
+ error:(NSError **)error
1145
+ {
1146
+ const uint8_t *bytes = (const uint8_t *)data.bytes;
1147
+ NSUInteger length = data.length;
1148
+ NSUInteger offset = 0;
1149
+ NSFileManager *fm = [NSFileManager defaultManager];
1150
+
1151
+ while (offset + 30 <= length) {
1152
+ // Local file header signature
1153
+ uint32_t sig = 0;
1154
+ memcpy(&sig, bytes + offset, 4);
1155
+ if (sig != 0x04034b50) break; // no more local headers
1156
+
1157
+ uint16_t method = 0; memcpy(&method, bytes + offset + 8, 2);
1158
+ uint32_t crc32val = 0; memcpy(&crc32val, bytes + offset + 14, 4);
1159
+ uint32_t compSize = 0; memcpy(&compSize, bytes + offset + 18, 4);
1160
+ uint32_t uncompSize = 0; memcpy(&uncompSize, bytes + offset + 22, 4);
1161
+ uint16_t fileNameLen = 0; memcpy(&fileNameLen, bytes + offset + 26, 2);
1162
+ uint16_t extraFieldLen = 0; memcpy(&extraFieldLen, bytes + offset + 28, 2);
1163
+
1164
+ // Little-endian on all platforms
1165
+ method = CFSwapInt16LittleToHost(method);
1166
+ compSize = CFSwapInt32LittleToHost(compSize);
1167
+ uncompSize = CFSwapInt32LittleToHost(uncompSize);
1168
+ fileNameLen = CFSwapInt16LittleToHost(fileNameLen);
1169
+ extraFieldLen = CFSwapInt16LittleToHost(extraFieldLen);
1170
+
1171
+ offset += 30;
1172
+ if (offset + fileNameLen > length) break;
1173
+
1174
+ NSString *fileName = [[NSString alloc] initWithBytes:bytes + offset
1175
+ length:fileNameLen
1176
+ encoding:NSUTF8StringEncoding];
1177
+ if (!fileName) fileName = [[NSString alloc] initWithBytes:bytes + offset
1178
+ length:fileNameLen
1179
+ encoding:NSISOLatin1StringEncoding];
1180
+ offset += fileNameLen + extraFieldLen;
1181
+
1182
+ if (!fileName || offset + compSize > length) {
1183
+ offset += compSize;
1184
+ continue;
1185
+ }
1186
+
1187
+ NSString *destPath = [destDir stringByAppendingPathComponent:fileName];
1188
+
1189
+ // Protect against zip-slip/path traversal (e.g. ../../outside.txt)
1190
+ NSString *standardizedDestDir = [destDir stringByStandardizingPath];
1191
+ NSString *standardizedDestPath = [destPath stringByStandardizingPath];
1192
+ NSString *safePrefix = [standardizedDestDir stringByAppendingString:@"/"];
1193
+ if (![standardizedDestPath isEqualToString:standardizedDestDir] &&
1194
+ ![standardizedDestPath hasPrefix:safePrefix]) {
1195
+ if (error) {
1196
+ *error = [NSError errorWithDomain:@"RNFileToolkit"
1197
+ code:-100
1198
+ userInfo:@{NSLocalizedDescriptionKey: @"ZIP entry has invalid path"}];
1199
+ }
1200
+ return NO;
1201
+ }
1202
+
1203
+ // Directory entry
1204
+ if ([fileName hasSuffix:@"/"]) {
1205
+ [fm createDirectoryAtPath:destPath withIntermediateDirectories:YES attributes:nil error:nil];
1206
+ continue;
1207
+ }
1208
+
1209
+ // Ensure parent directory
1210
+ NSString *parentDir = [destPath stringByDeletingLastPathComponent];
1211
+ [fm createDirectoryAtPath:parentDir withIntermediateDirectories:YES attributes:nil error:nil];
1212
+
1213
+ const uint8_t *compData = bytes + offset;
1214
+
1215
+ if (method == 0) {
1216
+ // Store (no compression)
1217
+ NSData *fileData = [NSData dataWithBytes:compData length:compSize];
1218
+ if (![fileData writeToFile:destPath options:NSDataWritingAtomic error:error]) {
1219
+ return NO;
1220
+ }
1221
+ } else if (method == 8) {
1222
+ // Deflate — use zlib inflate with raw deflate stream
1223
+ NSMutableData *output = [NSMutableData dataWithLength:uncompSize > 0 ? uncompSize : 65536];
1224
+ z_stream strm;
1225
+ memset(&strm, 0, sizeof(strm));
1226
+ strm.next_in = (Bytef *)compData;
1227
+ strm.avail_in = (uInt)compSize;
1228
+
1229
+ // inflateInit2 with -15 for raw deflate (no zlib wrapper)
1230
+ if (inflateInit2(&strm, -15) != Z_OK) {
1231
+ if (error) *error = [NSError errorWithDomain:@"RNFileToolkit" code:-1
1232
+ userInfo:@{NSLocalizedDescriptionKey: @"zlib inflateInit2 failed"}];
1233
+ return NO;
1234
+ }
1235
+
1236
+ if (uncompSize > 0) {
1237
+ [output setLength:uncompSize];
1238
+ strm.next_out = (Bytef *)output.mutableBytes;
1239
+ strm.avail_out = (uInt)uncompSize;
1240
+ int ret = inflate(&strm, Z_FINISH);
1241
+ inflateEnd(&strm);
1242
+ if (ret != Z_STREAM_END && ret != Z_OK) {
1243
+ if (error) *error = [NSError errorWithDomain:@"RNFileToolkit" code:-2
1244
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"inflate error %d", ret]}];
1245
+ return NO;
1246
+ }
1247
+ [output setLength:strm.total_out];
1248
+ } else {
1249
+ // Unknown uncompressed size: inflate in chunks
1250
+ NSMutableData *chunk = [NSMutableData dataWithLength:65536];
1251
+ NSMutableData *result = [NSMutableData new];
1252
+ int ret;
1253
+ do {
1254
+ strm.next_out = (Bytef *)chunk.mutableBytes;
1255
+ strm.avail_out = (uInt)chunk.length;
1256
+ ret = inflate(&strm, Z_NO_FLUSH);
1257
+ if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR) break;
1258
+ NSUInteger produced = chunk.length - strm.avail_out;
1259
+ [result appendBytes:chunk.mutableBytes length:produced];
1260
+ } while (ret != Z_STREAM_END);
1261
+ inflateEnd(&strm);
1262
+ output = result;
1263
+ }
1264
+
1265
+ if (![output writeToFile:destPath options:NSDataWritingAtomic error:error]) {
1266
+ return NO;
1267
+ }
1268
+ } else {
1269
+ // Unsupported compression method — skip
1270
+ offset += compSize;
1271
+ continue;
1272
+ }
1273
+
1274
+ [extractedFiles addObject:destPath];
1275
+ offset += compSize;
1276
+ }
1277
+
1278
+ return YES;
1279
+ }
1280
+
1281
+ // ─── Zip ──────────────────────────────────────────────────────────────────────
1282
+
1283
+ RCT_EXPORT_METHOD(zip:(NSString *)sourcePath
1284
+ destPath:(NSString *)destPath
1285
+ resolve:(RCTPromiseResolveBlock)resolve
1286
+ reject:(RCTPromiseRejectBlock)reject)
1287
+ {
1288
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
1289
+ NSFileManager *fm = [NSFileManager defaultManager];
1290
+ BOOL isDir = NO;
1291
+ if (![fm fileExistsAtPath:sourcePath isDirectory:&isDir]) {
1292
+ resolve(@{@"success": @NO, @"error": @"Source path does not exist"});
1293
+ return;
1294
+ }
1295
+
1296
+ // Ensure destination parent directory exists
1297
+ NSString *destParent = [destPath stringByDeletingLastPathComponent];
1298
+ [fm createDirectoryAtPath:destParent withIntermediateDirectories:YES attributes:nil error:nil];
1299
+
1300
+ // Delete existing destination file
1301
+ [fm removeItemAtPath:destPath error:nil];
1302
+
1303
+ NSMutableData *zipData = [NSMutableData new];
1304
+ NSMutableArray<NSDictionary *> *centralDirectory = [NSMutableArray new];
1305
+
1306
+ NSArray<NSString *> *filesToZip;
1307
+ NSString *baseDir;
1308
+ if (isDir) {
1309
+ NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:sourcePath];
1310
+ NSMutableArray *files = [NSMutableArray new];
1311
+ NSString *file;
1312
+ while ((file = [enumerator nextObject])) {
1313
+ NSString *fullPath = [sourcePath stringByAppendingPathComponent:file];
1314
+ BOOL entryIsDir = NO;
1315
+ [fm fileExistsAtPath:fullPath isDirectory:&entryIsDir];
1316
+ [files addObject:file];
1317
+ }
1318
+ filesToZip = files;
1319
+ baseDir = sourcePath;
1320
+ } else {
1321
+ filesToZip = @[[sourcePath lastPathComponent]];
1322
+ baseDir = [sourcePath stringByDeletingLastPathComponent];
1323
+ }
1324
+
1325
+ for (NSString *relativePath in filesToZip) {
1326
+ NSString *fullPath = [baseDir stringByAppendingPathComponent:relativePath];
1327
+ BOOL entryIsDir = NO;
1328
+ [fm fileExistsAtPath:fullPath isDirectory:&entryIsDir];
1329
+
1330
+ NSData *fileData = entryIsDir ? [NSData data] : [NSData dataWithContentsOfFile:fullPath];
1331
+ if (!fileData) continue;
1332
+
1333
+ NSData *entryNameData = [relativePath dataUsingEncoding:NSUTF8StringEncoding];
1334
+ uint16_t nameLen = (uint16_t)entryNameData.length;
1335
+
1336
+ // Deflate compress (skip compression for directories)
1337
+ NSData *compressedData;
1338
+ uint16_t method;
1339
+ uint32_t crc = 0;
1340
+
1341
+ if (entryIsDir || fileData.length == 0) {
1342
+ compressedData = fileData;
1343
+ method = 0;
1344
+ } else {
1345
+ // zlib deflate (raw, -15)
1346
+ uLongf bound = compressBound((uLong)fileData.length);
1347
+ NSMutableData *comp = [NSMutableData dataWithLength:bound];
1348
+ z_stream strm;
1349
+ memset(&strm, 0, sizeof(strm));
1350
+ strm.next_in = (Bytef *)fileData.bytes;
1351
+ strm.avail_in = (uInt)fileData.length;
1352
+ strm.next_out = (Bytef *)comp.mutableBytes;
1353
+ strm.avail_out = (uInt)bound;
1354
+ deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -15, 8, Z_DEFAULT_STRATEGY);
1355
+ deflate(&strm, Z_FINISH);
1356
+ deflateEnd(&strm);
1357
+ [comp setLength:strm.total_out];
1358
+ compressedData = comp;
1359
+ method = 8;
1360
+ }
1361
+
1362
+ // CRC32
1363
+ crc = (uint32_t)crc32(0L, (const Bytef *)fileData.bytes, (uInt)fileData.length);
1364
+
1365
+ uint32_t localHeaderOffset = (uint32_t)zipData.length;
1366
+
1367
+ // Local file header
1368
+ uint32_t sig = CFSwapInt32HostToLittle(0x04034b50);
1369
+ uint16_t version = CFSwapInt16HostToLittle(20);
1370
+ uint16_t flags = 0;
1371
+ uint16_t meth = CFSwapInt16HostToLittle(method);
1372
+ uint16_t modTime = 0, modDate = 0; // no time for simplicity
1373
+ uint32_t crcLE = CFSwapInt32HostToLittle(crc);
1374
+ uint32_t compSz = CFSwapInt32HostToLittle((uint32_t)compressedData.length);
1375
+ uint32_t uncompSz = CFSwapInt32HostToLittle((uint32_t)fileData.length);
1376
+ uint16_t extraLen = 0;
1377
+
1378
+ [zipData appendBytes:&sig length:4];
1379
+ [zipData appendBytes:&version length:2];
1380
+ [zipData appendBytes:&flags length:2];
1381
+ [zipData appendBytes:&meth length:2];
1382
+ [zipData appendBytes:&modTime length:2];
1383
+ [zipData appendBytes:&modDate length:2];
1384
+ [zipData appendBytes:&crcLE length:4];
1385
+ [zipData appendBytes:&compSz length:4];
1386
+ [zipData appendBytes:&uncompSz length:4];
1387
+ [zipData appendBytes:&nameLen length:2];
1388
+ [zipData appendBytes:&extraLen length:2];
1389
+ [zipData appendData:entryNameData];
1390
+ [zipData appendData:compressedData];
1391
+
1392
+ [centralDirectory addObject:@{
1393
+ @"name": entryNameData,
1394
+ @"method": @(method),
1395
+ @"crc": @(crc),
1396
+ @"compSize": @(compressedData.length),
1397
+ @"uncompSize": @(fileData.length),
1398
+ @"localOffset": @(localHeaderOffset),
1399
+ }];
1400
+ }
1401
+
1402
+ // Central directory
1403
+ uint32_t cdOffset = (uint32_t)zipData.length;
1404
+ for (NSDictionary *entry in centralDirectory) {
1405
+ NSData *nameData = entry[@"name"];
1406
+ uint16_t nameLen = (uint16_t)nameData.length;
1407
+ uint32_t cdSig = CFSwapInt32HostToLittle(0x02014b50);
1408
+ uint16_t verMade = CFSwapInt16HostToLittle(20);
1409
+ uint16_t verNeeded = CFSwapInt16HostToLittle(20);
1410
+ uint16_t flags = 0;
1411
+ uint16_t meth = CFSwapInt16HostToLittle((uint16_t)[entry[@"method"] unsignedShortValue]);
1412
+ uint16_t modTime = 0, modDate = 0;
1413
+ uint32_t crcLE = CFSwapInt32HostToLittle((uint32_t)[entry[@"crc"] unsignedIntValue]);
1414
+ uint32_t compSz = CFSwapInt32HostToLittle((uint32_t)[entry[@"compSize"] unsignedIntValue]);
1415
+ uint32_t uncompSz = CFSwapInt32HostToLittle((uint32_t)[entry[@"uncompSize"] unsignedIntValue]);
1416
+ uint16_t extraLen = 0, commentLen = 0;
1417
+ uint16_t disk = 0;
1418
+ uint16_t intAttr = 0;
1419
+ uint32_t extAttr = 0;
1420
+ uint32_t localOff = CFSwapInt32HostToLittle((uint32_t)[entry[@"localOffset"] unsignedIntValue]);
1421
+
1422
+ [zipData appendBytes:&cdSig length:4];
1423
+ [zipData appendBytes:&verMade length:2];
1424
+ [zipData appendBytes:&verNeeded length:2];
1425
+ [zipData appendBytes:&flags length:2];
1426
+ [zipData appendBytes:&meth length:2];
1427
+ [zipData appendBytes:&modTime length:2];
1428
+ [zipData appendBytes:&modDate length:2];
1429
+ [zipData appendBytes:&crcLE length:4];
1430
+ [zipData appendBytes:&compSz length:4];
1431
+ [zipData appendBytes:&uncompSz length:4];
1432
+ [zipData appendBytes:&nameLen length:2];
1433
+ [zipData appendBytes:&extraLen length:2];
1434
+ [zipData appendBytes:&commentLen length:2];
1435
+ [zipData appendBytes:&disk length:2];
1436
+ [zipData appendBytes:&intAttr length:2];
1437
+ [zipData appendBytes:&extAttr length:4];
1438
+ [zipData appendBytes:&localOff length:4];
1439
+ [zipData appendData:nameData];
1440
+ }
1441
+
1442
+ uint32_t cdSize = CFSwapInt32HostToLittle((uint32_t)(zipData.length - cdOffset));
1443
+ uint32_t cdOffLE = CFSwapInt32HostToLittle(cdOffset);
1444
+ uint16_t numEntries = CFSwapInt16HostToLittle((uint16_t)centralDirectory.count);
1445
+ uint16_t commentLen = 0;
1446
+
1447
+ // End of central directory record
1448
+ uint32_t eocdSig = CFSwapInt32HostToLittle(0x06054b50);
1449
+ uint16_t disk = 0;
1450
+ [zipData appendBytes:&eocdSig length:4];
1451
+ [zipData appendBytes:&disk length:2];
1452
+ [zipData appendBytes:&disk length:2];
1453
+ [zipData appendBytes:&numEntries length:2];
1454
+ [zipData appendBytes:&numEntries length:2];
1455
+ [zipData appendBytes:&cdSize length:4];
1456
+ [zipData appendBytes:&cdOffLE length:4];
1457
+ [zipData appendBytes:&commentLen length:2];
1458
+
1459
+ NSError *writeErr = nil;
1460
+ if ([zipData writeToFile:destPath options:NSDataWritingAtomic error:&writeErr]) {
1461
+ resolve(@{@"success": @YES, @"zipPath": destPath});
1462
+ } else {
1463
+ resolve(@{@"success": @NO, @"error": writeErr.localizedDescription ?: @"ZIP_WRITE_ERROR"});
1464
+ }
1465
+ });
1466
+ }
1467
+
1468
+ @end