@kesha-antonov/react-native-background-downloader 4.5.4 → 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.
@@ -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,11 @@ 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
+
69
78
  #ifdef RCT_NEW_ARCH_ENABLED
70
79
  // Queue of events that arrived before the TurboModule event emitter callback was set.
71
80
  // This prevents crashes (std::bad_function_call / SIGABRT) when NSURLSession delegate
@@ -240,6 +249,11 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
240
249
  isSessionActivated = NO;
241
250
  pendingDownloads = [[NSMutableArray alloc] init];
242
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
+
243
257
  #ifdef RCT_NEW_ARCH_ENABLED
244
258
  pendingEmitEvents = [[NSMutableArray alloc] init];
245
259
  #endif
@@ -260,6 +274,10 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
260
274
 
261
275
  // Initialize session early to receive background events on app relaunch
262
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];
263
281
  }
264
282
 
265
283
  return self;
@@ -341,10 +359,23 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
341
359
  selector:@selector(handleBridgeHotReload:)
342
360
  name:RCTJavaScriptWillStartLoadingNotification
343
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];
344
370
  }
345
371
  }
346
372
  }
347
373
 
374
+ - (void)handleProtectedDataAvailable:(NSNotification *) note {
375
+ DLog(nil, @"[RNBackgroundDownloader] - [handleProtectedDataAvailable]");
376
+ [self processPendingMoves];
377
+ }
378
+
348
379
  - (void)unregisterBridgeListener {
349
380
  DLog(nil, @"[RNBackgroundDownloader] - [unregisterBridgeListener]");
350
381
  if (isBridgeListenerInited == YES) {
@@ -356,6 +387,8 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
356
387
  - (void)handleBridgeAppEnterForeground:(NSNotification *) note {
357
388
  DLog(nil, @"[RNBackgroundDownloader] - [handleBridgeAppEnterForeground]");
358
389
  [self resumeTasks];
390
+ // Device is unlocked when foregrounded, so finish any locked-deferred file moves.
391
+ [self processPendingMoves];
359
392
  }
360
393
 
361
394
  - (void)resumeTasks {
@@ -414,15 +447,24 @@ RCT_EXPORT_METHOD(setLogsEnabled:(BOOL)enabled) {
414
447
 
415
448
  - (void)_setMaxParallelDownloadsInternal:(NSInteger)max {
416
449
  DLog(nil, @"[RNBackgroundDownloader] - [setMaxParallelDownloads:%ld]", (long)max);
417
- @synchronized (sharedLock) {
418
- if (max >= 1) {
419
- sessionConfig.HTTPMaximumConnectionsPerHost = max;
420
- // Recreate session with new config if it's already initialized
421
- if (urlSession != nil) {
422
- [self unregisterSession];
423
- [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
+ }
424
464
  }
425
465
  }
466
+ } @catch (NSException *exception) {
467
+ DLog(nil, @"[RNBackgroundDownloader] - [setMaxParallelDownloads] error: %@", exception.reason);
426
468
  }
427
469
  }
428
470
 
@@ -438,13 +480,20 @@ RCT_EXPORT_METHOD(setMaxParallelDownloads:(NSInteger)max) {
438
480
 
439
481
  - (void)_setAllowsCellularAccessInternal:(BOOL)allows {
440
482
  DLog(nil, @"[RNBackgroundDownloader] - [setAllowsCellularAccess:%@]", allows ? @"YES" : @"NO");
441
- @synchronized (sharedLock) {
442
- sessionConfig.allowsCellularAccess = allows;
443
- // Recreate session with new config if it's already initialized
444
- if (urlSession != nil) {
445
- [self unregisterSession];
446
- [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
+ }
447
494
  }
495
+ } @catch (NSException *exception) {
496
+ DLog(nil, @"[RNBackgroundDownloader] - [setAllowsCellularAccess] error: %@", exception.reason);
448
497
  }
449
498
  }
450
499
 
@@ -466,6 +515,7 @@ RCT_EXPORT_METHOD(setAllowsCellularAccess:(BOOL)allows) {
466
515
  NSString *destination = options.destination();
467
516
  NSString *metadata = options.metadata() ? options.metadata() : @"";
468
517
  NSDictionary *headers = options.headers() ? (NSDictionary *)options.headers() : nil;
518
+ NSString *dataProtection = options.iosDataProtection() ? options.iosDataProtection() : nil;
469
519
 
470
520
  if (options.progressInterval().has_value()) {
471
521
  progressInterval = options.progressInterval().value() / 1000.0;
@@ -486,6 +536,7 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
486
536
  NSString *destination = options[@"destination"];
487
537
  NSString *metadata = options[@"metadata"] ?: @"";
488
538
  NSDictionary *headers = options[@"headers"];
539
+ NSString *dataProtection = options[@"iosDataProtection"];
489
540
 
490
541
  NSNumber *progressIntervalScope = options[@"progressInterval"];
491
542
  if (progressIntervalScope) {
@@ -522,38 +573,47 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
522
573
  }
523
574
  }
524
575
 
525
- @synchronized (sharedLock) {
526
- [self sendDebugLog:@"download: calling lazyRegisterSession" taskId:identifier];
527
- [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];
528
584
 
529
- // If session is not yet activated, queue the download to be executed after activation
530
- // This fixes the issue where downloads don't start on fresh app installs
531
- if (!isSessionActivated) {
532
- DLog(identifier, @"[RNBackgroundDownloader] - [download] session not activated, queueing download");
533
- [self sendDebugLog:@"download: session not activated, queueing download" taskId:identifier];
534
- __weak RNBackgroundDownloader *weakSelf = self;
535
- dispatch_block_t downloadBlock = ^{
536
- RNBackgroundDownloader *strongSelf = weakSelf;
537
- if (strongSelf) {
538
- [strongSelf executeDownloadWithRequest:request identifier:identifier url:url destination:destination metadata:metadata];
539
- }
540
- };
541
- [pendingDownloads addObject:downloadBlock];
542
- return;
543
- }
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
+ }
544
600
 
545
- [self sendDebugLog:@"download: session activated, executing download" taskId:identifier];
546
- [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];
547
607
  }
548
608
  }
549
609
 
550
610
  // Internal method to execute the download after session is activated
551
- - (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 {
552
612
  @synchronized (sharedLock) {
553
613
  DLog(identifier, @"[RNBackgroundDownloader] - [executeDownloadWithRequest]");
554
614
  [self sendDebugLog:@"executeDownloadWithRequest: creating download task" taskId:identifier];
555
615
 
556
- NSURLSessionDownloadTask __strong *task = [urlSession downloadTaskWithRequest:request];
616
+ NSURLSessionDownloadTask __strong *task = [self createDownloadTaskWithRequest:request identifier:identifier];
557
617
  if (task == nil) {
558
618
  DLog(identifier, @"[RNBackgroundDownloader] - [Error] failed to create download task");
559
619
  [self sendDebugLog:@"executeDownloadWithRequest: ERROR - failed to create download task" taskId:identifier];
@@ -568,6 +628,8 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
568
628
  @"destination": destination,
569
629
  @"metadata": metadata
570
630
  }];
631
+ // nil means "use the library default" when saving the file (see -nsFileProtectionFromKey:)
632
+ taskConfig.dataProtection = dataProtection;
571
633
 
572
634
  taskToConfigMap[@(task.taskIdentifier)] = taskConfig;
573
635
  [mmkv setData:[self serialize: taskToConfigMap] forKey:ID_TO_CONFIG_MAP_KEY];
@@ -582,6 +644,36 @@ RCT_EXPORT_METHOD(download: (NSDictionary *) options) {
582
644
  }
583
645
  }
584
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
+
585
677
  - (void)pauseTaskInternal:(NSString *)identifier resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
586
678
  DLog(identifier, @"[RNBackgroundDownloader] - [pauseTask]");
587
679
  @try {
@@ -776,6 +868,15 @@ RCT_EXPORT_METHOD(resumeTask:(NSString *)id resolver:(RCTPromiseResolveBlock)res
776
868
  [idsToPauseSet removeObject:identifier];
777
869
  // Clean up any updated headers
778
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
+ }
779
880
  }
780
881
  resolve(nil);
781
882
  } @catch (NSException *exception) {
@@ -1194,17 +1295,27 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
1194
1295
  [self sendDebugLog:@"didFinishDownloadingToURL: download finished" taskId:taskConfig.id];
1195
1296
 
1196
1297
  NSError *error = [self getServerError:downloadTask];
1298
+ BOOL deferred = NO;
1197
1299
  if (!error) {
1198
- [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;
1199
1313
  }
1200
1314
 
1201
1315
  if (error) {
1202
1316
  [self sendDebugLog:[NSString stringWithFormat:@"didFinishDownloadingToURL: error - %@", error.localizedDescription] taskId:taskConfig.id];
1203
1317
  }
1204
1318
 
1205
- // Drop any buffered progress for this task so it cannot arrive in JS after downloadComplete
1206
- [progressReports removeObjectForKey:taskConfig.id];
1207
-
1208
1319
  [self sendDownloadCompletionEvent:taskConfig task:downloadTask error:error];
1209
1320
 
1210
1321
  [self removeTaskFromMap:downloadTask];
@@ -1981,8 +2092,20 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
1981
2092
  userInfo:@{NSLocalizedDescriptionKey: [NSHTTPURLResponse localizedStringForStatusCode:statusCode]}];
1982
2093
  }
1983
2094
 
1984
- - (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 {
1985
2107
  DLog(taskConfig.id, @"[RNBackgroundDownloader] - [saveFile]");
2108
+ if (deferred) *deferred = NO;
1986
2109
  // taskConfig.destination is absolute path.
1987
2110
  // The absolute path may change when the application is restarted.
1988
2111
  // But the relative path remains the same.
@@ -2004,10 +2127,170 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
2004
2127
  NSURL *destinationURL = [NSURL fileURLWithPath:fileAbsolutePath];
2005
2128
 
2006
2129
  NSFileManager *fileManager = [NSFileManager defaultManager];
2007
- [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];
2008
2136
  [fileManager removeItemAtURL:destinationURL error:nil];
2009
2137
 
2010
- 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];
2011
2294
  }
2012
2295
 
2013
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
+ }