@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.
- package/CHANGELOG.md +294 -0
- package/README.md +116 -10
- package/android/build.gradle +4 -2
- package/android/src/main/java/com/eko/Downloader.kt +10 -0
- package/android/src/main/java/com/eko/RNBackgroundDownloaderModuleImpl.kt +55 -9
- package/android/src/main/java/com/eko/UIDTDownloadJobService.kt +130 -30
- package/android/src/main/java/com/eko/uidt/UIDTJobManager.kt +17 -3
- package/android/src/main/java/com/eko/uidt/UIDTJobState.kt +164 -1
- package/android/src/main/java/com/eko/uidt/UIDTNotificationManager.kt +213 -4
- package/example/android/app/debug.keystore +0 -0
- package/ios/.DS_Store +0 -0
- package/ios/RNBackgroundDownloader.mm +53 -9
- package/package.json +1 -1
- package/plugin/build/index.d.ts +2 -2
- package/plugin/build/index.js +1 -1
- package/src/NativeRNBackgroundDownloader.ts +1 -1
- package/src/config.ts +3 -2
- package/src/index.ts +3 -1
- package/src/types.ts +9 -0
- package/plugin/package.json +0 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
166
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
313
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
338
|
-
|
|
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
|
-
//
|
|
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
|
/**
|