@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.
@@ -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 channelId = if (config.showNotificationsEnabled) {
76
- UIDTConstants.NOTIFICATION_CHANNEL_ID
77
- } else {
78
- UIDTConstants.NOTIFICATION_CHANNEL_SILENT_ID
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
  /**
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 emitOnDownloadFailed:@{
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 emitOnDownloadComplete:@{
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 emitOnDownloadBegin:@{
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 emitOnDownloadProgress:[progressReports allValues]];
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 emitOnDownloadFailed:@{
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 emitOnUploadBegin:@{
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 emitOnUploadProgress:[uploadProgressReports allValues]];
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 emitOnUploadFailed:@{
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 emitOnUploadComplete:@{
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.2",
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",
@@ -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 '2.2.4'
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: "2.2.4" }]
11
+ * ["@kesha-antonov/react-native-background-downloader", { mmkvVersion: "1.3.16" }]
12
12
  */
13
13
  mmkvVersion?: string;
14
14
  /**