@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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
package com.eko.uidt
|
|
2
2
|
|
|
3
3
|
import android.app.job.JobParameters
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import com.eko.RNBackgroundDownloaderModuleImpl
|
|
4
6
|
import com.eko.ResumableDownloader
|
|
7
|
+
import org.json.JSONObject
|
|
5
8
|
import java.util.concurrent.ConcurrentHashMap
|
|
6
9
|
|
|
7
10
|
/**
|
|
@@ -14,15 +17,27 @@ data class JobState(
|
|
|
14
17
|
val groupId: String = "",
|
|
15
18
|
val groupName: String = "",
|
|
16
19
|
var lastNotifiedProgress: Int = -1,
|
|
17
|
-
var lastNotificationUpdateTime: Long = 0
|
|
20
|
+
var lastNotificationUpdateTime: Long = 0,
|
|
21
|
+
// Track download progress for summary notification
|
|
22
|
+
@Volatile var bytesDownloaded: Long = 0L,
|
|
23
|
+
@Volatile var bytesTotal: Long = -1L
|
|
18
24
|
)
|
|
19
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Mode for notification display when grouping is enabled.
|
|
28
|
+
*/
|
|
29
|
+
enum class NotificationGroupingMode {
|
|
30
|
+
INDIVIDUAL, // Show all notifications (default, current behavior)
|
|
31
|
+
SUMMARY_ONLY // Show only summary notification, minimize individual ones
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
/**
|
|
21
35
|
* Configuration for notification display.
|
|
22
36
|
*/
|
|
23
37
|
data class NotificationConfig(
|
|
24
38
|
var groupingEnabled: Boolean = false,
|
|
25
39
|
var showNotificationsEnabled: Boolean = false,
|
|
40
|
+
var mode: NotificationGroupingMode = NotificationGroupingMode.INDIVIDUAL,
|
|
26
41
|
var updateInterval: Long = 500L,
|
|
27
42
|
val texts: MutableMap<String, String> = mutableMapOf(
|
|
28
43
|
"downloadTitle" to "Download",
|
|
@@ -49,6 +64,19 @@ data class NotificationConfig(
|
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Tracks aggregate progress for a download group.
|
|
69
|
+
*/
|
|
70
|
+
data class GroupProgress(
|
|
71
|
+
var totalFiles: Int = 0,
|
|
72
|
+
var completedFiles: Int = 0,
|
|
73
|
+
var totalBytes: Long = 0L,
|
|
74
|
+
var downloadedBytes: Long = 0L
|
|
75
|
+
) {
|
|
76
|
+
val progressPercent: Int
|
|
77
|
+
get() = if (totalBytes > 0) ((downloadedBytes * 100) / totalBytes).toInt().coerceIn(0, 100) else 0
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
/**
|
|
53
81
|
* Constants for UIDT jobs.
|
|
54
82
|
*/
|
|
@@ -72,6 +100,9 @@ object UIDTConstants {
|
|
|
72
100
|
// Notification channel for UIDT jobs (silent/hidden notifications)
|
|
73
101
|
const val NOTIFICATION_CHANNEL_SILENT_ID = "uidt_download_channel_silent"
|
|
74
102
|
|
|
103
|
+
// Notification channel for UIDT jobs (ultra-silent for summaryOnly mode)
|
|
104
|
+
const val NOTIFICATION_CHANNEL_ULTRA_SILENT_ID = "uidt_download_channel_ultra_silent"
|
|
105
|
+
|
|
75
106
|
// Notification group for grouping all download notifications together
|
|
76
107
|
const val NOTIFICATION_GROUP_KEY = "com.eko.DOWNLOAD_GROUP"
|
|
77
108
|
|
|
@@ -99,6 +130,65 @@ data class UIDTJobInfo(
|
|
|
99
130
|
* Singleton for managing active UIDT jobs state.
|
|
100
131
|
*/
|
|
101
132
|
object UIDTJobRegistry {
|
|
133
|
+
|
|
134
|
+
private const val PREFS_NAME = "rnbd_uidt_resume"
|
|
135
|
+
private const val KEY_BYTES_PREFIX = "bytes_"
|
|
136
|
+
private const val KEY_HEADERS_PREFIX = "headers_"
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Persist UIDT resume state (headers + byte position) to disk so it
|
|
140
|
+
* survives process death and can be used when the job is rescheduled in a
|
|
141
|
+
* new process.
|
|
142
|
+
*/
|
|
143
|
+
fun saveResumeState(context: Context, configId: String, headers: Map<String, String>, bytesDownloaded: Long) {
|
|
144
|
+
try {
|
|
145
|
+
val headersJson = JSONObject(headers as Map<*, *>).toString()
|
|
146
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
|
147
|
+
.putLong("$KEY_BYTES_PREFIX$configId", bytesDownloaded)
|
|
148
|
+
.putString("$KEY_HEADERS_PREFIX$configId", headersJson)
|
|
149
|
+
.apply()
|
|
150
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Saved UIDT resume state for $configId: bytes=$bytesDownloaded")
|
|
151
|
+
} catch (e: Exception) {
|
|
152
|
+
RNBackgroundDownloaderModuleImpl.logE(UIDTConstants.TAG, "Failed to save UIDT resume state for $configId: ${e.message}")
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Load persisted UIDT resume state for a download.
|
|
158
|
+
* Returns (headers, bytesDownloaded) or null if no state was saved.
|
|
159
|
+
*/
|
|
160
|
+
fun loadResumeState(context: Context, configId: String): Pair<Map<String, String>, Long>? {
|
|
161
|
+
try {
|
|
162
|
+
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
163
|
+
val bytesDownloaded = prefs.getLong("$KEY_BYTES_PREFIX$configId", -1L)
|
|
164
|
+
val headersJson = prefs.getString("$KEY_HEADERS_PREFIX$configId", null)
|
|
165
|
+
if (bytesDownloaded < 0 || headersJson == null) return null
|
|
166
|
+
val json = JSONObject(headersJson)
|
|
167
|
+
val headers = mutableMapOf<String, String>()
|
|
168
|
+
for (key in json.keys()) headers[key] = json.getString(key)
|
|
169
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Loaded UIDT resume state for $configId: bytes=$bytesDownloaded, headers=${headers.size}")
|
|
170
|
+
return Pair(headers, bytesDownloaded)
|
|
171
|
+
} catch (e: Exception) {
|
|
172
|
+
RNBackgroundDownloaderModuleImpl.logE(UIDTConstants.TAG, "Failed to load UIDT resume state for $configId: ${e.message}")
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clear persisted UIDT resume state for a download once it is no longer needed.
|
|
179
|
+
*/
|
|
180
|
+
fun clearResumeState(context: Context, configId: String) {
|
|
181
|
+
try {
|
|
182
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
|
183
|
+
.remove("$KEY_BYTES_PREFIX$configId")
|
|
184
|
+
.remove("$KEY_HEADERS_PREFIX$configId")
|
|
185
|
+
.apply()
|
|
186
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Cleared UIDT resume state for $configId")
|
|
187
|
+
} catch (e: Exception) {
|
|
188
|
+
RNBackgroundDownloaderModuleImpl.logE(UIDTConstants.TAG, "Failed to clear UIDT resume state for $configId: ${e.message}")
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
102
192
|
// Track active jobs for pause/resume
|
|
103
193
|
val activeJobs = ConcurrentHashMap<String, JobState>()
|
|
104
194
|
|
|
@@ -116,6 +206,13 @@ object UIDTJobRegistry {
|
|
|
116
206
|
// Notification configuration
|
|
117
207
|
val notificationConfig = NotificationConfig()
|
|
118
208
|
|
|
209
|
+
// Group progress tracking for each groupId
|
|
210
|
+
val groupProgress = ConcurrentHashMap<String, GroupProgress>()
|
|
211
|
+
|
|
212
|
+
// Track groups that have been finalized (all jobs completed)
|
|
213
|
+
// This prevents race conditions where progress updates recreate cancelled notifications
|
|
214
|
+
val finalizedGroups = ConcurrentHashMap.newKeySet<String>()
|
|
215
|
+
|
|
119
216
|
fun isActiveJob(configId: String): Boolean = activeJobs.containsKey(configId)
|
|
120
217
|
|
|
121
218
|
fun isPausedJob(configId: String): Boolean {
|
|
@@ -128,6 +225,72 @@ object UIDTJobRegistry {
|
|
|
128
225
|
return jobState.resumableDownloader.getState(configId)
|
|
129
226
|
}
|
|
130
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Update aggregate progress for a group.
|
|
230
|
+
*/
|
|
231
|
+
fun updateGroupProgress(groupId: String, configId: String, bytesDownloaded: Long, bytesTotal: Long) {
|
|
232
|
+
if (groupId.isEmpty()) return
|
|
233
|
+
|
|
234
|
+
val progress = groupProgress.getOrPut(groupId) { GroupProgress() }
|
|
235
|
+
// We track per-file progress by summing from all active jobs in the group
|
|
236
|
+
var totalDownloaded = 0L
|
|
237
|
+
var totalTotal = 0L
|
|
238
|
+
var fileCount = 0
|
|
239
|
+
|
|
240
|
+
activeJobs.values.filter { it.groupId == groupId }.forEach { job ->
|
|
241
|
+
val state = job.resumableDownloader.getState(activeJobs.entries.find { it.value == job }?.key ?: return@forEach)
|
|
242
|
+
totalDownloaded += state?.bytesDownloaded?.get() ?: 0L
|
|
243
|
+
if (state?.bytesTotal ?: -1L > 0) {
|
|
244
|
+
totalTotal += state?.bytesTotal ?: 0L
|
|
245
|
+
}
|
|
246
|
+
fileCount++
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
progress.downloadedBytes = totalDownloaded
|
|
250
|
+
progress.totalBytes = totalTotal
|
|
251
|
+
progress.totalFiles = fileCount
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Mark a file as completed in a group.
|
|
256
|
+
*/
|
|
257
|
+
fun markFileCompleted(groupId: String) {
|
|
258
|
+
if (groupId.isEmpty()) return
|
|
259
|
+
val progress = groupProgress[groupId] ?: return
|
|
260
|
+
progress.completedFiles++
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Clear group progress when all downloads complete.
|
|
265
|
+
*/
|
|
266
|
+
fun clearGroupProgress(groupId: String) {
|
|
267
|
+
groupProgress.remove(groupId)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Mark a group as finalized (all downloads complete).
|
|
272
|
+
* This prevents race conditions where delayed progress updates might recreate the notification.
|
|
273
|
+
*/
|
|
274
|
+
fun markGroupFinalized(groupId: String) {
|
|
275
|
+
if (groupId.isNotEmpty()) {
|
|
276
|
+
finalizedGroups.add(groupId)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check if a group is finalized.
|
|
282
|
+
*/
|
|
283
|
+
fun isGroupFinalized(groupId: String): Boolean {
|
|
284
|
+
return finalizedGroups.contains(groupId)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Unmark a group as finalized (when new downloads start).
|
|
289
|
+
*/
|
|
290
|
+
fun unmarkGroupFinalized(groupId: String) {
|
|
291
|
+
finalizedGroups.remove(groupId)
|
|
292
|
+
}
|
|
293
|
+
|
|
131
294
|
/**
|
|
132
295
|
* Returns a snapshot of all currently active UIDT jobs.
|
|
133
296
|
* Used by the module to populate getExistingDownloads on Android 14+.
|
|
@@ -60,6 +60,20 @@ object UIDTNotificationManager {
|
|
|
60
60
|
setShowBadge(false)
|
|
61
61
|
}
|
|
62
62
|
notificationManager.createNotificationChannel(silentChannel)
|
|
63
|
+
|
|
64
|
+
// Create ultra-silent channel for summaryOnly mode (IMPORTANCE_MIN, no alert)
|
|
65
|
+
val ultraSilentChannel = NotificationChannel(
|
|
66
|
+
UIDTConstants.NOTIFICATION_CHANNEL_ULTRA_SILENT_ID,
|
|
67
|
+
"Background Downloads (Grouped)",
|
|
68
|
+
NotificationManager.IMPORTANCE_MIN
|
|
69
|
+
).apply {
|
|
70
|
+
description = "Ultra-silent notifications for grouped background downloads"
|
|
71
|
+
setShowBadge(false)
|
|
72
|
+
enableLights(false)
|
|
73
|
+
enableVibration(false)
|
|
74
|
+
setSound(null, null)
|
|
75
|
+
}
|
|
76
|
+
notificationManager.createNotificationChannel(ultraSilentChannel)
|
|
63
77
|
}
|
|
64
78
|
}
|
|
65
79
|
|
|
@@ -72,10 +86,13 @@ object UIDTNotificationManager {
|
|
|
72
86
|
groupId: String = "",
|
|
73
87
|
groupName: String = ""
|
|
74
88
|
): Notification {
|
|
75
|
-
val
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
val isSummaryOnlyMode = config.mode == NotificationGroupingMode.SUMMARY_ONLY
|
|
90
|
+
|
|
91
|
+
// Determine channel based on mode and settings
|
|
92
|
+
val channelId = when {
|
|
93
|
+
!config.showNotificationsEnabled -> UIDTConstants.NOTIFICATION_CHANNEL_SILENT_ID
|
|
94
|
+
isSummaryOnlyMode && config.groupingEnabled && groupId.isNotEmpty() -> UIDTConstants.NOTIFICATION_CHANNEL_ULTRA_SILENT_ID
|
|
95
|
+
else -> UIDTConstants.NOTIFICATION_CHANNEL_ID
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
// When notifications are disabled, create minimal silent notification
|
|
@@ -91,6 +108,21 @@ object UIDTNotificationManager {
|
|
|
91
108
|
.build()
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
// In summaryOnly mode with grouping, create minimal individual notifications
|
|
112
|
+
if (isSummaryOnlyMode && config.groupingEnabled && groupId.isNotEmpty()) {
|
|
113
|
+
val groupKey = "${UIDTConstants.NOTIFICATION_GROUP_KEY}_$groupId"
|
|
114
|
+
return NotificationCompat.Builder(context, channelId)
|
|
115
|
+
.setContentTitle("")
|
|
116
|
+
.setContentText("")
|
|
117
|
+
.setSmallIcon(android.R.drawable.stat_sys_download)
|
|
118
|
+
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
119
|
+
.setOngoing(true)
|
|
120
|
+
.setSilent(true)
|
|
121
|
+
.setGroup(groupKey)
|
|
122
|
+
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
|
123
|
+
.build()
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
val title = if (config.groupingEnabled && groupName.isNotEmpty()) {
|
|
95
127
|
groupName
|
|
96
128
|
} else {
|
|
@@ -128,6 +160,17 @@ object UIDTNotificationManager {
|
|
|
128
160
|
) {
|
|
129
161
|
if (!config.showNotificationsEnabled) return
|
|
130
162
|
|
|
163
|
+
val isSummaryOnlyMode = config.mode == NotificationGroupingMode.SUMMARY_ONLY
|
|
164
|
+
|
|
165
|
+
// In summaryOnly mode, skip updating individual notifications - only update summary
|
|
166
|
+
if (isSummaryOnlyMode && config.groupingEnabled && jobState.groupId.isNotEmpty()) {
|
|
167
|
+
// Update group progress tracking
|
|
168
|
+
UIDTJobRegistry.updateGroupProgress(jobState.groupId, "", bytesDownloaded, bytesTotal)
|
|
169
|
+
// Update the summary notification with aggregate progress
|
|
170
|
+
updateSummaryNotificationWithProgress(context, jobState.groupId, jobState.groupName)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
131
174
|
val progress = ProgressUtils.calculateProgress(bytesDownloaded, bytesTotal)
|
|
132
175
|
|
|
133
176
|
val title = if (config.groupingEnabled && jobState.groupName.isNotEmpty()) {
|
|
@@ -240,11 +283,36 @@ object UIDTNotificationManager {
|
|
|
240
283
|
}
|
|
241
284
|
}
|
|
242
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Update the summary notification for a download group.
|
|
288
|
+
* Automatically chooses between regular and progress-based summary
|
|
289
|
+
* depending on the notification mode.
|
|
290
|
+
*/
|
|
291
|
+
fun updateSummaryNotificationForGroup(context: Context, groupId: String, groupName: String) {
|
|
292
|
+
if (!config.groupingEnabled || groupId.isEmpty() || !config.showNotificationsEnabled) return
|
|
293
|
+
// Skip if group is finalized (all downloads complete) to prevent race conditions
|
|
294
|
+
if (UIDTJobRegistry.isGroupFinalized(groupId)) {
|
|
295
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Skipping summary update for finalized group: $groupId")
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (config.mode == NotificationGroupingMode.SUMMARY_ONLY) {
|
|
300
|
+
updateSummaryNotificationWithProgress(context, groupId, groupName)
|
|
301
|
+
} else {
|
|
302
|
+
updateSummaryNotification(context, groupId, groupName)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
243
306
|
/**
|
|
244
307
|
* Update the summary notification for a download group.
|
|
245
308
|
*/
|
|
246
309
|
fun updateSummaryNotification(context: Context, groupId: String, groupName: String) {
|
|
247
310
|
if (!config.groupingEnabled || groupId.isEmpty() || !config.showNotificationsEnabled) return
|
|
311
|
+
// Skip if group is finalized (all downloads complete) to prevent race conditions
|
|
312
|
+
if (UIDTJobRegistry.isGroupFinalized(groupId)) {
|
|
313
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Skipping summary update for finalized group: $groupId")
|
|
314
|
+
return
|
|
315
|
+
}
|
|
248
316
|
|
|
249
317
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
250
318
|
|
|
@@ -255,6 +323,7 @@ object UIDTNotificationManager {
|
|
|
255
323
|
if (groupDownloads == 0) {
|
|
256
324
|
// Remove summary for this group when no active downloads
|
|
257
325
|
notificationManager.cancel(summaryNotificationId)
|
|
326
|
+
UIDTJobRegistry.clearGroupProgress(groupId)
|
|
258
327
|
return
|
|
259
328
|
}
|
|
260
329
|
|
|
@@ -277,6 +346,104 @@ object UIDTNotificationManager {
|
|
|
277
346
|
notificationManager.notify(summaryNotificationId, summaryNotification)
|
|
278
347
|
}
|
|
279
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Update the summary notification with aggregate progress (for summaryOnly mode).
|
|
351
|
+
*/
|
|
352
|
+
fun updateSummaryNotificationWithProgress(context: Context, groupId: String, groupName: String) {
|
|
353
|
+
if (!config.groupingEnabled || groupId.isEmpty() || !config.showNotificationsEnabled) return
|
|
354
|
+
if (config.mode != NotificationGroupingMode.SUMMARY_ONLY) return
|
|
355
|
+
// Skip if group is finalized (all downloads complete) to prevent race conditions
|
|
356
|
+
if (UIDTJobRegistry.isGroupFinalized(groupId)) {
|
|
357
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Skipping summary progress update for finalized group: $groupId")
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
362
|
+
|
|
363
|
+
// Get all active jobs for this group and calculate aggregate progress
|
|
364
|
+
val groupJobs = UIDTJobRegistry.activeJobs.entries.filter { it.value.groupId == groupId }
|
|
365
|
+
val groupDownloads = groupJobs.size
|
|
366
|
+
val summaryNotificationId = UIDTConstants.SUMMARY_NOTIFICATION_ID + groupId.hashCode()
|
|
367
|
+
|
|
368
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "updateSummaryNotificationWithProgress: groupId=$groupId, groupDownloads=$groupDownloads")
|
|
369
|
+
|
|
370
|
+
if (groupDownloads == 0) {
|
|
371
|
+
// Remove summary for this group when no active downloads
|
|
372
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Cancelling summary notification $summaryNotificationId for empty group $groupId")
|
|
373
|
+
notificationManager.cancel(summaryNotificationId)
|
|
374
|
+
UIDTJobRegistry.clearGroupProgress(groupId)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Calculate aggregate progress from all active jobs in the group
|
|
379
|
+
var totalBytesDownloaded = 0L
|
|
380
|
+
var totalBytesTotal = 0L
|
|
381
|
+
var hasKnownTotal = false
|
|
382
|
+
|
|
383
|
+
for ((_, jobState) in groupJobs) {
|
|
384
|
+
// Use JobState's tracked progress (updated on every progress callback)
|
|
385
|
+
totalBytesDownloaded += jobState.bytesDownloaded
|
|
386
|
+
if (jobState.bytesTotal > 0) {
|
|
387
|
+
totalBytesTotal += jobState.bytesTotal
|
|
388
|
+
hasKnownTotal = true
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
val groupKey = "${UIDTConstants.NOTIFICATION_GROUP_KEY}_$groupId"
|
|
393
|
+
val title = groupName.ifEmpty { config.getText("groupTitle") }
|
|
394
|
+
|
|
395
|
+
// Calculate progress percentage
|
|
396
|
+
val progress = if (hasKnownTotal && totalBytesTotal > 0) {
|
|
397
|
+
((totalBytesDownloaded * 100) / totalBytesTotal).toInt().coerceIn(0, 100)
|
|
398
|
+
} else {
|
|
399
|
+
0
|
|
400
|
+
}
|
|
401
|
+
val indeterminate = !hasKnownTotal || totalBytesTotal <= 0
|
|
402
|
+
|
|
403
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Summary progress: downloaded=$totalBytesDownloaded, total=$totalBytesTotal, progress=$progress%, indeterminate=$indeterminate")
|
|
404
|
+
|
|
405
|
+
// Build progress text
|
|
406
|
+
val text = if (hasKnownTotal && totalBytesTotal > 0) {
|
|
407
|
+
"$progress% - $groupDownloads file${if (groupDownloads != 1) "s" else ""}"
|
|
408
|
+
} else {
|
|
409
|
+
config.getText("groupText", "count" to groupDownloads)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Create a standalone progress notification (NOT part of the group)
|
|
413
|
+
// This ensures progress bar is visible in collapsed view
|
|
414
|
+
val progressNotification = NotificationCompat.Builder(context, UIDTConstants.NOTIFICATION_CHANNEL_ID)
|
|
415
|
+
.setContentTitle(title)
|
|
416
|
+
.setContentText(text)
|
|
417
|
+
.setSmallIcon(android.R.drawable.stat_sys_download)
|
|
418
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
419
|
+
// Don't set group - this notification stands alone with full progress visibility
|
|
420
|
+
.setOngoing(true)
|
|
421
|
+
.setOnlyAlertOnce(true)
|
|
422
|
+
.setShowWhen(false)
|
|
423
|
+
.setProgress(100, progress, indeterminate)
|
|
424
|
+
.build()
|
|
425
|
+
|
|
426
|
+
notificationManager.notify(summaryNotificationId, progressNotification)
|
|
427
|
+
|
|
428
|
+
// Create a hidden group summary to collapse all individual UIDT notifications
|
|
429
|
+
// This prevents individual notifications from cluttering the notification shade
|
|
430
|
+
val hiddenGroupSummaryId = summaryNotificationId + 1
|
|
431
|
+
val hiddenGroupSummary = NotificationCompat.Builder(context, UIDTConstants.NOTIFICATION_CHANNEL_ULTRA_SILENT_ID)
|
|
432
|
+
.setContentTitle("")
|
|
433
|
+
.setContentText("")
|
|
434
|
+
.setSmallIcon(android.R.drawable.stat_sys_download)
|
|
435
|
+
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
436
|
+
.setGroup(groupKey)
|
|
437
|
+
.setGroupSummary(true)
|
|
438
|
+
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
|
439
|
+
.setSilent(true)
|
|
440
|
+
.setOngoing(true)
|
|
441
|
+
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
|
|
442
|
+
.build()
|
|
443
|
+
|
|
444
|
+
notificationManager.notify(hiddenGroupSummaryId, hiddenGroupSummary)
|
|
445
|
+
}
|
|
446
|
+
|
|
280
447
|
/**
|
|
281
448
|
* Cancel notification for a specific download.
|
|
282
449
|
*/
|
|
@@ -293,6 +460,48 @@ object UIDTNotificationManager {
|
|
|
293
460
|
fun cancelNotification(context: Context, notificationId: Int) {
|
|
294
461
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
295
462
|
notificationManager.cancel(notificationId)
|
|
463
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Cancelled notification by ID: $notificationId")
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Cancel summary notification for a group.
|
|
468
|
+
* Also cancels all notifications in the group to prevent Android from auto-recreating the summary.
|
|
469
|
+
*/
|
|
470
|
+
fun cancelSummaryNotification(context: Context, groupId: String) {
|
|
471
|
+
if (groupId.isEmpty()) return
|
|
472
|
+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
473
|
+
val summaryNotificationId = UIDTConstants.SUMMARY_NOTIFICATION_ID + groupId.hashCode()
|
|
474
|
+
|
|
475
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "cancelSummaryNotification called for group '$groupId', hashCode=${groupId.hashCode()}, summaryNotificationId=$summaryNotificationId")
|
|
476
|
+
|
|
477
|
+
// Also cancel the hidden group summary
|
|
478
|
+
val hiddenGroupSummaryId = summaryNotificationId + 1
|
|
479
|
+
notificationManager.cancel(hiddenGroupSummaryId)
|
|
480
|
+
|
|
481
|
+
// First, cancel all active status bar notifications to ensure the group is completely removed
|
|
482
|
+
// This prevents Android from auto-recreating a group summary from orphaned child notifications
|
|
483
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
484
|
+
val groupKey = "${UIDTConstants.NOTIFICATION_GROUP_KEY}_$groupId"
|
|
485
|
+
try {
|
|
486
|
+
val activeNotifications = notificationManager.activeNotifications
|
|
487
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Found ${activeNotifications.size} active notifications")
|
|
488
|
+
for (notification in activeNotifications) {
|
|
489
|
+
val notifGroup = notification.notification?.group
|
|
490
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Active notification id=${notification.id}, group=$notifGroup, target=$groupKey")
|
|
491
|
+
if (notifGroup == groupKey) {
|
|
492
|
+
notificationManager.cancel(notification.id)
|
|
493
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Cancelled orphaned group notification: ${notification.id}")
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} catch (e: Exception) {
|
|
497
|
+
RNBackgroundDownloaderModuleImpl.logW(UIDTConstants.TAG, "Failed to cancel orphaned notifications: ${e.message}")
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Then cancel the summary notification
|
|
502
|
+
notificationManager.cancel(summaryNotificationId)
|
|
503
|
+
UIDTJobRegistry.clearGroupProgress(groupId)
|
|
504
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Cancelled summary notification $summaryNotificationId for group $groupId")
|
|
296
505
|
}
|
|
297
506
|
|
|
298
507
|
/**
|
|
Binary file
|
package/ios/.DS_Store
ADDED
|
Binary file
|
|
@@ -66,6 +66,13 @@ static CompletionHandler storedCompletionHandler;
|
|
|
66
66
|
// Controls whether debug logs are sent to JS
|
|
67
67
|
BOOL isLogsEnabled;
|
|
68
68
|
|
|
69
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
70
|
+
// Queue of events that arrived before the TurboModule event emitter callback was set.
|
|
71
|
+
// This prevents crashes (std::bad_function_call / SIGABRT) when NSURLSession delegate
|
|
72
|
+
// callbacks fire before JS has registered event listeners.
|
|
73
|
+
NSMutableArray<NSDictionary *> *pendingEmitEvents;
|
|
74
|
+
#endif
|
|
75
|
+
|
|
69
76
|
// Upload-specific instance variables
|
|
70
77
|
NSMutableDictionary<NSNumber *, RNBGDUploadTaskConfig *> *uploadTaskToConfigMap;
|
|
71
78
|
NSMutableDictionary<NSString *, NSURLSessionUploadTask *> *idToUploadTaskMap;
|
|
@@ -233,6 +240,10 @@ static const int kMaxEventRetries = 50; // 50 retries * 100ms = 5 seconds max w
|
|
|
233
240
|
isSessionActivated = NO;
|
|
234
241
|
pendingDownloads = [[NSMutableArray alloc] init];
|
|
235
242
|
|
|
243
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
244
|
+
pendingEmitEvents = [[NSMutableArray alloc] init];
|
|
245
|
+
#endif
|
|
246
|
+
|
|
236
247
|
// Initialize upload-specific data structures
|
|
237
248
|
NSData *uploadTaskToConfigMapData = [mmkv getDataForKey:ID_TO_UPLOAD_CONFIG_MAP_KEY];
|
|
238
249
|
NSMutableDictionary *uploadTaskToConfigMapDataDefault = [[NSMutableDictionary alloc] init];
|
|
@@ -1140,6 +1151,36 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1140
1151
|
}
|
|
1141
1152
|
}
|
|
1142
1153
|
|
|
1154
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1155
|
+
#pragma mark - Safe event emission (New Architecture)
|
|
1156
|
+
|
|
1157
|
+
// Safely emit an event, queuing it if the TurboModule event emitter callback
|
|
1158
|
+
// has not been registered yet. This prevents std::bad_function_call crashes
|
|
1159
|
+
// when NSURLSession delegate callbacks fire before JS has registered listeners
|
|
1160
|
+
// (e.g., background session delivering completions from a prior app session).
|
|
1161
|
+
- (void)safeEmitEvent:(NSString *)eventName value:(id)value {
|
|
1162
|
+
@synchronized (pendingEmitEvents) {
|
|
1163
|
+
if (_eventEmitterCallback) {
|
|
1164
|
+
_eventEmitterCallback(std::string([eventName UTF8String]), value);
|
|
1165
|
+
} else {
|
|
1166
|
+
[pendingEmitEvents addObject:@{@"name": eventName, @"value": value}];
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper {
|
|
1172
|
+
@synchronized (pendingEmitEvents) {
|
|
1173
|
+
[super setEventEmitterCallback:eventEmitterCallbackWrapper];
|
|
1174
|
+
|
|
1175
|
+
// Flush any events that arrived before the callback was set
|
|
1176
|
+
for (NSDictionary *event in pendingEmitEvents) {
|
|
1177
|
+
_eventEmitterCallback(std::string([event[@"name"] UTF8String]), event[@"value"]);
|
|
1178
|
+
}
|
|
1179
|
+
[pendingEmitEvents removeAllObjects];
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
#endif
|
|
1183
|
+
|
|
1143
1184
|
#pragma mark - NSURLSessionDownloadDelegate methods
|
|
1144
1185
|
- (void)URLSession:(nonnull NSURLSession *)session downloadTask:(nonnull NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(nonnull NSURL *)location {
|
|
1145
1186
|
@synchronized (sharedLock) {
|
|
@@ -1161,6 +1202,9 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1161
1202
|
[self sendDebugLog:[NSString stringWithFormat:@"didFinishDownloadingToURL: error - %@", error.localizedDescription] taskId:taskConfig.id];
|
|
1162
1203
|
}
|
|
1163
1204
|
|
|
1205
|
+
// Drop any buffered progress for this task so it cannot arrive in JS after downloadComplete
|
|
1206
|
+
[progressReports removeObjectForKey:taskConfig.id];
|
|
1207
|
+
|
|
1164
1208
|
[self sendDownloadCompletionEvent:taskConfig task:downloadTask error:error];
|
|
1165
1209
|
|
|
1166
1210
|
[self removeTaskFromMap:downloadTask];
|
|
@@ -1170,7 +1214,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1170
1214
|
- (void)sendDownloadCompletionEvent:(RNBGDTaskConfig *)taskConfig task:(NSURLSessionDownloadTask *)task error:(NSError *)error {
|
|
1171
1215
|
if (error) {
|
|
1172
1216
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1173
|
-
[self
|
|
1217
|
+
[self safeEmitEvent:@"onDownloadFailed" value:@{
|
|
1174
1218
|
@"id": taskConfig.id,
|
|
1175
1219
|
@"error": [error localizedDescription],
|
|
1176
1220
|
@"errorCode": @(error.code)
|
|
@@ -1184,7 +1228,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1184
1228
|
#endif
|
|
1185
1229
|
} else {
|
|
1186
1230
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1187
|
-
[self
|
|
1231
|
+
[self safeEmitEvent:@"onDownloadComplete" value:@{
|
|
1188
1232
|
@"id": taskConfig.id,
|
|
1189
1233
|
@"location": taskConfig.destination,
|
|
1190
1234
|
@"bytesDownloaded": @(task.countOfBytesReceived),
|
|
@@ -1243,7 +1287,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1243
1287
|
responseHeaders = @{};
|
|
1244
1288
|
}
|
|
1245
1289
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1246
|
-
[self
|
|
1290
|
+
[self safeEmitEvent:@"onDownloadBegin" value:@{
|
|
1247
1291
|
@"id": taskConfig.id,
|
|
1248
1292
|
@"expectedBytes": @(expectedBytes),
|
|
1249
1293
|
@"headers": responseHeaders
|
|
@@ -1290,7 +1334,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1290
1334
|
NSDate *now = [NSDate date];
|
|
1291
1335
|
if ([now timeIntervalSinceDate:lastProgressReportedAt] > progressInterval) {
|
|
1292
1336
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1293
|
-
[self
|
|
1337
|
+
[self safeEmitEvent:@"onDownloadProgress" value:[progressReports allValues]];
|
|
1294
1338
|
#else
|
|
1295
1339
|
[self sendEventWithName:@"downloadProgress" body:[progressReports allValues]];
|
|
1296
1340
|
#endif
|
|
@@ -1385,7 +1429,7 @@ RCT_EXPORT_METHOD(getExistingDownloadTasks: (RCTPromiseResolveBlock)resolve reje
|
|
|
1385
1429
|
|
|
1386
1430
|
// Handle failure
|
|
1387
1431
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1388
|
-
[self
|
|
1432
|
+
[self safeEmitEvent:@"onDownloadFailed" value:@{
|
|
1389
1433
|
@"id": taskConfig.id,
|
|
1390
1434
|
@"error": [error localizedDescription],
|
|
1391
1435
|
@"errorCode": @(error.code)
|
|
@@ -1764,7 +1808,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
|
|
|
1764
1808
|
if (!taskConfig.reportedBegin) {
|
|
1765
1809
|
taskConfig.reportedBegin = YES;
|
|
1766
1810
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1767
|
-
[self
|
|
1811
|
+
[self safeEmitEvent:@"onUploadBegin" value:@{
|
|
1768
1812
|
@"id": taskConfig.id,
|
|
1769
1813
|
@"expectedBytes": @(totalBytesExpectedToSend)
|
|
1770
1814
|
}];
|
|
@@ -1801,7 +1845,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
|
|
|
1801
1845
|
NSDate *now = [NSDate date];
|
|
1802
1846
|
if ([now timeIntervalSinceDate:lastUploadProgressReportedAt] > progressInterval) {
|
|
1803
1847
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1804
|
-
[self
|
|
1848
|
+
[self safeEmitEvent:@"onUploadProgress" value:[uploadProgressReports allValues]];
|
|
1805
1849
|
#else
|
|
1806
1850
|
[self sendEventWithName:@"uploadProgress" body:[uploadProgressReports allValues]];
|
|
1807
1851
|
#endif
|
|
@@ -1847,7 +1891,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
|
|
|
1847
1891
|
|
|
1848
1892
|
DLog(taskConfig.id, @"[RNBackgroundDownloader] - [handleUploadCompletion] error: %@", error);
|
|
1849
1893
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1850
|
-
[self
|
|
1894
|
+
[self safeEmitEvent:@"onUploadFailed" value:@{
|
|
1851
1895
|
@"id": taskConfig.id,
|
|
1852
1896
|
@"error": [error localizedDescription],
|
|
1853
1897
|
@"errorCode": @(error.code)
|
|
@@ -1873,7 +1917,7 @@ RCT_EXPORT_METHOD(getExistingUploadTasks:(RCTPromiseResolveBlock)resolve rejecte
|
|
|
1873
1917
|
|
|
1874
1918
|
DLog(taskConfig.id, @"[RNBackgroundDownloader] - [handleUploadCompletion] success, responseCode: %ld", (long)responseCode);
|
|
1875
1919
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
1876
|
-
[self
|
|
1920
|
+
[self safeEmitEvent:@"onUploadComplete" value:@{
|
|
1877
1921
|
@"id": taskConfig.id,
|
|
1878
1922
|
@"responseCode": @(responseCode),
|
|
1879
1923
|
@"responseBody": responseBody,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kesha-antonov/react-native-background-downloader",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.4",
|
|
4
4
|
"description": "A library for React-Native to help you download large files on iOS and Android both in the foreground and most importantly in the background.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
package/plugin/build/index.d.ts
CHANGED
|
@@ -3,12 +3,12 @@ interface PluginOptions {
|
|
|
3
3
|
/**
|
|
4
4
|
* Options for the MMKV dependency on Android.
|
|
5
5
|
* Pass a string to specify the version, or an object with version property.
|
|
6
|
-
* @default '
|
|
6
|
+
* @default '1.3.16'
|
|
7
7
|
* @example
|
|
8
8
|
* // Use default version
|
|
9
9
|
* ["@kesha-antonov/react-native-background-downloader"]
|
|
10
10
|
* // Specify version
|
|
11
|
-
* ["@kesha-antonov/react-native-background-downloader", { mmkvVersion: "
|
|
11
|
+
* ["@kesha-antonov/react-native-background-downloader", { mmkvVersion: "1.3.16" }]
|
|
12
12
|
*/
|
|
13
13
|
mmkvVersion?: string;
|
|
14
14
|
/**
|