@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 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**: Sets the `isAllowedOverMetered` flag on DownloadManager requests
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 = "{}"): Boolean {
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
- val pausedInfo = PausedDownloadInfo(
216
- configId = configId,
217
- url = url,
218
- destination = destination,
219
- headers = headers,
220
- bytesDownloaded = bytesDownloaded,
221
- bytesTotal = bytesTotal,
222
- metadata = metadata
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 for a ResumableDownloader download.
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: String,
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 resumable download $configId at $bytesDownloaded/$bytesTotal bytes")
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 ${pausedInfo.bytesDownloaded} bytes via service")
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 = startByte,
339
- totalBytes = 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(configId, url, destination, headers, startByte, totalBytes)
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
- configId: String,
380
- url: String,
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
- return downloadService?.resumeDownload(configId) ?: false
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
- 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
- )
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
- configId = configId,
567
- url = state.url,
568
- destination = state.destination,
569
- headers = headers,
570
- bytesDownloaded = bytesDownloaded,
571
- bytesTotal = bytesTotal,
572
- metadata = metadata
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(id, url, destination, headersMap, resumableDownloadListener, metadataValue)
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
- configId = configId,
882
- url = state.url,
883
- destination = state.destination,
884
- headers = headers,
885
- bytesDownloaded = state.bytesDownloaded.get(),
886
- bytesTotal = state.bytesTotal,
887
- metadata = metadata
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
  }