@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.
- package/README.md +74 -8
- package/android/build.gradle +4 -2
- package/android/src/main/java/com/eko/DownloadConstants.kt +3 -0
- package/android/src/main/java/com/eko/Downloader.kt +102 -0
- package/android/src/main/java/com/eko/RNBackgroundDownloaderModuleImpl.kt +86 -5
- package/android/src/main/java/com/eko/ResumableDownloader.kt +7 -0
- package/android/src/main/java/com/eko/UIDTDownloadJobService.kt +42 -4
- package/android/src/main/java/com/eko/uidt/UIDTJobManager.kt +11 -1
- package/android/src/main/java/com/eko/uidt/UIDTJobState.kt +62 -0
- package/android/src/main/java/com/eko/utils/StorageManager.kt +95 -0
- package/ios/RNBGDTaskConfig.h +3 -0
- package/ios/RNBGDTaskConfig.mm +3 -0
- package/ios/RNBackgroundDownloader.mm +375 -48
- package/lib/DownloadTask.d.ts +39 -0
- package/lib/DownloadTask.js +176 -0
- package/lib/NativeRNBackgroundDownloader.d.ts +127 -0
- package/lib/NativeRNBackgroundDownloader.js +4 -0
- package/lib/UploadTask.d.ts +30 -0
- package/lib/UploadTask.js +174 -0
- package/lib/config.d.ts +33 -0
- package/lib/config.js +79 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.js +498 -0
- package/lib/types.d.ts +249 -0
- package/lib/types.js +2 -0
- package/package.json +7 -4
- package/plugin/build/index.d.ts +2 -2
- package/plugin/build/index.js +1 -1
- package/react-native-background-downloader.podspec +8 -2
- package/src/DownloadTask.ts +2 -0
- package/src/NativeRNBackgroundDownloader.ts +2 -0
- package/src/config.ts +6 -1
- package/src/index.ts +4 -0
- 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: "
|
|
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 | `'
|
|
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:
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
82
|
-
compileOnly 'com.tencent:mmkv-shared:
|
|
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
|
-
|
|
1085
|
-
//
|
|
1086
|
-
// (
|
|
1087
|
-
//
|
|
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
|
|
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
|
-
//
|
|
169
|
-
|
|
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
|
-
|
|
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()) {
|