@kesha-antonov/react-native-background-downloader 4.5.6 → 4.5.8
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 +5 -1
- package/android/src/main/java/com/eko/DownloadConstants.kt +7 -0
- package/android/src/main/java/com/eko/Downloader.kt +80 -111
- package/android/src/main/java/com/eko/RNBGDTaskConfig.kt +4 -1
- package/android/src/main/java/com/eko/RNBackgroundDownloaderModuleImpl.kt +97 -17
- package/android/src/main/java/com/eko/ResumableDownloadService.kt +153 -37
- package/android/src/main/java/com/eko/ResumableDownloader.kt +66 -12
- package/android/src/main/java/com/eko/UIDTDownloadJobService.kt +25 -4
- package/android/src/main/java/com/eko/UnmeteredNetworkGate.kt +366 -0
- package/android/src/main/java/com/eko/uidt/UIDTJobManager.kt +76 -14
- package/android/src/main/java/com/eko/uidt/UIDTJobState.kt +11 -0
- package/android/src/main/java/com/eko/utils/NetworkRequestUtils.kt +43 -0
- package/android/src/main/java/com/eko/utils/StorageManager.kt +32 -45
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://badge.fury.io/js/@kesha-antonov%2Freact-native-background-downloader"><img src="https://badge.fury.io/js/@kesha-antonov%2Freact-native-background-downloader.svg" alt="npm version"></a>
|
|
7
7
|
<a href="https://www.npmjs.com/package/@kesha-antonov/react-native-background-downloader"><img src="https://img.shields.io/npm/dm/@kesha-antonov/react-native-background-downloader.svg" alt="npm downloads"></a>
|
|
8
|
+
<a href="https://npm-stat.com/charts.html?package=%40kesha-antonov%2Freact-native-background-downloader&from=2015-01-01"><img src="https://img.shields.io/badge/total%20downloads-1.07M-blue.svg" alt="total npm downloads"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@kesha-antonov/react-native-background-downloader"><img src="https://img.shields.io/npm/dt/@kesha-antonov/react-native-background-downloader.svg?label=18-months%20downloads" alt="npm downloads (last 18 months)"></a>
|
|
8
10
|
<a href="https://github.com/kesha-antonov/react-native-background-downloader/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="license"></a>
|
|
9
11
|
<img src="https://img.shields.io/badge/platforms-iOS%20%7C%20Android-lightgrey.svg" alt="platforms">
|
|
10
12
|
<img src="https://img.shields.io/badge/TypeScript-supported-blue.svg" alt="TypeScript">
|
|
@@ -508,7 +510,9 @@ setConfig({
|
|
|
508
510
|
|
|
509
511
|
This is a cross-platform setting that works on both iOS and Android:
|
|
510
512
|
- **iOS**: Sets the `allowsCellularAccess` property on the NSURLSession configuration
|
|
511
|
-
- **Android**:
|
|
513
|
+
- **Android**: Applied on all three download mechanisms - `DownloadManager` requests (`setAllowedOverMetered`), the UIDT/JobScheduler jobs used on Android 14+ (the job requires an unmetered network), and the foreground-service fallback used on Android < 14 (gated on a `ConnectivityManager` unmetered-network callback)
|
|
514
|
+
|
|
515
|
+
When cellular is disallowed and the device only has a metered connection, the download doesn't fail - it waits until an unmetered network (e.g. WiFi) becomes available, then starts automatically. If the unmetered network is lost mid-download (e.g. WiFi drops and the device falls back to cellular), the download pauses instead of failing and automatically resumes from where it left off once an unmetered network returns. The restriction is kept across pause/resume and app restarts.
|
|
512
516
|
|
|
513
517
|
**Per-download override (Android only):** On Android, you can override the global cellular setting for individual downloads using the `isAllowedOverMetered` option in `createDownloadTask()`:
|
|
514
518
|
|
|
@@ -37,6 +37,13 @@ object DownloadConstants {
|
|
|
37
37
|
/** Interval for persisting in-progress resumable download recovery snapshots (milliseconds) */
|
|
38
38
|
const val RECOVERY_SNAPSHOT_INTERVAL_MS = 2_000L
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Grace period before classifying an IO error on an unmetered-only download (milliseconds).
|
|
42
|
+
* A socket abort caused by network loss/metering usually arrives before ConnectivityService
|
|
43
|
+
* updates the network's capabilities and delivers onLost - re-check after this delay.
|
|
44
|
+
*/
|
|
45
|
+
const val UNMETERED_RECHECK_DELAY_MS = 3_000L
|
|
46
|
+
|
|
40
47
|
// ========== HTTP Headers ==========
|
|
41
48
|
|
|
42
49
|
/** Keep-Alive header value for connection pooling */
|
|
@@ -76,6 +76,12 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
76
76
|
val resumableDownloader: ResumableDownloader
|
|
77
77
|
get() = downloadService?.resumableDownloader ?: fallbackResumableDownloader
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The canonical per-download record: everything needed to start, persist, or
|
|
81
|
+
* resume one download. It travels through start, pause/resume and the
|
|
82
|
+
* recovery-snapshot paths, so a new per-download option is added here once
|
|
83
|
+
* instead of in every signature along the way.
|
|
84
|
+
*/
|
|
79
85
|
data class PausedDownloadInfo(
|
|
80
86
|
val configId: String,
|
|
81
87
|
val url: String,
|
|
@@ -83,7 +89,8 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
83
89
|
val headers: Map<String, String>,
|
|
84
90
|
val bytesDownloaded: Long,
|
|
85
91
|
val bytesTotal: Long,
|
|
86
|
-
val metadata: String = "{}"
|
|
92
|
+
val metadata: String = "{}",
|
|
93
|
+
val isAllowedOverMetered: Boolean = true
|
|
87
94
|
)
|
|
88
95
|
|
|
89
96
|
init {
|
|
@@ -199,7 +206,7 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
199
206
|
* Pause a download. This cancels the DownloadManager download and saves state
|
|
200
207
|
* so it can be resumed later using HTTP Range headers.
|
|
201
208
|
*/
|
|
202
|
-
fun pause(downloadId: Long, configId: String, url: String, destination: String, headers: Map<String, String>, metadata: String
|
|
209
|
+
fun pause(downloadId: Long, configId: String, url: String, destination: String, headers: Map<String, String>, metadata: String, isAllowedOverMetered: Boolean): Boolean {
|
|
203
210
|
// Mark this download as being paused to ignore broadcast events
|
|
204
211
|
cancellingDownloads[downloadId] = CancelIntent.PAUSING
|
|
205
212
|
|
|
@@ -212,53 +219,35 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
212
219
|
downloadManager.remove(downloadId)
|
|
213
220
|
|
|
214
221
|
// Save paused state for later resume
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
savePausedDownloadState(
|
|
223
|
+
PausedDownloadInfo(
|
|
224
|
+
configId = configId,
|
|
225
|
+
url = url,
|
|
226
|
+
destination = destination,
|
|
227
|
+
headers = headers,
|
|
228
|
+
bytesDownloaded = bytesDownloaded,
|
|
229
|
+
bytesTotal = bytesTotal,
|
|
230
|
+
metadata = metadata,
|
|
231
|
+
isAllowedOverMetered = isAllowedOverMetered
|
|
232
|
+
)
|
|
223
233
|
)
|
|
224
|
-
pausedDownloads[configId] = pausedInfo
|
|
225
|
-
|
|
226
|
-
// Persist to storage for app restart persistence
|
|
227
|
-
savePausedDownloads()
|
|
228
234
|
|
|
229
235
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "Paused download $configId at $bytesDownloaded/$bytesTotal bytes")
|
|
230
236
|
return true
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
/**
|
|
234
|
-
* Save paused download state
|
|
240
|
+
* Save paused download state so the download can be resumed later.
|
|
235
241
|
* This is called when pausing a download that was started via ResumableDownloader
|
|
236
242
|
* (e.g., on Android 16+ or when DownloadManager path restrictions apply).
|
|
237
243
|
*/
|
|
238
|
-
fun savePausedDownloadState(
|
|
239
|
-
configId
|
|
240
|
-
url: String,
|
|
241
|
-
destination: String,
|
|
242
|
-
headers: Map<String, String>,
|
|
243
|
-
bytesDownloaded: Long,
|
|
244
|
-
bytesTotal: Long,
|
|
245
|
-
metadata: String = "{}"
|
|
246
|
-
) {
|
|
247
|
-
val pausedInfo = PausedDownloadInfo(
|
|
248
|
-
configId = configId,
|
|
249
|
-
url = url,
|
|
250
|
-
destination = destination,
|
|
251
|
-
headers = headers,
|
|
252
|
-
bytesDownloaded = bytesDownloaded,
|
|
253
|
-
bytesTotal = bytesTotal,
|
|
254
|
-
metadata = metadata
|
|
255
|
-
)
|
|
256
|
-
pausedDownloads[configId] = pausedInfo
|
|
244
|
+
fun savePausedDownloadState(info: PausedDownloadInfo) {
|
|
245
|
+
pausedDownloads[info.configId] = info
|
|
257
246
|
|
|
258
247
|
// Persist to storage for app restart persistence
|
|
259
248
|
savePausedDownloads()
|
|
260
249
|
|
|
261
|
-
RNBackgroundDownloaderModuleImpl.logD(TAG, "Saved paused state for
|
|
250
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Saved paused state for download ${info.configId} at ${info.bytesDownloaded}/${info.bytesTotal} bytes")
|
|
262
251
|
}
|
|
263
252
|
|
|
264
253
|
/**
|
|
@@ -287,19 +276,23 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
287
276
|
// Persist the removal from paused state (it's now actively downloading)
|
|
288
277
|
savePausedDownloads()
|
|
289
278
|
|
|
279
|
+
// The record's byte count can lag the partial file (e.g. the download was
|
|
280
|
+
// resumed in-service and transferred further before a force-stop froze the
|
|
281
|
+
// record). The file grows by sequential appends, so its on-disk length is
|
|
282
|
+
// the authoritative resume offset - resuming below it would append
|
|
283
|
+
// mid-stream data at the end of the file and corrupt it.
|
|
284
|
+
val destFile = File(pausedInfo.destination)
|
|
285
|
+
val resumeInfo = if (destFile.exists() && destFile.length() != pausedInfo.bytesDownloaded) {
|
|
286
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Adjusting resume offset for $configId from ${pausedInfo.bytesDownloaded} to on-disk ${destFile.length()} bytes")
|
|
287
|
+
pausedInfo.copy(bytesDownloaded = destFile.length())
|
|
288
|
+
} else {
|
|
289
|
+
pausedInfo
|
|
290
|
+
}
|
|
291
|
+
|
|
290
292
|
// Start the foreground service for background download
|
|
291
|
-
startDownloadService(
|
|
292
|
-
configId,
|
|
293
|
-
pausedInfo.url,
|
|
294
|
-
pausedInfo.destination,
|
|
295
|
-
pausedInfo.headers,
|
|
296
|
-
pausedInfo.bytesDownloaded,
|
|
297
|
-
pausedInfo.bytesTotal,
|
|
298
|
-
listener,
|
|
299
|
-
pausedInfo.metadata
|
|
300
|
-
)
|
|
293
|
+
startDownloadService(resumeInfo, listener)
|
|
301
294
|
|
|
302
|
-
RNBackgroundDownloaderModuleImpl.logD(TAG, "Resuming download $configId from ${
|
|
295
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Resuming download $configId from ${resumeInfo.bytesDownloaded} bytes via service")
|
|
303
296
|
return true
|
|
304
297
|
}
|
|
305
298
|
|
|
@@ -313,16 +306,7 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
313
306
|
*
|
|
314
307
|
* On Android < 14, uses the foreground service with dataSync type.
|
|
315
308
|
*/
|
|
316
|
-
private fun startDownloadService(
|
|
317
|
-
configId: String,
|
|
318
|
-
url: String,
|
|
319
|
-
destination: String,
|
|
320
|
-
headers: Map<String, String>,
|
|
321
|
-
startByte: Long,
|
|
322
|
-
totalBytes: Long,
|
|
323
|
-
listener: ResumableDownloader.DownloadListener,
|
|
324
|
-
metadata: String = "{}"
|
|
325
|
-
) {
|
|
309
|
+
private fun startDownloadService(info: PausedDownloadInfo, listener: ResumableDownloader.DownloadListener) {
|
|
326
310
|
// On Android 14+, use UIDT jobs for better background execution
|
|
327
311
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
328
312
|
// Set the listener for UIDT job callbacks
|
|
@@ -331,17 +315,18 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
331
315
|
// Schedule UIDT job
|
|
332
316
|
val scheduled = UIDTDownloadJobService.scheduleDownload(
|
|
333
317
|
context = context,
|
|
334
|
-
configId = configId,
|
|
335
|
-
url = url,
|
|
336
|
-
destination = destination,
|
|
337
|
-
headers = headers,
|
|
338
|
-
startByte =
|
|
339
|
-
totalBytes =
|
|
340
|
-
metadata = metadata
|
|
318
|
+
configId = info.configId,
|
|
319
|
+
url = info.url,
|
|
320
|
+
destination = info.destination,
|
|
321
|
+
headers = info.headers,
|
|
322
|
+
startByte = info.bytesDownloaded,
|
|
323
|
+
totalBytes = info.bytesTotal,
|
|
324
|
+
metadata = info.metadata,
|
|
325
|
+
isAllowedOverMetered = info.isAllowedOverMetered
|
|
341
326
|
)
|
|
342
327
|
|
|
343
328
|
if (scheduled) {
|
|
344
|
-
RNBackgroundDownloaderModuleImpl.logD(TAG, "Using UIDT job for download: $configId")
|
|
329
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Using UIDT job for download: ${info.configId}")
|
|
345
330
|
return
|
|
346
331
|
}
|
|
347
332
|
|
|
@@ -349,7 +334,10 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
349
334
|
RNBackgroundDownloaderModuleImpl.logW(TAG, "UIDT scheduling failed, falling back to foreground service")
|
|
350
335
|
}
|
|
351
336
|
|
|
352
|
-
// On Android < 14 or if UIDT fails, use foreground service
|
|
337
|
+
// On Android < 14 or if UIDT fails, use foreground service.
|
|
338
|
+
// When isAllowedOverMetered=false the service gates the transfer on an
|
|
339
|
+
// unmetered network via a ConnectivityManager callback (waits like
|
|
340
|
+
// DownloadManager's "queued for WiFi" instead of failing).
|
|
353
341
|
// First, ensure the service is started as a foreground service
|
|
354
342
|
// Use a no-op action to just wake up the service
|
|
355
343
|
val startIntent = Intent(context, ResumableDownloadService::class.java)
|
|
@@ -366,7 +354,15 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
366
354
|
// Now use the direct service call path
|
|
367
355
|
executeWhenServiceReady {
|
|
368
356
|
downloadService?.setDownloadListener(listener)
|
|
369
|
-
downloadService?.startDownload(
|
|
357
|
+
downloadService?.startDownload(
|
|
358
|
+
info.configId,
|
|
359
|
+
info.url,
|
|
360
|
+
info.destination,
|
|
361
|
+
info.headers,
|
|
362
|
+
info.bytesDownloaded,
|
|
363
|
+
info.bytesTotal,
|
|
364
|
+
info.isAllowedOverMetered
|
|
365
|
+
)
|
|
370
366
|
}
|
|
371
367
|
}
|
|
372
368
|
|
|
@@ -375,25 +371,9 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
375
371
|
* This is used as a fallback when DownloadManager can't handle the external storage path
|
|
376
372
|
* on devices like OnePlus that return invalid paths from getExternalFilesDir().
|
|
377
373
|
*/
|
|
378
|
-
fun startResumableDownload(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
destination: String,
|
|
382
|
-
headers: Map<String, String>,
|
|
383
|
-
listener: ResumableDownloader.DownloadListener,
|
|
384
|
-
metadata: String = "{}"
|
|
385
|
-
) {
|
|
386
|
-
startDownloadService(
|
|
387
|
-
configId,
|
|
388
|
-
url,
|
|
389
|
-
destination,
|
|
390
|
-
headers,
|
|
391
|
-
0L, // Start from beginning
|
|
392
|
-
-1L, // Total bytes unknown
|
|
393
|
-
listener,
|
|
394
|
-
metadata
|
|
395
|
-
)
|
|
396
|
-
RNBackgroundDownloaderModuleImpl.logD(TAG, "Started ResumableDownloader for $configId (DownloadManager fallback)")
|
|
374
|
+
fun startResumableDownload(info: PausedDownloadInfo, listener: ResumableDownloader.DownloadListener) {
|
|
375
|
+
startDownloadService(info, listener)
|
|
376
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Started ResumableDownloader for ${info.configId} (DownloadManager fallback)")
|
|
397
377
|
}
|
|
398
378
|
|
|
399
379
|
/**
|
|
@@ -422,7 +402,16 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
422
402
|
executeWhenServiceReady {
|
|
423
403
|
downloadService?.setDownloadListener(listener)
|
|
424
404
|
}
|
|
425
|
-
|
|
405
|
+
val resumed = downloadService?.resumeDownload(configId) ?: false
|
|
406
|
+
if (resumed) {
|
|
407
|
+
// The download is transferring again - drop the paused record so it can't
|
|
408
|
+
// resurface after a restart with a byte count that lags the partial file
|
|
409
|
+
val removed = pausedDownloads.remove(configId)
|
|
410
|
+
if (removed != null) {
|
|
411
|
+
savePausedDownloads()
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return resumed
|
|
426
415
|
}
|
|
427
416
|
|
|
428
417
|
/**
|
|
@@ -488,26 +477,8 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
488
477
|
* The byte counter stored here is advisory only - on recovery the resume offset
|
|
489
478
|
* is recomputed from the actual on-disk file length to avoid corruption.
|
|
490
479
|
*/
|
|
491
|
-
fun saveActiveDownloadSnapshot(
|
|
492
|
-
|
|
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
|
-
)
|
|
480
|
+
fun saveActiveDownloadSnapshot(info: PausedDownloadInfo) {
|
|
481
|
+
storageManager?.saveActiveDownload(info)
|
|
511
482
|
}
|
|
512
483
|
|
|
513
484
|
/**
|
|
@@ -544,12 +515,10 @@ class Downloader(private val context: Context, private val storageManager: com.e
|
|
|
544
515
|
|
|
545
516
|
val destFile = File(info.destination)
|
|
546
517
|
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
518
|
|
|
552
|
-
// Use the on-disk length as the authoritative resume offset.
|
|
519
|
+
// Use the on-disk length as the authoritative resume offset. A length of 0
|
|
520
|
+
// is still recoverable (restart from the beginning) - e.g. a download that
|
|
521
|
+
// was parked waiting for an unmetered network when the app was killed.
|
|
553
522
|
pausedDownloads[configId] = info.copy(bytesDownloaded = fileLength)
|
|
554
523
|
recoveredCount++
|
|
555
524
|
}
|
|
@@ -7,5 +7,8 @@ data class RNBGDTaskConfig(
|
|
|
7
7
|
var url: String,
|
|
8
8
|
var destination: String,
|
|
9
9
|
var metadata: String = "{}",
|
|
10
|
-
var reportedBegin: Boolean = false
|
|
10
|
+
var reportedBegin: Boolean = false,
|
|
11
|
+
// Nullable so configs persisted by older versions (key absent in JSON)
|
|
12
|
+
// load as null and fall back to true instead of Gson's default false.
|
|
13
|
+
var isAllowedOverMetered: Boolean? = null
|
|
11
14
|
) : Serializable
|
|
@@ -160,6 +160,20 @@ 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
|
+
// Map to store the metered-network permission per download so pause/resume and
|
|
164
|
+
// recovery snapshots keep the constraint that the download was started with
|
|
165
|
+
private val configIdToIsAllowedOverMetered = mutableMapOf<String, Boolean>()
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the metered-network permission for a download: the in-memory intent
|
|
169
|
+
* recorded at start wins, then the caller-supplied persisted source (UIDT job
|
|
170
|
+
* extras, download state or task config), then the permissive default. Keeps
|
|
171
|
+
* the precedence policy in one place for every pause/snapshot path.
|
|
172
|
+
*/
|
|
173
|
+
private fun resolveIsAllowedOverMetered(configId: String, persistedValue: Boolean?): Boolean {
|
|
174
|
+
return configIdToIsAllowedOverMetered[configId] ?: persistedValue ?: true
|
|
175
|
+
}
|
|
176
|
+
|
|
163
177
|
// Last time (ms) a recovery snapshot was persisted for a resumable download,
|
|
164
178
|
// used to throttle snapshot writes from the frequent progress callbacks.
|
|
165
179
|
private val configIdToLastSnapshotMs = mutableMapOf<String, Long>()
|
|
@@ -522,6 +536,7 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
522
536
|
configIdToDownloadId.remove(configId)
|
|
523
537
|
configIdToHeaders.remove(configId)
|
|
524
538
|
configIdToMetadata.remove(configId)
|
|
539
|
+
configIdToIsAllowedOverMetered.remove(configId)
|
|
525
540
|
configIdToLastSnapshotMs.remove(configId)
|
|
526
541
|
progressReporter.clearDownloadState(configId)
|
|
527
542
|
// Drop any force-stop recovery snapshot for this download.
|
|
@@ -561,15 +576,26 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
561
576
|
?: return
|
|
562
577
|
val headers = configIdToHeaders[configId] ?: state.headers
|
|
563
578
|
val metadata = configIdToMetadata[configId] ?: "{}"
|
|
579
|
+
// Persisted fallback: the UIDT job extras survive process death for jobs the
|
|
580
|
+
// JobScheduler restarted in a fresh process; the download state carries the
|
|
581
|
+
// flag on the foreground-service path.
|
|
582
|
+
val persistedIsAllowedOverMetered =
|
|
583
|
+
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
584
|
+
UIDTDownloadJobService.getJobIsAllowedOverMetered(configId)
|
|
585
|
+
else null)
|
|
586
|
+
?: state.isAllowedOverMetered
|
|
564
587
|
|
|
565
588
|
downloader.saveActiveDownloadSnapshot(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
589
|
+
Downloader.PausedDownloadInfo(
|
|
590
|
+
configId = configId,
|
|
591
|
+
url = state.url,
|
|
592
|
+
destination = state.destination,
|
|
593
|
+
headers = headers,
|
|
594
|
+
bytesDownloaded = bytesDownloaded,
|
|
595
|
+
bytesTotal = bytesTotal,
|
|
596
|
+
metadata = metadata,
|
|
597
|
+
isAllowedOverMetered = resolveIsAllowedOverMetered(configId, persistedIsAllowedOverMetered)
|
|
598
|
+
)
|
|
573
599
|
)
|
|
574
600
|
}
|
|
575
601
|
|
|
@@ -775,7 +801,7 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
775
801
|
|
|
776
802
|
// Helper function to start download with DownloadManager and track state
|
|
777
803
|
fun startDownloadManagerDownload(downloadId: Long) {
|
|
778
|
-
val config = RNBGDTaskConfig(id, url, destination, metadataValue)
|
|
804
|
+
val config = RNBGDTaskConfig(id, url, destination, metadataValue, isAllowedOverMetered = isAllowedOverMetered)
|
|
779
805
|
synchronized(sharedLock) {
|
|
780
806
|
// Clean up any stale state from previous downloads with the same ID
|
|
781
807
|
downloader.cleanupStaleState(id)
|
|
@@ -785,6 +811,7 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
785
811
|
configIdToDownloadId[id] = downloadId
|
|
786
812
|
configIdToHeaders[id] = headersMap
|
|
787
813
|
configIdToMetadata[id] = metadataValue
|
|
814
|
+
configIdToIsAllowedOverMetered[id] = isAllowedOverMetered
|
|
788
815
|
downloadIdToConfig[downloadId] = config
|
|
789
816
|
saveDownloadIdToConfigMap()
|
|
790
817
|
resumeTasks(downloadId, config)
|
|
@@ -793,6 +820,16 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
793
820
|
|
|
794
821
|
// Helper function to fall back to ResumableDownloader
|
|
795
822
|
fun startWithResumableDownloader() {
|
|
823
|
+
val spec = Downloader.PausedDownloadInfo(
|
|
824
|
+
configId = id,
|
|
825
|
+
url = url,
|
|
826
|
+
destination = destination,
|
|
827
|
+
headers = headersMap,
|
|
828
|
+
bytesDownloaded = 0L,
|
|
829
|
+
bytesTotal = -1L,
|
|
830
|
+
metadata = metadataValue,
|
|
831
|
+
isAllowedOverMetered = isAllowedOverMetered
|
|
832
|
+
)
|
|
796
833
|
synchronized(sharedLock) {
|
|
797
834
|
// Clean up any stale state from previous downloads with the same ID
|
|
798
835
|
downloader.cleanupStaleState(id)
|
|
@@ -801,8 +838,15 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
801
838
|
progressReporter.initializeDownload(id)
|
|
802
839
|
configIdToHeaders[id] = headersMap
|
|
803
840
|
configIdToMetadata[id] = metadataValue
|
|
841
|
+
configIdToIsAllowedOverMetered[id] = isAllowedOverMetered
|
|
842
|
+
// Persist an initial recovery snapshot right away: progress-driven
|
|
843
|
+
// snapshots only start with the first received byte, so without this a
|
|
844
|
+
// download parked at 0 bytes (e.g. waiting for an unmetered network)
|
|
845
|
+
// would vanish without a trace if the app is force-stopped.
|
|
846
|
+
downloader.saveActiveDownloadSnapshot(spec)
|
|
847
|
+
configIdToLastSnapshotMs[id] = System.currentTimeMillis()
|
|
804
848
|
}
|
|
805
|
-
downloader.startResumableDownload(
|
|
849
|
+
downloader.startResumableDownload(spec, resumableDownloadListener)
|
|
806
850
|
}
|
|
807
851
|
|
|
808
852
|
// On Android 16+ (API 36), DownloadManager has strict path restrictions and throws
|
|
@@ -858,10 +902,14 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
858
902
|
if (downloader.isResumableDownload(configId)) {
|
|
859
903
|
// Get state before pausing - check both UIDT jobs and regular service
|
|
860
904
|
var state: ResumableDownloader.DownloadState? = null
|
|
905
|
+
var uidtIsAllowedOverMetered: Boolean? = null
|
|
861
906
|
|
|
862
907
|
// Check UIDT jobs first (Android 14+)
|
|
863
908
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
864
909
|
state = UIDTDownloadJobService.getJobDownloadState(configId)
|
|
910
|
+
// Read from the job extras before pausing - pausing removes the job
|
|
911
|
+
// (and its extras) from the registry
|
|
912
|
+
uidtIsAllowedOverMetered = UIDTDownloadJobService.getJobIsAllowedOverMetered(configId)
|
|
865
913
|
}
|
|
866
914
|
|
|
867
915
|
// Fall back to regular service if not found in UIDT jobs
|
|
@@ -878,13 +926,19 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
878
926
|
val metadata = configIdToMetadata[configId] ?: "{}"
|
|
879
927
|
|
|
880
928
|
downloader.savePausedDownloadState(
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
929
|
+
Downloader.PausedDownloadInfo(
|
|
930
|
+
configId = configId,
|
|
931
|
+
url = state.url,
|
|
932
|
+
destination = state.destination,
|
|
933
|
+
headers = headers,
|
|
934
|
+
bytesDownloaded = state.bytesDownloaded.get(),
|
|
935
|
+
bytesTotal = state.bytesTotal,
|
|
936
|
+
metadata = metadata,
|
|
937
|
+
isAllowedOverMetered = resolveIsAllowedOverMetered(
|
|
938
|
+
configId,
|
|
939
|
+
uidtIsAllowedOverMetered ?: state.isAllowedOverMetered
|
|
940
|
+
)
|
|
941
|
+
)
|
|
888
942
|
)
|
|
889
943
|
|
|
890
944
|
// Now tracked as an explicit paused download - drop the recovery snapshot.
|
|
@@ -903,12 +957,13 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
903
957
|
if (config != null) {
|
|
904
958
|
val headers = configIdToHeaders[configId] ?: emptyMap()
|
|
905
959
|
val metadata = configIdToMetadata[configId] ?: config.metadata
|
|
960
|
+
val isAllowedOverMetered = resolveIsAllowedOverMetered(configId, config.isAllowedOverMetered)
|
|
906
961
|
|
|
907
962
|
// Stop progress tracking
|
|
908
963
|
stopTaskProgress(configId)
|
|
909
964
|
|
|
910
965
|
// Pause the download (this cancels DownloadManager and saves state)
|
|
911
|
-
val paused = downloader.pause(downloadId, configId, config.url, config.destination, headers, metadata)
|
|
966
|
+
val paused = downloader.pause(downloadId, configId, config.url, config.destination, headers, metadata, isAllowedOverMetered)
|
|
912
967
|
|
|
913
968
|
if (paused) {
|
|
914
969
|
// Remove from DownloadManager tracking
|
|
@@ -918,6 +973,31 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
|
|
|
918
973
|
logD(NAME, "Paused DownloadManager download: $configId")
|
|
919
974
|
}
|
|
920
975
|
}
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Finally, check for a UIDT job that is scheduled but not yet running -
|
|
980
|
+
// e.g. held pending by its unmetered-network constraint. Such a job is
|
|
981
|
+
// invisible to the branches above (no registry entry until onStartJob),
|
|
982
|
+
// and without this it would start downloading despite the user's pause
|
|
983
|
+
// once its network constraint is satisfied.
|
|
984
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
985
|
+
val pending = UIDTDownloadJobService.cancelPendingJob(reactContext, configId)
|
|
986
|
+
if (pending != null) {
|
|
987
|
+
downloader.savePausedDownloadState(
|
|
988
|
+
Downloader.PausedDownloadInfo(
|
|
989
|
+
configId = configId,
|
|
990
|
+
url = pending.url,
|
|
991
|
+
destination = pending.destination,
|
|
992
|
+
headers = configIdToHeaders[configId] ?: pending.headers,
|
|
993
|
+
bytesDownloaded = pending.startByte,
|
|
994
|
+
bytesTotal = pending.totalBytes,
|
|
995
|
+
metadata = configIdToMetadata[configId] ?: pending.metadata,
|
|
996
|
+
isAllowedOverMetered = pending.isAllowedOverMetered
|
|
997
|
+
)
|
|
998
|
+
)
|
|
999
|
+
logD(NAME, "Paused pending UIDT job: $configId at byte ${pending.startByte}")
|
|
1000
|
+
}
|
|
921
1001
|
}
|
|
922
1002
|
}
|
|
923
1003
|
}
|