@kesha-antonov/react-native-background-downloader 4.5.5 → 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.
@@ -42,6 +42,7 @@ class ResumableDownloadService : Service() {
42
42
  const val EXTRA_HEADERS = "headers"
43
43
  const val EXTRA_START_BYTE = "start_byte"
44
44
  const val EXTRA_TOTAL_BYTES = "total_bytes"
45
+ const val EXTRA_IS_ALLOWED_OVER_METERED = "is_allowed_over_metered"
45
46
  }
46
47
 
47
48
  private val binder = LocalBinder()
@@ -61,6 +62,26 @@ class ResumableDownloadService : Service() {
61
62
  // Shared ResumableDownloader instance
62
63
  val resumableDownloader = ResumableDownloader()
63
64
 
65
+ // Enforces isAllowedOverMetered=false on this path (no scheduler to hold the
66
+ // transfer): parks downloads until an unmetered network is available, binds
67
+ // the transfer to it, auto-pauses on loss and auto-resumes
68
+ private val unmeteredGate by lazy {
69
+ UnmeteredNetworkGate(this, resumableDownloader, object : UnmeteredNetworkGate.Host {
70
+ override fun createGateListener(id: String): ResumableDownloader.DownloadListener? {
71
+ val job = activeDownloads[id] ?: return null
72
+ return createValidatingListener(id, job.sessionToken, downloadGeneration[id] ?: 0)
73
+ }
74
+
75
+ override fun onGatedTransferStarting() {
76
+ acquireWakeLock()
77
+ }
78
+
79
+ override fun onGateStateChanged() {
80
+ updateNotification()
81
+ }
82
+ })
83
+ }
84
+
64
85
  inner class LocalBinder : Binder() {
65
86
  fun getService(): ResumableDownloadService = this@ResumableDownloadService
66
87
  }
@@ -70,16 +91,19 @@ class ResumableDownloadService : Service() {
70
91
  * before delegating events to the actual listener. This prevents stale events
71
92
  * from old download sessions from being processed.
72
93
  *
94
+ * On a valid terminal event (complete/error) the service's job entry is always
95
+ * removed - the session/generation check already guarantees only the current
96
+ * session's listener can get here, and a leaked entry would keep the foreground
97
+ * service (and its notification) alive forever via stopServiceIfIdle().
98
+ *
73
99
  * @param id The download ID
74
100
  * @param sessionToken The session token from when the download was started
75
101
  * @param generation The generation counter from when the download was started
76
- * @param cleanupOnTerminal Whether to clean up state on complete/error (true for start, false for resume since job already exists)
77
102
  */
78
103
  private fun createValidatingListener(
79
104
  id: String,
80
105
  sessionToken: Long,
81
- generation: Long,
82
- cleanupOnTerminal: Boolean = true
106
+ generation: Long
83
107
  ): ResumableDownloader.DownloadListener {
84
108
  return object : ResumableDownloader.DownloadListener {
85
109
 
@@ -128,11 +152,7 @@ class ResumableDownloadService : Service() {
128
152
  override fun onComplete(id: String, location: String, bytesDownloaded: Long, bytesTotal: Long) {
129
153
  if (isValid()) {
130
154
  listener?.onComplete(id, location, bytesDownloaded, bytesTotal)
131
- if (cleanupOnTerminal) {
132
- activeDownloads.remove(id)
133
- lastProgressLogTime.remove(id)
134
- }
135
- stopServiceIfIdle()
155
+ cleanupTerminalDownload(id)
136
156
  } else {
137
157
  logStale("onComplete")
138
158
  }
@@ -140,12 +160,22 @@ class ResumableDownloadService : Service() {
140
160
 
141
161
  override fun onError(id: String, error: String, errorCode: Int) {
142
162
  if (isValid()) {
143
- listener?.onError(id, error, errorCode)
144
- if (cleanupOnTerminal) {
145
- activeDownloads.remove(id)
146
- lastProgressLogTime.remove(id)
163
+ // A gated download whose bound network died raises a socket error before
164
+ // the ConnectivityManager onLost callback arrives - reclassify it as
165
+ // "waiting for unmetered network" instead of failing the download
166
+ if (unmeteredGate.regateAfterNetworkLoss(id, errorCode)) {
167
+ RNBackgroundDownloaderModuleImpl.logD(TAG, "Gated download $id lost its unmetered network mid-transfer, re-waiting (suppressed error: $error)")
168
+ updateNotification()
169
+ return
170
+ }
171
+ // The grace period inside regateAfterNetworkLoss may have raced a
172
+ // cancel or restart - re-check validity before reporting the error
173
+ if (!isValid()) {
174
+ logStale("onError (after network grace check)")
175
+ return
147
176
  }
148
- stopServiceIfIdle()
177
+ listener?.onError(id, error, errorCode)
178
+ cleanupTerminalDownload(id)
149
179
  } else {
150
180
  logStale("onError")
151
181
  }
@@ -197,9 +227,10 @@ class ResumableDownloadService : Service() {
197
227
  val headers = getHeadersFromIntent(intent)
198
228
  val startByte = intent.getLongExtra(EXTRA_START_BYTE, 0)
199
229
  val totalBytes = intent.getLongExtra(EXTRA_TOTAL_BYTES, -1)
230
+ val isAllowedOverMetered = intent.getBooleanExtra(EXTRA_IS_ALLOWED_OVER_METERED, true)
200
231
 
201
232
  if (id != null && url != null && destination != null) {
202
- startDownloadInternal(id, url, destination, headers, startByte, totalBytes)
233
+ startDownloadInternal(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered)
203
234
  }
204
235
  }
205
236
  ACTION_PAUSE_DOWNLOAD -> {
@@ -223,31 +254,59 @@ class ResumableDownloadService : Service() {
223
254
  ACTION_STOP_SERVICE -> {
224
255
  stopServiceIfIdle()
225
256
  }
257
+ else -> {
258
+ // Downloader starts the service with a blank intent and delivers the actual
259
+ // work over the binder. After Context.startForegroundService() the service
260
+ // MUST call startForeground() promptly or the system kills the app - do it
261
+ // here instead of relying on the bound startDownload call arriving in time.
262
+ startForegroundWithNotification()
263
+ }
226
264
  }
227
265
 
228
- return START_STICKY
266
+ // NOT_STICKY: all download state is in-memory (recovery goes through the
267
+ // module's persisted snapshots on the next app launch), so a sticky restart
268
+ // with a null intent would only produce an idle foreground service whose
269
+ // notification nothing ever clears.
270
+ return START_NOT_STICKY
229
271
  }
230
272
 
231
273
  override fun onDestroy() {
232
274
  RNBackgroundDownloaderModuleImpl.logD(TAG, "Service destroyed")
275
+ unmeteredGate.shutdown()
233
276
  releaseWakeLock()
234
277
  executorService.shutdownNow()
278
+ isForeground = false
235
279
  super.onDestroy()
236
280
  }
237
281
 
282
+ /**
283
+ * Shared terminal cleanup for a download that completed or failed.
284
+ * Must always run on a valid terminal event or the service job entry leaks and
285
+ * keeps the foreground service (and its notification) alive forever.
286
+ */
287
+ private fun cleanupTerminalDownload(id: String) {
288
+ activeDownloads.remove(id)
289
+ lastProgressLogTime.remove(id)
290
+ unmeteredGate.clear(id)
291
+ stopServiceIfIdle()
292
+ }
293
+
238
294
  fun setDownloadListener(listener: ResumableDownloader.DownloadListener?) {
239
295
  this.listener = listener
240
296
  }
241
297
 
298
+ // isAllowedOverMetered is deliberately not defaulted: a call site that forgets
299
+ // it must not compile, or the metered restriction silently reverts to allowed
242
300
  fun startDownload(
243
301
  id: String,
244
302
  url: String,
245
303
  destination: String,
246
304
  headers: Map<String, String>,
247
305
  startByte: Long = 0,
248
- totalBytes: Long = -1
306
+ totalBytes: Long = -1,
307
+ isAllowedOverMetered: Boolean
249
308
  ) {
250
- startDownloadInternal(id, url, destination, headers, startByte, totalBytes)
309
+ startDownloadInternal(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered)
251
310
  }
252
311
 
253
312
  private fun startDownloadInternal(
@@ -256,13 +315,13 @@ class ResumableDownloadService : Service() {
256
315
  destination: String,
257
316
  headers: Map<String, String>,
258
317
  startByte: Long,
259
- totalBytes: Long
318
+ totalBytes: Long,
319
+ isAllowedOverMetered: Boolean
260
320
  ) {
261
- RNBackgroundDownloaderModuleImpl.logD(TAG, "Starting download: $id from byte $startByte")
321
+ RNBackgroundDownloaderModuleImpl.logD(TAG, "Starting download: $id from byte $startByte (isAllowedOverMetered=$isAllowedOverMetered)")
262
322
 
263
323
  // Start foreground service if not already
264
324
  startForegroundWithNotification()
265
- acquireWakeLock()
266
325
 
267
326
  // Increment generation counter for this download ID
268
327
  // This ensures we can detect stale events even if job was removed and re-added
@@ -273,8 +332,35 @@ class ResumableDownloadService : Service() {
273
332
  val job = DownloadJob(id, url, destination, headers, startByte, totalBytes)
274
333
  activeDownloads[id] = job
275
334
 
335
+ if (!isAllowedOverMetered) {
336
+ // Park the download and let the unmetered-network gate start it: it starts
337
+ // immediately when an unmetered network is already connected, otherwise it
338
+ // waits like DownloadManager's "queued for WiFi" state. No wake lock while
339
+ // waiting - one is acquired when the transfer actually starts.
340
+ val gated = unmeteredGate.parkNewDownload(id) {
341
+ resumableDownloader.prepareWaitingDownload(id, url, destination, headers, startByte, totalBytes)
342
+ }
343
+ if (!gated) {
344
+ // The constraint can't be enforced (network callback registration
345
+ // failed) - fail the download loudly instead of stranding it forever
346
+ RNBackgroundDownloaderModuleImpl.logE(TAG, "Cannot register unmetered network callback, failing download $id")
347
+ activeDownloads.remove(id)
348
+ resumableDownloader.cancel(id)
349
+ listener?.onError(id, "Cannot enforce isAllowedOverMetered=false: network callback registration failed", -1)
350
+ stopServiceIfIdle()
351
+ return
352
+ }
353
+ updateNotification()
354
+ return
355
+ }
356
+
357
+ // Clear stale gate bookkeeping from a previous gated download with this ID
358
+ unmeteredGate.clear(id)
359
+
360
+ acquireWakeLock()
361
+
276
362
  // Create a validating listener wrapper
277
- val serviceListener = createValidatingListener(id, job.sessionToken, generation, cleanupOnTerminal = true)
363
+ val serviceListener = createValidatingListener(id, job.sessionToken, generation)
278
364
 
279
365
  // Start the download using ResumableDownloader
280
366
  resumableDownloader.startDownload(
@@ -284,13 +370,19 @@ class ResumableDownloadService : Service() {
284
370
  headers = headers,
285
371
  listener = serviceListener,
286
372
  startByte = startByte,
287
- totalBytes = totalBytes
373
+ totalBytes = totalBytes,
374
+ isAllowedOverMetered = isAllowedOverMetered
288
375
  )
289
376
  }
290
377
 
291
378
  fun pauseDownload(id: String): Boolean {
292
379
  RNBackgroundDownloaderModuleImpl.logD(TAG, "Pausing download: $id")
293
- val result = resumableDownloader.pause(id)
380
+ // Atomic with the gate: a user pause takes the download out of the unmetered
381
+ // gate under the gate lock, so a concurrent onAvailable drain can't claim it
382
+ // and restart the transfer the user just paused
383
+ val result = unmeteredGate.withGateCleared(id) {
384
+ resumableDownloader.pause(id)
385
+ }
294
386
  if (result) {
295
387
  updateNotification()
296
388
  // Don't stop service - keep it alive for potential resume
@@ -307,20 +399,36 @@ class ResumableDownloadService : Service() {
307
399
  val generation = downloadGeneration[id] ?: 0
308
400
  RNBackgroundDownloaderModuleImpl.logD(TAG, "Resuming $id with session=$sessionToken, generation=$generation")
309
401
 
310
- // Create a validating listener wrapper (don't cleanup since job already exists)
311
- val serviceListener = createValidatingListener(id, sessionToken, generation, cleanupOnTerminal = false)
312
-
313
402
  // Make sure service is in foreground
314
403
  startForegroundWithNotification()
404
+
405
+ // Unmetered-only downloads resume through the network gate so the transfer
406
+ // only starts (and stays) on an unmetered network
407
+ val state = resumableDownloader.getState(id)
408
+ if (state != null && !state.isAllowedOverMetered) {
409
+ val gated = unmeteredGate.parkForResume(id)
410
+ if (gated) {
411
+ updateNotification()
412
+ }
413
+ return gated
414
+ }
415
+
315
416
  acquireWakeLock()
316
417
 
418
+ // Create a validating listener wrapper
419
+ val serviceListener = createValidatingListener(id, sessionToken, generation)
420
+
317
421
  return resumableDownloader.resume(id, serviceListener)
318
422
  }
319
423
 
320
424
  fun cancelDownload(id: String): Boolean {
321
425
  RNBackgroundDownloaderModuleImpl.logD(TAG, "Cancelling download: $id")
322
- activeDownloads.remove(id)
323
- val result = resumableDownloader.cancel(id)
426
+ // Atomic with the gate so an in-flight onAvailable drain can't restart the
427
+ // download between the bookkeeping removal and the downloader cancel
428
+ val result = unmeteredGate.withGateCleared(id) {
429
+ activeDownloads.remove(id)
430
+ resumableDownloader.cancel(id)
431
+ }
324
432
  stopServiceIfIdle()
325
433
  return result
326
434
  }
@@ -329,7 +437,11 @@ class ResumableDownloadService : Service() {
329
437
 
330
438
  fun getState(id: String) = resumableDownloader.getState(id)
331
439
 
440
+ @Volatile
441
+ private var isForeground = false
442
+
332
443
  private fun startForegroundWithNotification() {
444
+ if (isForeground) return
333
445
  val notification = createNotification()
334
446
  try {
335
447
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -337,6 +449,7 @@ class ResumableDownloadService : Service() {
337
449
  } else {
338
450
  startForeground(DownloadConstants.NOTIFICATION_ID, notification)
339
451
  }
452
+ isForeground = true
340
453
  } catch (e: Exception) {
341
454
  RNBackgroundDownloaderModuleImpl.logE(TAG, "Failed to start foreground service: ${e.message}")
342
455
  }
@@ -359,15 +472,17 @@ class ResumableDownloadService : Service() {
359
472
 
360
473
  private fun createNotification(): Notification {
361
474
  val activeCount = activeDownloads.size
362
- val pausedCount = activeDownloads.keys.count { resumableDownloader.isPaused(it) }
363
- val runningCount = activeCount - pausedCount
364
-
365
- val contentText = when {
366
- runningCount > 0 && pausedCount > 0 -> "$runningCount downloading, $pausedCount paused"
367
- runningCount > 0 -> "$runningCount download${if (runningCount > 1) "s" else ""} in progress"
368
- pausedCount > 0 -> "$pausedCount download${if (pausedCount > 1) "s" else ""} paused"
369
- else -> "Download service running"
370
- }
475
+ // Downloads held by the unmetered-network gate are paused-like but shown separately
476
+ val waitingIds = unmeteredGate.waitingIds()
477
+ val waitingCount = activeDownloads.keys.count { waitingIds.contains(it) }
478
+ val pausedCount = activeDownloads.keys.count { resumableDownloader.isPaused(it) && !waitingIds.contains(it) }
479
+ val runningCount = activeCount - pausedCount - waitingCount
480
+
481
+ val parts = mutableListOf<String>()
482
+ if (runningCount > 0) parts.add("$runningCount downloading")
483
+ if (waitingCount > 0) parts.add("$waitingCount waiting for unmetered network")
484
+ if (pausedCount > 0) parts.add("$pausedCount paused")
485
+ val contentText = if (parts.isEmpty()) "Download service running" else parts.joinToString(", ")
371
486
 
372
487
  // Use download icon when actively downloading, pause icon when all paused
373
488
  val icon = if (runningCount > 0) {
@@ -425,6 +540,7 @@ class ResumableDownloadService : Service() {
425
540
  RNBackgroundDownloaderModuleImpl.logD(TAG, "No active downloads, stopping service")
426
541
  releaseWakeLock()
427
542
  stopForeground(STOP_FOREGROUND_REMOVE)
543
+ isForeground = false
428
544
  stopSelf()
429
545
  } else {
430
546
  RNBackgroundDownloaderModuleImpl.logD(TAG, "Service has active downloads, keeping alive")
@@ -1,5 +1,6 @@
1
1
  package com.eko
2
2
 
3
+ import android.net.Network
3
4
  import com.eko.utils.HeaderUtils
4
5
  import java.io.File
5
6
  import java.io.FileOutputStream
@@ -34,7 +35,12 @@ class ResumableDownloader {
34
35
  @Volatile var inputStream: InputStream? = null,
35
36
  var hasReportedBegin: Boolean = false,
36
37
  // Session counter to detect stale threads after pause/resume
37
- val sessionId: AtomicLong = AtomicLong(0)
38
+ val sessionId: AtomicLong = AtomicLong(0),
39
+ // Whether this download may transfer over metered networks
40
+ val isAllowedOverMetered: Boolean = true,
41
+ // When set, the HTTP connection is opened on this specific network so an
42
+ // unmetered-only download can never leak onto a metered default network
43
+ @Volatile var network: Network? = null
38
44
  )
39
45
 
40
46
  private val activeDownloads = ConcurrentHashMap<String, DownloadState>()
@@ -51,6 +57,8 @@ class ResumableDownloader {
51
57
  * @param startByte The byte position to start from (for resuming paused DownloadManager downloads)
52
58
  * @param totalBytes The total bytes if known (for resuming)
53
59
  */
60
+ // isAllowedOverMetered is deliberately not defaulted: a call site that forgets
61
+ // it must not compile, or the metered restriction silently reverts to allowed
54
62
  fun startDownload(
55
63
  id: String,
56
64
  url: String,
@@ -58,8 +66,54 @@ class ResumableDownloader {
58
66
  headers: Map<String, String>,
59
67
  listener: DownloadListener,
60
68
  startByte: Long = 0,
61
- totalBytes: Long = -1
69
+ totalBytes: Long = -1,
70
+ isAllowedOverMetered: Boolean,
71
+ network: Network? = null
62
72
  ) {
73
+ val state = registerNewDownload(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered)
74
+ state.network = network
75
+
76
+ val currentSessionId = state.sessionId.get()
77
+ val thread = Thread {
78
+ downloadWithResume(state, listener, currentSessionId)
79
+ }
80
+ state.thread = thread
81
+ thread.start()
82
+ }
83
+
84
+ /**
85
+ * Register an unmetered-only download in a paused/waiting state WITHOUT
86
+ * starting the transfer. Used by the unmetered-network gate on Android < 14:
87
+ * the state is visible to pause/cancel/getState immediately, and the actual
88
+ * transfer is started later via resume() once a suitable network is available.
89
+ */
90
+ fun prepareWaitingDownload(
91
+ id: String,
92
+ url: String,
93
+ destination: String,
94
+ headers: Map<String, String>,
95
+ startByte: Long = 0,
96
+ totalBytes: Long = -1
97
+ ): DownloadState {
98
+ val state = registerNewDownload(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered = false)
99
+ // Paused-like state so resume() can start the first transfer
100
+ state.isPaused.set(true)
101
+ return state
102
+ }
103
+
104
+ /**
105
+ * Cancel any existing download with the same ID, clean up a stale destination
106
+ * file, then create and register a fresh DownloadState (no thread started).
107
+ */
108
+ private fun registerNewDownload(
109
+ id: String,
110
+ url: String,
111
+ destination: String,
112
+ headers: Map<String, String>,
113
+ startByte: Long,
114
+ totalBytes: Long,
115
+ isAllowedOverMetered: Boolean
116
+ ): DownloadState {
63
117
  // Cancel any existing download with the same ID first
64
118
  val existingState = activeDownloads[id]
65
119
  if (existingState != null) {
@@ -93,7 +147,8 @@ class ResumableDownloader {
93
147
  url = url,
94
148
  destination = destination,
95
149
  headers = headers,
96
- bytesTotal = totalBytes
150
+ bytesTotal = totalBytes,
151
+ isAllowedOverMetered = isAllowedOverMetered
97
152
  )
98
153
 
99
154
  // Set initial bytes downloaded (only for explicit resume with startByte > 0)
@@ -112,13 +167,7 @@ class ResumableDownloader {
112
167
  }
113
168
 
114
169
  activeDownloads[id] = state
115
-
116
- val currentSessionId = state.sessionId.get()
117
- val thread = Thread {
118
- downloadWithResume(state, listener, currentSessionId)
119
- }
120
- state.thread = thread
121
- thread.start()
170
+ return state
122
171
  }
123
172
 
124
173
  fun pause(id: String): Boolean {
@@ -278,7 +327,10 @@ class ResumableDownloader {
278
327
  }
279
328
 
280
329
  val url = URL(state.url)
281
- connection = url.openConnection() as HttpURLConnection
330
+ // When the download is bound to a specific network (unmetered-network gate),
331
+ // open the connection on that network so bytes can't leak onto the metered
332
+ // default network. Otherwise use the default network.
333
+ connection = (state.network?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
282
334
  // Store connection reference so it can be disconnected on cancel
283
335
  state.connection = connection
284
336
  connection.connectTimeout = DownloadConstants.CONNECT_TIMEOUT_MS
@@ -549,7 +601,9 @@ class ResumableDownloader {
549
601
  connection = this.connection,
550
602
  inputStream = this.inputStream,
551
603
  hasReportedBegin = this.hasReportedBegin,
552
- sessionId = this.sessionId
604
+ sessionId = this.sessionId,
605
+ isAllowedOverMetered = this.isAllowedOverMetered,
606
+ network = this.network
553
607
  )
554
608
  }
555
609
  }
@@ -53,8 +53,9 @@ class UIDTDownloadJobService : JobService() {
53
53
  headers: Map<String, String>,
54
54
  startByte: Long = 0,
55
55
  totalBytes: Long = -1,
56
- metadata: String = "{}"
57
- ): Boolean = UIDTJobManager.scheduleDownload(context, configId, url, destination, headers, startByte, totalBytes, metadata)
56
+ metadata: String = "{}",
57
+ isAllowedOverMetered: Boolean
58
+ ): Boolean = UIDTJobManager.scheduleDownload(context, configId, url, destination, headers, startByte, totalBytes, metadata, isAllowedOverMetered)
58
59
 
59
60
  /**
60
61
  * Cancel a scheduled UIDT job.
@@ -76,11 +77,23 @@ class UIDTDownloadJobService : JobService() {
76
77
  */
77
78
  fun getJobDownloadState(configId: String): ResumableDownloader.DownloadState? = UIDTJobRegistry.getJobDownloadState(configId)
78
79
 
80
+ /**
81
+ * Get the metered-network permission of an active UIDT job (null if not active).
82
+ */
83
+ fun getJobIsAllowedOverMetered(configId: String): Boolean? = UIDTJobRegistry.getJobIsAllowedOverMetered(configId)
84
+
79
85
  /**
80
86
  * Pause an active UIDT download.
81
87
  */
82
88
  fun pauseJob(context: Context, configId: String): Boolean = UIDTJobManager.pauseJob(context, configId)
83
89
 
90
+ /**
91
+ * Cancel a scheduled-but-not-running UIDT job (e.g. held pending by its
92
+ * unmetered-network constraint) and return its persistable info.
93
+ */
94
+ fun cancelPendingJob(context: Context, configId: String): UIDTJobManager.PendingJobInfo? =
95
+ UIDTJobManager.cancelPendingJob(context, configId)
96
+
84
97
  /**
85
98
  * Resume a paused UIDT download.
86
99
  */
@@ -150,6 +163,7 @@ class UIDTDownloadJobService : JobService() {
150
163
  val destination = extras.getString(UIDTConstants.KEY_DESTINATION) ?: return false
151
164
  val startByteFromExtras = extras.getLong(UIDTConstants.KEY_START_BYTE, 0)
152
165
  val totalBytes = extras.getLong(UIDTConstants.KEY_TOTAL_BYTES, -1)
166
+ val isAllowedOverMetered = extras.getBoolean(UIDTConstants.KEY_IS_ALLOWED_OVER_METERED, true)
153
167
 
154
168
  // Extract group info from metadata (for notification grouping)
155
169
  val metadataJson = extras.getString(UIDTConstants.KEY_METADATA) ?: "{}"
@@ -223,7 +237,12 @@ class UIDTDownloadJobService : JobService() {
223
237
  // Create listener that will notify completion
224
238
  val jobListener = createJobListener(configId, params, groupId, groupName)
225
239
 
226
- // Start the download asynchronously
240
+ // Start the download asynchronously. The network constraint is enforced by
241
+ // the JobScheduler; the flag is passed so the DownloadState stays truthful
242
+ // (it is a fallback source for pause/snapshot persistence in the module).
243
+ // Bind the transfer to the network that satisfied the job's constraint -
244
+ // otherwise the sockets use the DEFAULT network, which can be metered
245
+ // cellular even while the satisfying unmetered network is connected.
227
246
  resumableDownloader.startDownload(
228
247
  id = configId,
229
248
  url = url,
@@ -231,7 +250,9 @@ class UIDTDownloadJobService : JobService() {
231
250
  headers = headers,
232
251
  listener = jobListener,
233
252
  startByte = startByte,
234
- totalBytes = totalBytes
253
+ totalBytes = totalBytes,
254
+ isAllowedOverMetered = isAllowedOverMetered,
255
+ network = params.network
235
256
  )
236
257
 
237
258
  RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Started UIDT download: $configId from byte $startByte")