@kesha-antonov/react-native-background-downloader 4.5.4 → 4.5.6

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.
package/README.md CHANGED
@@ -523,6 +523,39 @@ const task = createDownloadTask({
523
523
 
524
524
  </details>
525
525
 
526
+ <details>
527
+ <summary><strong>iOS Data Protection (downloading while the device is locked)</strong></summary>
528
+
529
+ On iOS, files protected with `NSFileProtectionComplete` cannot be written while the device is locked. Since a background download can finish while the screen is locked, the library saves files with **`completeUntilFirstUserAuthentication`** by default - writable while locked after the first unlock since boot - and, if a save still can't happen because the device is locked, it stages the bytes and finishes the save (emitting `complete`) when the device is next unlocked.
530
+
531
+ You usually don't need to change this. If your app has stricter security requirements you can raise the protection level globally or per task (iOS only - ignored on Android):
532
+
533
+ ```javascript
534
+ import { setConfig, createDownloadTask, directories } from '@kesha-antonov/react-native-background-downloader'
535
+
536
+ // Global default for all downloads
537
+ setConfig({
538
+ iosDataProtection: 'completeUntilFirstUserAuthentication', // default
539
+ })
540
+
541
+ // Per-download override
542
+ const task = createDownloadTask({
543
+ id: 'secret-doc',
544
+ url: 'https://example.com/secret.pdf',
545
+ destination: `${directories.documents}/secret.pdf`,
546
+ iosDataProtection: 'complete', // strongest; note: won't be writable while locked before first unlock
547
+ })
548
+ ```
549
+
550
+ | Value | NSFileProtection level | Notes |
551
+ |-------|------------------------|-------|
552
+ | `'completeUntilFirstUserAuthentication'` | `NSFileProtectionCompleteUntilFirstUserAuthentication` | **Default.** Accessible after the first unlock since boot - recommended for background downloads |
553
+ | `'complete'` | `NSFileProtectionComplete` | Strongest. File is inaccessible whenever the device is locked |
554
+ | `'completeUnlessOpen'` | `NSFileProtectionCompleteUnlessOpen` | Accessible while open, even if the device locks afterward |
555
+ | `'none'` | `NSFileProtectionNone` | No protection |
556
+
557
+ </details>
558
+
526
559
  <details>
527
560
  <summary><strong>Enabling debug logs</strong></summary>
528
561
 
@@ -865,6 +898,18 @@ If you're using `react-native-mmkv`, you don't need to add the MMKV dependency m
865
898
  This was fixed in v4.4.0. Update to the latest version. If you're not using `react-native-mmkv`, add `pod 'MMKV', '>= 1.0.0'` to your Podfile.
866
899
  </details>
867
900
 
901
+ <details>
902
+ <summary><strong>Downloads "don't work" when the iOS screen is locked</strong></summary>
903
+
904
+ On iOS the actual transfer is handed to the system `nsurlsessiond` daemon and **keeps running while the app is suspended and the screen is locked** - this library already configures the background session correctly (`discretionary = NO`, `sessionSendsLaunchEvents = YES`). If it looks like downloads stop when locked, check these in order:
905
+
906
+ 1. **Did you force-quit the app?** If the user swipes the app away in the app switcher, iOS halts *that app's* background transfers until it's relaunched. This is an OS policy and cannot be worked around by any library.
907
+ 2. **You won't get live `progress` events while locked.** Your JavaScript isn't running while the app is suspended, so `begin` / `progress` / `complete` callbacks fire when the app resumes or is relaunched - not in real time. Re-attach with `getExistingDownloadTasks()` on launch.
908
+ 3. **AppDelegate wiring.** Make sure `handleEventsForBackgroundURLSession` is implemented (see [Installation](#-installation)) and that you call `completeHandler(jobId)` from JS in your `complete`/`error` handlers, or iOS will throttle future background time.
909
+ 4. **Data Protection (the one this library handles).** On a passcoded, locked device, files protected with `NSFileProtectionComplete` can't be written, so a download that *finishes* while locked could previously fail to save. The library now writes the file with `NSFileProtectionCompleteUntilFirstUserAuthentication` by default (writable while locked after the first unlock since boot), and if the move still can't happen because the device is locked it stages the bytes and completes the save automatically when the device is next unlocked - emitting `complete` then. You can change the level with `setConfig({ iosDataProtection })` or per task (see Advanced Configuration).
910
+ 5. **Test on a real device.** The iOS Simulator's background-transfer behavior is unreliable; verify on hardware.
911
+ </details>
912
+
868
913
  <details>
869
914
  <summary><strong>Downloads not resuming after app restart</strong></summary>
870
915
 
@@ -34,6 +34,9 @@ object DownloadConstants {
34
34
  /** Interval for throttling progress log messages (milliseconds) */
35
35
  const val PROGRESS_LOG_INTERVAL_MS = 500L
36
36
 
37
+ /** Interval for persisting in-progress resumable download recovery snapshots (milliseconds) */
38
+ const val RECOVERY_SNAPSHOT_INTERVAL_MS = 2_000L
39
+
37
40
  // ========== HTTP Headers ==========
38
41
 
39
42
  /** Keep-Alive header value for connection pooling */
@@ -95,6 +95,9 @@ class Downloader(private val context: Context, private val storageManager: com.e
95
95
  pausedDownloads.putAll(loadedPaused)
96
96
  }
97
97
  }
98
+ // Recover any resumable downloads that were active when the app was last
99
+ // force-stopped (their foreground service and in-memory state were lost).
100
+ restoreRecoverableDownloads()
98
101
  }
99
102
 
100
103
  private fun bindToService() {
@@ -471,6 +474,95 @@ class Downloader(private val context: Context, private val storageManager: com.e
471
474
  return pausedDownloads.toMap()
472
475
  }
473
476
 
477
+ /**
478
+ * Get all in-progress resumable downloads tracked by the foreground service.
479
+ * On Android 14+ active downloads run as UIDT jobs instead and are not included here.
480
+ */
481
+ fun getActiveResumableDownloads(): Map<String, ResumableDownloader.DownloadState> {
482
+ return resumableDownloader.getActiveDownloads()
483
+ }
484
+
485
+ /**
486
+ * Persist a recovery snapshot for an in-progress resumable download so it can be
487
+ * recovered after a force-stop. Called throttled from progress callbacks.
488
+ * The byte counter stored here is advisory only - on recovery the resume offset
489
+ * is recomputed from the actual on-disk file length to avoid corruption.
490
+ */
491
+ fun saveActiveDownloadSnapshot(
492
+ configId: String,
493
+ url: String,
494
+ destination: String,
495
+ headers: Map<String, String>,
496
+ bytesDownloaded: Long,
497
+ bytesTotal: Long,
498
+ metadata: String = "{}"
499
+ ) {
500
+ storageManager?.saveActiveDownload(
501
+ PausedDownloadInfo(
502
+ configId = configId,
503
+ url = url,
504
+ destination = destination,
505
+ headers = headers,
506
+ bytesDownloaded = bytesDownloaded,
507
+ bytesTotal = bytesTotal,
508
+ metadata = metadata
509
+ )
510
+ )
511
+ }
512
+
513
+ /**
514
+ * Remove the recovery snapshot for a download (on complete, error, pause or stop).
515
+ */
516
+ fun removeActiveDownloadSnapshot(configId: String) {
517
+ storageManager?.removeActiveDownload(configId)
518
+ }
519
+
520
+ /**
521
+ * Recover resumable downloads that were active when the app was force-stopped.
522
+ * Moves their snapshots into the paused-downloads map (so getExistingDownloadTasks
523
+ * surfaces them and resumeTask works), recomputing the resume offset from the
524
+ * partial file's actual length. Snapshots without a partial file on disk are dropped.
525
+ */
526
+ private fun restoreRecoverableDownloads() {
527
+ val sm = storageManager ?: return
528
+ val recoverable = sm.loadActiveDownloads()
529
+ if (recoverable.isEmpty()) {
530
+ return
531
+ }
532
+
533
+ var recoveredCount = 0
534
+ for ((configId, info) in recoverable) {
535
+ // Skip if it is already tracked as paused, or still running as a UIDT job.
536
+ if (pausedDownloads.containsKey(configId)) {
537
+ continue
538
+ }
539
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
540
+ UIDTDownloadJobService.isActiveJob(configId)
541
+ ) {
542
+ continue
543
+ }
544
+
545
+ val destFile = File(info.destination)
546
+ val fileLength = if (destFile.exists()) destFile.length() else 0L
547
+ if (fileLength <= 0L) {
548
+ // Nothing downloaded yet - nothing to resume from.
549
+ continue
550
+ }
551
+
552
+ // Use the on-disk length as the authoritative resume offset.
553
+ pausedDownloads[configId] = info.copy(bytesDownloaded = fileLength)
554
+ recoveredCount++
555
+ }
556
+
557
+ if (recoveredCount > 0) {
558
+ savePausedDownloads()
559
+ RNBackgroundDownloaderModuleImpl.logD(TAG, "Recovered $recoveredCount interrupted resumable download(s) after force-stop")
560
+ }
561
+
562
+ // Clear the active store now that recovery has been processed.
563
+ sm.clearActiveDownloads()
564
+ }
565
+
474
566
  /**
475
567
  * Persist paused downloads to storage.
476
568
  */
@@ -160,6 +160,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
160
160
  // Map to store metadata for paused downloads
161
161
  private val configIdToMetadata = mutableMapOf<String, String>()
162
162
 
163
+ // Last time (ms) a recovery snapshot was persisted for a resumable download,
164
+ // used to throttle snapshot writes from the frequent progress callbacks.
165
+ private val configIdToLastSnapshotMs = mutableMapOf<String, Long>()
166
+
163
167
  /**
164
168
  * Get the event emitter, ensuring it's initialized.
165
169
  * Returns null if the module hasn't been initialized yet.
@@ -222,6 +226,9 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
222
226
 
223
227
  override fun onProgress(id: String, bytesDownloaded: Long, bytesTotal: Long) {
224
228
  onProgressDownload(id, bytesDownloaded, bytesTotal)
229
+ // Persist a recovery snapshot so this download can be recovered if the app
230
+ // is force-stopped (which kills the foreground service and its in-memory state).
231
+ maybeSnapshotActiveResumable(id, bytesDownloaded, bytesTotal)
225
232
  }
226
233
 
227
234
  override fun onComplete(id: String, location: String, bytesDownloaded: Long, bytesTotal: Long) {
@@ -515,7 +522,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
515
522
  configIdToDownloadId.remove(configId)
516
523
  configIdToHeaders.remove(configId)
517
524
  configIdToMetadata.remove(configId)
525
+ configIdToLastSnapshotMs.remove(configId)
518
526
  progressReporter.clearDownloadState(configId)
527
+ // Drop any force-stop recovery snapshot for this download.
528
+ downloader.removeActiveDownloadSnapshot(configId)
519
529
 
520
530
  if (downloadId != null) {
521
531
  downloadIdToConfig.remove(downloadId)
@@ -527,6 +537,42 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
527
537
  }
528
538
  }
529
539
 
540
+ /**
541
+ * Persist a recovery snapshot for an in-progress resumable download, throttled so
542
+ * the frequent progress callbacks don't write to storage on every buffer read.
543
+ * If the app is force-stopped, restoreRecoverableDownloads() uses this snapshot to
544
+ * surface the download again via getExistingDownloadTasks.
545
+ */
546
+ private fun maybeSnapshotActiveResumable(configId: String, bytesDownloaded: Long, bytesTotal: Long) {
547
+ val now = System.currentTimeMillis()
548
+ val last = configIdToLastSnapshotMs[configId] ?: 0L
549
+ if (now - last < DownloadConstants.RECOVERY_SNAPSHOT_INTERVAL_MS) {
550
+ return
551
+ }
552
+ configIdToLastSnapshotMs[configId] = now
553
+
554
+ // The active state lives in the foreground-service ResumableDownloader (Android < 14)
555
+ // or, on Android 14+, in the UIDT job. Check both so force-stop recovery works on
556
+ // every supported version.
557
+ val state = downloader.getActiveResumableDownloads()[configId]
558
+ ?: (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
559
+ UIDTDownloadJobService.getJobDownloadState(configId)
560
+ else null)
561
+ ?: return
562
+ val headers = configIdToHeaders[configId] ?: state.headers
563
+ val metadata = configIdToMetadata[configId] ?: "{}"
564
+
565
+ downloader.saveActiveDownloadSnapshot(
566
+ configId = configId,
567
+ url = state.url,
568
+ destination = state.destination,
569
+ headers = headers,
570
+ bytesDownloaded = bytesDownloaded,
571
+ bytesTotal = bytesTotal,
572
+ metadata = metadata
573
+ )
574
+ }
575
+
530
576
  /**
531
577
  * Resolve redirects for a URL up to maxRedirects limit
532
578
  * @param originalUrl The original URL to follow
@@ -841,6 +887,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
841
887
  metadata = metadata
842
888
  )
843
889
 
890
+ // Now tracked as an explicit paused download - drop the recovery snapshot.
891
+ downloader.removeActiveDownloadSnapshot(configId)
892
+ configIdToLastSnapshotMs.remove(configId)
893
+
844
894
  logD(NAME, "Paused resumable download: $configId (saved state: ${state.bytesDownloaded.get()}/${state.bytesTotal} bytes)")
845
895
  }
846
896
  return
@@ -1087,11 +1137,35 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
1087
1137
  processedIds.add(configId)
1088
1138
  }
1089
1139
 
1090
- // Phase 4: Add active resumable downloads (in-progress via ResumableDownloader)
1091
- val resumableDownloader = downloader.resumableDownloader
1092
- // Check for any paused resumable downloads that weren't in the persisted state
1093
- // (e.g., paused during current session but not yet persisted)
1094
- // This is handled by the pausedDownloads map above since we persist on pause
1140
+ // Phase 4: Add active resumable downloads (in-progress via ResumableDownloader).
1141
+ // These run in the foreground service (Android < 14) and are invisible to
1142
+ // DownloadManager, so without this phase getExistingDownloadTasks misses them
1143
+ // while they are actively downloading (it only saw them once paused, via Phase 3).
1144
+ // On Android 14+ active downloads run as UIDT jobs and are surfaced in Phase 2.
1145
+ for ((configId, state) in downloader.getActiveResumableDownloads()) {
1146
+ if (processedIds.contains(configId) || state.isCancelled.get()) {
1147
+ continue
1148
+ }
1149
+
1150
+ val params = Arguments.createMap()
1151
+ params.putString("id", configId)
1152
+ params.putString("metadata", configIdToMetadata[configId] ?: "{}")
1153
+ params.putInt(
1154
+ "state",
1155
+ if (state.isPaused.get()) DownloadConstants.TASK_SUSPENDED else DownloadConstants.TASK_RUNNING
1156
+ )
1157
+
1158
+ val bytesDownloaded = state.bytesDownloaded.get()
1159
+ val bytesTotal = state.bytesTotal
1160
+ params.putDouble("bytesDownloaded", bytesDownloaded.toDouble())
1161
+ params.putDouble("bytesTotal", bytesTotal.toDouble())
1162
+ params.putString("destination", state.destination)
1163
+
1164
+ val percent = if (bytesTotal > 0) bytesDownloaded.toDouble() / bytesTotal else 0.0
1165
+ foundTasks.pushMap(params)
1166
+ processedIds.add(configId)
1167
+ progressReporter.setPercent(configId, percent)
1168
+ }
1095
1169
 
1096
1170
  logD(NAME, "getExistingDownloadTasks: found ${foundTasks.size()} tasks")
1097
1171
  }
@@ -218,6 +218,13 @@ class ResumableDownloader {
218
218
 
219
219
  fun getState(id: String): DownloadState? = activeDownloads[id]
220
220
 
221
+ /**
222
+ * Returns a snapshot of all currently tracked download states.
223
+ * Used by getExistingDownloadTasks to surface in-progress resumable downloads
224
+ * (which are not visible to DownloadManager).
225
+ */
226
+ fun getActiveDownloads(): Map<String, DownloadState> = activeDownloads.toMap()
227
+
221
228
  fun isPaused(id: String): Boolean = activeDownloads[id]?.isPaused?.get() ?: false
222
229
 
223
230
  fun getBytesDownloaded(id: String): Long = activeDownloads[id]?.bytesDownloaded?.get() ?: 0
@@ -20,6 +20,7 @@ class StorageManager(context: Context, private val name: String) {
20
20
  private const val TAG = "StorageManager"
21
21
  private const val KEY_DOWNLOAD_ID_TO_CONFIG = "_downloadIdToConfig"
22
22
  private const val KEY_PAUSED_DOWNLOADS = "_pausedDownloads"
23
+ private const val KEY_ACTIVE_DOWNLOADS = "_activeDownloads"
23
24
  private const val KEY_UPLOAD_CONFIGS = "_uploadConfigs"
24
25
  private const val KEY_PROGRESS_INTERVAL = "_progressInterval"
25
26
  private const val KEY_PROGRESS_MIN_BYTES = "_progressMinBytes"
@@ -280,6 +281,100 @@ class StorageManager(context: Context, private val name: String) {
280
281
  }
281
282
  }
282
283
 
284
+ /**
285
+ * Save the set of in-progress resumable downloads ("recovery snapshots").
286
+ * These let getExistingDownloadTasks recover an active download after the app
287
+ * is force-stopped (which kills the foreground service and loses in-memory
288
+ * state). Stored separately from paused downloads so it never interferes with
289
+ * the live pause/resume bookkeeping.
290
+ */
291
+ fun saveActiveDownloads(activeDownloads: Map<String, Downloader.PausedDownloadInfo>) {
292
+ try {
293
+ val gson = Gson()
294
+ val mapCopy = activeDownloads.mapValues { (_, info) ->
295
+ PausedDownloadInfoData(
296
+ configId = info.configId,
297
+ url = info.url,
298
+ destination = info.destination,
299
+ headers = info.headers,
300
+ bytesDownloaded = info.bytesDownloaded,
301
+ bytesTotal = info.bytesTotal,
302
+ metadata = info.metadata
303
+ )
304
+ }
305
+ val str = gson.toJson(mapCopy)
306
+
307
+ if (isMMKVAvailable && mmkv != null) {
308
+ mmkv!!.encode("$name$KEY_ACTIVE_DOWNLOADS", str)
309
+ } else {
310
+ sharedPreferences.edit()
311
+ .putString("$name$KEY_ACTIVE_DOWNLOADS", str)
312
+ .commit()
313
+ }
314
+ } catch (e: Exception) {
315
+ RNBackgroundDownloaderModuleImpl.logE(TAG, "Failed to save active downloads: ${e.message}")
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Load the in-progress resumable download recovery snapshots.
321
+ */
322
+ fun loadActiveDownloads(): MutableMap<String, Downloader.PausedDownloadInfo> {
323
+ try {
324
+ val str = if (isMMKVAvailable && mmkv != null) {
325
+ mmkv!!.decodeString("$name$KEY_ACTIVE_DOWNLOADS")
326
+ } else {
327
+ sharedPreferences.getString("$name$KEY_ACTIVE_DOWNLOADS", null)
328
+ }
329
+
330
+ if (str != null && str.isNotEmpty()) {
331
+ val gson = Gson()
332
+ val mapType = object : TypeToken<Map<String, PausedDownloadInfoData>>() {}.type
333
+ val dataMap: Map<String, PausedDownloadInfoData> = gson.fromJson(str, mapType)
334
+ return dataMap.mapValues { (_, data) ->
335
+ Downloader.PausedDownloadInfo(
336
+ configId = data.configId,
337
+ url = data.url,
338
+ destination = data.destination,
339
+ headers = data.headers,
340
+ bytesDownloaded = data.bytesDownloaded,
341
+ bytesTotal = data.bytesTotal,
342
+ metadata = data.metadata
343
+ )
344
+ }.toMutableMap()
345
+ }
346
+ } catch (e: Exception) {
347
+ RNBackgroundDownloaderModuleImpl.logE(TAG, "Failed to load active downloads: ${e.message}")
348
+ }
349
+ return mutableMapOf()
350
+ }
351
+
352
+ /**
353
+ * Upsert a single in-progress resumable download recovery snapshot.
354
+ */
355
+ fun saveActiveDownload(info: Downloader.PausedDownloadInfo) {
356
+ val map = loadActiveDownloads()
357
+ map[info.configId] = info
358
+ saveActiveDownloads(map)
359
+ }
360
+
361
+ /**
362
+ * Remove a single recovery snapshot by config ID.
363
+ */
364
+ fun removeActiveDownload(configId: String) {
365
+ val map = loadActiveDownloads()
366
+ if (map.remove(configId) != null) {
367
+ saveActiveDownloads(map)
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Clear all recovery snapshots.
373
+ */
374
+ fun clearActiveDownloads() {
375
+ saveActiveDownloads(emptyMap())
376
+ }
377
+
283
378
  /**
284
379
  * Save upload configs map.
285
380
  */
@@ -14,6 +14,9 @@ NS_ASSUME_NONNULL_BEGIN
14
14
  @property (nonatomic, assign) NSInteger state;
15
15
  @property (nonatomic, assign) NSInteger errorCode;
16
16
  @property (nonatomic, assign) BOOL hasResumeData;
17
+ // iOS Data Protection level key for the saved file
18
+ // (e.g. NSFileProtectionCompleteUntilFirstUserAuthentication). nil = library default.
19
+ @property (nonatomic, copy, nullable) NSString *dataProtection;
17
20
 
18
21
  - (instancetype)initWithDictionary:(NSDictionary *)dict;
19
22
 
@@ -16,6 +16,7 @@
16
16
  self.url = dict[@"url"];
17
17
  self.destination = dict[@"destination"];
18
18
  self.metadata = dict[@"metadata"];
19
+ self.dataProtection = dict[@"dataProtection"];
19
20
  self.reportedBegin = NO;
20
21
  self.bytesDownloaded = 0;
21
22
  self.bytesTotal = 0;
@@ -33,6 +34,7 @@
33
34
  [aCoder encodeObject:self.url forKey:@"url"];
34
35
  [aCoder encodeObject:self.destination forKey:@"destination"];
35
36
  [aCoder encodeObject:self.metadata forKey:@"metadata"];
37
+ [aCoder encodeObject:self.dataProtection forKey:@"dataProtection"];
36
38
  [aCoder encodeBool:self.reportedBegin forKey:@"reportedBegin"];
37
39
  [aCoder encodeInt64:self.bytesDownloaded forKey:@"bytesDownloaded"];
38
40
  [aCoder encodeInt64:self.bytesTotal forKey:@"bytesTotal"];
@@ -52,6 +54,7 @@
52
54
  self.destination = [aDecoder decodeObjectOfClass:[NSString class] forKey:@"destination"];
53
55
  NSString *metadata = [aDecoder decodeObjectOfClass:[NSString class] forKey:@"metadata"];
54
56
  self.metadata = metadata != nil ? metadata : @"{}";
57
+ self.dataProtection = [aDecoder decodeObjectOfClass:[NSString class] forKey:@"dataProtection"];
55
58
  self.reportedBegin = [aDecoder decodeBoolForKey:@"reportedBegin"];
56
59
  self.bytesDownloaded = [aDecoder decodeInt64ForKey:@"bytesDownloaded"];
57
60
  self.bytesTotal = [aDecoder decodeInt64ForKey:@"bytesTotal"];