@kesha-antonov/react-native-background-downloader 4.5.2 → 4.5.4

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.
@@ -18,6 +18,7 @@ import com.eko.utils.StorageManager
18
18
  import com.facebook.react.bridge.Arguments
19
19
  import com.facebook.react.bridge.Promise
20
20
  import com.facebook.react.bridge.ReactApplicationContext
21
+ import com.facebook.react.bridge.ReadableArray
21
22
  import com.facebook.react.bridge.ReadableMap
22
23
  import com.facebook.react.bridge.ReadableType
23
24
  import com.facebook.react.bridge.WritableMap
@@ -61,6 +62,47 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
61
62
  fun logE(tag: String, message: String) {
62
63
  if (isLogsEnabled) Log.e(tag, message)
63
64
  }
65
+
66
+ /**
67
+ * Convert a ReadableMap to JSONObject.
68
+ * Handles nested maps and arrays properly.
69
+ */
70
+ fun readableMapToJsonObject(map: ReadableMap?): JSONObject? {
71
+ if (map == null) return null
72
+ val json = JSONObject()
73
+ val iterator = map.keySetIterator()
74
+ while (iterator.hasNextKey()) {
75
+ val key = iterator.nextKey()
76
+ when (map.getType(key)) {
77
+ ReadableType.Null -> json.put(key, JSONObject.NULL)
78
+ ReadableType.Boolean -> json.put(key, map.getBoolean(key))
79
+ ReadableType.Number -> json.put(key, map.getDouble(key))
80
+ ReadableType.String -> json.put(key, map.getString(key))
81
+ ReadableType.Map -> json.put(key, readableMapToJsonObject(map.getMap(key)))
82
+ ReadableType.Array -> json.put(key, readableArrayToJsonArray(map.getArray(key)))
83
+ }
84
+ }
85
+ return json
86
+ }
87
+
88
+ /**
89
+ * Convert a ReadableArray to JSONArray.
90
+ */
91
+ private fun readableArrayToJsonArray(array: ReadableArray?): org.json.JSONArray? {
92
+ if (array == null) return null
93
+ val json = org.json.JSONArray()
94
+ for (i in 0 until array.size()) {
95
+ when (array.getType(i)) {
96
+ ReadableType.Null -> json.put(JSONObject.NULL)
97
+ ReadableType.Boolean -> json.put(array.getBoolean(i))
98
+ ReadableType.Number -> json.put(array.getDouble(i))
99
+ ReadableType.String -> json.put(array.getString(i))
100
+ ReadableType.Map -> json.put(readableMapToJsonObject(array.getMap(i)))
101
+ ReadableType.Array -> json.put(readableArrayToJsonArray(array.getArray(i)))
102
+ }
103
+ }
104
+ return json
105
+ }
64
106
  }
65
107
 
66
108
  // Storage manager for persistent state
@@ -183,6 +225,8 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
183
225
  }
184
226
 
185
227
  override fun onComplete(id: String, location: String, bytesDownloaded: Long, bytesTotal: Long) {
228
+ // Drop any buffered progress for this task so it cannot arrive in JS after downloadComplete
229
+ progressReporter.clearPendingReport(id)
186
230
  eventEmitter.emitComplete(id, location, bytesDownloaded, bytesTotal)
187
231
 
188
232
  // Clean up all download state
@@ -192,6 +236,8 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
192
236
  }
193
237
 
194
238
  override fun onError(id: String, error: String, errorCode: Int) {
239
+ // Drop any buffered progress for this task so it cannot arrive in JS after downloadFailed
240
+ progressReporter.clearPendingReport(id)
195
241
  eventEmitter.emitFailed(id, error, errorCode)
196
242
 
197
243
  // Clean up all download state
@@ -243,6 +289,7 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
243
289
  fun setNotificationGroupingConfig(config: ReadableMap) {
244
290
  val enabled = if (config.hasKey("enabled")) config.getBoolean("enabled") else false
245
291
  val showNotificationsEnabled = if (config.hasKey("showNotificationsEnabled")) config.getBoolean("showNotificationsEnabled") else false
292
+ val mode = if (config.hasKey("mode")) config.getString("mode") ?: "individual" else "individual"
246
293
  val texts = if (config.hasKey("texts")) config.getMap("texts") else null
247
294
 
248
295
  val textsMap = mutableMapOf<String, String>()
@@ -256,10 +303,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
256
303
 
257
304
  // Store the config for use by UIDTDownloadJobService
258
305
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
259
- UIDTDownloadJobService.setNotificationGroupingConfig(enabled, showNotificationsEnabled, textsMap)
306
+ UIDTDownloadJobService.setNotificationGroupingConfig(enabled, showNotificationsEnabled, mode, textsMap)
260
307
  }
261
308
 
262
- logD(NAME, "setNotificationGroupingConfig: enabled=$enabled, showNotificationsEnabled=$showNotificationsEnabled, texts=$textsMap")
309
+ logD(NAME, "setNotificationGroupingConfig: enabled=$enabled, showNotificationsEnabled=$showNotificationsEnabled, mode=$mode, texts=$textsMap")
263
310
  }
264
311
 
265
312
  fun initialize() {
@@ -342,6 +389,9 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
342
389
  stopTaskProgress(config.id)
343
390
 
344
391
  synchronized(sharedLock) {
392
+ // Drop any buffered progress that slipped past stopTaskProgress's clearPendingReport
393
+ // (the polling thread may have re-added it between clearPendingReport and this lock)
394
+ progressReporter.clearPendingReport(config.id)
345
395
  when (status) {
346
396
  DownloadManager.STATUS_SUCCESSFUL -> {
347
397
  onSuccessfulDownload(config, downloadStatus)
@@ -579,12 +629,10 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
579
629
  when (options.getType("metadata")) {
580
630
  ReadableType.String -> options.getString("metadata")
581
631
  ReadableType.Map -> {
582
- // If passed as object, convert to JSON string
632
+ // If passed as object, convert to JSON string properly
583
633
  try {
584
634
  val map = options.getMap("metadata")
585
- Arguments.toBundle(map)?.let {
586
- JSONObject(it.toString()).toString()
587
- } ?: "{}"
635
+ readableMapToJsonObject(map)?.toString() ?: "{}"
588
636
  } catch (e: Exception) {
589
637
  logW(NAME, "Failed to convert metadata map to string: ${e.message}")
590
638
  "{}"
@@ -1233,9 +1281,7 @@ class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicatio
1233
1281
  ReadableType.Map -> {
1234
1282
  try {
1235
1283
  val map = options.getMap("metadata")
1236
- Arguments.toBundle(map)?.let {
1237
- JSONObject(it.toString()).toString()
1238
- } ?: "{}"
1284
+ readableMapToJsonObject(map)?.toString() ?: "{}"
1239
1285
  } catch (e: Exception) {
1240
1286
  logW(NAME, "Failed to convert metadata map to string: ${e.message}")
1241
1287
  "{}"
@@ -7,6 +7,7 @@ import android.os.Build
7
7
  import android.os.PowerManager
8
8
  import androidx.annotation.RequiresApi
9
9
  import com.eko.uidt.JobState
10
+ import com.eko.uidt.NotificationGroupingMode
10
11
  import com.eko.uidt.UIDTConstants
11
12
  import com.eko.uidt.UIDTJobInfo
12
13
  import com.eko.uidt.UIDTJobManager
@@ -89,8 +90,8 @@ class UIDTDownloadJobService : JobService() {
89
90
  /**
90
91
  * Configure notification grouping and texts.
91
92
  */
92
- fun setNotificationGroupingConfig(enabled: Boolean, showNotificationsEnabled: Boolean, texts: Map<String, String>) =
93
- UIDTJobManager.setNotificationConfig(enabled, showNotificationsEnabled, texts)
93
+ fun setNotificationGroupingConfig(enabled: Boolean, showNotificationsEnabled: Boolean, mode: String, texts: Map<String, String>) =
94
+ UIDTJobManager.setNotificationConfig(enabled, showNotificationsEnabled, mode, texts)
94
95
 
95
96
  /**
96
97
  * Set notification update interval.
@@ -147,23 +148,53 @@ class UIDTDownloadJobService : JobService() {
147
148
  }
148
149
  val url = extras.getString(UIDTConstants.KEY_URL) ?: return false
149
150
  val destination = extras.getString(UIDTConstants.KEY_DESTINATION) ?: return false
150
- val startByte = extras.getLong(UIDTConstants.KEY_START_BYTE, 0)
151
+ val startByteFromExtras = extras.getLong(UIDTConstants.KEY_START_BYTE, 0)
151
152
  val totalBytes = extras.getLong(UIDTConstants.KEY_TOTAL_BYTES, -1)
152
153
 
153
154
  // Extract group info from metadata (for notification grouping)
154
155
  val metadataJson = extras.getString(UIDTConstants.KEY_METADATA) ?: "{}"
155
156
  var groupId = ""
156
157
  var groupName = ""
158
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "onStartJob: configId=$configId, metadataJson=$metadataJson")
157
159
  try {
158
160
  val json = JSONObject(metadataJson)
159
161
  groupId = json.optString("groupId", "")
160
162
  groupName = json.optString("groupName", "")
163
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Parsed metadata: groupId='$groupId', groupName='$groupName'")
161
164
  } catch (e: Exception) {
162
165
  RNBackgroundDownloaderModuleImpl.logE(UIDTConstants.TAG, "Failed to parse metadata: ${e.message}")
163
166
  }
164
167
 
165
- // Retrieve headers
166
- val headers = UIDTJobRegistry.pendingHeaders.remove(configId) ?: emptyMap()
168
+ // Resolve headers and start byte.
169
+ // In-memory pendingHeaders is populated in the same process (scheduleDownload / onStopJob).
170
+ // The disk-persisted resume state is the fallback for a fresh process after a restart.
171
+ val inMemoryHeaders = UIDTJobRegistry.pendingHeaders.remove(configId)
172
+ val diskResumeState = UIDTJobRegistry.loadResumeState(this, configId)
173
+ // Clear disk state now that we've consumed it.
174
+ UIDTJobRegistry.clearResumeState(this, configId)
175
+
176
+ val headers: Map<String, String>
177
+ val startByte: Long
178
+ when {
179
+ inMemoryHeaders != null -> {
180
+ // Same-process restart: use in-memory headers.
181
+ // Prefer the disk byte position if it is more advanced than the extras
182
+ // (written by onStopJob with the actual download offset).
183
+ headers = inMemoryHeaders
184
+ startByte = if (diskResumeState != null && diskResumeState.second > startByteFromExtras)
185
+ diskResumeState.second else startByteFromExtras
186
+ }
187
+ diskResumeState != null -> {
188
+ // Cross-process restart: use the persisted state.
189
+ headers = diskResumeState.first
190
+ startByte = diskResumeState.second
191
+ }
192
+ else -> {
193
+ headers = emptyMap()
194
+ startByte = startByteFromExtras
195
+ }
196
+ }
197
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "onStartJob: configId=$configId, startByte=$startByte, headers=${headers.size}")
167
198
 
168
199
  // Create notification for UIDT job (required)
169
200
  val notificationId = UIDTNotificationManager.getNotificationIdForConfig(configId)
@@ -172,18 +203,23 @@ class UIDTDownloadJobService : JobService() {
172
203
  // Set the notification for this job (required for UIDT)
173
204
  setNotification(params, notificationId, notification, JOB_END_NOTIFICATION_POLICY_DETACH)
174
205
 
175
- // Update summary notification if grouping enabled
176
- UIDTNotificationManager.updateSummaryNotification(this, groupId, groupName)
177
-
178
206
  // Acquire wake lock
179
207
  acquireWakeLock()
180
208
 
181
209
  // Create ResumableDownloader for this job
182
210
  val resumableDownloader = ResumableDownloader()
183
211
 
184
- // Store job state
212
+ // Unmark group as finalized if it was previously (in case of new batch with same groupId)
213
+ if (groupId.isNotEmpty()) {
214
+ UIDTJobRegistry.unmarkGroupFinalized(groupId)
215
+ }
216
+
217
+ // Store job state BEFORE updating summary (so count includes this job)
185
218
  UIDTJobRegistry.activeJobs[configId] = JobState(params, resumableDownloader, notificationId, groupId, groupName)
186
219
 
220
+ // Update summary notification if grouping enabled (now includes new job in count)
221
+ UIDTNotificationManager.updateSummaryNotificationForGroup(this, groupId, groupName)
222
+
187
223
  // Create listener that will notify completion
188
224
  val jobListener = createJobListener(configId, params, groupId, groupName)
189
225
 
@@ -225,8 +261,12 @@ class UIDTDownloadJobService : JobService() {
225
261
  // Save state for potential resume
226
262
  val state = jobState.resumableDownloader.getState(configId)
227
263
  if (state != null) {
228
- // Store resume info
264
+ val bytesDownloaded = state.bytesDownloaded.get()
265
+ // Keep in-memory headers so the same-process reschedule works.
229
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")
230
270
  }
231
271
  }
232
272
  UIDTJobRegistry.activeJobs.remove(configId)
@@ -251,6 +291,8 @@ class UIDTDownloadJobService : JobService() {
251
291
  // Update notification with size info
252
292
  val jobState = UIDTJobRegistry.activeJobs[id]
253
293
  if (jobState != null) {
294
+ // Store total bytes for summary progress calculation
295
+ jobState.bytesTotal = expectedBytes
254
296
  UIDTNotificationManager.updateProgressNotification(this@UIDTDownloadJobService, jobState, 0, expectedBytes)
255
297
  }
256
298
 
@@ -261,6 +303,12 @@ class UIDTDownloadJobService : JobService() {
261
303
  override fun onProgress(id: String, bytesDownloaded: Long, bytesTotal: Long) {
262
304
  val jobState = UIDTJobRegistry.activeJobs[id]
263
305
  if (jobState != null) {
306
+ // Always update progress tracking in JobState for summary calculation
307
+ jobState.bytesDownloaded = bytesDownloaded
308
+ if (bytesTotal > 0) {
309
+ jobState.bytesTotal = bytesTotal
310
+ }
311
+
264
312
  // Check if download is paused - don't update notification with progress
265
313
  val isPaused = jobState.resumableDownloader.isPaused(id)
266
314
  if (isPaused) {
@@ -269,10 +317,12 @@ class UIDTDownloadJobService : JobService() {
269
317
  return
270
318
  }
271
319
 
320
+ val config = UIDTJobRegistry.notificationConfig
321
+ val isSummaryOnlyMode = config.mode == NotificationGroupingMode.SUMMARY_ONLY
322
+
272
323
  if (bytesTotal > 0) {
273
324
  val progress = ProgressUtils.calculateProgress(bytesDownloaded, bytesTotal)
274
325
  val currentTime = System.currentTimeMillis()
275
- val config = UIDTJobRegistry.notificationConfig
276
326
 
277
327
  val shouldUpdate = ProgressUtils.shouldUpdateProgress(
278
328
  progress,
@@ -285,7 +335,18 @@ class UIDTDownloadJobService : JobService() {
285
335
  if (shouldUpdate) {
286
336
  jobState.lastNotifiedProgress = progress
287
337
  jobState.lastNotificationUpdateTime = currentTime
288
- UIDTNotificationManager.updateProgressNotification(this@UIDTDownloadJobService, jobState, bytesDownloaded, bytesTotal)
338
+
339
+ if (isSummaryOnlyMode && config.groupingEnabled && jobState.groupId.isNotEmpty()) {
340
+ // In summaryOnly mode, update only the summary notification
341
+ UIDTNotificationManager.updateSummaryNotificationForGroup(
342
+ this@UIDTDownloadJobService, jobState.groupId, jobState.groupName
343
+ )
344
+ } else {
345
+ // Update individual notification
346
+ UIDTNotificationManager.updateProgressNotification(
347
+ this@UIDTDownloadJobService, jobState, bytesDownloaded, bytesTotal
348
+ )
349
+ }
289
350
  }
290
351
  }
291
352
  }
@@ -295,26 +356,49 @@ class UIDTDownloadJobService : JobService() {
295
356
  }
296
357
 
297
358
  override fun onComplete(id: String, location: String, bytesDownloaded: Long, bytesTotal: Long) {
298
- RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "UIDT download complete: $id")
359
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "UIDT download complete: $id, groupId=$groupId")
299
360
 
300
361
  val jobState = UIDTJobRegistry.activeJobs[id]
301
362
  val notificationId = jobState?.notificationId
302
363
 
303
- // Cancel individual notification
304
- if (notificationId != null) {
305
- UIDTNotificationManager.cancelNotification(this@UIDTDownloadJobService, notificationId)
306
- }
364
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "onComplete: notificationId=$notificationId, jobState groupId=${jobState?.groupId}")
307
365
 
308
- // Clean up
366
+ // Clean up - remove from activeJobs first
309
367
  UIDTJobRegistry.activeJobs.remove(id)
310
368
  releaseWakeLock()
311
369
 
312
- // Update summary notification for this group
313
- UIDTNotificationManager.updateSummaryNotification(this@UIDTDownloadJobService, groupId, groupName)
370
+ // Check if this was the last job in the group
371
+ val remainingInGroup = UIDTJobRegistry.activeJobs.values.count { it.groupId == groupId }
372
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Jobs remaining in group '$groupId': $remainingInGroup, activeJobs count=${UIDTJobRegistry.activeJobs.size}")
373
+
374
+ if (remainingInGroup == 0 && groupId.isNotEmpty()) {
375
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Last job in group completed. Marking finalized and cancelling summary.")
376
+ // Mark group as finalized FIRST to prevent race conditions
377
+ // where delayed progress callbacks might recreate the notification
378
+ UIDTJobRegistry.markGroupFinalized(groupId)
379
+ // Last job in group - cancel summary notification
380
+ UIDTNotificationManager.cancelSummaryNotification(this@UIDTDownloadJobService, groupId)
381
+ } else if (groupId.isEmpty()) {
382
+ RNBackgroundDownloaderModuleImpl.logW(UIDTConstants.TAG, "groupId is empty in onComplete for $id")
383
+ } else {
384
+ // Update summary notification for this group (update count/progress)
385
+ UIDTNotificationManager.updateSummaryNotificationForGroup(this@UIDTDownloadJobService, groupId, groupName)
386
+ }
314
387
 
315
- // Signal job completion
388
+ // Use setNotification with REMOVE policy to tell Android to remove the UIDT-controlled notification
389
+ // This is required because UIDT notifications are managed by the system, not NotificationManager
390
+ if (notificationId != null) {
391
+ val emptyNotification = UIDTNotificationManager.createEmptyNotification(this@UIDTDownloadJobService)
392
+ setNotification(params, notificationId, emptyNotification, JOB_END_NOTIFICATION_POLICY_REMOVE)
393
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Set REMOVE policy for notification $notificationId")
394
+ }
395
+
396
+ // Signal job completion - this triggers the REMOVE policy
316
397
  jobFinished(params, false)
317
398
 
399
+ // Clear persisted resume state - no longer needed after successful completion
400
+ UIDTJobRegistry.clearResumeState(this@UIDTDownloadJobService, id)
401
+
318
402
  // Notify external listener
319
403
  UIDTJobRegistry.downloadListener?.onComplete(id, location, bytesDownloaded, bytesTotal)
320
404
  }
@@ -325,21 +409,37 @@ class UIDTDownloadJobService : JobService() {
325
409
  val jobState = UIDTJobRegistry.activeJobs[id]
326
410
  val notificationId = jobState?.notificationId
327
411
 
328
- // Cancel individual notification
329
- if (notificationId != null) {
330
- UIDTNotificationManager.cancelNotification(this@UIDTDownloadJobService, notificationId)
331
- }
332
-
333
- // Clean up
412
+ // Clean up - remove from activeJobs first
334
413
  UIDTJobRegistry.activeJobs.remove(id)
335
414
  releaseWakeLock()
336
415
 
337
- // Update summary notification for this group
338
- UIDTNotificationManager.updateSummaryNotification(this@UIDTDownloadJobService, groupId, groupName)
416
+ // Check if this was the last job in the group
417
+ val remainingInGroup = UIDTJobRegistry.activeJobs.values.count { it.groupId == groupId }
418
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Jobs remaining in group '$groupId' after error: $remainingInGroup")
419
+
420
+ if (remainingInGroup == 0 && groupId.isNotEmpty()) {
421
+ // Mark group as finalized FIRST to prevent race conditions
422
+ UIDTJobRegistry.markGroupFinalized(groupId)
423
+ // Last job in group - cancel summary notification
424
+ UIDTNotificationManager.cancelSummaryNotification(this@UIDTDownloadJobService, groupId)
425
+ } else {
426
+ // Update summary notification for this group (update count/progress)
427
+ UIDTNotificationManager.updateSummaryNotificationForGroup(this@UIDTDownloadJobService, groupId, groupName)
428
+ }
339
429
 
340
- // Signal job completion with no reschedule
430
+ // Use setNotification with REMOVE policy to tell Android to remove the UIDT-controlled notification
431
+ if (notificationId != null) {
432
+ val emptyNotification = UIDTNotificationManager.createEmptyNotification(this@UIDTDownloadJobService)
433
+ setNotification(params, notificationId, emptyNotification, JOB_END_NOTIFICATION_POLICY_REMOVE)
434
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Set REMOVE policy for notification $notificationId (error)")
435
+ }
436
+
437
+ // Signal job completion with no reschedule - this triggers the REMOVE policy
341
438
  jobFinished(params, false)
342
439
 
440
+ // Clear persisted resume state - no longer needed after failure
441
+ UIDTJobRegistry.clearResumeState(this@UIDTDownloadJobService, id)
442
+
343
443
  // Notify external listener
344
444
  UIDTJobRegistry.downloadListener?.onError(id, error, errorCode)
345
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()) {
@@ -222,11 +232,15 @@ object UIDTJobManager {
222
232
  /**
223
233
  * Configure notification settings.
224
234
  */
225
- fun setNotificationConfig(enabled: Boolean, showNotifications: Boolean, texts: Map<String, String>) {
235
+ fun setNotificationConfig(enabled: Boolean, showNotifications: Boolean, mode: String, texts: Map<String, String>) {
226
236
  config.groupingEnabled = enabled
227
237
  config.showNotificationsEnabled = showNotifications
238
+ config.mode = when (mode) {
239
+ "summaryOnly" -> NotificationGroupingMode.SUMMARY_ONLY
240
+ else -> NotificationGroupingMode.INDIVIDUAL
241
+ }
228
242
  config.updateTexts(texts)
229
- RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Notification config updated: grouping=$enabled, showNotificationsEnabled=$showNotifications, texts=$texts")
243
+ RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Notification config updated: grouping=$enabled, showNotificationsEnabled=$showNotifications, mode=${config.mode}, texts=$texts")
230
244
  }
231
245
 
232
246
  /**