rn-file-toolkit 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,9 @@
29
29
  @property (nonatomic, strong) NSMutableDictionary *uploadTaskIdMap; // taskIdentifier → uploadId
30
30
  // Strong ref to prevent ARC deallocation during preview
31
31
  @property (nonatomic, strong) UIDocumentInteractionController *documentController;
32
+ // Serial queue for thread-safe dictionary access
33
+ @property (nonatomic, strong) dispatch_queue_t syncQueue;
34
+ @property (nonatomic, assign) BOOL hasListeners;
32
35
  @end
33
36
 
34
37
  @implementation FileToolkit
@@ -37,6 +40,7 @@ RCT_EXPORT_MODULE()
37
40
 
38
41
  - (instancetype)init {
39
42
  if (self = [super init]) {
43
+ self.syncQueue = dispatch_queue_create("com.filetoolkit.syncQueue", DISPATCH_QUEUE_SERIAL);
40
44
  self.activePromises = [NSMutableDictionary new];
41
45
  self.downloadOptions = [NSMutableDictionary new];
42
46
  self.activeTasks = [NSMutableDictionary new];
@@ -53,8 +57,9 @@ RCT_EXPORT_MODULE()
53
57
  self.fgSession = [NSURLSession sessionWithConfiguration:fgConfig delegate:self delegateQueue:nil];
54
58
 
55
59
  // Background session (survives app suspension)
60
+ NSString *bgId = [NSString stringWithFormat:@"%@.filetoolkit.background", NSBundle.mainBundle.bundleIdentifier];
56
61
  NSURLSessionConfiguration *bgConfig =
57
- [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.filetoolkit.background"];
62
+ [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:bgId];
58
63
  bgConfig.discretionary = NO;
59
64
  bgConfig.sessionSendsLaunchEvents = YES;
60
65
  self.bgSession = [NSURLSession sessionWithConfiguration:bgConfig delegate:self delegateQueue:nil];
@@ -66,6 +71,14 @@ RCT_EXPORT_MODULE()
66
71
  return @[@"onDownloadProgress", @"onDownloadComplete", @"onDownloadError", @"onUploadProgress", @"onDownloadRetry"];
67
72
  }
68
73
 
74
+ - (void)startObserving {
75
+ self.hasListeners = YES;
76
+ }
77
+
78
+ - (void)stopObserving {
79
+ self.hasListeners = NO;
80
+ }
81
+
69
82
  // ─── Helpers ──────────────────────────────────────────────────────────────────
70
83
 
71
84
  - (NSString *)generateDownloadId {
@@ -91,26 +104,63 @@ RCT_EXPORT_MODULE()
91
104
  [[NSFileManager defaultManager] createDirectoryAtURL:dirURL withIntermediateDirectories:YES attributes:nil error:nil];
92
105
  }
93
106
 
107
+ // Use a toolkit-owned subdirectory for cache and documents to avoid
108
+ // conflicts with the app's own files (clearCache only removes this folder)
109
+ if ([destType isEqualToString:@"cache"] || [destType isEqualToString:@"documents"]) {
110
+ dirURL = [dirURL URLByAppendingPathComponent:@"RNFileToolkit"];
111
+ [[NSFileManager defaultManager] createDirectoryAtURL:dirURL withIntermediateDirectories:YES attributes:nil error:nil];
112
+ }
113
+
94
114
  return [dirURL URLByAppendingPathComponent:fileName];
95
115
  }
96
116
 
97
117
  - (NSString *)calculateChecksumForPath:(NSString *)path algorithm:(NSString *)algo {
98
- NSData *data = [NSData dataWithContentsOfFile:path];
99
- if (!data) return nil;
100
-
101
- unsigned char digest[CC_SHA256_DIGEST_LENGTH];
118
+ NSInputStream *inputStream = [NSInputStream inputStreamWithFileAtPath:path];
119
+ if (!inputStream) return nil;
120
+ [inputStream open];
121
+
122
+ const NSUInteger bufferSize = 65536; // 64KB
123
+ uint8_t buffer[bufferSize];
124
+
102
125
  if ([algo isEqualToString:@"MD5"]) {
103
- CC_MD5(data.bytes, (CC_LONG)data.length, digest);
126
+ CC_MD5_CTX ctx;
127
+ CC_MD5_Init(&ctx);
128
+ while ([inputStream hasBytesAvailable]) {
129
+ NSInteger bytesRead = [inputStream read:buffer maxLength:bufferSize];
130
+ if (bytesRead > 0) CC_MD5_Update(&ctx, buffer, (CC_LONG)bytesRead);
131
+ else break;
132
+ }
133
+ [inputStream close];
134
+ unsigned char digest[CC_MD5_DIGEST_LENGTH];
135
+ CC_MD5_Final(digest, &ctx);
104
136
  NSMutableString *output = [NSMutableString stringWithCapacity:CC_MD5_DIGEST_LENGTH * 2];
105
137
  for (int i = 0; i < CC_MD5_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
106
138
  return output;
107
139
  } else if ([algo isEqualToString:@"SHA1"]) {
108
- CC_SHA1(data.bytes, (CC_LONG)data.length, digest);
140
+ CC_SHA1_CTX ctx;
141
+ CC_SHA1_Init(&ctx);
142
+ while ([inputStream hasBytesAvailable]) {
143
+ NSInteger bytesRead = [inputStream read:buffer maxLength:bufferSize];
144
+ if (bytesRead > 0) CC_SHA1_Update(&ctx, buffer, (CC_LONG)bytesRead);
145
+ else break;
146
+ }
147
+ [inputStream close];
148
+ unsigned char digest[CC_SHA1_DIGEST_LENGTH];
149
+ CC_SHA1_Final(digest, &ctx);
109
150
  NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2];
110
151
  for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
111
152
  return output;
112
153
  } else {
113
- CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
154
+ CC_SHA256_CTX ctx;
155
+ CC_SHA256_Init(&ctx);
156
+ while ([inputStream hasBytesAvailable]) {
157
+ NSInteger bytesRead = [inputStream read:buffer maxLength:bufferSize];
158
+ if (bytesRead > 0) CC_SHA256_Update(&ctx, buffer, (CC_LONG)bytesRead);
159
+ else break;
160
+ }
161
+ [inputStream close];
162
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
163
+ CC_SHA256_Final(digest, &ctx);
114
164
  NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
115
165
  for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
116
166
  return output;
@@ -140,6 +190,10 @@ RCT_EXPORT_MODULE()
140
190
  BOOL isBackground = [options[@"background"] boolValue];
141
191
  NSString *downloadId = options[@"downloadId"] ?: [self generateDownloadId];
142
192
  NSURL *url = [NSURL URLWithString:urlString];
193
+ if (!url) {
194
+ resolve(@{@"success": @NO, @"error": @"Invalid URL"});
195
+ return;
196
+ }
143
197
  NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
144
198
 
145
199
  // Add custom headers
@@ -154,16 +208,20 @@ RCT_EXPORT_MODULE()
154
208
  NSURLSessionDownloadTask *task = [session downloadTaskWithRequest:request];
155
209
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
156
210
 
157
- self.taskIdMap[taskKey] = downloadId;
158
- self.activeTasks[downloadId] = task;
159
- self.downloadOptions[downloadId] = options;
211
+ dispatch_sync(self.syncQueue, ^{
212
+ self.taskIdMap[taskKey] = downloadId;
213
+ self.activeTasks[downloadId] = task;
214
+ self.downloadOptions[downloadId] = options;
215
+ });
160
216
  task.taskDescription = downloadId;
161
217
 
162
218
  if (isBackground) {
163
219
  // Resolve immediately with the downloadId — result comes via event
164
220
  resolve(@{@"success": @YES, @"downloadId": downloadId});
165
221
  } else {
166
- self.activePromises[downloadId] = @{@"resolve": resolve, @"reject": reject};
222
+ dispatch_sync(self.syncQueue, ^{
223
+ self.activePromises[downloadId] = @{@"resolve": resolve, @"reject": reject};
224
+ });
167
225
  }
168
226
 
169
227
  [task resume];
@@ -172,17 +230,22 @@ RCT_EXPORT_MODULE()
172
230
  // ─── pauseDownload ────────────────────────────────────────────────────────────
173
231
 
174
232
  - (void)pauseDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
175
- NSURLSessionDownloadTask *task = self.activeTasks[downloadId];
233
+ __block NSURLSessionDownloadTask *task = nil;
234
+ dispatch_sync(self.syncQueue, ^{
235
+ task = self.activeTasks[downloadId];
236
+ });
176
237
  if (!task) {
177
238
  resolve(@{@"success": @NO, @"error": @"Download not found"});
178
239
  return;
179
240
  }
180
241
 
181
242
  [task cancelByProducingResumeData:^(NSData *resumeData) {
182
- if (resumeData) {
183
- self.resumeDataStore[downloadId] = resumeData;
184
- }
185
- [self.activeTasks removeObjectForKey:downloadId];
243
+ dispatch_sync(self.syncQueue, ^{
244
+ if (resumeData) {
245
+ self.resumeDataStore[downloadId] = resumeData;
246
+ }
247
+ [self.activeTasks removeObjectForKey:downloadId];
248
+ });
186
249
  resolve(@{@"success": @YES});
187
250
  }];
188
251
  }
@@ -190,22 +253,28 @@ RCT_EXPORT_MODULE()
190
253
  // ─── resumeDownload ───────────────────────────────────────────────────────────
191
254
 
192
255
  - (void)resumeDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
193
- NSData *resumeData = self.resumeDataStore[downloadId];
256
+ __block NSData *resumeData = nil;
257
+ __block NSDictionary *options = nil;
258
+ dispatch_sync(self.syncQueue, ^{
259
+ resumeData = self.resumeDataStore[downloadId];
260
+ options = self.downloadOptions[downloadId];
261
+ });
194
262
  if (!resumeData) {
195
263
  resolve(@{@"success": @NO, @"error": @"No resume data — download was not paused or was cancelled"});
196
264
  return;
197
265
  }
198
266
 
199
- NSDictionary *options = self.downloadOptions[downloadId];
200
267
  BOOL isBackground = [options[@"background"] boolValue];
201
268
  NSURLSession *session = isBackground ? self.bgSession : self.fgSession;
202
269
 
203
270
  NSURLSessionDownloadTask *task = [session downloadTaskWithResumeData:resumeData];
204
271
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
205
272
 
206
- self.taskIdMap[taskKey] = downloadId;
207
- self.activeTasks[downloadId] = task;
208
- [self.resumeDataStore removeObjectForKey:downloadId];
273
+ dispatch_sync(self.syncQueue, ^{
274
+ self.taskIdMap[taskKey] = downloadId;
275
+ self.activeTasks[downloadId] = task;
276
+ [self.resumeDataStore removeObjectForKey:downloadId];
277
+ });
209
278
 
210
279
  [task resume];
211
280
  resolve(@{@"success": @YES});
@@ -214,15 +283,26 @@ RCT_EXPORT_MODULE()
214
283
  // ─── cancelDownload ───────────────────────────────────────────────────────────
215
284
 
216
285
  - (void)cancelDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
217
- NSURLSessionDownloadTask *task = self.activeTasks[downloadId];
286
+ __block NSURLSessionDownloadTask *task = nil;
287
+ __block NSDictionary *funcs = nil;
288
+ dispatch_sync(self.syncQueue, ^{
289
+ task = self.activeTasks[downloadId];
290
+ funcs = self.activePromises[downloadId];
291
+ if (task) {
292
+ [self.activeTasks removeObjectForKey:downloadId];
293
+ }
294
+ [self.resumeDataStore removeObjectForKey:downloadId];
295
+ [self.activePromises removeObjectForKey:downloadId];
296
+ [self.downloadOptions removeObjectForKey:downloadId];
297
+ [self.retryAttempts removeObjectForKey:downloadId];
298
+ });
299
+ if (funcs) {
300
+ RCTPromiseResolveBlock dlResolve = funcs[@"resolve"];
301
+ if (dlResolve) dlResolve(@{@"success": @NO, @"error": @"Cancelled"});
302
+ }
218
303
  if (task) {
219
304
  [task cancel];
220
- [self.activeTasks removeObjectForKey:downloadId];
221
305
  }
222
- [self.resumeDataStore removeObjectForKey:downloadId];
223
- [self.activePromises removeObjectForKey:downloadId];
224
- [self.downloadOptions removeObjectForKey:downloadId];
225
- [self.retryAttempts removeObjectForKey:downloadId];
226
306
  resolve(@{@"success": @YES});
227
307
  }
228
308
 
@@ -239,10 +319,12 @@ RCT_EXPORT_MODULE()
239
319
  URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
240
320
  downloadsDir = [fallbackDocs URLByAppendingPathComponent:@"Downloads"];
241
321
  }
242
- NSURL *cacheDir = [[NSFileManager defaultManager]
243
- URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject;
244
- NSURL *docsDir = [[NSFileManager defaultManager]
245
- URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
322
+ NSURL *cacheDir = [[[NSFileManager defaultManager]
323
+ URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject
324
+ URLByAppendingPathComponent:@"RNFileToolkit"];
325
+ NSURL *docsDir = [[[NSFileManager defaultManager]
326
+ URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject
327
+ URLByAppendingPathComponent:@"RNFileToolkit"];
246
328
 
247
329
  NSMutableArray<NSURL *> *dirs = [NSMutableArray new];
248
330
  if (downloadsDir) [dirs addObject:downloadsDir];
@@ -292,15 +374,15 @@ RCT_EXPORT_MODULE()
292
374
  // ─── clearCache ───────────────────────────────────────────────────────────────
293
375
 
294
376
  - (void)clearCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
295
- // Only clear app-private directories — never touch Downloads
377
+ // Only clear the toolkit-owned subdirectory — never touch the app's own files or Downloads
296
378
  NSURL *cacheDir = [[NSFileManager defaultManager]
297
379
  URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject;
298
380
  NSURL *docsDir = [[NSFileManager defaultManager]
299
381
  URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
300
382
 
301
383
  NSMutableArray<NSURL *> *dirs = [NSMutableArray new];
302
- if (cacheDir) [dirs addObject:cacheDir];
303
- if (docsDir) [dirs addObject:docsDir];
384
+ if (cacheDir) [dirs addObject:[cacheDir URLByAppendingPathComponent:@"RNFileToolkit"]];
385
+ if (docsDir) [dirs addObject:[docsDir URLByAppendingPathComponent:@"RNFileToolkit"]];
304
386
 
305
387
  for (NSURL *dirURL in dirs) {
306
388
  NSArray<NSURL *> *files = [[NSFileManager defaultManager]
@@ -572,17 +654,24 @@ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
572
654
  if (totalBytesExpectedToWrite > 0) {
573
655
  int progress = (int)((totalBytesWritten * 100) / totalBytesExpectedToWrite);
574
656
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)downloadTask.taskIdentifier];
575
- NSString *downloadId = self.taskIdMap[taskKey] ?: @"";
576
657
  NSString *url = downloadTask.originalRequest.URL.absoluteString ?: @"";
577
658
 
578
- [self sendEventWithName:@"onDownloadProgress"
579
- body:@{
580
- @"url": url,
581
- @"downloadId": downloadId,
582
- @"progress": @(progress),
583
- @"bytesDownloaded": @(totalBytesWritten),
584
- @"totalBytes": @(totalBytesExpectedToWrite)
585
- }];
659
+ __weak typeof(self) weakSelf = self;
660
+ dispatch_async(self.syncQueue, ^{
661
+ __strong typeof(weakSelf) strongSelf = weakSelf;
662
+ if (!strongSelf) return;
663
+ NSString *downloadId = strongSelf.taskIdMap[taskKey];
664
+ if (!downloadId) return;
665
+
666
+ [strongSelf sendEventWithName:@"onDownloadProgress"
667
+ body:@{
668
+ @"url": url,
669
+ @"downloadId": downloadId,
670
+ @"progress": @(progress),
671
+ @"bytesDownloaded": @(totalBytesWritten),
672
+ @"totalBytes": @(totalBytesExpectedToWrite)
673
+ }];
674
+ });
586
675
  }
587
676
  }
588
677
 
@@ -590,10 +679,16 @@ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
590
679
  downloadTask:(NSURLSessionDownloadTask *)downloadTask
591
680
  didFinishDownloadingToURL:(NSURL *)location {
592
681
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)downloadTask.taskIdentifier];
593
- NSString *downloadId = self.taskIdMap[taskKey];
682
+ __block NSString *downloadId = nil;
683
+ __block NSDictionary *options = nil;
684
+ dispatch_sync(self.syncQueue, ^{
685
+ downloadId = self.taskIdMap[taskKey];
686
+ if (downloadId) {
687
+ options = self.downloadOptions[downloadId];
688
+ }
689
+ });
594
690
  if (!downloadId) return;
595
691
 
596
- NSDictionary *options = self.downloadOptions[downloadId];
597
692
  NSString *fileName = [self fileNameFromOptions:options task:downloadTask];
598
693
  NSString *destType = options[@"destination"] ?: @"downloads";
599
694
  NSURL *destURL = [self destURLForFileName:fileName destination:destType];
@@ -630,24 +725,34 @@ didFinishDownloadingToURL:(NSURL *)location {
630
725
  }
631
726
  }
632
727
 
633
- NSDictionary *funcs = self.activePromises[downloadId];
728
+ __block NSDictionary *funcs = nil;
634
729
  BOOL isBackground = [options[@"background"] boolValue];
730
+ dispatch_sync(self.syncQueue, ^{
731
+ funcs = self.activePromises[downloadId];
732
+ });
635
733
 
636
734
  if (funcs && !isBackground) {
637
735
  // Foreground: resolve the promise
638
736
  RCTPromiseResolveBlock resolve = funcs[@"resolve"];
639
737
  resolve(resultDict);
738
+ // Also emit the error event for foreground failures so global listeners fire
739
+ // consistently across platforms (mirrors Android behaviour).
740
+ if (isError) {
741
+ [self sendEventWithName:@"onDownloadError" body:resultDict];
742
+ }
640
743
  } else {
641
- // Background: fire the correct event based on isError flag (Bug #1 fix)
744
+ // Background: fire the correct event based on isError flag
642
745
  NSString *event = isError ? @"onDownloadError" : @"onDownloadComplete";
643
746
  [self sendEventWithName:event body:resultDict];
644
747
  }
645
748
 
646
- [self.activePromises removeObjectForKey:downloadId];
647
- [self.downloadOptions removeObjectForKey:downloadId];
648
- [self.activeTasks removeObjectForKey:downloadId];
649
- [self.taskIdMap removeObjectForKey:taskKey];
650
- [self.retryAttempts removeObjectForKey:downloadId];
749
+ dispatch_sync(self.syncQueue, ^{
750
+ [self.activePromises removeObjectForKey:downloadId];
751
+ [self.downloadOptions removeObjectForKey:downloadId];
752
+ [self.activeTasks removeObjectForKey:downloadId];
753
+ [self.taskIdMap removeObjectForKey:taskKey];
754
+ [self.retryAttempts removeObjectForKey:downloadId];
755
+ });
651
756
  }
652
757
 
653
758
  - (void)URLSession:(NSURLSession *)session
@@ -655,12 +760,20 @@ didFinishDownloadingToURL:(NSURL *)location {
655
760
  didCompleteWithError:(NSError *)error {
656
761
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
657
762
 
658
- // ── Upload task completion ─────────────────────────────────────────────
659
- NSString *uploadId = self.uploadTaskIdMap[taskKey];
763
+ // ── Upload task completion ─────────────────────────────────────────────────
764
+ __block NSString *uploadId = nil;
765
+ __block NSDictionary *uploadFuncs = nil;
766
+ __block NSData *uploadRespData = nil;
767
+ dispatch_sync(self.syncQueue, ^{
768
+ uploadId = self.uploadTaskIdMap[taskKey];
769
+ if (uploadId) {
770
+ uploadFuncs = self.uploadPromises[uploadId];
771
+ uploadRespData = self.uploadResponseData[uploadId];
772
+ }
773
+ });
660
774
  if (uploadId) {
661
- NSDictionary *funcs = self.uploadPromises[uploadId];
662
- RCTPromiseResolveBlock uploadResolve = funcs[@"resolve"];
663
- NSString *tempFile = funcs[@"tempFile"];
775
+ RCTPromiseResolveBlock uploadResolve = uploadFuncs[@"resolve"];
776
+ NSString *tempFile = uploadFuncs[@"tempFile"];
664
777
 
665
778
  if (tempFile) {
666
779
  [[NSFileManager defaultManager] removeItemAtPath:tempFile error:nil];
@@ -670,7 +783,7 @@ didCompleteWithError:(NSError *)error {
670
783
  if (uploadResolve) uploadResolve(@{@"success": @NO, @"error": error.localizedDescription, @"uploadId": uploadId});
671
784
  } else {
672
785
  NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response;
673
- NSData *responseData = self.uploadResponseData[uploadId] ?: [NSData data];
786
+ NSData *responseData = uploadRespData ?: [NSData data];
674
787
  NSString *respString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] ?: @"";
675
788
 
676
789
  if (uploadResolve) uploadResolve(@{
@@ -681,45 +794,66 @@ didCompleteWithError:(NSError *)error {
681
794
  });
682
795
  }
683
796
 
684
- [self.uploadPromises removeObjectForKey:uploadId];
685
- [self.uploadUrls removeObjectForKey:uploadId];
686
- [self.uploadResponseData removeObjectForKey:uploadId];
687
- [self.uploadTaskIdMap removeObjectForKey:taskKey];
688
- return;
797
+ dispatch_sync(self.syncQueue, ^{
798
+ // Guard against double-resolution in upload
799
+ if (self.uploadPromises[uploadId]) {
800
+ [self.uploadPromises removeObjectForKey:uploadId];
801
+ [self.uploadUrls removeObjectForKey:uploadId];
802
+ [self.uploadResponseData removeObjectForKey:uploadId];
803
+ [self.uploadTaskIdMap removeObjectForKey:taskKey];
804
+ } else {
805
+ uploadId = nil; // Already resolved
806
+ }
807
+ });
808
+ if (!uploadId) return;
689
809
  }
690
810
 
691
811
  // ── Download task error handling ───────────────────────────────────────
692
812
  if (!error) return;
693
- // Ignore cancellation
694
- if (error.code == NSURLErrorCancelled) return;
813
+ // Ignore cancellation — but still clean up taskIdMap to prevent memory leak
814
+ if (error.code == NSURLErrorCancelled) {
815
+ dispatch_sync(self.syncQueue, ^{
816
+ [self.taskIdMap removeObjectForKey:taskKey];
817
+ });
818
+ return;
819
+ }
695
820
 
696
- NSString *downloadId = self.taskIdMap[taskKey];
821
+ __block NSString *downloadId = nil;
822
+ __block NSDictionary *options = nil;
823
+ __block NSInteger currentAttempt = 0;
824
+ dispatch_sync(self.syncQueue, ^{
825
+ downloadId = self.taskIdMap[taskKey];
826
+ if (downloadId) {
827
+ options = self.downloadOptions[downloadId];
828
+ currentAttempt = [self.retryAttempts[downloadId] integerValue];
829
+ }
830
+ });
697
831
  if (!downloadId) return;
698
832
 
699
- NSDictionary *options = self.downloadOptions[downloadId];
700
833
  BOOL isBackground = [options[@"background"] boolValue];
701
834
 
702
- // ── Retry logic ────────────────────────────────────────────────────────
835
+ // ── Retry logic ────────────────────────────────────────────────────
703
836
  NSDictionary *retryConfig = options[@"retry"];
704
837
  NSInteger maxAttempts = retryConfig ? [retryConfig[@"attempts"] integerValue] : 0;
705
838
  NSInteger baseDelay = retryConfig ? ([retryConfig[@"delay"] integerValue] ?: 1000) : 1000;
706
- NSInteger currentAttempt = [self.retryAttempts[downloadId] integerValue];
707
839
 
708
840
  // Remove old task mapping — will be replaced on retry
709
- [self.taskIdMap removeObjectForKey:taskKey];
710
- [self.activeTasks removeObjectForKey:downloadId];
841
+ dispatch_sync(self.syncQueue, ^{
842
+ [self.taskIdMap removeObjectForKey:taskKey];
843
+ [self.activeTasks removeObjectForKey:downloadId];
844
+ });
711
845
 
712
846
  if (currentAttempt < maxAttempts) {
713
847
  // Schedule a retry
714
848
  NSInteger nextAttempt = currentAttempt + 1;
715
- self.retryAttempts[downloadId] = @(nextAttempt);
849
+ dispatch_sync(self.syncQueue, ^{
850
+ self.retryAttempts[downloadId] = @(nextAttempt);
851
+ });
716
852
 
717
- // Bug #4 fix: avoid integer overflow — cap shift operand, then apply MIN
718
853
  NSInteger shiftBits = currentAttempt < 15 ? currentAttempt : 15;
719
854
  NSInteger delayMs = MIN(baseDelay * (1 << shiftBits), (NSInteger)30000);
720
855
 
721
856
  // Emit retry event so JS onRetry callback is called
722
- // Bug #3 fix: include the error description that triggered this retry
723
857
  [self sendEventWithName:@"onDownloadRetry" body:@{
724
858
  @"downloadId": downloadId,
725
859
  @"url": options[@"url"] ?: @"",
@@ -727,16 +861,14 @@ didCompleteWithError:(NSError *)error {
727
861
  @"error": error.localizedDescription ?: @""
728
862
  }];
729
863
 
730
- // Bug #2 fix: use __weak self to avoid retain cycle and crash on dealloc during delay
731
864
  __weak typeof(self) weakSelf = self;
732
865
  dispatch_after(
733
866
  dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayMs * NSEC_PER_MSEC)),
734
867
  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
735
868
  ^{
736
869
  __strong typeof(weakSelf) strongSelf = weakSelf;
737
- if (!strongSelf) return; // module deallocated during delay — bail safely
870
+ if (!strongSelf) return;
738
871
 
739
- // Recreate task with same options & downloadId
740
872
  NSString *urlString = options[@"url"];
741
873
  if (!urlString) return;
742
874
  NSURL *url = [NSURL URLWithString:urlString];
@@ -752,8 +884,10 @@ didCompleteWithError:(NSError *)error {
752
884
  NSURLSessionDownloadTask *newTask = [sess downloadTaskWithRequest:request];
753
885
  NSString *newTaskKey = [NSString stringWithFormat:@"%lu",
754
886
  (unsigned long)newTask.taskIdentifier];
755
- strongSelf.taskIdMap[newTaskKey] = downloadId;
756
- strongSelf.activeTasks[downloadId] = newTask;
887
+ dispatch_sync(strongSelf.syncQueue, ^{
888
+ strongSelf.taskIdMap[newTaskKey] = downloadId;
889
+ strongSelf.activeTasks[downloadId] = newTask;
890
+ });
757
891
  newTask.taskDescription = downloadId;
758
892
  [newTask resume];
759
893
  }
@@ -761,21 +895,25 @@ didCompleteWithError:(NSError *)error {
761
895
  return; // Don't resolve promise yet — retry is in flight
762
896
  }
763
897
 
764
- // ── No more retries — normal error path ────────────────────────────────
765
- [self.retryAttempts removeObjectForKey:downloadId];
766
-
898
+ // ── No more retries — normal error path ──────────────────────────────
767
899
  NSDictionary *errDict = @{@"success": @NO, @"downloadId": downloadId, @"error": error.localizedDescription};
768
900
 
769
- NSDictionary *funcs = self.activePromises[downloadId];
901
+ __block NSDictionary *funcs = nil;
902
+ dispatch_sync(self.syncQueue, ^{
903
+ [self.retryAttempts removeObjectForKey:downloadId];
904
+ funcs = self.activePromises[downloadId];
905
+ });
906
+ // Always emit the event so global onDownloadError listeners fire on both platforms.
907
+ [self sendEventWithName:@"onDownloadError" body:errDict];
770
908
  if (funcs && !isBackground) {
771
909
  RCTPromiseResolveBlock resolve = funcs[@"resolve"];
772
910
  resolve(errDict);
773
- } else {
774
- [self sendEventWithName:@"onDownloadError" body:errDict];
775
911
  }
776
912
 
777
- [self.activePromises removeObjectForKey:downloadId];
778
- [self.downloadOptions removeObjectForKey:downloadId];
913
+ dispatch_sync(self.syncQueue, ^{
914
+ [self.activePromises removeObjectForKey:downloadId];
915
+ [self.downloadOptions removeObjectForKey:downloadId];
916
+ });
779
917
  }
780
918
 
781
919
  // ─── Upload progress delegate ────────────────────────────────────────────────
@@ -785,15 +923,22 @@ didCompleteWithError:(NSError *)error {
785
923
  didSendBodyData:(int64_t)bytesSent
786
924
  totalBytesSent:(int64_t)totalBytesSent
787
925
  totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
788
- NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
789
- NSString *uploadId = self.uploadTaskIdMap[taskKey];
790
- if (!uploadId) return;
791
-
792
- NSString *url = self.uploadUrls[uploadId] ?: @"";
793
926
  if (totalBytesExpectedToSend > 0) {
927
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
794
928
  int progress = (int)((totalBytesSent * 100) / totalBytesExpectedToSend);
795
- [self sendEventWithName:@"onUploadProgress"
796
- body:@{@"url": url, @"uploadId": uploadId, @"progress": @(progress)}];
929
+
930
+ __weak typeof(self) weakSelf = self;
931
+ dispatch_async(self.syncQueue, ^{
932
+ __strong typeof(weakSelf) strongSelf = weakSelf;
933
+ if (!strongSelf) return;
934
+
935
+ NSString *uploadId = strongSelf.uploadTaskIdMap[taskKey];
936
+ if (uploadId) {
937
+ NSString *url = strongSelf.uploadUrls[uploadId] ?: @"";
938
+ [strongSelf sendEventWithName:@"onUploadProgress"
939
+ body:@{@"url": url, @"uploadId": uploadId, @"progress": @(progress)}];
940
+ }
941
+ });
797
942
  }
798
943
  }
799
944
 
@@ -803,15 +948,20 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
803
948
  dataTask:(NSURLSessionDataTask *)dataTask
804
949
  didReceiveData:(NSData *)data {
805
950
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)dataTask.taskIdentifier];
806
- NSString *uploadId = self.uploadTaskIdMap[taskKey];
951
+ __block NSString *uploadId = nil;
952
+ dispatch_sync(self.syncQueue, ^{
953
+ uploadId = self.uploadTaskIdMap[taskKey];
954
+ });
807
955
  if (!uploadId) return;
808
956
 
809
- NSMutableData *responseData = self.uploadResponseData[uploadId];
810
- if (!responseData) {
811
- responseData = [NSMutableData new];
812
- self.uploadResponseData[uploadId] = responseData;
813
- }
814
- [responseData appendData:data];
957
+ dispatch_sync(self.syncQueue, ^{
958
+ NSMutableData *responseData = self.uploadResponseData[uploadId];
959
+ if (!responseData) {
960
+ responseData = [NSMutableData new];
961
+ self.uploadResponseData[uploadId] = responseData;
962
+ }
963
+ [responseData appendData:data];
964
+ });
815
965
  }
816
966
 
817
967
  // ─── TurboModule ──────────────────────────────────────────────────────────────
@@ -904,23 +1054,24 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
904
1054
  NSURLSessionUploadTask *task = [self.fgSession uploadTaskWithRequest:request fromFile:tempFileURL];
905
1055
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
906
1056
 
907
- self.uploadTaskIdMap[taskKey] = uploadId;
908
- self.uploadPromises[uploadId] = @{
909
- @"resolve": resolve,
910
- @"reject": reject,
911
- @"tempFile": tempFilePath
912
- };
913
- self.uploadUrls[uploadId] = urlString;
1057
+ dispatch_sync(self.syncQueue, ^{
1058
+ self.uploadTaskIdMap[taskKey] = uploadId;
1059
+ self.uploadPromises[uploadId] = @{
1060
+ @"resolve": resolve,
1061
+ @"reject": reject,
1062
+ @"tempFile": tempFilePath
1063
+ };
1064
+ self.uploadUrls[uploadId] = urlString;
1065
+ });
914
1066
 
915
1067
  [task resume];
916
1068
  }
917
1069
 
918
1070
  // ─── saveBase64AsFile ─────────────────────────────────────────────────────────
919
1071
 
920
- RCT_REMAP_METHOD(saveBase64AsFile,
921
- base64Options:(NSDictionary *)options
922
- saveResolver:(RCTPromiseResolveBlock)resolve
923
- saveRejecter:(RCTPromiseRejectBlock)reject)
1072
+ RCT_EXPORT_METHOD(saveBase64AsFile:(NSDictionary *)options
1073
+ resolve:(RCTPromiseResolveBlock)resolve
1074
+ reject:(RCTPromiseRejectBlock)reject)
924
1075
  {
925
1076
  NSString *base64String = options[@"base64Data"];
926
1077
  if (!base64String || base64String.length == 0) {
@@ -959,10 +1110,9 @@ RCT_REMAP_METHOD(saveBase64AsFile,
959
1110
 
960
1111
  // ─── urlToBase64 ──────────────────────────────────────────────────────────────
961
1112
 
962
- RCT_REMAP_METHOD(urlToBase64,
963
- urlOptions:(NSDictionary *)options
964
- urlResolver:(RCTPromiseResolveBlock)resolve
965
- urlRejecter:(RCTPromiseRejectBlock)reject)
1113
+ RCT_EXPORT_METHOD(urlToBase64:(NSDictionary *)options
1114
+ resolve:(RCTPromiseResolveBlock)resolve
1115
+ reject:(RCTPromiseRejectBlock)reject)
966
1116
  {
967
1117
  NSString *urlString = options[@"url"];
968
1118
  if (!urlString || urlString.length == 0) {
@@ -987,7 +1137,10 @@ RCT_REMAP_METHOD(urlToBase64,
987
1137
  }
988
1138
  }
989
1139
 
990
- NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
1140
+ // Create ephemeral session instead of using sharedSession
1141
+ NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
1142
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
1143
+ NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
991
1144
  if (error) {
992
1145
  resolve(@{@"success": @NO, @"error": error.localizedDescription});
993
1146
  return;
@@ -1022,13 +1175,35 @@ RCT_REMAP_METHOD(urlToBase64,
1022
1175
  [task resume];
1023
1176
  }
1024
1177
 
1178
+ // ─── topMostViewController (works with both AppDelegate-window and SceneDelegate) ─────
1179
+
1180
+ - (UIViewController *)topMostViewController {
1181
+ UIWindow *window = nil;
1182
+ if (@available(iOS 13.0, *)) {
1183
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
1184
+ if (scene.activationState == UISceneActivationStateForegroundActive &&
1185
+ [scene isKindOfClass:[UIWindowScene class]]) {
1186
+ window = ((UIWindowScene *)scene).windows.firstObject;
1187
+ break;
1188
+ }
1189
+ }
1190
+ }
1191
+ if (!window) {
1192
+ window = [UIApplication sharedApplication].delegate.window;
1193
+ }
1194
+ UIViewController *rootVC = window.rootViewController;
1195
+ while (rootVC.presentedViewController) {
1196
+ rootVC = rootVC.presentedViewController;
1197
+ }
1198
+ return rootVC;
1199
+ }
1200
+
1025
1201
  // ─── shareFile ────────────────────────────────────────────────────────────────
1026
1202
 
1027
- RCT_REMAP_METHOD(shareFile,
1028
- shareFilePath:(NSString *)filePath
1029
- shareOptions:(NSDictionary *)options
1030
- shareResolver:(RCTPromiseResolveBlock)resolve
1031
- shareRejecter:(RCTPromiseRejectBlock)reject)
1203
+ RCT_EXPORT_METHOD(shareFile:(NSString *)filePath
1204
+ options:(NSDictionary *)options
1205
+ resolve:(RCTPromiseResolveBlock)resolve
1206
+ reject:(RCTPromiseRejectBlock)reject)
1032
1207
  {
1033
1208
  if (!filePath || filePath.length == 0) {
1034
1209
  resolve(@{@"success": @NO, @"error": @"File path is required"});
@@ -1043,11 +1218,10 @@ RCT_REMAP_METHOD(shareFile,
1043
1218
  NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1044
1219
 
1045
1220
  dispatch_async(dispatch_get_main_queue(), ^{
1046
- UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1047
-
1048
- // Find the topmost view controller
1049
- while (rootViewController.presentedViewController) {
1050
- rootViewController = rootViewController.presentedViewController;
1221
+ UIViewController *rootViewController = [self topMostViewController];
1222
+ if (!rootViewController) {
1223
+ resolve(@{@"success": @NO, @"error": @"No visible view controller found"});
1224
+ return;
1051
1225
  }
1052
1226
 
1053
1227
  NSArray *itemsToShare = @[fileURL];
@@ -1076,11 +1250,10 @@ RCT_REMAP_METHOD(shareFile,
1076
1250
 
1077
1251
  // ─── openFile ─────────────────────────────────────────────────────────────────
1078
1252
 
1079
- RCT_REMAP_METHOD(openFile,
1080
- openFilePath:(NSString *)filePath
1081
- mimeType:(NSString *)mimeType
1082
- openResolver:(RCTPromiseResolveBlock)resolve
1083
- openRejecter:(RCTPromiseRejectBlock)reject)
1253
+ RCT_EXPORT_METHOD(openFile:(NSString *)filePath
1254
+ mimeType:(NSString *)mimeType
1255
+ resolve:(RCTPromiseResolveBlock)resolve
1256
+ reject:(RCTPromiseRejectBlock)reject)
1084
1257
  {
1085
1258
  if (!filePath || filePath.length == 0) {
1086
1259
  resolve(@{@"success": @NO, @"error": @"File path is required"});
@@ -1095,11 +1268,10 @@ RCT_REMAP_METHOD(openFile,
1095
1268
  NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1096
1269
 
1097
1270
  dispatch_async(dispatch_get_main_queue(), ^{
1098
- UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1099
-
1100
- // Find the topmost view controller
1101
- while (rootViewController.presentedViewController) {
1102
- rootViewController = rootViewController.presentedViewController;
1271
+ UIViewController *rootViewController = [self topMostViewController];
1272
+ if (!rootViewController) {
1273
+ resolve(@{@"success": @NO, @"error": @"No visible view controller found"});
1274
+ return;
1103
1275
  }
1104
1276
 
1105
1277
  // Use UIDocumentInteractionController for opening files
@@ -1127,11 +1299,7 @@ RCT_REMAP_METHOD(openFile,
1127
1299
 
1128
1300
  // UIDocumentInteractionControllerDelegate method
1129
1301
  - (UIViewController *)documentInteractionControllerViewControllerForPreview:(UIDocumentInteractionController *)controller {
1130
- UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1131
- while (rootViewController.presentedViewController) {
1132
- rootViewController = rootViewController.presentedViewController;
1133
- }
1134
- return rootViewController;
1302
+ return [self topMostViewController];
1135
1303
  }
1136
1304
 
1137
1305
  // ─── Unzip ────────────────────────────────────────────────────────────────────
@@ -1267,12 +1435,31 @@ RCT_EXPORT_METHOD(unzip:(NSString *)sourcePath
1267
1435
  if (method == 0) {
1268
1436
  // Store (no compression)
1269
1437
  NSData *fileData = [NSData dataWithBytes:compData length:compSize];
1438
+ // Verify CRC32 before writing
1439
+ uint32_t expectedCRC = CFSwapInt32LittleToHost(crc32val);
1440
+ uLong actualCRC = crc32(0L, (const Bytef *)fileData.bytes, (uInt)fileData.length);
1441
+ if ((uint32_t)actualCRC != expectedCRC) {
1442
+ if (error) *error = [NSError errorWithDomain:@"RNFileToolkit" code:-3
1443
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"CRC32 mismatch for entry: %@", fileName]}];
1444
+ return NO;
1445
+ }
1270
1446
  if (![fileData writeToFile:destPath options:NSDataWritingAtomic error:error]) {
1271
1447
  return NO;
1272
1448
  }
1273
1449
  } else if (method == 8) {
1274
1450
  // Deflate — use zlib inflate with raw deflate stream
1275
- NSMutableData *output = [NSMutableData dataWithLength:uncompSize > 0 ? uncompSize : 65536];
1451
+ // Guard against maliciously crafted entries claiming huge uncompressed sizes.
1452
+ // Cap at 512 MB — large enough for legitimate files, small enough to be safe.
1453
+ const uint32_t kMaxUncompSize = 512u * 1024u * 1024u;
1454
+ if (uncompSize > kMaxUncompSize) {
1455
+ if (error) {
1456
+ *error = [NSError errorWithDomain:@"RNFileToolkit" code:-4
1457
+ userInfo:@{NSLocalizedDescriptionKey:
1458
+ [NSString stringWithFormat:@"ZIP entry uncompressed size (%u bytes) exceeds limit", uncompSize]}];
1459
+ }
1460
+ return NO;
1461
+ }
1462
+ NSMutableData *output = [NSMutableData dataWithLength:uncompSize > 0 ? uncompSize : 65536];
1276
1463
  z_stream strm;
1277
1464
  memset(&strm, 0, sizeof(strm));
1278
1465
  strm.next_in = (Bytef *)compData;
@@ -1314,6 +1501,14 @@ RCT_EXPORT_METHOD(unzip:(NSString *)sourcePath
1314
1501
  output = result;
1315
1502
  }
1316
1503
 
1504
+ // Verify CRC32 of decompressed data before writing
1505
+ uint32_t expectedCRC = CFSwapInt32LittleToHost(crc32val);
1506
+ uLong actualCRC = crc32(0L, (const Bytef *)output.bytes, (uInt)output.length);
1507
+ if ((uint32_t)actualCRC != expectedCRC) {
1508
+ if (error) *error = [NSError errorWithDomain:@"RNFileToolkit" code:-3
1509
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"CRC32 mismatch for entry: %@", fileName]}];
1510
+ return NO;
1511
+ }
1317
1512
  if (![output writeToFile:destPath options:NSDataWritingAtomic error:error]) {
1318
1513
  return NO;
1319
1514
  }
@@ -1737,10 +1932,9 @@ RCT_EXPORT_METHOD(zip:(NSString *)sourcePath
1737
1932
 
1738
1933
  // ─── saveToMediaStore ─────────────────────────────────────────────────────
1739
1934
 
1740
- RCT_REMAP_METHOD(saveToMediaStore,
1741
- mediaStoreOptions:(NSDictionary *)options
1742
- mediaStoreResolver:(RCTPromiseResolveBlock)resolve
1743
- mediaStoreRejecter:(RCTPromiseRejectBlock)reject)
1935
+ RCT_EXPORT_METHOD(saveToMediaStore:(NSDictionary *)options
1936
+ resolve:(RCTPromiseResolveBlock)resolve
1937
+ reject:(RCTPromiseRejectBlock)reject)
1744
1938
  {
1745
1939
  NSString *filePath = options[@"filePath"];
1746
1940
  if (!filePath || filePath.length == 0) {
@@ -1757,21 +1951,27 @@ RCT_REMAP_METHOD(saveToMediaStore,
1757
1951
 
1758
1952
  if ([mediaType isEqualToString:@"image"] || [mediaType isEqualToString:@"video"]) {
1759
1953
  // Use Photos framework for images and videos
1760
- PHPhotoLibrary *photoLibrary = [PHPhotoLibrary sharedPhotoLibrary];
1761
-
1762
- [photoLibrary performChanges:^{
1763
- NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1764
- if ([mediaType isEqualToString:@"image"]) {
1765
- [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:fileURL];
1766
- } else {
1767
- [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
1768
- }
1769
- } completionHandler:^(BOOL success, NSError *error) {
1770
- if (success) {
1771
- resolve(@{@"success": @YES, @"uri": filePath});
1772
- } else {
1773
- resolve(@{@"success": @NO, @"error": error.localizedDescription ?: @"Failed to save to Photos"});
1954
+ [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
1955
+ if (status != PHAuthorizationStatusAuthorized && status != PHAuthorizationStatusLimited) {
1956
+ resolve(@{@"success": @NO, @"error": @"Photo library access denied"});
1957
+ return;
1774
1958
  }
1959
+
1960
+ PHPhotoLibrary *photoLibrary = [PHPhotoLibrary sharedPhotoLibrary];
1961
+ [photoLibrary performChanges:^{
1962
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1963
+ if ([mediaType isEqualToString:@"image"]) {
1964
+ [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:fileURL];
1965
+ } else {
1966
+ [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
1967
+ }
1968
+ } completionHandler:^(BOOL success, NSError *error) {
1969
+ if (success) {
1970
+ resolve(@{@"success": @YES, @"uri": filePath});
1971
+ } else {
1972
+ resolve(@{@"success": @NO, @"error": error.localizedDescription ?: @"Failed to save to Photos"});
1973
+ }
1974
+ }];
1775
1975
  }];
1776
1976
  } else {
1777
1977
  // For audio/download types, copy to Documents directory (iOS has no shared media store for these)