@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
package/README.md CHANGED
@@ -15,14 +15,14 @@
15
15
  <h1 align="center">React Native Background Downloader</h1>
16
16
 
17
17
  <p align="center">
18
- Download and upload large files on iOS & Android — even when your app is in the background or terminated.
18
+ Download and upload large files on iOS & Android — even when your app is in the background or terminated by the OS.
19
19
  </p>
20
20
 
21
21
  ---
22
22
 
23
23
  ## ✨ Features
24
24
 
25
- - 📥 **Background Downloads** - Downloads continue even when app is in background or terminated
25
+ - 📥 **Background Downloads** - Downloads continue even when app is in background or terminated by the OS
26
26
  - 📤 **Background Uploads** - Upload files reliably in the background
27
27
  - ⏸️ **Pause/Resume** - Full pause and resume support on both iOS and Android
28
28
  - 🔄 **Re-attach to Downloads** - Reconnect to ongoing downloads after app restart
@@ -37,7 +37,7 @@
37
37
  **The Problem:** Standard network requests in React Native are tied to your app's lifecycle. When the user switches to another app or the OS terminates your app to free memory, your downloads stop. For small files this is fine, but for large files (videos, podcasts, documents) this creates a frustrating user experience.
38
38
 
39
39
  **The Solution:** Both iOS and Android provide system-level APIs for background file transfers:
40
- - **iOS:** [`NSURLSession`](https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background) - handles downloads in a separate process, continuing even after your app is terminated
40
+ - **iOS:** [`NSURLSession`](https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background) - handles downloads in a separate process, continuing even after your app is terminated by the OS. Note: if the user explicitly force-kills the app via the App Switcher, iOS cancels all background tasks — this is an iOS system limitation that cannot be overridden
41
41
  - **Android:** A combination of [`DownloadManager`](https://developer.android.com/reference/android/app/DownloadManager) for system-managed downloads, [Foreground Services](https://developer.android.com/develop/background-work/services/foreground-services) for pause/resume support, and [MMKV](https://github.com/Tencent/MMKV) for persistent state storage
42
42
 
43
43
  **The Challenge:** These APIs are powerful but complex. Downloads run in a separate process, so your app might restart from scratch while downloads are still in progress. Keeping your UI in sync with background downloads requires careful state management.
@@ -53,6 +53,7 @@
53
53
  - [📦 Installation](#-installation)
54
54
  - [Expo Projects](#expo-projects)
55
55
  - [Bare React Native Projects](#bare-react-native-projects)
56
+ - [MMKV version comparison](#mmkv-version-comparison)
56
57
  - [🚀 Usage](#-usage)
57
58
  - [Downloading a file](#downloading-a-file)
58
59
  - [Re-Attaching to background tasks](#re-attaching-to-background-tasks)
@@ -113,7 +114,7 @@ export default {
113
114
  expo: {
114
115
  plugins: [
115
116
  ["@kesha-antonov/react-native-background-downloader", {
116
- mmkvVersion: "2.2.4", // Customize MMKV version on Android
117
+ mmkvVersion: "1.3.16", // Customize MMKV version on Android
117
118
  skipMmkvDependency: true // Skip if you want to add MMKV manually
118
119
  }]
119
120
  ]
@@ -123,8 +124,8 @@ export default {
123
124
 
124
125
  | Option | Type | Default | Description |
125
126
  |--------|------|---------|-------------|
126
- | `mmkvVersion` | string | `'2.2.4'` | The version of [MMKV](https://github.com/Tencent/MMKV/releases) to use on Android. |
127
- | `skipMmkvDependency` | boolean | `false` | Skip adding MMKV dependency. Set to `true` if you're using [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) to avoid duplicate class errors. The plugin auto-detects `react-native-mmkv` but you can use this option to explicitly skip. |
127
+ | `mmkvVersion` | string | `'1.3.16'` | The version of [MMKV](https://github.com/Tencent/MMKV/releases) to use on Android. See [MMKV version comparison](#mmkv-version-comparison) for details. |
128
+ | `skipMmkvDependency` | boolean | `false` | Skip adding MMKV dependency. Set to `true` if you're using [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) to avoid duplicate class errors. The plugin auto-detects `react-native-mmkv` but you can use this option to explicitly skip. See [MMKV version comparison](#mmkv-version-comparison). |
128
129
 
129
130
  </details>
130
131
 
@@ -214,11 +215,29 @@ Add MMKV to your `android/app/build.gradle`:
214
215
 
215
216
  ```gradle
216
217
  dependencies {
217
- implementation 'com.tencent:mmkv-shared:2.2.4'
218
+ implementation 'com.tencent:mmkv-shared:1.3.16'
218
219
  }
219
220
  ```
220
221
 
221
- > **Note:** If you're already using [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) in your project, skip this step — it already includes MMKV.
222
+ > **Note:** If you're already using [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) in your project, skip this step — it already includes MMKV. Note that `react-native-mmkv` v4.x uses [Margelo's fork of MMKV](https://github.com/margelo/MMKV) (`io.github.zhongwuzw:mmkv`) which re-adds armeabi-v7a (32-bit ARM) support that was dropped in the official MMKV 2.x release.
223
+
224
+ > **⚠️ armeabi-v7a (32-bit ARM) users:** MMKV 2.x dropped 32-bit ABI support (since v2.0.0). If you need armeabi-v7a support and get a CMake error like `No compatible library found for //mmkv/mmkv`, use the MMKV 1.3.x LTS series instead — it supports both armeabi-v7a **and** 16KB page sizes (since v1.3.14):
225
+ > ```gradle
226
+ > dependencies {
227
+ > implementation 'com.tencent:mmkv-shared:1.3.16'
228
+ > }
229
+ > ```
230
+
231
+ #### MMKV version comparison
232
+
233
+ | Dependency | armeabi-v7a (32-bit) | arm64-v8a | 16KB page size | Recommended for |
234
+ |---|:---:|:---:|:---:|---|
235
+ | `com.tencent:mmkv-shared:1.3.16` (**default**) | ✅ | ✅ | ✅ (since 1.3.14) | Most apps — broadest device coverage |
236
+ | `com.tencent:mmkv-shared:2.x` | ❌ | ✅ | ✅ | 64-bit only apps (no legacy devices) |
237
+ | `io.github.zhongwuzw:mmkv:2.3.0` ([Margelo fork](https://github.com/margelo/MMKV)) | ✅ | ✅ | ✅ | Used automatically by `react-native-mmkv` v4.x — skip manual dependency |
238
+ | `react-native-mmkv` (already in project) | ✅ | ✅ | ✅ | If you already use `react-native-mmkv` — skip Step 4 entirely |
239
+
240
+ **TL;DR:** Use the default `1.3.16`. If you already have `react-native-mmkv` in your project, skip Step 4.
222
241
 
223
242
  ## 🚀 Usage
224
243
 
@@ -504,6 +523,39 @@ const task = createDownloadTask({
504
523
 
505
524
  </details>
506
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
+
507
559
  <details>
508
560
  <summary><strong>Enabling debug logs</strong></summary>
509
561
 
@@ -836,6 +888,8 @@ This can happen with slow-responding servers. The library automatically adds kee
836
888
  <summary><strong>Duplicate class errors with react-native-mmkv (Android)</strong></summary>
837
889
 
838
890
  If you're using `react-native-mmkv`, you don't need to add the MMKV dependency manually - it's already included. The library uses `compileOnly` to avoid conflicts.
891
+
892
+ `react-native-mmkv` v4.x uses [Margelo's fork of MMKV](https://github.com/margelo/MMKV) (`io.github.zhongwuzw:mmkv`) which re-adds armeabi-v7a (32-bit ARM) support, so you have full ABI coverage including 32-bit devices when using `react-native-mmkv`.
839
893
  </details>
840
894
 
841
895
  <details>
@@ -844,6 +898,18 @@ If you're using `react-native-mmkv`, you don't need to add the MMKV dependency m
844
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.
845
899
  </details>
846
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
+
847
913
  <details>
848
914
  <summary><strong>Downloads not resuming after app restart</strong></summary>
849
915
 
@@ -36,6 +36,8 @@ android {
36
36
  versionName '1.0'
37
37
 
38
38
  // Support for 16KB memory page sizes (Android 15+)
39
+ // Note: MMKV 2.x dropped armeabi-v7a support. If your app targets armeabi-v7a,
40
+ // downgrade to MMKV 1.x in your app's build.gradle (see README for details).
39
41
  ndk {
40
42
  abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
41
43
  }
@@ -78,8 +80,8 @@ dependencies {
78
80
  // MMKV dependency for persistent download state storage
79
81
  // Uses compileOnly to avoid duplicate class errors when app also uses react-native-mmkv
80
82
  // The app must provide MMKV dependency (either directly or via react-native-mmkv)
81
- // MMKV 2.0.0+ is required for 16KB memory page size support (Android 15+)
82
- compileOnly 'com.tencent:mmkv-shared:2.0.0'
83
+ // MMKV 1.3.14+ supports both armeabi-v7a (32-bit) and 16KB page sizes (Android 15+)
84
+ compileOnly 'com.tencent:mmkv-shared:1.3.16'
83
85
 
84
86
  implementation 'com.google.code.gson:gson:2.12.1'
85
87
  }
@@ -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() {
@@ -111,8 +114,18 @@ class Downloader(private val context: Context, private val storageManager: com.e
111
114
 
112
115
  /**
113
116
  * Set the download listener for resumable downloads.
117
+ * Also sets the UIDT listener on Android 14+ so that ongoing UIDT jobs
118
+ * (e.g. ones that survived app termination) can forward events to JS after
119
+ * the app is reopened.
114
120
  */
115
121
  fun setResumableDownloadListener(listener: ResumableDownloader.DownloadListener) {
122
+ // For UIDT jobs (Android 14+), set the listener directly on the registry.
123
+ // This reconnects event forwarding after app restart when UIDT jobs are
124
+ // already running but UIDTJobRegistry.downloadListener was null in the
125
+ // fresh process.
126
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
127
+ UIDTDownloadJobService.downloadListener = listener
128
+ }
116
129
  executeWhenServiceReady {
117
130
  downloadService?.setDownloadListener(listener)
118
131
  }
@@ -461,6 +474,95 @@ class Downloader(private val context: Context, private val storageManager: com.e
461
474
  return pausedDownloads.toMap()
462
475
  }
463
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
+
464
566
  /**
465
567
  * Persist paused downloads to storage.
466
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,9 +226,14 @@ 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) {
235
+ // Drop any buffered progress for this task so it cannot arrive in JS after downloadComplete
236
+ progressReporter.clearPendingReport(id)
228
237
  eventEmitter.emitComplete(id, location, bytesDownloaded, bytesTotal)
229
238
 
230
239
  // Clean up all download state
@@ -234,6 +243,8 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
234
243
  }
235
244
 
236
245
  override fun onError(id: String, error: String, errorCode: Int) {
246
+ // Drop any buffered progress for this task so it cannot arrive in JS after downloadFailed
247
+ progressReporter.clearPendingReport(id)
237
248
  eventEmitter.emitFailed(id, error, errorCode)
238
249
 
239
250
  // Clean up all download state
@@ -385,6 +396,9 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
385
396
  stopTaskProgress(config.id)
386
397
 
387
398
  synchronized(sharedLock) {
399
+ // Drop any buffered progress that slipped past stopTaskProgress's clearPendingReport
400
+ // (the polling thread may have re-added it between clearPendingReport and this lock)
401
+ progressReporter.clearPendingReport(config.id)
388
402
  when (status) {
389
403
  DownloadManager.STATUS_SUCCESSFUL -> {
390
404
  onSuccessfulDownload(config, downloadStatus)
@@ -508,7 +522,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
508
522
  configIdToDownloadId.remove(configId)
509
523
  configIdToHeaders.remove(configId)
510
524
  configIdToMetadata.remove(configId)
525
+ configIdToLastSnapshotMs.remove(configId)
511
526
  progressReporter.clearDownloadState(configId)
527
+ // Drop any force-stop recovery snapshot for this download.
528
+ downloader.removeActiveDownloadSnapshot(configId)
512
529
 
513
530
  if (downloadId != null) {
514
531
  downloadIdToConfig.remove(downloadId)
@@ -520,6 +537,42 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
520
537
  }
521
538
  }
522
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
+
523
576
  /**
524
577
  * Resolve redirects for a URL up to maxRedirects limit
525
578
  * @param originalUrl The original URL to follow
@@ -834,6 +887,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
834
887
  metadata = metadata
835
888
  )
836
889
 
890
+ // Now tracked as an explicit paused download - drop the recovery snapshot.
891
+ downloader.removeActiveDownloadSnapshot(configId)
892
+ configIdToLastSnapshotMs.remove(configId)
893
+
837
894
  logD(NAME, "Paused resumable download: $configId (saved state: ${state.bytesDownloaded.get()}/${state.bytesTotal} bytes)")
838
895
  }
839
896
  return
@@ -1080,11 +1137,35 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
1080
1137
  processedIds.add(configId)
1081
1138
  }
1082
1139
 
1083
- // Phase 4: Add active resumable downloads (in-progress via ResumableDownloader)
1084
- val resumableDownloader = downloader.resumableDownloader
1085
- // Check for any paused resumable downloads that weren't in the persisted state
1086
- // (e.g., paused during current session but not yet persisted)
1087
- // 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
+ }
1088
1169
 
1089
1170
  logD(NAME, "getExistingDownloadTasks: found ${foundTasks.size()} tasks")
1090
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
@@ -148,7 +148,7 @@ class UIDTDownloadJobService : JobService() {
148
148
  }
149
149
  val url = extras.getString(UIDTConstants.KEY_URL) ?: return false
150
150
  val destination = extras.getString(UIDTConstants.KEY_DESTINATION) ?: return false
151
- val startByte = extras.getLong(UIDTConstants.KEY_START_BYTE, 0)
151
+ val startByteFromExtras = extras.getLong(UIDTConstants.KEY_START_BYTE, 0)
152
152
  val totalBytes = extras.getLong(UIDTConstants.KEY_TOTAL_BYTES, -1)
153
153
 
154
154
  // Extract group info from metadata (for notification grouping)
@@ -165,8 +165,36 @@ class UIDTDownloadJobService : JobService() {
165
165
  RNBackgroundDownloaderModuleImpl.logE(UIDTConstants.TAG, "Failed to parse metadata: ${e.message}")
166
166
  }
167
167
 
168
- // Retrieve headers
169
- val headers = UIDTJobRegistry.pendingHeaders.remove(configId) ?: emptyMap()
168
+ // Resolve headers and start byte.
169
+ // In-memory pendingHeaders is populated in the same process (scheduleDownload / onStopJob).
170
+ // The disk-persisted resume state is the fallback for a fresh process after a restart.
171
+ val inMemoryHeaders = UIDTJobRegistry.pendingHeaders.remove(configId)
172
+ val diskResumeState = UIDTJobRegistry.loadResumeState(this, configId)
173
+ // Clear disk state now that we've consumed it.
174
+ UIDTJobRegistry.clearResumeState(this, configId)
175
+
176
+ val headers: Map<String, String>
177
+ val startByte: Long
178
+ when {
179
+ inMemoryHeaders != null -> {
180
+ // Same-process restart: use in-memory headers.
181
+ // Prefer the disk byte position if it is more advanced than the extras
182
+ // (written by onStopJob with the actual download offset).
183
+ headers = inMemoryHeaders
184
+ startByte = if (diskResumeState != null && diskResumeState.second > startByteFromExtras)
185
+ diskResumeState.second else startByteFromExtras
186
+ }
187
+ diskResumeState != null -> {
188
+ // Cross-process restart: use the persisted state.
189
+ headers = diskResumeState.first
190
+ startByte = diskResumeState.second
191
+ }
192
+ else -> {
193
+ headers = emptyMap()
194
+ startByte = startByteFromExtras
195
+ }
196
+ }
197
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "onStartJob: configId=$configId, startByte=$startByte, headers=${headers.size}")
170
198
 
171
199
  // Create notification for UIDT job (required)
172
200
  val notificationId = UIDTNotificationManager.getNotificationIdForConfig(configId)
@@ -233,8 +261,12 @@ class UIDTDownloadJobService : JobService() {
233
261
  // Save state for potential resume
234
262
  val state = jobState.resumableDownloader.getState(configId)
235
263
  if (state != null) {
236
- // Store resume info
264
+ val bytesDownloaded = state.bytesDownloaded.get()
265
+ // Keep in-memory headers so the same-process reschedule works.
237
266
  UIDTJobRegistry.pendingHeaders[configId] = state.headers
267
+ // Persist to disk so a fresh process can resume from the right position.
268
+ UIDTJobRegistry.saveResumeState(this, configId, state.headers, bytesDownloaded)
269
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "onStopJob: saved resume state for $configId at $bytesDownloaded bytes")
238
270
  }
239
271
  }
240
272
  UIDTJobRegistry.activeJobs.remove(configId)
@@ -364,6 +396,9 @@ class UIDTDownloadJobService : JobService() {
364
396
  // Signal job completion - this triggers the REMOVE policy
365
397
  jobFinished(params, false)
366
398
 
399
+ // Clear persisted resume state - no longer needed after successful completion
400
+ UIDTJobRegistry.clearResumeState(this@UIDTDownloadJobService, id)
401
+
367
402
  // Notify external listener
368
403
  UIDTJobRegistry.downloadListener?.onComplete(id, location, bytesDownloaded, bytesTotal)
369
404
  }
@@ -402,6 +437,9 @@ class UIDTDownloadJobService : JobService() {
402
437
  // Signal job completion with no reschedule - this triggers the REMOVE policy
403
438
  jobFinished(params, false)
404
439
 
440
+ // Clear persisted resume state - no longer needed after failure
441
+ UIDTJobRegistry.clearResumeState(this@UIDTDownloadJobService, id)
442
+
405
443
  // Notify external listener
406
444
  UIDTJobRegistry.downloadListener?.onError(id, error, errorCode)
407
445
  }
@@ -66,6 +66,9 @@ object UIDTJobManager {
66
66
 
67
67
  // Store headers for later retrieval (PersistableBundle can't store Map<String, String>)
68
68
  UIDTJobRegistry.pendingHeaders[configId] = headers
69
+ // Also persist to disk so headers survive process death and are available
70
+ // in onStartJob even when the process is restarted by the JobScheduler.
71
+ UIDTJobRegistry.saveResumeState(context, configId, headers, startByte)
69
72
 
70
73
  // Create extras bundle
71
74
  val extras = PersistableBundle().apply {
@@ -77,9 +80,14 @@ object UIDTJobManager {
77
80
  putString(UIDTConstants.KEY_METADATA, metadata)
78
81
  }
79
82
 
80
- // Build network request - require internet connectivity
83
+ // Build network request - require internet connectivity.
84
+ // Remove NET_CAPABILITY_NOT_VPN (added by Builder default) so that VPN networks
85
+ // (e.g. Proton VPN, full-tunnel VPNs) are accepted. Without this, the JobScheduler
86
+ // only considers non-VPN networks; a kill-switch VPN blocks that traffic and the
87
+ // job never starts, causing callbacks to never fire.
81
88
  val networkRequest = NetworkRequest.Builder()
82
89
  .addCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
90
+ .removeCapability(android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
83
91
  .build()
84
92
 
85
93
  // Build the job with UIDT flag
@@ -150,6 +158,8 @@ object UIDTJobManager {
150
158
  jobScheduler.cancel(jobId)
151
159
  UIDTJobRegistry.pendingHeaders.remove(configId)
152
160
  UIDTJobRegistry.activeJobs.remove(configId)
161
+ // Clear persisted resume state so stale headers/bytes don't affect future downloads
162
+ UIDTJobRegistry.clearResumeState(context, configId)
153
163
 
154
164
  // Update summary notification if grouping was enabled
155
165
  if (jobState != null && config.groupingEnabled && jobState.groupId.isNotEmpty()) {