rn-file-toolkit 1.0.10 → 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 {
@@ -102,22 +115,52 @@ RCT_EXPORT_MODULE()
102
115
  }
103
116
 
104
117
  - (NSString *)calculateChecksumForPath:(NSString *)path algorithm:(NSString *)algo {
105
- NSData *data = [NSData dataWithContentsOfFile:path];
106
- if (!data) return nil;
107
-
108
- 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
+
109
125
  if ([algo isEqualToString:@"MD5"]) {
110
- 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);
111
136
  NSMutableString *output = [NSMutableString stringWithCapacity:CC_MD5_DIGEST_LENGTH * 2];
112
137
  for (int i = 0; i < CC_MD5_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
113
138
  return output;
114
139
  } else if ([algo isEqualToString:@"SHA1"]) {
115
- 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);
116
150
  NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2];
117
151
  for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
118
152
  return output;
119
153
  } else {
120
- 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);
121
164
  NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
122
165
  for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [output appendFormat:@"%02x", digest[i]];
123
166
  return output;
@@ -147,6 +190,10 @@ RCT_EXPORT_MODULE()
147
190
  BOOL isBackground = [options[@"background"] boolValue];
148
191
  NSString *downloadId = options[@"downloadId"] ?: [self generateDownloadId];
149
192
  NSURL *url = [NSURL URLWithString:urlString];
193
+ if (!url) {
194
+ resolve(@{@"success": @NO, @"error": @"Invalid URL"});
195
+ return;
196
+ }
150
197
  NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
151
198
 
152
199
  // Add custom headers
@@ -161,16 +208,20 @@ RCT_EXPORT_MODULE()
161
208
  NSURLSessionDownloadTask *task = [session downloadTaskWithRequest:request];
162
209
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
163
210
 
164
- self.taskIdMap[taskKey] = downloadId;
165
- self.activeTasks[downloadId] = task;
166
- 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
+ });
167
216
  task.taskDescription = downloadId;
168
217
 
169
218
  if (isBackground) {
170
219
  // Resolve immediately with the downloadId — result comes via event
171
220
  resolve(@{@"success": @YES, @"downloadId": downloadId});
172
221
  } else {
173
- self.activePromises[downloadId] = @{@"resolve": resolve, @"reject": reject};
222
+ dispatch_sync(self.syncQueue, ^{
223
+ self.activePromises[downloadId] = @{@"resolve": resolve, @"reject": reject};
224
+ });
174
225
  }
175
226
 
176
227
  [task resume];
@@ -179,17 +230,22 @@ RCT_EXPORT_MODULE()
179
230
  // ─── pauseDownload ────────────────────────────────────────────────────────────
180
231
 
181
232
  - (void)pauseDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
182
- NSURLSessionDownloadTask *task = self.activeTasks[downloadId];
233
+ __block NSURLSessionDownloadTask *task = nil;
234
+ dispatch_sync(self.syncQueue, ^{
235
+ task = self.activeTasks[downloadId];
236
+ });
183
237
  if (!task) {
184
238
  resolve(@{@"success": @NO, @"error": @"Download not found"});
185
239
  return;
186
240
  }
187
241
 
188
242
  [task cancelByProducingResumeData:^(NSData *resumeData) {
189
- if (resumeData) {
190
- self.resumeDataStore[downloadId] = resumeData;
191
- }
192
- [self.activeTasks removeObjectForKey:downloadId];
243
+ dispatch_sync(self.syncQueue, ^{
244
+ if (resumeData) {
245
+ self.resumeDataStore[downloadId] = resumeData;
246
+ }
247
+ [self.activeTasks removeObjectForKey:downloadId];
248
+ });
193
249
  resolve(@{@"success": @YES});
194
250
  }];
195
251
  }
@@ -197,22 +253,28 @@ RCT_EXPORT_MODULE()
197
253
  // ─── resumeDownload ───────────────────────────────────────────────────────────
198
254
 
199
255
  - (void)resumeDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
200
- 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
+ });
201
262
  if (!resumeData) {
202
263
  resolve(@{@"success": @NO, @"error": @"No resume data — download was not paused or was cancelled"});
203
264
  return;
204
265
  }
205
266
 
206
- NSDictionary *options = self.downloadOptions[downloadId];
207
267
  BOOL isBackground = [options[@"background"] boolValue];
208
268
  NSURLSession *session = isBackground ? self.bgSession : self.fgSession;
209
269
 
210
270
  NSURLSessionDownloadTask *task = [session downloadTaskWithResumeData:resumeData];
211
271
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
212
272
 
213
- self.taskIdMap[taskKey] = downloadId;
214
- self.activeTasks[downloadId] = task;
215
- [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
+ });
216
278
 
217
279
  [task resume];
218
280
  resolve(@{@"success": @YES});
@@ -221,15 +283,26 @@ RCT_EXPORT_MODULE()
221
283
  // ─── cancelDownload ───────────────────────────────────────────────────────────
222
284
 
223
285
  - (void)cancelDownload:(NSString *)downloadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
224
- 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
+ }
225
303
  if (task) {
226
304
  [task cancel];
227
- [self.activeTasks removeObjectForKey:downloadId];
228
305
  }
229
- [self.resumeDataStore removeObjectForKey:downloadId];
230
- [self.activePromises removeObjectForKey:downloadId];
231
- [self.downloadOptions removeObjectForKey:downloadId];
232
- [self.retryAttempts removeObjectForKey:downloadId];
233
306
  resolve(@{@"success": @YES});
234
307
  }
235
308
 
@@ -581,17 +654,24 @@ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
581
654
  if (totalBytesExpectedToWrite > 0) {
582
655
  int progress = (int)((totalBytesWritten * 100) / totalBytesExpectedToWrite);
583
656
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)downloadTask.taskIdentifier];
584
- NSString *downloadId = self.taskIdMap[taskKey] ?: @"";
585
657
  NSString *url = downloadTask.originalRequest.URL.absoluteString ?: @"";
586
658
 
587
- [self sendEventWithName:@"onDownloadProgress"
588
- body:@{
589
- @"url": url,
590
- @"downloadId": downloadId,
591
- @"progress": @(progress),
592
- @"bytesDownloaded": @(totalBytesWritten),
593
- @"totalBytes": @(totalBytesExpectedToWrite)
594
- }];
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
+ });
595
675
  }
596
676
  }
597
677
 
@@ -599,10 +679,16 @@ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
599
679
  downloadTask:(NSURLSessionDownloadTask *)downloadTask
600
680
  didFinishDownloadingToURL:(NSURL *)location {
601
681
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)downloadTask.taskIdentifier];
602
- 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
+ });
603
690
  if (!downloadId) return;
604
691
 
605
- NSDictionary *options = self.downloadOptions[downloadId];
606
692
  NSString *fileName = [self fileNameFromOptions:options task:downloadTask];
607
693
  NSString *destType = options[@"destination"] ?: @"downloads";
608
694
  NSURL *destURL = [self destURLForFileName:fileName destination:destType];
@@ -639,24 +725,34 @@ didFinishDownloadingToURL:(NSURL *)location {
639
725
  }
640
726
  }
641
727
 
642
- NSDictionary *funcs = self.activePromises[downloadId];
728
+ __block NSDictionary *funcs = nil;
643
729
  BOOL isBackground = [options[@"background"] boolValue];
730
+ dispatch_sync(self.syncQueue, ^{
731
+ funcs = self.activePromises[downloadId];
732
+ });
644
733
 
645
734
  if (funcs && !isBackground) {
646
735
  // Foreground: resolve the promise
647
736
  RCTPromiseResolveBlock resolve = funcs[@"resolve"];
648
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
+ }
649
743
  } else {
650
- // Background: fire the correct event based on isError flag (Bug #1 fix)
744
+ // Background: fire the correct event based on isError flag
651
745
  NSString *event = isError ? @"onDownloadError" : @"onDownloadComplete";
652
746
  [self sendEventWithName:event body:resultDict];
653
747
  }
654
748
 
655
- [self.activePromises removeObjectForKey:downloadId];
656
- [self.downloadOptions removeObjectForKey:downloadId];
657
- [self.activeTasks removeObjectForKey:downloadId];
658
- [self.taskIdMap removeObjectForKey:taskKey];
659
- [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
+ });
660
756
  }
661
757
 
662
758
  - (void)URLSession:(NSURLSession *)session
@@ -664,12 +760,20 @@ didFinishDownloadingToURL:(NSURL *)location {
664
760
  didCompleteWithError:(NSError *)error {
665
761
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
666
762
 
667
- // ── Upload task completion ─────────────────────────────────────────────
668
- 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
+ });
669
774
  if (uploadId) {
670
- NSDictionary *funcs = self.uploadPromises[uploadId];
671
- RCTPromiseResolveBlock uploadResolve = funcs[@"resolve"];
672
- NSString *tempFile = funcs[@"tempFile"];
775
+ RCTPromiseResolveBlock uploadResolve = uploadFuncs[@"resolve"];
776
+ NSString *tempFile = uploadFuncs[@"tempFile"];
673
777
 
674
778
  if (tempFile) {
675
779
  [[NSFileManager defaultManager] removeItemAtPath:tempFile error:nil];
@@ -679,7 +783,7 @@ didCompleteWithError:(NSError *)error {
679
783
  if (uploadResolve) uploadResolve(@{@"success": @NO, @"error": error.localizedDescription, @"uploadId": uploadId});
680
784
  } else {
681
785
  NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response;
682
- NSData *responseData = self.uploadResponseData[uploadId] ?: [NSData data];
786
+ NSData *responseData = uploadRespData ?: [NSData data];
683
787
  NSString *respString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] ?: @"";
684
788
 
685
789
  if (uploadResolve) uploadResolve(@{
@@ -690,45 +794,66 @@ didCompleteWithError:(NSError *)error {
690
794
  });
691
795
  }
692
796
 
693
- [self.uploadPromises removeObjectForKey:uploadId];
694
- [self.uploadUrls removeObjectForKey:uploadId];
695
- [self.uploadResponseData removeObjectForKey:uploadId];
696
- [self.uploadTaskIdMap removeObjectForKey:taskKey];
697
- 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;
698
809
  }
699
810
 
700
811
  // ── Download task error handling ───────────────────────────────────────
701
812
  if (!error) return;
702
- // Ignore cancellation
703
- 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
+ }
704
820
 
705
- 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
+ });
706
831
  if (!downloadId) return;
707
832
 
708
- NSDictionary *options = self.downloadOptions[downloadId];
709
833
  BOOL isBackground = [options[@"background"] boolValue];
710
834
 
711
- // ── Retry logic ────────────────────────────────────────────────────────
835
+ // ── Retry logic ────────────────────────────────────────────────────
712
836
  NSDictionary *retryConfig = options[@"retry"];
713
837
  NSInteger maxAttempts = retryConfig ? [retryConfig[@"attempts"] integerValue] : 0;
714
838
  NSInteger baseDelay = retryConfig ? ([retryConfig[@"delay"] integerValue] ?: 1000) : 1000;
715
- NSInteger currentAttempt = [self.retryAttempts[downloadId] integerValue];
716
839
 
717
840
  // Remove old task mapping — will be replaced on retry
718
- [self.taskIdMap removeObjectForKey:taskKey];
719
- [self.activeTasks removeObjectForKey:downloadId];
841
+ dispatch_sync(self.syncQueue, ^{
842
+ [self.taskIdMap removeObjectForKey:taskKey];
843
+ [self.activeTasks removeObjectForKey:downloadId];
844
+ });
720
845
 
721
846
  if (currentAttempt < maxAttempts) {
722
847
  // Schedule a retry
723
848
  NSInteger nextAttempt = currentAttempt + 1;
724
- self.retryAttempts[downloadId] = @(nextAttempt);
849
+ dispatch_sync(self.syncQueue, ^{
850
+ self.retryAttempts[downloadId] = @(nextAttempt);
851
+ });
725
852
 
726
- // Bug #4 fix: avoid integer overflow — cap shift operand, then apply MIN
727
853
  NSInteger shiftBits = currentAttempt < 15 ? currentAttempt : 15;
728
854
  NSInteger delayMs = MIN(baseDelay * (1 << shiftBits), (NSInteger)30000);
729
855
 
730
856
  // Emit retry event so JS onRetry callback is called
731
- // Bug #3 fix: include the error description that triggered this retry
732
857
  [self sendEventWithName:@"onDownloadRetry" body:@{
733
858
  @"downloadId": downloadId,
734
859
  @"url": options[@"url"] ?: @"",
@@ -736,16 +861,14 @@ didCompleteWithError:(NSError *)error {
736
861
  @"error": error.localizedDescription ?: @""
737
862
  }];
738
863
 
739
- // Bug #2 fix: use __weak self to avoid retain cycle and crash on dealloc during delay
740
864
  __weak typeof(self) weakSelf = self;
741
865
  dispatch_after(
742
866
  dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayMs * NSEC_PER_MSEC)),
743
867
  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
744
868
  ^{
745
869
  __strong typeof(weakSelf) strongSelf = weakSelf;
746
- if (!strongSelf) return; // module deallocated during delay — bail safely
870
+ if (!strongSelf) return;
747
871
 
748
- // Recreate task with same options & downloadId
749
872
  NSString *urlString = options[@"url"];
750
873
  if (!urlString) return;
751
874
  NSURL *url = [NSURL URLWithString:urlString];
@@ -761,8 +884,10 @@ didCompleteWithError:(NSError *)error {
761
884
  NSURLSessionDownloadTask *newTask = [sess downloadTaskWithRequest:request];
762
885
  NSString *newTaskKey = [NSString stringWithFormat:@"%lu",
763
886
  (unsigned long)newTask.taskIdentifier];
764
- strongSelf.taskIdMap[newTaskKey] = downloadId;
765
- strongSelf.activeTasks[downloadId] = newTask;
887
+ dispatch_sync(strongSelf.syncQueue, ^{
888
+ strongSelf.taskIdMap[newTaskKey] = downloadId;
889
+ strongSelf.activeTasks[downloadId] = newTask;
890
+ });
766
891
  newTask.taskDescription = downloadId;
767
892
  [newTask resume];
768
893
  }
@@ -770,21 +895,25 @@ didCompleteWithError:(NSError *)error {
770
895
  return; // Don't resolve promise yet — retry is in flight
771
896
  }
772
897
 
773
- // ── No more retries — normal error path ────────────────────────────────
774
- [self.retryAttempts removeObjectForKey:downloadId];
775
-
898
+ // ── No more retries — normal error path ──────────────────────────────
776
899
  NSDictionary *errDict = @{@"success": @NO, @"downloadId": downloadId, @"error": error.localizedDescription};
777
900
 
778
- 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];
779
908
  if (funcs && !isBackground) {
780
909
  RCTPromiseResolveBlock resolve = funcs[@"resolve"];
781
910
  resolve(errDict);
782
- } else {
783
- [self sendEventWithName:@"onDownloadError" body:errDict];
784
911
  }
785
912
 
786
- [self.activePromises removeObjectForKey:downloadId];
787
- [self.downloadOptions removeObjectForKey:downloadId];
913
+ dispatch_sync(self.syncQueue, ^{
914
+ [self.activePromises removeObjectForKey:downloadId];
915
+ [self.downloadOptions removeObjectForKey:downloadId];
916
+ });
788
917
  }
789
918
 
790
919
  // ─── Upload progress delegate ────────────────────────────────────────────────
@@ -794,15 +923,22 @@ didCompleteWithError:(NSError *)error {
794
923
  didSendBodyData:(int64_t)bytesSent
795
924
  totalBytesSent:(int64_t)totalBytesSent
796
925
  totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
797
- NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
798
- NSString *uploadId = self.uploadTaskIdMap[taskKey];
799
- if (!uploadId) return;
800
-
801
- NSString *url = self.uploadUrls[uploadId] ?: @"";
802
926
  if (totalBytesExpectedToSend > 0) {
927
+ NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
803
928
  int progress = (int)((totalBytesSent * 100) / totalBytesExpectedToSend);
804
- [self sendEventWithName:@"onUploadProgress"
805
- 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
+ });
806
942
  }
807
943
  }
808
944
 
@@ -812,15 +948,20 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
812
948
  dataTask:(NSURLSessionDataTask *)dataTask
813
949
  didReceiveData:(NSData *)data {
814
950
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)dataTask.taskIdentifier];
815
- NSString *uploadId = self.uploadTaskIdMap[taskKey];
951
+ __block NSString *uploadId = nil;
952
+ dispatch_sync(self.syncQueue, ^{
953
+ uploadId = self.uploadTaskIdMap[taskKey];
954
+ });
816
955
  if (!uploadId) return;
817
956
 
818
- NSMutableData *responseData = self.uploadResponseData[uploadId];
819
- if (!responseData) {
820
- responseData = [NSMutableData new];
821
- self.uploadResponseData[uploadId] = responseData;
822
- }
823
- [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
+ });
824
965
  }
825
966
 
826
967
  // ─── TurboModule ──────────────────────────────────────────────────────────────
@@ -913,23 +1054,24 @@ totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
913
1054
  NSURLSessionUploadTask *task = [self.fgSession uploadTaskWithRequest:request fromFile:tempFileURL];
914
1055
  NSString *taskKey = [NSString stringWithFormat:@"%lu", (unsigned long)task.taskIdentifier];
915
1056
 
916
- self.uploadTaskIdMap[taskKey] = uploadId;
917
- self.uploadPromises[uploadId] = @{
918
- @"resolve": resolve,
919
- @"reject": reject,
920
- @"tempFile": tempFilePath
921
- };
922
- 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
+ });
923
1066
 
924
1067
  [task resume];
925
1068
  }
926
1069
 
927
1070
  // ─── saveBase64AsFile ─────────────────────────────────────────────────────────
928
1071
 
929
- RCT_REMAP_METHOD(saveBase64AsFile,
930
- base64Options:(NSDictionary *)options
931
- saveResolver:(RCTPromiseResolveBlock)resolve
932
- saveRejecter:(RCTPromiseRejectBlock)reject)
1072
+ RCT_EXPORT_METHOD(saveBase64AsFile:(NSDictionary *)options
1073
+ resolve:(RCTPromiseResolveBlock)resolve
1074
+ reject:(RCTPromiseRejectBlock)reject)
933
1075
  {
934
1076
  NSString *base64String = options[@"base64Data"];
935
1077
  if (!base64String || base64String.length == 0) {
@@ -968,10 +1110,9 @@ RCT_REMAP_METHOD(saveBase64AsFile,
968
1110
 
969
1111
  // ─── urlToBase64 ──────────────────────────────────────────────────────────────
970
1112
 
971
- RCT_REMAP_METHOD(urlToBase64,
972
- urlOptions:(NSDictionary *)options
973
- urlResolver:(RCTPromiseResolveBlock)resolve
974
- urlRejecter:(RCTPromiseRejectBlock)reject)
1113
+ RCT_EXPORT_METHOD(urlToBase64:(NSDictionary *)options
1114
+ resolve:(RCTPromiseResolveBlock)resolve
1115
+ reject:(RCTPromiseRejectBlock)reject)
975
1116
  {
976
1117
  NSString *urlString = options[@"url"];
977
1118
  if (!urlString || urlString.length == 0) {
@@ -996,7 +1137,10 @@ RCT_REMAP_METHOD(urlToBase64,
996
1137
  }
997
1138
  }
998
1139
 
999
- 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) {
1000
1144
  if (error) {
1001
1145
  resolve(@{@"success": @NO, @"error": error.localizedDescription});
1002
1146
  return;
@@ -1031,13 +1175,35 @@ RCT_REMAP_METHOD(urlToBase64,
1031
1175
  [task resume];
1032
1176
  }
1033
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
+
1034
1201
  // ─── shareFile ────────────────────────────────────────────────────────────────
1035
1202
 
1036
- RCT_REMAP_METHOD(shareFile,
1037
- shareFilePath:(NSString *)filePath
1038
- shareOptions:(NSDictionary *)options
1039
- shareResolver:(RCTPromiseResolveBlock)resolve
1040
- shareRejecter:(RCTPromiseRejectBlock)reject)
1203
+ RCT_EXPORT_METHOD(shareFile:(NSString *)filePath
1204
+ options:(NSDictionary *)options
1205
+ resolve:(RCTPromiseResolveBlock)resolve
1206
+ reject:(RCTPromiseRejectBlock)reject)
1041
1207
  {
1042
1208
  if (!filePath || filePath.length == 0) {
1043
1209
  resolve(@{@"success": @NO, @"error": @"File path is required"});
@@ -1052,11 +1218,10 @@ RCT_REMAP_METHOD(shareFile,
1052
1218
  NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1053
1219
 
1054
1220
  dispatch_async(dispatch_get_main_queue(), ^{
1055
- UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1056
-
1057
- // Find the topmost view controller
1058
- while (rootViewController.presentedViewController) {
1059
- rootViewController = rootViewController.presentedViewController;
1221
+ UIViewController *rootViewController = [self topMostViewController];
1222
+ if (!rootViewController) {
1223
+ resolve(@{@"success": @NO, @"error": @"No visible view controller found"});
1224
+ return;
1060
1225
  }
1061
1226
 
1062
1227
  NSArray *itemsToShare = @[fileURL];
@@ -1085,11 +1250,10 @@ RCT_REMAP_METHOD(shareFile,
1085
1250
 
1086
1251
  // ─── openFile ─────────────────────────────────────────────────────────────────
1087
1252
 
1088
- RCT_REMAP_METHOD(openFile,
1089
- openFilePath:(NSString *)filePath
1090
- mimeType:(NSString *)mimeType
1091
- openResolver:(RCTPromiseResolveBlock)resolve
1092
- openRejecter:(RCTPromiseRejectBlock)reject)
1253
+ RCT_EXPORT_METHOD(openFile:(NSString *)filePath
1254
+ mimeType:(NSString *)mimeType
1255
+ resolve:(RCTPromiseResolveBlock)resolve
1256
+ reject:(RCTPromiseRejectBlock)reject)
1093
1257
  {
1094
1258
  if (!filePath || filePath.length == 0) {
1095
1259
  resolve(@{@"success": @NO, @"error": @"File path is required"});
@@ -1104,11 +1268,10 @@ RCT_REMAP_METHOD(openFile,
1104
1268
  NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1105
1269
 
1106
1270
  dispatch_async(dispatch_get_main_queue(), ^{
1107
- UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1108
-
1109
- // Find the topmost view controller
1110
- while (rootViewController.presentedViewController) {
1111
- rootViewController = rootViewController.presentedViewController;
1271
+ UIViewController *rootViewController = [self topMostViewController];
1272
+ if (!rootViewController) {
1273
+ resolve(@{@"success": @NO, @"error": @"No visible view controller found"});
1274
+ return;
1112
1275
  }
1113
1276
 
1114
1277
  // Use UIDocumentInteractionController for opening files
@@ -1136,11 +1299,7 @@ RCT_REMAP_METHOD(openFile,
1136
1299
 
1137
1300
  // UIDocumentInteractionControllerDelegate method
1138
1301
  - (UIViewController *)documentInteractionControllerViewControllerForPreview:(UIDocumentInteractionController *)controller {
1139
- UIViewController *rootViewController = [UIApplication sharedApplication].delegate.window.rootViewController;
1140
- while (rootViewController.presentedViewController) {
1141
- rootViewController = rootViewController.presentedViewController;
1142
- }
1143
- return rootViewController;
1302
+ return [self topMostViewController];
1144
1303
  }
1145
1304
 
1146
1305
  // ─── Unzip ────────────────────────────────────────────────────────────────────
@@ -1276,12 +1435,31 @@ RCT_EXPORT_METHOD(unzip:(NSString *)sourcePath
1276
1435
  if (method == 0) {
1277
1436
  // Store (no compression)
1278
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
+ }
1279
1446
  if (![fileData writeToFile:destPath options:NSDataWritingAtomic error:error]) {
1280
1447
  return NO;
1281
1448
  }
1282
1449
  } else if (method == 8) {
1283
1450
  // Deflate — use zlib inflate with raw deflate stream
1284
- 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];
1285
1463
  z_stream strm;
1286
1464
  memset(&strm, 0, sizeof(strm));
1287
1465
  strm.next_in = (Bytef *)compData;
@@ -1323,6 +1501,14 @@ RCT_EXPORT_METHOD(unzip:(NSString *)sourcePath
1323
1501
  output = result;
1324
1502
  }
1325
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
+ }
1326
1512
  if (![output writeToFile:destPath options:NSDataWritingAtomic error:error]) {
1327
1513
  return NO;
1328
1514
  }
@@ -1746,10 +1932,9 @@ RCT_EXPORT_METHOD(zip:(NSString *)sourcePath
1746
1932
 
1747
1933
  // ─── saveToMediaStore ─────────────────────────────────────────────────────
1748
1934
 
1749
- RCT_REMAP_METHOD(saveToMediaStore,
1750
- mediaStoreOptions:(NSDictionary *)options
1751
- mediaStoreResolver:(RCTPromiseResolveBlock)resolve
1752
- mediaStoreRejecter:(RCTPromiseRejectBlock)reject)
1935
+ RCT_EXPORT_METHOD(saveToMediaStore:(NSDictionary *)options
1936
+ resolve:(RCTPromiseResolveBlock)resolve
1937
+ reject:(RCTPromiseRejectBlock)reject)
1753
1938
  {
1754
1939
  NSString *filePath = options[@"filePath"];
1755
1940
  if (!filePath || filePath.length == 0) {
@@ -1766,21 +1951,27 @@ RCT_REMAP_METHOD(saveToMediaStore,
1766
1951
 
1767
1952
  if ([mediaType isEqualToString:@"image"] || [mediaType isEqualToString:@"video"]) {
1768
1953
  // Use Photos framework for images and videos
1769
- PHPhotoLibrary *photoLibrary = [PHPhotoLibrary sharedPhotoLibrary];
1770
-
1771
- [photoLibrary performChanges:^{
1772
- NSURL *fileURL = [NSURL fileURLWithPath:filePath];
1773
- if ([mediaType isEqualToString:@"image"]) {
1774
- [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:fileURL];
1775
- } else {
1776
- [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
1777
- }
1778
- } completionHandler:^(BOOL success, NSError *error) {
1779
- if (success) {
1780
- resolve(@{@"success": @YES, @"uri": filePath});
1781
- } else {
1782
- 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;
1783
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
+ }];
1784
1975
  }];
1785
1976
  } else {
1786
1977
  // For audio/download types, copy to Documents directory (iOS has no shared media store for these)