@kesha-antonov/react-native-background-downloader 4.5.3 → 4.5.5

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/README.md +74 -8
  2. package/android/build.gradle +4 -2
  3. package/android/src/main/java/com/eko/DownloadConstants.kt +3 -0
  4. package/android/src/main/java/com/eko/Downloader.kt +102 -0
  5. package/android/src/main/java/com/eko/RNBackgroundDownloaderModuleImpl.kt +86 -5
  6. package/android/src/main/java/com/eko/ResumableDownloader.kt +7 -0
  7. package/android/src/main/java/com/eko/UIDTDownloadJobService.kt +42 -4
  8. package/android/src/main/java/com/eko/uidt/UIDTJobManager.kt +11 -1
  9. package/android/src/main/java/com/eko/uidt/UIDTJobState.kt +62 -0
  10. package/android/src/main/java/com/eko/utils/StorageManager.kt +95 -0
  11. package/ios/RNBGDTaskConfig.h +3 -0
  12. package/ios/RNBGDTaskConfig.mm +3 -0
  13. package/ios/RNBackgroundDownloader.mm +375 -48
  14. package/lib/DownloadTask.d.ts +39 -0
  15. package/lib/DownloadTask.js +176 -0
  16. package/lib/NativeRNBackgroundDownloader.d.ts +127 -0
  17. package/lib/NativeRNBackgroundDownloader.js +4 -0
  18. package/lib/UploadTask.d.ts +30 -0
  19. package/lib/UploadTask.js +174 -0
  20. package/lib/config.d.ts +33 -0
  21. package/lib/config.js +79 -0
  22. package/lib/index.d.ts +26 -0
  23. package/lib/index.js +498 -0
  24. package/lib/types.d.ts +249 -0
  25. package/lib/types.js +2 -0
  26. package/package.json +7 -4
  27. package/plugin/build/index.d.ts +2 -2
  28. package/plugin/build/index.js +1 -1
  29. package/react-native-background-downloader.podspec +8 -2
  30. package/src/DownloadTask.ts +2 -0
  31. package/src/NativeRNBackgroundDownloader.ts +2 -0
  32. package/src/config.ts +6 -1
  33. package/src/index.ts +4 -0
  34. package/src/types.ts +27 -0
@@ -13,6 +13,10 @@
13
13
  #define ID_TO_UPLOAD_CONFIG_MAP_KEY @"com.eko.bguploadidmap"
14
14
  #define PROGRESS_INTERVAL_KEY @"progressInterval"
15
15
  #define PROGRESS_MIN_BYTES_KEY @"progressMinBytes"
16
+ // Persisted map of downloads whose finished file could not be moved to its destination
17
+ // yet because the device was locked (Data Protection). The move is retried when protected
18
+ // data becomes available / on next launch. See issue #101.
19
+ #define PENDING_MOVES_MAP_KEY @"com.eko.bgdpendingmoves"
16
20
 
17
21
  // Session configuration constants
18
22
  static const NSInteger kMaxConnectionsPerHost = 4;
@@ -66,6 +70,18 @@ static CompletionHandler storedCompletionHandler;
66
70
  // Controls whether debug logs are sent to JS
67
71
  BOOL isLogsEnabled;
68
72
 
73
+ // Downloads that finished but whose file couldn't be moved to its destination because
74
+ // the device was locked. Keyed by configId -> staging/destination info. Retried on
75
+ // UIApplicationProtectedDataDidBecomeAvailable, app foreground, and launch. See #101.
76
+ NSMutableDictionary<NSString *, NSDictionary *> *pendingMoves;
77
+
78
+ #ifdef RCT_NEW_ARCH_ENABLED
79
+ // Queue of events that arrived before the TurboModule event emitter callback was set.
80
+ // This prevents crashes (std::bad_function_call / SIGABRT) when NSURLSession delegate
81
+ // callbacks fire before JS has registered event listeners.
82
+ NSMutableArray<NSDictionary *> *pendingEmitEvents;
83
+ #endif
84
+
69
85
  // Upload-specific instance variables
70
86
  NSMutableDictionary<NSNumber *, RNBGDUploadTaskConfig *> *uploadTaskToConfigMap;
71
87
  NSMutableDictionary<NSString *, NSURLSessionUploadTask *> *idToUploadTaskMap;
@@ -233,6 +249,15 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
233
249
  isSessionActivated = NO;
234
250
  pendingDownloads = [[NSMutableArray alloc] init];
235
251
 
252
+ // Restore any downloads whose file move was deferred because the device was locked
253
+ NSData *pendingMovesData = [mmkv getDataForKey:PENDING_MOVES_MAP_KEY];
254
+ NSMutableDictionary *pendingMovesDecoded = pendingMovesData != nil ? [self deserializePendingMoves:pendingMovesData] : nil;
255
+ pendingMoves = pendingMovesDecoded != nil ? pendingMovesDecoded : [[NSMutableDictionary alloc] init];
256
+
257
+ #ifdef RCT_NEW_ARCH_ENABLED
258
+ pendingEmitEvents = [[NSMutableArray alloc] init];
259
+ #endif
260
+
236
261
  // Initialize upload-specific data structures
237
262
  NSData *uploadTaskToConfigMapData = [mmkv getDataForKey:ID_TO_UPLOAD_CONFIG_MAP_KEY];
238
263
  NSMutableDictionary *uploadTaskToConfigMapDataDefault = [[NSMutableDictionary alloc] init];
@@ -249,6 +274,10 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
249
274
 
250
275
  // Initialize session early to receive background events on app relaunch
251
276
  [self lazyRegisterSession];
277
+
278
+ // Retry any moves that were deferred while the device was locked (e.g. a download
279
+ // that finished in the background before the first unlock). See #101.
280
+ [self processPendingMoves];
252
281
  }
253
282
 
254
283
  return self;
@@ -330,10 +359,23 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
330
359
  selector:@selector(handleBridgeHotReload:)
331
360
  name:RCTJavaScriptWillStartLoadingNotification
332
361
  object:nil];
362
+
363
+ // Finish any moves deferred while the device was locked, as soon as
364
+ // protected data becomes available again (device unlocked). See #101.
365
+ // Note: the ObjC constant has no "Notification" suffix (unlike most UIApplication ones).
366
+ [[NSNotificationCenter defaultCenter] addObserver:self
367
+ selector:@selector(handleProtectedDataAvailable:)
368
+ name:UIApplicationProtectedDataDidBecomeAvailable
369
+ object:nil];
333
370
  }
334
371
  }
335
372
  }
336
373
 
374
+ - (void)handleProtectedDataAvailable:(NSNotification *) note {
375
+ DLog(nil, @"[RNBackgroundDownloader] - [handleProtectedDataAvailable]");
376
+ [self processPendingMoves];
377
+ }
378
+
337
379
  - (void)unregisterBridgeListener {
338
380
  DLog(nil, @"[RNBackgroundDownloader] - [unregisterBridgeListener]");
339
381
  if (isBridgeListenerInited == YES) {
@@ -345,6 +387,8 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
345
387
  - (void)handleBridgeAppEnterForeground:(NSNotification *) note {
346
388
  DLog(nil, @"[RNBackgroundDownloader] - [handleBridgeAppEnterForeground]");
347
389
  [self resumeTasks];
390
+ // Device is unlocked when foregrounded, so finish any locked-deferred file moves.
391
+ [self processPendingMoves];
348
392
  }
349
393
 
350
394
  - (void)resumeTasks {
@@ -403,15 +447,24 @@ RCT_EXPORT_METHOD(setLogsEnabled:(BOOL)enabled) {
403
447
 
404
448
  - (void)_setMaxParallelDownloadsInternal:(NSInteger)max {
405
449
  DLog(nil, @"[RNBackgroundDownloader] - [setMaxParallelDownloads:%ld]", (long)max);
406
- @synchronized (sharedLock) {
407
- if (max >= 1) {
408
- sessionConfig.HTTPMaximumConnectionsPerHost = max;
409
- // Recreate session with new config if it's already initialized
410
- if (urlSession != nil) {
411
- [self unregisterSession];
412
- [self lazyRegisterSession];
450
+ // Never let an NSException escape this void method. methodQueue is a custom
451
+ // background queue, and on the new architecture the TurboModule bridge would
452
+ // convert an escaping exception into a JS error via Hermes JSI on this thread
453
+ // (not the JS thread). Hermes is not thread-safe, so that crashes with SIGSEGV.
454
+ // See https://github.com/kesha-antonov/react-native-background-downloader/issues/161
455
+ @try {
456
+ @synchronized (sharedLock) {
457
+ if (max >= 1) {
458
+ sessionConfig.HTTPMaximumConnectionsPerHost = max;
459
+ // Recreate session with new config if it's already initialized
460
+ if (urlSession != nil) {
461
+ [self unregisterSession];
462
+ [self lazyRegisterSession];
463
+ }
413
464
  }
414
465
  }
466
+ } @catch (NSException *exception) {
467
+ DLog(nil, @"[RNBackgroundDownloader] - [setMaxParallelDownloads] error: %@", exception.reason);
415
468
  }
416
469
  }
417
470
 
@@ -427,13 +480,20 @@ RCT_EXPORT_METHOD(setMaxParallelDownloads:(NSInteger)max) {
427
480
 
428
481
  - (void)_setAllowsCellularAccessInternal:(BOOL)allows {
429
482
  DLog(nil, @"[RNBackgroundDownloader] - [setAllowsCellularAccess:%@]", allows ? @"YES" : @"NO");
430
- @synchronized (sharedLock) {
431
- sessionConfig.allowsCellularAccess = allows;
432
- // Recreate session with new config if it's already initialized
433
- if (urlSession != nil) {
434
- [self unregisterSession];
435
- [self lazyRegisterSession];
483
+ // Never let an NSException escape this void method - see the note on
484
+ // -_setMaxParallelDownloadsInternal: and issue #161. Recreating the session
485
+ // here can throw, and an escaping exception crashes Hermes off the JS thread.
486
+ @try {
487
+ @synchronized (sharedLock) {
488
+ sessionConfig.allowsCellularAccess = allows;
489
+ // Recreate session with new config if it's already initialized
490
+ if (urlSession != nil) {
491
+ [self unregisterSession];
492
+ [self lazyRegisterSession];
493
+ }
436
494
  }
495
+ } @catch (NSException *exception) {
496
+ DLog(nil, @"[RNBackgroundDownloader] - [setAllowsCellularAccess] error: %@", exception.reason);
437
497
  }
438
498
  }
439
499
 
@@ -455,6 +515,7 @@ RCT_EXPORT_METHOD(setAllowsCellularAccess:(BOOL)allows) {
455
515
  NSString *destination = options.destination();
456
516
  NSString *metadata = options.metadata() ? options.metadata() : @"";
457
517
  NSDictionary *headers = options.headers() ? (NSDictionary *)options.headers() : nil;
518
+ NSString *dataProtection = options.iosDataProtection() ? options.iosDataProtection() : nil;
458
519
 
459
520
  if (options.progressInterval().has_value()) {
460
521
  progressInterval = options.progressInterval().value() / 1000.0;
@@ -475,6 +536,7 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
475
536
  NSString *destination = options[@"destination"];
476
537
  NSString *metadata = options[@"metadata"] ?: @"";
477
538
  NSDictionary *headers = options[@"headers"];
539
+ NSString *dataProtection = options[@"iosDataProtection"];
478
540
 
479
541
  NSNumber *progressIntervalScope = options[@"progressInterval"];
480
542
  if (progressIntervalScope) {
@@ -511,38 +573,47 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
511
573
  }
512
574
  }
513
575
 
514
- @synchronized (sharedLock) {
515
- [self sendDebugLog:@"download: calling lazyRegisterSession" taskId:identifier];
516
- [self lazyRegisterSession];
576
+ // download is a void method dispatched on the custom background methodQueue.
577
+ // Any NSException that escapes would be converted to a JS error via Hermes JSI
578
+ // on this thread (not the JS thread) and crash with SIGSEGV (issue #161), so we
579
+ // never let one propagate out of this method.
580
+ @try {
581
+ @synchronized (sharedLock) {
582
+ [self sendDebugLog:@"download: calling lazyRegisterSession" taskId:identifier];
583
+ [self lazyRegisterSession];
517
584
 
518
- // If session is not yet activated, queue the download to be executed after activation
519
- // This fixes the issue where downloads don't start on fresh app installs
520
- if (!isSessionActivated) {
521
- DLog(identifier, @"[RNBackgroundDownloader] - [download] session not activated, queueing download");
522
- [self sendDebugLog:@"download: session not activated, queueing download" taskId:identifier];
523
- __weak RNBackgroundDownloader *weakSelf = self;
524
- dispatch_block_t downloadBlock = ^{
525
- RNBackgroundDownloader *strongSelf = weakSelf;
526
- if (strongSelf) {
527
- [strongSelf executeDownloadWithRequest:request identifier:identifier url:url destination:destination metadata:metadata];
528
- }
529
- };
530
- [pendingDownloads addObject:downloadBlock];
531
- return;
532
- }
585
+ // If session is not yet activated, queue the download to be executed after activation
586
+ // This fixes the issue where downloads don't start on fresh app installs
587
+ if (!isSessionActivated) {
588
+ DLog(identifier, @"[RNBackgroundDownloader] - [download] session not activated, queueing download");
589
+ [self sendDebugLog:@"download: session not activated, queueing download" taskId:identifier];
590
+ __weak RNBackgroundDownloader *weakSelf = self;
591
+ dispatch_block_t downloadBlock = ^{
592
+ RNBackgroundDownloader *strongSelf = weakSelf;
593
+ if (strongSelf) {
594
+ [strongSelf executeDownloadWithRequest:request identifier:identifier url:url destination:destination metadata:metadata dataProtection:dataProtection];
595
+ }
596
+ };
597
+ [pendingDownloads addObject:downloadBlock];
598
+ return;
599
+ }
533
600
 
534
- [self sendDebugLog:@"download: session activated, executing download" taskId:identifier];
535
- [self executeDownloadWithRequest:request identifier:identifier url:url destination:destination metadata:metadata];
601
+ [self sendDebugLog:@"download: session activated, executing download" taskId:identifier];
602
+ [self executeDownloadWithRequest:request identifier:identifier url:url destination:destination metadata:metadata dataProtection:dataProtection];
603
+ }
604
+ } @catch (NSException *exception) {
605
+ DLog(identifier, @"[RNBackgroundDownloader] - [download] error: %@", exception.reason);
606
+ [self sendDebugLog:[NSString stringWithFormat:@"download: ERROR - %@", exception.reason] taskId:identifier];
536
607
  }
537
608
  }
538
609
 
539
610
  // Internal method to execute the download after session is activated
540
- - (void)executeDownloadWithRequest:(NSMutableURLRequest *)request identifier:(NSString *)identifier url:(NSString *)url destination:(NSString *)destination metadata:(NSString *)metadata {
611
+ - (void)executeDownloadWithRequest:(NSMutableURLRequest *)request identifier:(NSString *)identifier url:(NSString *)url destination:(NSString *)destination metadata:(NSString *)metadata dataProtection:(NSString *)dataProtection {
541
612
  @synchronized (sharedLock) {
542
613
  DLog(identifier, @"[RNBackgroundDownloader] - [executeDownloadWithRequest]");
543
614
  [self sendDebugLog:@"executeDownloadWithRequest: creating download task" taskId:identifier];
544
615
 
545
- NSURLSessionDownloadTask __strong *task = [urlSession downloadTaskWithRequest:request];
616
+ NSURLSessionDownloadTask __strong *task = [self createDownloadTaskWithRequest:request identifier:identifier];
546
617
  if (task == nil) {
547
618
  DLog(identifier, @"[RNBackgroundDownloader] - [Error] failed to create download task");
548
619
  [self sendDebugLog:@"executeDownloadWithRequest: ERROR - failed to create download task" taskId:identifier];
@@ -557,6 +628,8 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
557
628
  @"destination": destination,
558
629
  @"metadata": metadata
559
630
  }];
631
+ // nil means "use the library default" when saving the file (see -nsFileProtectionFromKey:)
632
+ taskConfig.dataProtection = dataProtection;
560
633
 
561
634
  taskToConfigMap[@(task.taskIdentifier)] = taskConfig;
562
635
  [mmkv setData:[self serialize: taskToConfigMap] forKey:ID_TO_CONFIG_MAP_KEY];
@@ -571,6 +644,36 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
571
644
  }
572
645
  }
573
646
 
647
+ // Creates a download task on the current session. If the session has been
648
+ // invalidated (e.g. after a hot reload or a prior invalidateAndCancel),
649
+ // -downloadTaskWithRequest: raises:
650
+ // "attempted to create a NSURLSessionDownloadTask in a session that has
651
+ // been invalidated"
652
+ // In that case we recreate the background session and retry once.
653
+ // Must be called while holding sharedLock.
654
+ // See https://github.com/kesha-antonov/react-native-background-downloader/issues/157
655
+ - (NSURLSessionDownloadTask *)createDownloadTaskWithRequest:(NSMutableURLRequest *)request identifier:(NSString *)identifier {
656
+ @try {
657
+ return [urlSession downloadTaskWithRequest:request];
658
+ } @catch (NSException *exception) {
659
+ DLog(identifier, @"[RNBackgroundDownloader] - [createDownloadTask] session invalidated, recreating: %@", exception.reason);
660
+ [self sendDebugLog:[NSString stringWithFormat:@"createDownloadTask: session invalidated, recreating (%@)", exception.reason] taskId:identifier];
661
+
662
+ // Recreate the background session directly (do not call unregisterSession,
663
+ // which would invalidateAndCancel and drop queued downloads).
664
+ urlSession = [NSURLSession sessionWithConfiguration:sessionConfig delegate:self delegateQueue:nil];
665
+ isSessionActivated = YES;
666
+
667
+ @try {
668
+ return [urlSession downloadTaskWithRequest:request];
669
+ } @catch (NSException *retryException) {
670
+ DLog(identifier, @"[RNBackgroundDownloader] - [createDownloadTask] retry failed: %@", retryException.reason);
671
+ [self sendDebugLog:[NSString stringWithFormat:@"createDownloadTask: retry failed (%@)", retryException.reason] taskId:identifier];
672
+ return nil;
673
+ }
674
+ }
675
+ }
676
+
574
677
  - (void)pauseTaskInternal:(NSString *)identifier resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
575
678
  DLog(identifier, @"[RNBackgroundDownloader] - [pauseTask]");
576
679
  @try {
@@ -765,6 +868,15 @@ RCT_EXPORT_METHOD(resumeTask:(NSString *)id resolver:(RCTPromiseResolveBlock)res
765
868
  [idsToPauseSet removeObject:identifier];
766
869
  // Clean up any updated headers
767
870
  [idToUpdatedHeadersMap removeObjectForKey:identifier];
871
+ // Drop any locked-deferred move staged for this task (#101)
872
+ NSDictionary *pendingMove = pendingMoves[identifier];
873
+ if (pendingMove != nil) {
874
+ NSString *stagingPath = pendingMove[@"staging"];
875
+ if (stagingPath != nil)
876
+ [[NSFileManager defaultManager] removeItemAtPath:stagingPath error:nil];
877
+ [pendingMoves removeObjectForKey:identifier];
878
+ [self persistPendingMoves];
879
+ }
768
880
  }
769
881
  resolve(nil);
770
882
  } @catch (NSException *exception) {
@@ -1140,6 +1252,36 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1140
1252
  }
1141
1253
  }
1142
1254
 
1255
+ #ifdef RCT_NEW_ARCH_ENABLED
1256
+ #pragma mark - Safe event emission (New Architecture)
1257
+
1258
+ // Safely emit an event, queuing it if the TurboModule event emitter callback
1259
+ // has not been registered yet. This prevents std::bad_function_call crashes
1260
+ // when NSURLSession delegate callbacks fire before JS has registered listeners
1261
+ // (e.g., background session delivering completions from a prior app session).
1262
+ - (void)safeEmitEvent:(NSString *)eventName value:(id)value {
1263
+ @synchronized (pendingEmitEvents) {
1264
+ if (_eventEmitterCallback) {
1265
+ _eventEmitterCallback(std::string([eventName UTF8String]), value);
1266
+ } else {
1267
+ [pendingEmitEvents addObject:@{@"name": eventName, @"value": value}];
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ - (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper {
1273
+ @synchronized (pendingEmitEvents) {
1274
+ [super setEventEmitterCallback:eventEmitterCallbackWrapper];
1275
+
1276
+ // Flush any events that arrived before the callback was set
1277
+ for (NSDictionary *event in pendingEmitEvents) {
1278
+ _eventEmitterCallback(std::string([event[@"name"] UTF8String]), event[@"value"]);
1279
+ }
1280
+ [pendingEmitEvents removeAllObjects];
1281
+ }
1282
+ }
1283
+ #endif
1284
+
1143
1285
  #pragma mark - NSURLSessionDownloadDelegate methods
1144
1286
  - (void)URLSession:(nonnull NSURLSession *)session downloadTask:(nonnull NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(nonnull NSURL *)location {
1145
1287
  @synchronized (sharedLock) {
@@ -1153,8 +1295,21 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1153
1295
  [self sendDebugLog:@"didFinishDownloadingToURL: download finished" taskId:taskConfig.id];
1154
1296
 
1155
1297
  NSError *error = [self getServerError:downloadTask];
1298
+ BOOL deferred = NO;
1156
1299
  if (!error) {
1157
- [self saveFile:taskConfig downloadURL:location error:&error];
1300
+ [self saveFile:taskConfig downloadURL:location bytesDownloaded:downloadTask.countOfBytesReceived bytesTotal:downloadTask.countOfBytesExpectedToReceive error:&error deferred:&deferred];
1301
+ }
1302
+
1303
+ // Drop any buffered progress for this task so it cannot arrive in JS after downloadComplete
1304
+ [progressReports removeObjectForKey:taskConfig.id];
1305
+
1306
+ if (deferred) {
1307
+ // The device was locked and the file couldn't be moved to its destination yet.
1308
+ // The bytes are staged and the move (and the downloadComplete event) will happen
1309
+ // once protected data becomes available. Do not emit complete/failed now. See #101.
1310
+ [self sendDebugLog:@"didFinishDownloadingToURL: move deferred until device unlock" taskId:taskConfig.id];
1311
+ [self removeTaskFromMap:downloadTask];
1312
+ return;
1158
1313
  }
1159
1314
 
1160
1315
  if (error) {
@@ -1170,7 +1325,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1170
1325
  - (void)sendDownloadCompletionEvent:(RNBGDTaskConfig *)taskConfig task:(NSURLSessionDownloadTask *)task error:(NSError *)error {
1171
1326
  if (error) {
1172
1327
  #ifdef RCT_NEW_ARCH_ENABLED
1173
- [self emitOnDownloadFailed:@{
1328
+ [self safeEmitEvent:@"onDownloadFailed" value:@{
1174
1329
  @"id": taskConfig.id,
1175
1330
  @"error": [error localizedDescription],
1176
1331
  @"errorCode": @(error.code)
@@ -1184,7 +1339,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1184
1339
  #endif
1185
1340
  } else {
1186
1341
  #ifdef RCT_NEW_ARCH_ENABLED
1187
- [self emitOnDownloadComplete:@{
1342
+ [self safeEmitEvent:@"onDownloadComplete" value:@{
1188
1343
  @"id": taskConfig.id,
1189
1344
  @"location": taskConfig.destination,
1190
1345
  @"bytesDownloaded": @(task.countOfBytesReceived),
@@ -1243,7 +1398,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1243
1398
  responseHeaders = @{};
1244
1399
  }
1245
1400
  #ifdef RCT_NEW_ARCH_ENABLED
1246
- [self emitOnDownloadBegin:@{
1401
+ [self safeEmitEvent:@"onDownloadBegin" value:@{
1247
1402
  @"id": taskConfig.id,
1248
1403
  @"expectedBytes": @(expectedBytes),
1249
1404
  @"headers": responseHeaders
@@ -1290,7 +1445,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1290
1445
  NSDate *now = [NSDate date];
1291
1446
  if ([now timeIntervalSinceDate:lastProgressReportedAt] > progressInterval) {
1292
1447
  #ifdef RCT_NEW_ARCH_ENABLED
1293
- [self emitOnDownloadProgress:[progressReports allValues]];
1448
+ [self safeEmitEvent:@"onDownloadProgress" value:[progressReports allValues]];
1294
1449
  #else
1295
1450
  [self sendEventWithName:@"downloadProgress" body:[progressReports allValues]];
1296
1451
  #endif
@@ -1385,7 +1540,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1385
1540
 
1386
1541
  // Handle failure
1387
1542
  #ifdef RCT_NEW_ARCH_ENABLED
1388
- [self emitOnDownloadFailed:@{
1543
+ [self safeEmitEvent:@"onDownloadFailed" value:@{
1389
1544
  @"id": taskConfig.id,
1390
1545
  @"error": [error localizedDescription],
1391
1546
  @"errorCode": @(error.code)
@@ -1764,7 +1919,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1764
1919
  if (!taskConfig.reportedBegin) {
1765
1920
  taskConfig.reportedBegin = YES;
1766
1921
  #ifdef RCT_NEW_ARCH_ENABLED
1767
- [self emitOnUploadBegin:@{
1922
+ [self safeEmitEvent:@"onUploadBegin" value:@{
1768
1923
  @"id": taskConfig.id,
1769
1924
  @"expectedBytes": @(totalBytesExpectedToSend)
1770
1925
  }];
@@ -1801,7 +1956,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1801
1956
  NSDate *now = [NSDate date];
1802
1957
  if ([now timeIntervalSinceDate:lastUploadProgressReportedAt] > progressInterval) {
1803
1958
  #ifdef RCT_NEW_ARCH_ENABLED
1804
- [self emitOnUploadProgress:[uploadProgressReports allValues]];
1959
+ [self safeEmitEvent:@"onUploadProgress" value:[uploadProgressReports allValues]];
1805
1960
  #else
1806
1961
  [self sendEventWithName:@"uploadProgress" body:[uploadProgressReports allValues]];
1807
1962
  #endif
@@ -1847,7 +2002,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1847
2002
 
1848
2003
  DLog(taskConfig.id, @"[RNBackgroundDownloader] - [handleUploadCompletion] error: %@", error);
1849
2004
  #ifdef RCT_NEW_ARCH_ENABLED
1850
- [self emitOnUploadFailed:@{
2005
+ [self safeEmitEvent:@"onUploadFailed" value:@{
1851
2006
  @"id": taskConfig.id,
1852
2007
  @"error": [error localizedDescription],
1853
2008
  @"errorCode": @(error.code)
@@ -1873,7 +2028,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1873
2028
 
1874
2029
  DLog(taskConfig.id, @"[RNBackgroundDownloader] - [handleUploadCompletion] success, responseCode: %ld", (long)responseCode);
1875
2030
  #ifdef RCT_NEW_ARCH_ENABLED
1876
- [self emitOnUploadComplete:@{
2031
+ [self safeEmitEvent:@"onUploadComplete" value:@{
1877
2032
  @"id": taskConfig.id,
1878
2033
  @"responseCode": @(responseCode),
1879
2034
  @"responseBody": responseBody,
@@ -1937,8 +2092,20 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1937
2092
  userInfo:@{NSLocalizedDescriptionKey: [NSHTTPURLResponse localizedStringForStatusCode:statusCode]}];
1938
2093
  }
1939
2094
 
1940
- - (BOOL)saveFile: (nonnull RNBGDTaskConfig *) taskConfig downloadURL:(nonnull NSURL *)location error:(NSError **)saveError {
2095
+ // Maps the JS data-protection key to an NSFileProtection* constant.
2096
+ // nil / unknown -> NSFileProtectionCompleteUntilFirstUserAuthentication, which lets a
2097
+ // background download save its file even while the device is locked (after first unlock).
2098
+ // See https://github.com/kesha-antonov/react-native-background-downloader/issues/101
2099
+ - (NSString *)nsFileProtectionFromKey:(NSString *)key {
2100
+ if ([key isEqualToString:@"complete"]) return NSFileProtectionComplete;
2101
+ if ([key isEqualToString:@"completeUnlessOpen"]) return NSFileProtectionCompleteUnlessOpen;
2102
+ if ([key isEqualToString:@"none"]) return NSFileProtectionNone;
2103
+ return NSFileProtectionCompleteUntilFirstUserAuthentication;
2104
+ }
2105
+
2106
+ - (BOOL)saveFile: (nonnull RNBGDTaskConfig *) taskConfig downloadURL:(nonnull NSURL *)location bytesDownloaded:(int64_t)bytesDownloaded bytesTotal:(int64_t)bytesTotal error:(NSError **)saveError deferred:(BOOL *)deferred {
1941
2107
  DLog(taskConfig.id, @"[RNBackgroundDownloader] - [saveFile]");
2108
+ if (deferred) *deferred = NO;
1942
2109
  // taskConfig.destination is absolute path.
1943
2110
  // The absolute path may change when the application is restarted.
1944
2111
  // But the relative path remains the same.
@@ -1960,10 +2127,170 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1960
2127
  NSURL *destinationURL = [NSURL fileURLWithPath:fileAbsolutePath];
1961
2128
 
1962
2129
  NSFileManager *fileManager = [NSFileManager defaultManager];
1963
- [fileManager createDirectoryAtURL:[destinationURL URLByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
2130
+ // Create the destination directory with a protection class that allows writing while the
2131
+ // device is locked, so a background download can save when it finishes on a locked device.
2132
+ [fileManager createDirectoryAtURL:[destinationURL URLByDeletingLastPathComponent]
2133
+ withIntermediateDirectories:YES
2134
+ attributes:@{NSFileProtectionKey: NSFileProtectionCompleteUntilFirstUserAuthentication}
2135
+ error:nil];
1964
2136
  [fileManager removeItemAtURL:destinationURL error:nil];
1965
2137
 
1966
- return [fileManager moveItemAtURL:location toURL:destinationURL error:saveError];
2138
+ NSError *moveError = nil;
2139
+ BOOL moved = [fileManager moveItemAtURL:location toURL:destinationURL error:&moveError];
2140
+ if (moved) {
2141
+ // Apply the requested protection level to the final file (best-effort).
2142
+ NSString *protection = [self nsFileProtectionFromKey:taskConfig.dataProtection];
2143
+ [fileManager setAttributes:@{NSFileProtectionKey: protection} ofItemAtPath:destinationURL.path error:nil];
2144
+ return YES;
2145
+ }
2146
+
2147
+ // The move failed - most commonly because the device is locked and the destination is
2148
+ // protected (Data Protection). Stage the downloaded bytes to an unprotected location and
2149
+ // finish the move once the device is unlocked, instead of losing the download. See #101.
2150
+ if ([self stageAndDeferMoveForConfig:taskConfig location:location destination:destinationURL bytesDownloaded:bytesDownloaded bytesTotal:bytesTotal]) {
2151
+ if (deferred) *deferred = YES;
2152
+ return NO;
2153
+ }
2154
+
2155
+ // Couldn't even stage the file - report the original move error.
2156
+ if (saveError) *saveError = moveError;
2157
+ return NO;
2158
+ }
2159
+
2160
+ #pragma mark - Locked-device deferred move (issue #101)
2161
+
2162
+ // Directory (with NSFileProtectionNone) used to hold finished downloads whose move to the
2163
+ // final destination is deferred because the device is locked.
2164
+ - (NSURL *)pendingMovesDirectory {
2165
+ NSFileManager *fileManager = [NSFileManager defaultManager];
2166
+ NSURL *cachesDir = [[fileManager URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask] firstObject];
2167
+ NSURL *dir = [cachesDir URLByAppendingPathComponent:@"RNBGDPendingMoves" isDirectory:YES];
2168
+ if (![fileManager fileExistsAtPath:dir.path]) {
2169
+ // NSFileProtectionNone so we can write here even while the device is locked.
2170
+ [fileManager createDirectoryAtURL:dir
2171
+ withIntermediateDirectories:YES
2172
+ attributes:@{NSFileProtectionKey: NSFileProtectionNone}
2173
+ error:nil];
2174
+ }
2175
+ return dir;
2176
+ }
2177
+
2178
+ // Moves the just-finished download out of the system temp into an unprotected staging file and
2179
+ // records the pending move. Returns YES if the bytes were safely staged. Must hold sharedLock.
2180
+ - (BOOL)stageAndDeferMoveForConfig:(RNBGDTaskConfig *)taskConfig location:(NSURL *)location destination:(NSURL *)destinationURL bytesDownloaded:(int64_t)bytesDownloaded bytesTotal:(int64_t)bytesTotal {
2181
+ NSFileManager *fileManager = [NSFileManager defaultManager];
2182
+ NSURL *stagingURL = [[self pendingMovesDirectory] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.staged", taskConfig.id]];
2183
+
2184
+ [fileManager removeItemAtURL:stagingURL error:nil];
2185
+ NSError *stageError = nil;
2186
+ if (![fileManager moveItemAtURL:location toURL:stagingURL error:&stageError]) {
2187
+ DLog(taskConfig.id, @"[RNBackgroundDownloader] - [stageAndDeferMove] staging failed: %@", stageError.localizedDescription);
2188
+ return NO;
2189
+ }
2190
+ // Make sure the staged file is readable while locked.
2191
+ [fileManager setAttributes:@{NSFileProtectionKey: NSFileProtectionNone} ofItemAtPath:stagingURL.path error:nil];
2192
+
2193
+ pendingMoves[taskConfig.id] = @{
2194
+ @"id": taskConfig.id,
2195
+ @"staging": stagingURL.path,
2196
+ @"destination": destinationURL.path,
2197
+ @"dataProtection": taskConfig.dataProtection ?: @"",
2198
+ @"bytesDownloaded": @(bytesDownloaded),
2199
+ @"bytesTotal": @(bytesTotal)
2200
+ };
2201
+ [self persistPendingMoves];
2202
+ DLog(taskConfig.id, @"[RNBackgroundDownloader] - [stageAndDeferMove] staged %lld bytes, move deferred until unlock", (long long)bytesDownloaded);
2203
+ return YES;
2204
+ }
2205
+
2206
+ // Attempts to complete any deferred moves. Safe to call repeatedly; only succeeds for items
2207
+ // whose destination is currently writable (i.e. the device is unlocked enough). Emits
2208
+ // downloadComplete for each move that finishes.
2209
+ - (void)processPendingMoves {
2210
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
2211
+ @synchronized (self->sharedLock) {
2212
+ if (self->pendingMoves.count == 0) return;
2213
+
2214
+ NSFileManager *fileManager = [NSFileManager defaultManager];
2215
+ NSArray<NSString *> *ids = [self->pendingMoves allKeys];
2216
+ BOOL changed = NO;
2217
+
2218
+ for (NSString *taskId in ids) {
2219
+ NSDictionary *info = self->pendingMoves[taskId];
2220
+ NSString *stagingPath = info[@"staging"];
2221
+ NSString *destinationPath = info[@"destination"];
2222
+ if (stagingPath == nil || destinationPath == nil) {
2223
+ [self->pendingMoves removeObjectForKey:taskId];
2224
+ changed = YES;
2225
+ continue;
2226
+ }
2227
+
2228
+ if (![fileManager fileExistsAtPath:stagingPath]) {
2229
+ // Staged file is gone - nothing we can do, drop the record.
2230
+ [self->pendingMoves removeObjectForKey:taskId];
2231
+ changed = YES;
2232
+ continue;
2233
+ }
2234
+
2235
+ NSURL *destinationURL = [NSURL fileURLWithPath:destinationPath];
2236
+ [fileManager createDirectoryAtURL:[destinationURL URLByDeletingLastPathComponent]
2237
+ withIntermediateDirectories:YES
2238
+ attributes:@{NSFileProtectionKey: NSFileProtectionCompleteUntilFirstUserAuthentication}
2239
+ error:nil];
2240
+ [fileManager removeItemAtURL:destinationURL error:nil];
2241
+
2242
+ NSError *moveError = nil;
2243
+ BOOL moved = [fileManager moveItemAtURL:[NSURL fileURLWithPath:stagingPath] toURL:destinationURL error:&moveError];
2244
+ if (!moved) {
2245
+ // Still can't write (device likely still locked) - keep it for the next attempt.
2246
+ DLog(taskId, @"[RNBackgroundDownloader] - [processPendingMoves] still deferred: %@", moveError.localizedDescription);
2247
+ continue;
2248
+ }
2249
+
2250
+ NSString *protection = [self nsFileProtectionFromKey:(info[@"dataProtection"] != nil && [info[@"dataProtection"] length] > 0 ? info[@"dataProtection"] : nil)];
2251
+ [fileManager setAttributes:@{NSFileProtectionKey: protection} ofItemAtPath:destinationURL.path error:nil];
2252
+
2253
+ [self->pendingMoves removeObjectForKey:taskId];
2254
+ changed = YES;
2255
+
2256
+ DLog(taskId, @"[RNBackgroundDownloader] - [processPendingMoves] completed deferred move");
2257
+ NSDictionary *body = @{
2258
+ @"id": taskId,
2259
+ @"location": destinationPath,
2260
+ @"bytesDownloaded": info[@"bytesDownloaded"] ?: @0,
2261
+ @"bytesTotal": info[@"bytesTotal"] ?: @0
2262
+ };
2263
+ #ifdef RCT_NEW_ARCH_ENABLED
2264
+ [self safeEmitEvent:@"onDownloadComplete" value:body];
2265
+ #else
2266
+ [self sendEventWithName:@"downloadComplete" body:body];
2267
+ #endif
2268
+ }
2269
+
2270
+ if (changed) [self persistPendingMoves];
2271
+ }
2272
+ });
2273
+ }
2274
+
2275
+ - (void)persistPendingMoves {
2276
+ NSError *error = nil;
2277
+ NSData *data = [NSKeyedArchiver archivedDataWithRootObject:pendingMoves requiringSecureCoding:YES error:&error];
2278
+ if (error) {
2279
+ DLog(nil, @"[RNBackgroundDownloader] - [persistPendingMoves] serialization error: %@", error);
2280
+ return;
2281
+ }
2282
+ [mmkv setData:data forKey:PENDING_MOVES_MAP_KEY];
2283
+ }
2284
+
2285
+ - (NSMutableDictionary *)deserializePendingMoves:(NSData *)data {
2286
+ NSError *error = nil;
2287
+ NSSet *classes = [NSSet setWithObjects:[NSMutableDictionary class], [NSDictionary class], [NSString class], [NSNumber class], nil];
2288
+ NSDictionary *decoded = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes fromData:data error:&error];
2289
+ if (error || decoded == nil) {
2290
+ DLog(nil, @"[RNBackgroundDownloader] - [deserializePendingMoves] error: %@", error);
2291
+ return nil;
2292
+ }
2293
+ return [decoded mutableCopy];
1967
2294
  }
1968
2295
 
1969
2296
  #pragma mark - serialization
@@ -0,0 +1,39 @@
1
+ import { TaskInfo, DownloadTask as DownloadTaskType, BeginHandler, ProgressHandler, DoneHandler, ErrorHandler, BeginHandlerParams, ProgressHandlerParams, DoneHandlerParams, ErrorHandlerParams, TaskInfoNative, DownloadParams, DownloadTaskState, Metadata } from './types';
2
+ export declare class DownloadTask {
3
+ id: string;
4
+ metadata: Metadata;
5
+ destination?: string;
6
+ state: DownloadTaskState;
7
+ errorCode: number;
8
+ bytesDownloaded: number;
9
+ bytesTotal: number;
10
+ downloadParams?: DownloadParams;
11
+ beginHandler?: BeginHandler;
12
+ progressHandler?: ProgressHandler;
13
+ doneHandler?: DoneHandler;
14
+ errorHandler?: ErrorHandler;
15
+ constructor(taskParams: TaskInfo | TaskInfoNative, originalTask?: DownloadTaskType);
16
+ begin(handler: BeginHandler): this;
17
+ progress(handler: ProgressHandler): this;
18
+ done(handler: DoneHandler): this;
19
+ error(handler: ErrorHandler): this;
20
+ onBegin(params: BeginHandlerParams): void;
21
+ onProgress(params: ProgressHandlerParams): void;
22
+ onDone(params: DoneHandlerParams): void;
23
+ onError(params: ErrorHandlerParams): void;
24
+ /**
25
+ * Update download parameters.
26
+ * If the task is paused, this will also update headers in the native layer.
27
+ * If the task is in-progress or completed, only the local JS object is updated.
28
+ *
29
+ * @param downloadParams - The new download parameters
30
+ * @returns Promise<boolean> - true if native headers were updated, false otherwise
31
+ */
32
+ setDownloadParams(downloadParams: DownloadParams): Promise<boolean>;
33
+ pause(): Promise<void>;
34
+ resume(): Promise<void>;
35
+ start(): void;
36
+ stop(): Promise<void>;
37
+ tryParseJson(metadata?: string | Metadata | object): Metadata | null;
38
+ private headersToUnsafeObject;
39
+ }