@capgo/capacitor-downloader 8.3.21 → 8.4.1

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/README.md CHANGED
@@ -36,6 +36,25 @@ We build, improve and fix our plugins with heavy use of AI, so issues usually ge
36
36
  - **Status and files**: `checkStatus()` for a task and `getFileInfo()` for a downloaded file.
37
37
  - **Platforms**: iOS and Android. iOS uses a background `URLSession`, Android uses `DownloadManager`. Not available on web.
38
38
 
39
+ ## Download completed notifications
40
+
41
+ Long downloads often finish while your user is in another app. Set `downloadCompletedNotificationText` on `download()` to show an opt-in, caller-localized system notification when the file is ready. Tapping it returns to your app.
42
+
43
+ - **Opt in**: omit the option (or pass empty text) to rely on `downloadCompleted` events only.
44
+ - **Permissions**: request notification permission in your app. If the user denies it, downloads still complete and the plugin skips the alert.
45
+ - **Android**: the plugin manifest already merges `POST_NOTIFICATIONS`; on Android 13+ your app only needs to request it at runtime. Works alongside the DownloadManager `notification` visibility option. While your app is in the foreground, Android still posts the completion alert when the download succeeds.
46
+ - **iOS**: request authorization with `UNUserNotificationCenter` before the download finishes. While your app is in the foreground, iOS shows a delivered notification only if your app's `UNUserNotificationCenter` delegate presents it, so without a notifications plugin it is usually silent; use `downloadCompleted` events in that case.
47
+
48
+ ```typescript
49
+ await Downloader.download({
50
+ id: 'episode-12',
51
+ url: audioUrl,
52
+ destination: 'podcasts/episode-12.mp3',
53
+ notification: 'hidden',
54
+ downloadCompletedNotificationText: t('downloads.episodeReady'),
55
+ });
56
+ ```
57
+
39
58
  ## Why Capacitor Downloader?
40
59
 
41
60
  A reliable **native file downloader** built for Capacitor apps that continues downloads even when your app is backgrounded or closed:
@@ -311,15 +330,16 @@ Represents the current state and progress of a download task.
311
330
 
312
331
  Configuration options for starting a download.
313
332
 
314
- | Prop | Type | Description | Since |
315
- | ------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
316
- | **`id`** | <code>string</code> | Unique identifier for this download task | |
317
- | **`url`** | <code>string</code> | URL of the file to download | |
318
- | **`destination`** | <code>string</code> | Local file path where the download will be saved | |
319
- | **`headers`** | <code>{ [key: string]: string; }</code> | Optional HTTP headers to include in the request | |
320
- | **`network`** | <code>'cellular' \| 'wifi-only'</code> | Network type requirement for download | |
321
- | **`priority`** | <code>'high' \| 'normal' \| 'low'</code> | Download priority level | |
322
- | **`notification`** | <code>'completed' \| 'progress' \| 'hidden'</code> | Android DownloadManager notification visibility. - `'completed'` (default): show while downloading and keep the notification after completion. - `'progress'`: show while downloading, remove the notification when finished. - `'hidden'`: no system notification; use plugin progress events instead. Ignored on iOS and Web. | 8.2.0 |
333
+ | Prop | Type | Description | Since |
334
+ | --------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
335
+ | **`id`** | <code>string</code> | Unique identifier for this download task | |
336
+ | **`url`** | <code>string</code> | URL of the file to download | |
337
+ | **`destination`** | <code>string</code> | Local file path where the download will be saved | |
338
+ | **`headers`** | <code>{ [key: string]: string; }</code> | Optional HTTP headers to include in the request | |
339
+ | **`network`** | <code>'cellular' \| 'wifi-only'</code> | Network type requirement for download | |
340
+ | **`priority`** | <code>'high' \| 'normal' \| 'low'</code> | Download priority level | |
341
+ | **`notification`** | <code>'completed' \| 'progress' \| 'hidden'</code> | Android DownloadManager notification visibility. - `'completed'` (default): show while downloading and keep the notification after completion. - `'progress'`: show while downloading, remove the notification when finished. - `'hidden'`: no system notification; use plugin progress events instead. Ignored on iOS and Web. | 8.2.0 |
342
+ | **`downloadCompletedNotificationText`** | <code>string</code> | Optional localized text for a system notification when the download finishes. When set, iOS and Android may show a local notification after a successful download if the user granted notification permission. If permission is denied, the download still completes and no notification is shown. Tapping the notification reopens the app. Omitted or empty means no completion notification from the plugin (use `downloadCompleted` events while the app is open). | 8.4.0 |
323
343
 
324
344
 
325
345
  #### PluginListenerHandle
@@ -1,3 +1,4 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
3
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
3
4
  </manifest>
@@ -27,19 +27,22 @@ import java.util.concurrent.atomic.AtomicLong;
27
27
  @CapacitorPlugin(name = "CapacitorDownloader")
28
28
  public class CapacitorDownloaderPlugin extends Plugin {
29
29
 
30
- private final String pluginVersion = "8.3.21";
30
+ private final String pluginVersion = "8.4.1";
31
31
 
32
32
  private DownloadManager downloadManager;
33
33
  private final Map<String, Long> downloads = new ConcurrentHashMap<>();
34
+ private final Map<String, Long> downloadGenerations = new ConcurrentHashMap<>();
34
35
  private final AtomicLong pendingDownloadSequence = new AtomicLong(0);
35
36
  private ExecutorService downloadManagerExecutor;
36
37
  private final Handler handler = new Handler(Looper.getMainLooper());
37
38
  private BroadcastReceiver downloadReceiver;
39
+ private DownloadCompletedNotificationStore completedNotificationStore;
38
40
 
39
41
  @Override
40
42
  public void load() {
41
43
  downloadManager = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
42
44
  downloadManagerExecutor = Executors.newCachedThreadPool();
45
+ completedNotificationStore = new DownloadCompletedNotificationStore(getContext());
43
46
  registerDownloadReceiver();
44
47
  }
45
48
 
@@ -84,6 +87,24 @@ public class CapacitorDownloaderPlugin extends Plugin {
84
87
  return trackedDownloadId != null && trackedDownloadId.longValue() == systemDownloadId;
85
88
  }
86
89
 
90
+ private long nextGeneration(String id) {
91
+ while (true) {
92
+ Long current = downloadGenerations.get(id);
93
+ long next = (current == null ? 0L : current.longValue()) + 1L;
94
+ if (current == null) {
95
+ if (downloadGenerations.putIfAbsent(id, next) == null) {
96
+ return next;
97
+ }
98
+ } else if (downloadGenerations.replace(id, current, next)) {
99
+ return next;
100
+ }
101
+ }
102
+ }
103
+
104
+ private Long generationFor(String id) {
105
+ return downloadGenerations.get(id);
106
+ }
107
+
87
108
  @PluginMethod
88
109
  public void download(PluginCall call) {
89
110
  String id = call.getString("id");
@@ -95,41 +116,54 @@ public class CapacitorDownloaderPlugin extends Plugin {
95
116
  return;
96
117
  }
97
118
 
98
- DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url))
99
- .setNotificationVisibility(DownloadNotificationVisibility.resolve(call.getString("notification")))
100
- .setAllowedOverMetered(true)
101
- .setAllowedOverRoaming(true);
102
-
103
- // Handle custom destination
104
- File destinationFile = new File(getContext().getExternalFilesDir(null), destination);
105
- Uri destinationUri = Uri.fromFile(destinationFile);
106
- request.setDestinationUri(destinationUri);
107
-
108
- JSObject headers = call.getObject("headers");
109
- if (headers != null) {
110
- for (Iterator<String> it = headers.keys(); it.hasNext(); ) {
111
- String key = it.next();
112
- request.addRequestHeader(key, headers.getString(key));
113
- }
119
+ if (downloads.containsKey(id)) {
120
+ call.reject("Download already exists");
121
+ return;
114
122
  }
115
123
 
116
- String network = call.getString("network");
117
- if ("wifi-only".equals(network)) {
118
- request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
119
- } else {
120
- request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE | DownloadManager.Request.NETWORK_WIFI);
124
+ final String notification = call.getString("notification");
125
+ final DownloadManager.Request request;
126
+ try {
127
+ request = new DownloadManager.Request(Uri.parse(url))
128
+ .setNotificationVisibility(DownloadNotificationVisibility.resolve(notification))
129
+ .setAllowedOverMetered(true)
130
+ .setAllowedOverRoaming(true);
131
+
132
+ File destinationFile = new File(getContext().getExternalFilesDir(null), destination);
133
+ Uri destinationUri = Uri.fromFile(destinationFile);
134
+ request.setDestinationUri(destinationUri);
135
+
136
+ JSObject headers = call.getObject("headers");
137
+ if (headers != null) {
138
+ for (Iterator<String> it = headers.keys(); it.hasNext(); ) {
139
+ String key = it.next();
140
+ request.addRequestHeader(key, headers.getString(key));
141
+ }
142
+ }
143
+
144
+ String network = call.getString("network");
145
+ if ("wifi-only".equals(network)) {
146
+ request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
147
+ } else {
148
+ request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE | DownloadManager.Request.NETWORK_WIFI);
149
+ }
150
+ } catch (RuntimeException e) {
151
+ call.reject("Invalid download request", e);
152
+ return;
121
153
  }
122
154
 
123
- final String notification = call.getString("notification");
124
155
  final long pendingToken = reservePendingDownload();
125
156
  if (downloads.putIfAbsent(id, pendingToken) != null) {
126
157
  call.reject("Download already exists");
127
158
  return;
128
159
  }
160
+ final long downloadGeneration = nextGeneration(id);
161
+ completedNotificationStore.setText(id, call.getString("downloadCompletedNotificationText"), downloadGeneration);
129
162
  runDownloadManagerWork(
130
163
  call,
131
164
  () -> {
132
165
  if (!isTrackedDownload(id, pendingToken)) {
166
+ completedNotificationStore.remove(id, downloadGeneration);
133
167
  call.reject("Download was cancelled");
134
168
  return;
135
169
  }
@@ -139,6 +173,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
139
173
  downloadId = downloadManager.enqueue(request);
140
174
  } catch (SecurityException e) {
141
175
  downloads.remove(id, pendingToken);
176
+ completedNotificationStore.remove(id, downloadGeneration);
142
177
  if ("hidden".equals(notification)) {
143
178
  call.reject("Hidden downloads require android.permission.DOWNLOAD_WITHOUT_NOTIFICATION in the app manifest", e);
144
179
  } else {
@@ -147,6 +182,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
147
182
  return;
148
183
  } catch (RuntimeException e) {
149
184
  downloads.remove(id, pendingToken);
185
+ completedNotificationStore.remove(id, downloadGeneration);
150
186
  call.reject("Download could not be enqueued", e);
151
187
  return;
152
188
  }
@@ -155,9 +191,11 @@ public class CapacitorDownloaderPlugin extends Plugin {
155
191
  try {
156
192
  downloadManager.remove(downloadId);
157
193
  } catch (RuntimeException e) {
194
+ completedNotificationStore.remove(id, downloadGeneration);
158
195
  call.reject("Download was cancelled", e);
159
196
  return;
160
197
  }
198
+ completedNotificationStore.remove(id, downloadGeneration);
161
199
  call.reject("Download was cancelled");
162
200
  return;
163
201
  }
@@ -170,7 +208,10 @@ public class CapacitorDownloaderPlugin extends Plugin {
170
208
  // Start a periodic progress check
171
209
  startProgressCheck(id, downloadId);
172
210
  },
173
- () -> downloads.remove(id, pendingToken)
211
+ () -> {
212
+ downloads.remove(id, pendingToken);
213
+ completedNotificationStore.remove(id, downloadGeneration);
214
+ }
174
215
  );
175
216
  }
176
217
 
@@ -191,6 +232,8 @@ public class CapacitorDownloaderPlugin extends Plugin {
191
232
  return;
192
233
  }
193
234
 
235
+ final Long capturedGeneration = generationFor(id);
236
+
194
237
  ExecutorService executor = downloadManagerExecutor;
195
238
  if (executor == null) {
196
239
  return;
@@ -199,7 +242,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
199
242
  try {
200
243
  executor.execute(() -> {
201
244
  ProgressSnapshot snapshot = queryProgressSnapshot(id, systemDownloadId);
202
- handler.post(() -> deliverProgressSnapshot(id, systemDownloadId, scheduleNext, snapshot));
245
+ handler.post(() -> deliverProgressSnapshot(id, systemDownloadId, scheduleNext, snapshot, capturedGeneration));
203
246
  });
204
247
  } catch (RejectedExecutionException ignored) {
205
248
  // Plugin is shutting down; stop polling quietly.
@@ -229,7 +272,13 @@ public class CapacitorDownloaderPlugin extends Plugin {
229
272
  }
230
273
  }
231
274
 
232
- private void deliverProgressSnapshot(String id, long systemDownloadId, boolean scheduleNext, ProgressSnapshot snapshot) {
275
+ private void deliverProgressSnapshot(
276
+ String id,
277
+ long systemDownloadId,
278
+ boolean scheduleNext,
279
+ ProgressSnapshot snapshot,
280
+ Long capturedGeneration
281
+ ) {
233
282
  if (snapshot == null || !snapshot.found || !isTrackedDownload(id, systemDownloadId)) {
234
283
  return;
235
284
  }
@@ -244,12 +293,20 @@ public class CapacitorDownloaderPlugin extends Plugin {
244
293
  JSObject completedData = new JSObject();
245
294
  completedData.put("id", snapshot.id);
246
295
  notifyListeners("downloadCompleted", completedData);
296
+ String completionText =
297
+ capturedGeneration != null ? completedNotificationStore.takeText(snapshot.id, capturedGeneration) : null;
298
+ if (completionText != null) {
299
+ DownloadCompletedNotificationPresenter.showIfAllowed(getContext(), snapshot.id, completionText);
300
+ }
247
301
  shouldContinue = false;
248
302
  } else if (snapshot.status == DownloadManager.STATUS_FAILED) {
249
303
  JSObject failedData = new JSObject();
250
304
  failedData.put("id", snapshot.id);
251
305
  failedData.put("error", "Download failed");
252
306
  notifyListeners("downloadFailed", failedData);
307
+ if (capturedGeneration != null) {
308
+ completedNotificationStore.remove(snapshot.id, capturedGeneration);
309
+ }
253
310
  shouldContinue = false;
254
311
  }
255
312
 
@@ -297,6 +354,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
297
354
  return;
298
355
  }
299
356
 
357
+ final Long stoppedGeneration = generationFor(id);
300
358
  Long downloadId = downloads.remove(id);
301
359
  if (downloadId == null) {
302
360
  call.reject("Download not found");
@@ -304,17 +362,24 @@ public class CapacitorDownloaderPlugin extends Plugin {
304
362
  }
305
363
 
306
364
  if (isPendingDownload(downloadId)) {
365
+ if (stoppedGeneration != null) {
366
+ completedNotificationStore.remove(id, stoppedGeneration);
367
+ }
307
368
  call.resolve(new JSObject().put("removed", false));
308
369
  return;
309
370
  }
310
371
 
311
372
  final long systemDownloadId = downloadId;
312
373
  final String stoppedId = id;
374
+ final long generation = stoppedGeneration != null ? stoppedGeneration.longValue() : 0L;
313
375
  runDownloadManagerWork(
314
376
  call,
315
377
  () -> {
316
378
  try {
317
379
  int removedDownloads = downloadManager.remove(systemDownloadId);
380
+ if (stoppedGeneration != null) {
381
+ completedNotificationStore.remove(stoppedId, generation);
382
+ }
318
383
  call.resolve(new JSObject().put("removed", removedDownloads > 0));
319
384
  } catch (RuntimeException e) {
320
385
  downloads.putIfAbsent(stoppedId, systemDownloadId);
@@ -0,0 +1,91 @@
1
+ package ee.forgr.capacitor.plugin.downloader;
2
+
3
+ import android.Manifest;
4
+ import android.app.NotificationChannel;
5
+ import android.app.NotificationManager;
6
+ import android.app.PendingIntent;
7
+ import android.content.Context;
8
+ import android.content.Intent;
9
+ import android.content.pm.PackageManager;
10
+ import android.os.Build;
11
+ import androidx.core.app.NotificationCompat;
12
+ import androidx.core.app.NotificationManagerCompat;
13
+ import androidx.core.content.ContextCompat;
14
+
15
+ final class DownloadCompletedNotificationPresenter {
16
+
17
+ static final String CHANNEL_ID = "capacitor_downloader_completed";
18
+
19
+ private DownloadCompletedNotificationPresenter() {}
20
+
21
+ static boolean canPostNotifications(Context context) {
22
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
23
+ return NotificationManagerCompat.from(context).areNotificationsEnabled();
24
+ }
25
+ return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED;
26
+ }
27
+
28
+ static void showIfAllowed(Context context, String downloadId, String bodyText) {
29
+ String body = DownloadCompletedNotificationText.normalize(bodyText);
30
+ if (body == null || downloadId == null) {
31
+ return;
32
+ }
33
+ if (!canPostNotifications(context)) {
34
+ return;
35
+ }
36
+
37
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
38
+ ensureChannel(context);
39
+
40
+ Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
41
+ if (launchIntent == null) {
42
+ return;
43
+ }
44
+ launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
45
+
46
+ int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT;
47
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
48
+ pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE;
49
+ }
50
+
51
+ PendingIntent contentIntent = PendingIntent.getActivity(context, downloadId.hashCode(), launchIntent, pendingIntentFlags);
52
+
53
+ CharSequence appLabel = context.getApplicationInfo().loadLabel(context.getPackageManager());
54
+
55
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
56
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
57
+ .setContentTitle(appLabel)
58
+ .setContentText(body)
59
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(body))
60
+ .setContentIntent(contentIntent)
61
+ .setAutoCancel(true)
62
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT);
63
+
64
+ try {
65
+ notificationManager.notify(notificationIdFor(downloadId), builder.build());
66
+ } catch (SecurityException ignored) {
67
+ // POST_NOTIFICATIONS denied at show time.
68
+ }
69
+ }
70
+
71
+ private static void ensureChannel(Context context) {
72
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
73
+ return;
74
+ }
75
+ NotificationManager manager = context.getSystemService(NotificationManager.class);
76
+ if (manager == null) {
77
+ return;
78
+ }
79
+ NotificationChannel existing = manager.getNotificationChannel(CHANNEL_ID);
80
+ if (existing != null) {
81
+ return;
82
+ }
83
+ NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Download completed", NotificationManager.IMPORTANCE_DEFAULT);
84
+ channel.setDescription("Shown when a Capacitor Downloader task finishes");
85
+ manager.createNotificationChannel(channel);
86
+ }
87
+
88
+ static int notificationIdFor(String downloadId) {
89
+ return ("capacitor-downloader-completed-" + downloadId).hashCode();
90
+ }
91
+ }
@@ -0,0 +1,138 @@
1
+ package ee.forgr.capacitor.plugin.downloader;
2
+
3
+ import android.content.Context;
4
+ import android.content.SharedPreferences;
5
+ import java.util.HashMap;
6
+ import java.util.Iterator;
7
+ import java.util.Map;
8
+ import java.util.concurrent.ConcurrentHashMap;
9
+ import org.json.JSONException;
10
+ import org.json.JSONObject;
11
+
12
+ final class DownloadCompletedNotificationStore {
13
+
14
+ private static final String PREFS_NAME = "CapacitorDownloaderCompletedNotificationTexts";
15
+ private static final String MAP_KEY = "texts";
16
+
17
+ private final SharedPreferences prefs;
18
+ private final ConcurrentHashMap<String, Entry> inMemoryEntries;
19
+ private final Object lock = new Object();
20
+
21
+ DownloadCompletedNotificationStore(Context context) {
22
+ prefs = context.getApplicationContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
23
+ inMemoryEntries = null;
24
+ clearPersistedTexts();
25
+ }
26
+
27
+ DownloadCompletedNotificationStore() {
28
+ prefs = null;
29
+ inMemoryEntries = new ConcurrentHashMap<>();
30
+ }
31
+
32
+ void setText(String downloadId, String text, long generation) {
33
+ if (downloadId == null) {
34
+ return;
35
+ }
36
+ String normalized = DownloadCompletedNotificationText.normalize(text);
37
+ synchronized (lock) {
38
+ Map<String, Entry> entries = loadEntriesLocked();
39
+ if (normalized == null) {
40
+ entries.remove(downloadId);
41
+ } else {
42
+ entries.put(downloadId, new Entry(normalized, generation));
43
+ }
44
+ saveEntriesLocked(entries);
45
+ }
46
+ }
47
+
48
+ String takeText(String downloadId, long generation) {
49
+ if (downloadId == null) {
50
+ return null;
51
+ }
52
+ synchronized (lock) {
53
+ Map<String, Entry> entries = loadEntriesLocked();
54
+ Entry entry = entries.get(downloadId);
55
+ if (entry == null || entry.generation != generation) {
56
+ return null;
57
+ }
58
+ entries.remove(downloadId);
59
+ saveEntriesLocked(entries);
60
+ return entry.text;
61
+ }
62
+ }
63
+
64
+ void clearPersistedTexts() {
65
+ if (prefs == null) {
66
+ return;
67
+ }
68
+ synchronized (lock) {
69
+ prefs.edit().remove(MAP_KEY).apply();
70
+ }
71
+ }
72
+
73
+ void remove(String downloadId, long generation) {
74
+ if (downloadId == null) {
75
+ return;
76
+ }
77
+ synchronized (lock) {
78
+ Map<String, Entry> entries = loadEntriesLocked();
79
+ Entry entry = entries.get(downloadId);
80
+ if (entry == null || entry.generation != generation) {
81
+ return;
82
+ }
83
+ entries.remove(downloadId);
84
+ saveEntriesLocked(entries);
85
+ }
86
+ }
87
+
88
+ private Map<String, Entry> loadEntriesLocked() {
89
+ if (inMemoryEntries != null) {
90
+ return new HashMap<>(inMemoryEntries);
91
+ }
92
+ String json = prefs.getString(MAP_KEY, "{}");
93
+ Map<String, Entry> entries = new HashMap<>();
94
+ try {
95
+ JSONObject object = new JSONObject(json);
96
+ Iterator<String> keys = object.keys();
97
+ while (keys.hasNext()) {
98
+ String key = keys.next();
99
+ JSONObject entryObject = object.getJSONObject(key);
100
+ entries.put(key, new Entry(entryObject.getString("text"), entryObject.getLong("generation")));
101
+ }
102
+ } catch (JSONException ignored) {
103
+ return new HashMap<>();
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ private void saveEntriesLocked(Map<String, Entry> entries) {
109
+ if (inMemoryEntries != null) {
110
+ inMemoryEntries.clear();
111
+ inMemoryEntries.putAll(entries);
112
+ return;
113
+ }
114
+ JSONObject object = new JSONObject();
115
+ for (Map.Entry<String, Entry> entry : entries.entrySet()) {
116
+ try {
117
+ JSONObject entryObject = new JSONObject();
118
+ entryObject.put("text", entry.getValue().text);
119
+ entryObject.put("generation", entry.getValue().generation);
120
+ object.put(entry.getKey(), entryObject);
121
+ } catch (JSONException ignored) {
122
+ // Skip malformed serialization for a single key.
123
+ }
124
+ }
125
+ prefs.edit().putString(MAP_KEY, object.toString()).apply();
126
+ }
127
+
128
+ private static final class Entry {
129
+
130
+ private final String text;
131
+ private final long generation;
132
+
133
+ private Entry(String text, long generation) {
134
+ this.text = text;
135
+ this.generation = generation;
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,14 @@
1
+ package ee.forgr.capacitor.plugin.downloader;
2
+
3
+ final class DownloadCompletedNotificationText {
4
+
5
+ private DownloadCompletedNotificationText() {}
6
+
7
+ static String normalize(String text) {
8
+ if (text == null) {
9
+ return null;
10
+ }
11
+ String trimmed = text.trim();
12
+ return trimmed.isEmpty() ? null : trimmed;
13
+ }
14
+ }
package/dist/docs.json CHANGED
@@ -27,7 +27,7 @@
27
27
  },
28
28
  {
29
29
  "name": "example",
30
- "text": "```typescript\nconst task = await Downloader.download({\n id: 'my-download',\n url: 'https://example.com/file.pdf',\n destination: 'downloads/file.pdf',\n notification: 'hidden', // Android: no system notification; use downloadProgress events\n});\n```"
30
+ "text": "```typescript\nconst task = await Downloader.download({\n id: 'my-download',\n url: 'https://example.com/file.pdf',\n destination: 'downloads/file.pdf',\n notification: 'hidden', // Android: no system notification; use downloadProgress events\n downloadCompletedNotificationText: 'Your file is ready', // opt-in completion alert\n});\n```"
31
31
  }
32
32
  ],
33
33
  "docs": "Start a new download task.",
@@ -400,6 +400,18 @@
400
400
  "docs": "Android DownloadManager notification visibility.\n\n- `'completed'` (default): show while downloading and keep the notification after completion.\n- `'progress'`: show while downloading, remove the notification when finished.\n- `'hidden'`: no system notification; use plugin progress events instead.\n\nIgnored on iOS and Web.",
401
401
  "complexTypes": [],
402
402
  "type": "'completed' | 'progress' | 'hidden' | undefined"
403
+ },
404
+ {
405
+ "name": "downloadCompletedNotificationText",
406
+ "tags": [
407
+ {
408
+ "text": "8.4.0",
409
+ "name": "since"
410
+ }
411
+ ],
412
+ "docs": "Optional localized text for a system notification when the download finishes.\n\nWhen set, iOS and Android may show a local notification after a successful download\nif the user granted notification permission. If permission is denied, the download\nstill completes and no notification is shown. Tapping the notification reopens the app.\n\nOmitted or empty means no completion notification from the plugin (use\n`downloadCompleted` events while the app is open).",
413
+ "complexTypes": [],
414
+ "type": "string | undefined"
403
415
  }
404
416
  ]
405
417
  },
@@ -40,6 +40,19 @@ export interface DownloadOptions {
40
40
  * @since 8.2.0
41
41
  */
42
42
  notification?: 'completed' | 'progress' | 'hidden';
43
+ /**
44
+ * Optional localized text for a system notification when the download finishes.
45
+ *
46
+ * When set, iOS and Android may show a local notification after a successful download
47
+ * if the user granted notification permission. If permission is denied, the download
48
+ * still completes and no notification is shown. Tapping the notification reopens the app.
49
+ *
50
+ * Omitted or empty means no completion notification from the plugin (use
51
+ * `downloadCompleted` events while the app is open).
52
+ *
53
+ * @since 8.4.0
54
+ */
55
+ downloadCompletedNotificationText?: string;
43
56
  }
44
57
  /**
45
58
  * Capacitor plugin for downloading files with background support.
@@ -58,6 +71,7 @@ export interface CapacitorDownloaderPlugin {
58
71
  * url: 'https://example.com/file.pdf',
59
72
  * destination: 'downloads/file.pdf',
60
73
  * notification: 'hidden', // Android: no system notification; use downloadProgress events
74
+ * downloadCompletedNotificationText: 'Your file is ready', // opt-in completion alert
61
75
  * });
62
76
  * ```
63
77
  */
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * Represents the current state and progress of a download task.\n */\nexport interface DownloadTask {\n /** Unique identifier for the download task */\n id: string;\n /** Download progress from 0 to 100 */\n progress: number;\n /** Current state of the download */\n state: 'PENDING' | 'RUNNING' | 'PAUSED' | 'DONE' | 'ERROR';\n}\n\n/**\n * Configuration options for starting a download.\n */\nexport interface DownloadOptions {\n /** Unique identifier for this download task */\n id: string;\n /** URL of the file to download */\n url: string;\n /** Local file path where the download will be saved */\n destination: string;\n /** Optional HTTP headers to include in the request */\n headers?: { [key: string]: string };\n /** Network type requirement for download */\n network?: 'cellular' | 'wifi-only';\n /** Download priority level */\n priority?: 'high' | 'normal' | 'low';\n /**\n * Android DownloadManager notification visibility.\n *\n * - `'completed'` (default): show while downloading and keep the notification after completion.\n * - `'progress'`: show while downloading, remove the notification when finished.\n * - `'hidden'`: no system notification; use plugin progress events instead.\n *\n * Ignored on iOS and Web.\n *\n * @since 8.2.0\n */\n notification?: 'completed' | 'progress' | 'hidden';\n}\n\n/**\n * Capacitor plugin for downloading files with background support.\n * Provides resumable downloads with progress tracking.\n */\nexport interface CapacitorDownloaderPlugin {\n /**\n * Start a new download task.\n *\n * @param options - Download configuration\n * @returns Promise with initial download task status\n * @example\n * ```typescript\n * const task = await Downloader.download({\n * id: 'my-download',\n * url: 'https://example.com/file.pdf',\n * destination: 'downloads/file.pdf',\n * notification: 'hidden', // Android: no system notification; use downloadProgress events\n * });\n * ```\n */\n download(options: DownloadOptions): Promise<DownloadTask>;\n\n /**\n * Pause an active download.\n * Download can be resumed later from the same position.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when paused\n */\n pause(options: { id: string }): Promise<void>;\n\n /**\n * Resume a paused download.\n * Continues from where it was paused.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when resumed\n */\n resume(options: { id: string }): Promise<void>;\n\n /**\n * Stop and cancel a download permanently.\n * Downloaded data will be deleted.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when stopped\n */\n stop(options: { id: string }): Promise<void>;\n\n /**\n * Check the current status of a download.\n *\n * @param options - Options containing the download task ID\n * @returns Promise with current download task status\n */\n checkStatus(options: { id: string }): Promise<DownloadTask>;\n\n /**\n * Get information about a downloaded file.\n *\n * @param options - Options containing the file path\n * @returns Promise with file size and MIME type\n */\n getFileInfo(options: { path: string }): Promise<{ size: number; type: string }>;\n\n /**\n * Listen for download progress updates.\n * Fired periodically as download progresses.\n *\n * @param eventName - Must be 'downloadProgress'\n * @param listenerFunc - Callback receiving progress updates\n * @returns Promise with listener handle for removal\n * @example\n * ```typescript\n * const listener = await Downloader.addListener('downloadProgress', (data) => {\n * console.log(`Download ${data.id}: ${data.progress}%`);\n * });\n * ```\n */\n addListener(\n eventName: 'downloadProgress',\n listenerFunc: (progress: {\n id: string;\n progress: number;\n /** Bytes written so far */\n bytesWritten?: number;\n /** Total bytes expected, or 0 when the server did not report a length */\n bytesTotal?: number;\n }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download completion.\n * Fired when a download finishes successfully.\n *\n * @param eventName - Must be 'downloadCompleted'\n * @param listenerFunc - Callback receiving completion notification\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadCompleted',\n listenerFunc: (result: { id: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download failures.\n * Fired when a download encounters an error.\n *\n * @param eventName - Must be 'downloadFailed'\n * @param listenerFunc - Callback receiving error information\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadFailed',\n listenerFunc: (error: { id: string; error: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Remove all event listeners.\n * Cleanup method to prevent memory leaks.\n *\n * @returns Promise that resolves when all listeners removed\n */\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the plugin version number.\n *\n * @returns Promise with version string\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * Represents the current state and progress of a download task.\n */\nexport interface DownloadTask {\n /** Unique identifier for the download task */\n id: string;\n /** Download progress from 0 to 100 */\n progress: number;\n /** Current state of the download */\n state: 'PENDING' | 'RUNNING' | 'PAUSED' | 'DONE' | 'ERROR';\n}\n\n/**\n * Configuration options for starting a download.\n */\nexport interface DownloadOptions {\n /** Unique identifier for this download task */\n id: string;\n /** URL of the file to download */\n url: string;\n /** Local file path where the download will be saved */\n destination: string;\n /** Optional HTTP headers to include in the request */\n headers?: { [key: string]: string };\n /** Network type requirement for download */\n network?: 'cellular' | 'wifi-only';\n /** Download priority level */\n priority?: 'high' | 'normal' | 'low';\n /**\n * Android DownloadManager notification visibility.\n *\n * - `'completed'` (default): show while downloading and keep the notification after completion.\n * - `'progress'`: show while downloading, remove the notification when finished.\n * - `'hidden'`: no system notification; use plugin progress events instead.\n *\n * Ignored on iOS and Web.\n *\n * @since 8.2.0\n */\n notification?: 'completed' | 'progress' | 'hidden';\n /**\n * Optional localized text for a system notification when the download finishes.\n *\n * When set, iOS and Android may show a local notification after a successful download\n * if the user granted notification permission. If permission is denied, the download\n * still completes and no notification is shown. Tapping the notification reopens the app.\n *\n * Omitted or empty means no completion notification from the plugin (use\n * `downloadCompleted` events while the app is open).\n *\n * @since 8.4.0\n */\n downloadCompletedNotificationText?: string;\n}\n\n/**\n * Capacitor plugin for downloading files with background support.\n * Provides resumable downloads with progress tracking.\n */\nexport interface CapacitorDownloaderPlugin {\n /**\n * Start a new download task.\n *\n * @param options - Download configuration\n * @returns Promise with initial download task status\n * @example\n * ```typescript\n * const task = await Downloader.download({\n * id: 'my-download',\n * url: 'https://example.com/file.pdf',\n * destination: 'downloads/file.pdf',\n * notification: 'hidden', // Android: no system notification; use downloadProgress events\n * downloadCompletedNotificationText: 'Your file is ready', // opt-in completion alert\n * });\n * ```\n */\n download(options: DownloadOptions): Promise<DownloadTask>;\n\n /**\n * Pause an active download.\n * Download can be resumed later from the same position.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when paused\n */\n pause(options: { id: string }): Promise<void>;\n\n /**\n * Resume a paused download.\n * Continues from where it was paused.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when resumed\n */\n resume(options: { id: string }): Promise<void>;\n\n /**\n * Stop and cancel a download permanently.\n * Downloaded data will be deleted.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when stopped\n */\n stop(options: { id: string }): Promise<void>;\n\n /**\n * Check the current status of a download.\n *\n * @param options - Options containing the download task ID\n * @returns Promise with current download task status\n */\n checkStatus(options: { id: string }): Promise<DownloadTask>;\n\n /**\n * Get information about a downloaded file.\n *\n * @param options - Options containing the file path\n * @returns Promise with file size and MIME type\n */\n getFileInfo(options: { path: string }): Promise<{ size: number; type: string }>;\n\n /**\n * Listen for download progress updates.\n * Fired periodically as download progresses.\n *\n * @param eventName - Must be 'downloadProgress'\n * @param listenerFunc - Callback receiving progress updates\n * @returns Promise with listener handle for removal\n * @example\n * ```typescript\n * const listener = await Downloader.addListener('downloadProgress', (data) => {\n * console.log(`Download ${data.id}: ${data.progress}%`);\n * });\n * ```\n */\n addListener(\n eventName: 'downloadProgress',\n listenerFunc: (progress: {\n id: string;\n progress: number;\n /** Bytes written so far */\n bytesWritten?: number;\n /** Total bytes expected, or 0 when the server did not report a length */\n bytesTotal?: number;\n }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download completion.\n * Fired when a download finishes successfully.\n *\n * @param eventName - Must be 'downloadCompleted'\n * @param listenerFunc - Callback receiving completion notification\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadCompleted',\n listenerFunc: (result: { id: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download failures.\n * Fired when a download encounters an error.\n *\n * @param eventName - Must be 'downloadFailed'\n * @param listenerFunc - Callback receiving error information\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadFailed',\n listenerFunc: (error: { id: string; error: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Remove all event listeners.\n * Cleanup method to prevent memory leaks.\n *\n * @returns Promise that resolves when all listeners removed\n */\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the plugin version number.\n *\n * @returns Promise with version string\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
@@ -8,7 +8,7 @@ import Capacitor
8
8
 
9
9
  @objc(CapacitorDownloaderPlugin)
10
10
  public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
11
- private let pluginVersion: String = "8.3.21"
11
+ private let pluginVersion: String = "8.4.1"
12
12
  public let identifier = "CapacitorDownloaderPlugin"
13
13
  public let jsName = "CapacitorDownloader"
14
14
  public let pluginMethods: [CAPPluginMethod] = [
@@ -22,6 +22,8 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
22
22
  ]
23
23
 
24
24
  private var tasks: [String: URLSessionDownloadTask] = [:]
25
+ private var downloadGenerations: [String: UInt64] = [:]
26
+ private var taskGenerations: [Int: UInt64] = [:]
25
27
  private let tasksLock = NSLock()
26
28
  private lazy var session: URLSession = {
27
29
  let config = URLSessionConfiguration.background(withIdentifier: "CapacitorDownloader")
@@ -29,6 +31,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
29
31
  return URLSession(configuration: config, delegate: self, delegateQueue: nil)
30
32
  }()
31
33
 
34
+ public override func load() {
35
+ tasksLock.lock()
36
+ downloadGenerations = DownloadIdGenerationStore.shared.allGenerations()
37
+ taskGenerations = DownloadTaskGenerationStore.shared.allGenerations()
38
+ tasksLock.unlock()
39
+ }
40
+
32
41
  private func setTask(_ task: URLSessionDownloadTask, for id: String) {
33
42
  tasksLock.lock()
34
43
  defer { tasksLock.unlock() }
@@ -41,10 +50,48 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
41
50
  return tasks[id]
42
51
  }
43
52
 
44
- private func removeTask(for id: String) {
53
+ private func removeTask(for id: String, completing task: URLSessionDownloadTask) {
54
+ tasksLock.lock()
55
+ defer { tasksLock.unlock() }
56
+ if tasks[id] === task {
57
+ tasks.removeValue(forKey: id)
58
+ }
59
+ taskGenerations.removeValue(forKey: task.taskIdentifier)
60
+ }
61
+
62
+ private func nextGeneration(for id: String) -> UInt64 {
63
+ tasksLock.lock()
64
+ defer { tasksLock.unlock() }
65
+ let generation = (downloadGenerations[id] ?? 0) &+ 1
66
+ downloadGenerations[id] = generation
67
+ DownloadIdGenerationStore.shared.setGeneration(generation, forDownloadId: id)
68
+ return generation
69
+ }
70
+
71
+ private func generation(for id: String) -> UInt64? {
72
+ tasksLock.lock()
73
+ defer { tasksLock.unlock() }
74
+ return downloadGenerations[id]
75
+ }
76
+
77
+ private func setTaskGeneration(_ generation: UInt64, for task: URLSessionDownloadTask) {
78
+ tasksLock.lock()
79
+ defer { tasksLock.unlock() }
80
+ taskGenerations[task.taskIdentifier] = generation
81
+ DownloadTaskGenerationStore.shared.setGeneration(generation, forTaskIdentifier: task.taskIdentifier)
82
+ }
83
+
84
+ private func generation(for task: URLSessionTask) -> UInt64? {
45
85
  tasksLock.lock()
46
86
  defer { tasksLock.unlock() }
47
- tasks.removeValue(forKey: id)
87
+ return taskGenerations[task.taskIdentifier]
88
+ }
89
+
90
+ private func clearTaskGeneration(for task: URLSessionTask) {
91
+ tasksLock.lock()
92
+ defer { tasksLock.unlock() }
93
+ taskGenerations.removeValue(forKey: task.taskIdentifier)
94
+ DownloadTaskGenerationStore.shared.removeGeneration(forTaskIdentifier: task.taskIdentifier)
48
95
  }
49
96
 
50
97
  private func idForTask(_ task: URLSessionDownloadTask) -> String? {
@@ -86,7 +133,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
86
133
  }
87
134
 
88
135
  let destination = call.getString("destination") ?? ""
136
+ let generation = nextGeneration(for: id)
89
137
  DownloadDestinationStore.shared.setDestination(destination, for: id)
138
+ DownloadCompletedNotificationStore.shared.setText(
139
+ call.getString("downloadCompletedNotificationText"),
140
+ for: id,
141
+ generation: generation
142
+ )
90
143
 
91
144
  var request = URLRequest(url: url)
92
145
  if let headers = call.getObject("headers") as? [String: String] {
@@ -98,6 +151,7 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
98
151
  let task = session.downloadTask(with: request)
99
152
  task.taskDescription = id
100
153
  setTask(task, for: id)
154
+ setTaskGeneration(generation, for: task)
101
155
  task.resume()
102
156
 
103
157
  call.resolve([
@@ -136,9 +190,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
136
190
  return
137
191
  }
138
192
 
193
+ let generation = generation(for: id)
139
194
  task.cancel()
140
- removeTask(for: id)
195
+ removeTask(for: id, completing: task)
141
196
  DownloadDestinationStore.shared.removeDestination(for: id)
197
+ if let generation {
198
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
199
+ }
142
200
  call.resolve()
143
201
  }
144
202
 
@@ -203,7 +261,12 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
203
261
  }
204
262
 
205
263
  let storedDestination = DownloadDestinationStore.shared.takeDestination(for: id)
264
+ let generation = generation(for: downloadTask)
265
+
206
266
  guard let destinationURL = destinationURL(for: id, storedDestination: storedDestination) else {
267
+ if let generation {
268
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
269
+ }
207
270
  notifyListeners("downloadFailed", data: ["id": id, "error": "Unable to resolve destination"])
208
271
  return
209
272
  }
@@ -211,6 +274,9 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
211
274
  if let httpResponse = downloadTask.response as? HTTPURLResponse,
212
275
  !DownloadHTTPValidator.isSuccessfulStatusCode(httpResponse.statusCode) {
213
276
  try? FileManager.default.removeItem(at: location)
277
+ if let generation {
278
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
279
+ }
214
280
  notifyListeners(
215
281
  "downloadFailed",
216
282
  data: ["id": id, "error": "HTTP \(httpResponse.statusCode)"]
@@ -225,9 +291,17 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
225
291
  }
226
292
  try FileManager.default.moveItem(at: location, to: destinationURL)
227
293
  notifyListeners("downloadCompleted", data: ["id": id])
294
+ if let completionText = DownloadCompletedNotificationStore.shared.takeText(for: id, generation: generation) {
295
+ DownloadCompletedNotificationPresenter.presentIfAllowed(downloadId: id, body: completionText)
296
+ }
228
297
  } catch {
298
+ if let generation {
299
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
300
+ }
229
301
  notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
230
302
  }
303
+
304
+ clearTaskGeneration(for: downloadTask)
231
305
  }
232
306
 
233
307
  public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
@@ -236,15 +310,21 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
236
310
  return
237
311
  }
238
312
 
239
- removeTask(for: id)
313
+ let generation = generation(for: downloadTask)
314
+ removeTask(for: id, completing: downloadTask)
240
315
 
241
316
  if error != nil {
242
317
  _ = DownloadDestinationStore.shared.takeDestination(for: id)
243
318
  }
244
319
 
245
- if let error = error {
320
+ if let error {
321
+ if let generation {
322
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
323
+ }
246
324
  notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
247
325
  }
326
+
327
+ clearTaskGeneration(for: downloadTask)
248
328
  }
249
329
 
250
330
  public func urlSession(
@@ -0,0 +1,38 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ enum DownloadCompletedNotificationPresenter {
5
+ static func presentIfAllowed(downloadId: String, body: String) {
6
+ guard let bodyText = DownloadCompletedNotificationText.normalize(body) else {
7
+ return
8
+ }
9
+
10
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
11
+ let allowed: Bool
12
+ switch settings.authorizationStatus {
13
+ case .authorized, .provisional, .ephemeral:
14
+ allowed = true
15
+ case .denied, .notDetermined:
16
+ allowed = false
17
+ @unknown default:
18
+ allowed = false
19
+ }
20
+
21
+ guard allowed else {
22
+ return
23
+ }
24
+
25
+ let content = UNMutableNotificationContent()
26
+ content.body = bodyText
27
+ content.sound = .default
28
+
29
+ let request = UNNotificationRequest(
30
+ identifier: "capacitor-downloader-completed-\(downloadId)",
31
+ content: content,
32
+ trigger: nil
33
+ )
34
+
35
+ UNUserNotificationCenter.current().add(request)
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,74 @@
1
+ import Foundation
2
+
3
+ final class DownloadCompletedNotificationStore {
4
+ static let shared = DownloadCompletedNotificationStore()
5
+
6
+ static let userDefaultsKey = "CapacitorDownloaderCompletedNotificationTexts"
7
+
8
+ private let defaults: UserDefaults
9
+ private let lock = NSLock()
10
+
11
+ init(defaults: UserDefaults = .standard) {
12
+ self.defaults = defaults
13
+ }
14
+
15
+ func setText(_ text: String?, for id: String, generation: UInt64) {
16
+ lock.lock()
17
+ defer { lock.unlock() }
18
+ var map = entriesMapUnlocked()
19
+ if let normalized = DownloadCompletedNotificationText.normalize(text) {
20
+ map[id] = Self.encodeEntry(text: normalized, generation: generation)
21
+ } else {
22
+ map.removeValue(forKey: id)
23
+ }
24
+ defaults.set(map, forKey: Self.userDefaultsKey)
25
+ }
26
+
27
+ func takeText(for id: String, generation: UInt64?) -> String? {
28
+ lock.lock()
29
+ defer { lock.unlock() }
30
+ var map = entriesMapUnlocked()
31
+ guard let entry = map[id] else {
32
+ return nil
33
+ }
34
+ if let generation, Self.generation(from: entry) != generation {
35
+ return nil
36
+ }
37
+ map.removeValue(forKey: id)
38
+ defaults.set(map, forKey: Self.userDefaultsKey)
39
+ return Self.text(from: entry)
40
+ }
41
+
42
+ func removeText(for id: String, generation: UInt64?) {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ var map = entriesMapUnlocked()
46
+ guard let entry = map[id] else {
47
+ return
48
+ }
49
+ if let generation, Self.generation(from: entry) != generation {
50
+ return
51
+ }
52
+ map.removeValue(forKey: id)
53
+ defaults.set(map, forKey: Self.userDefaultsKey)
54
+ }
55
+
56
+ private func entriesMapUnlocked() -> [String: [String: Any]] {
57
+ defaults.dictionary(forKey: Self.userDefaultsKey) as? [String: [String: Any]] ?? [:]
58
+ }
59
+
60
+ private static func encodeEntry(text: String, generation: UInt64) -> [String: Any] {
61
+ ["text": text, "generation": NSNumber(value: generation)]
62
+ }
63
+
64
+ private static func text(from entry: [String: Any]) -> String? {
65
+ entry["text"] as? String
66
+ }
67
+
68
+ private static func generation(from entry: [String: Any]) -> UInt64? {
69
+ if let number = entry["generation"] as? NSNumber {
70
+ return number.uint64Value
71
+ }
72
+ return entry["generation"] as? UInt64
73
+ }
74
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ enum DownloadCompletedNotificationText {
4
+ static func normalize(_ text: String?) -> String? {
5
+ guard let text else {
6
+ return nil
7
+ }
8
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
9
+ return trimmed.isEmpty ? nil : trimmed
10
+ }
11
+ }
@@ -0,0 +1,39 @@
1
+ import Foundation
2
+
3
+ final class DownloadIdGenerationStore {
4
+ static let shared = DownloadIdGenerationStore()
5
+
6
+ static let userDefaultsKey = "CapacitorDownloaderDownloadGenerations"
7
+
8
+ private let defaults: UserDefaults
9
+ private let lock = NSLock()
10
+
11
+ init(defaults: UserDefaults = .standard) {
12
+ self.defaults = defaults
13
+ }
14
+
15
+ func setGeneration(_ generation: UInt64, forDownloadId id: String) {
16
+ lock.lock()
17
+ defer { lock.unlock() }
18
+ var map = generationsMapUnlocked()
19
+ map[id] = NSNumber(value: generation)
20
+ defaults.set(map, forKey: Self.userDefaultsKey)
21
+ }
22
+
23
+ func allGenerations() -> [String: UInt64] {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ var result: [String: UInt64] = [:]
27
+ for (key, value) in generationsMapUnlocked() {
28
+ guard let number = value as? NSNumber else {
29
+ continue
30
+ }
31
+ result[key] = number.uint64Value
32
+ }
33
+ return result
34
+ }
35
+
36
+ private func generationsMapUnlocked() -> [String: Any] {
37
+ defaults.dictionary(forKey: Self.userDefaultsKey) ?? [:]
38
+ }
39
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+
3
+ final class DownloadTaskGenerationStore {
4
+ static let shared = DownloadTaskGenerationStore()
5
+
6
+ static let userDefaultsKey = "CapacitorDownloaderTaskGenerations"
7
+
8
+ private let defaults: UserDefaults
9
+ private let lock = NSLock()
10
+
11
+ init(defaults: UserDefaults = .standard) {
12
+ self.defaults = defaults
13
+ }
14
+
15
+ func setGeneration(_ generation: UInt64, forTaskIdentifier taskIdentifier: Int) {
16
+ lock.lock()
17
+ defer { lock.unlock() }
18
+ var map = generationsMapUnlocked()
19
+ map[String(taskIdentifier)] = NSNumber(value: generation)
20
+ defaults.set(map, forKey: Self.userDefaultsKey)
21
+ }
22
+
23
+ func removeGeneration(forTaskIdentifier taskIdentifier: Int) {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ var map = generationsMapUnlocked()
27
+ map.removeValue(forKey: String(taskIdentifier))
28
+ defaults.set(map, forKey: Self.userDefaultsKey)
29
+ }
30
+
31
+ func allGenerations() -> [Int: UInt64] {
32
+ lock.lock()
33
+ defer { lock.unlock() }
34
+ var result: [Int: UInt64] = [:]
35
+ for (key, value) in generationsMapUnlocked() {
36
+ guard let taskIdentifier = Int(key), let number = value as? NSNumber else {
37
+ continue
38
+ }
39
+ result[taskIdentifier] = number.uint64Value
40
+ }
41
+ return result
42
+ }
43
+
44
+ private func generationsMapUnlocked() -> [String: Any] {
45
+ defaults.dictionary(forKey: Self.userDefaultsKey) ?? [:]
46
+ }
47
+ }
@@ -87,6 +87,127 @@ final class DownloadDestinationStoreTests: XCTestCase {
87
87
  }
88
88
  }
89
89
 
90
+ final class DownloadCompletedNotificationTextTests: XCTestCase {
91
+ func testNormalizeRejectsBlank() {
92
+ XCTAssertNil(DownloadCompletedNotificationText.normalize(nil))
93
+ XCTAssertNil(DownloadCompletedNotificationText.normalize(""))
94
+ XCTAssertNil(DownloadCompletedNotificationText.normalize(" "))
95
+ }
96
+
97
+ func testNormalizeTrims() {
98
+ XCTAssertEqual(DownloadCompletedNotificationText.normalize(" Ready "), "Ready")
99
+ }
100
+ }
101
+
102
+ final class DownloadCompletedNotificationStoreTests: XCTestCase {
103
+ private var defaults: UserDefaults!
104
+ private var store: DownloadCompletedNotificationStore!
105
+
106
+ override func setUp() {
107
+ super.setUp()
108
+ defaults = UserDefaults(suiteName: "CapacitorDownloaderNotificationTests")!
109
+ defaults.removePersistentDomain(forName: "CapacitorDownloaderNotificationTests")
110
+ store = DownloadCompletedNotificationStore(defaults: defaults)
111
+ }
112
+
113
+ override func tearDown() {
114
+ defaults.removePersistentDomain(forName: "CapacitorDownloaderNotificationTests")
115
+ super.tearDown()
116
+ }
117
+
118
+ func testSetAndTakeText() {
119
+ store.setText(" Finished ", for: "task-1", generation: 1)
120
+
121
+ XCTAssertEqual(store.takeText(for: "task-1", generation: 1), "Finished")
122
+ XCTAssertNil(store.takeText(for: "task-1", generation: 1))
123
+ }
124
+
125
+ func testRemoveText() {
126
+ store.setText("Done", for: "task-1", generation: 1)
127
+ store.removeText(for: "task-1", generation: 1)
128
+ XCTAssertNil(store.takeText(for: "task-1", generation: 1))
129
+ }
130
+
131
+ func testGenerationMismatchLeavesStoredText() {
132
+ store.setText("New", for: "task-1", generation: 2)
133
+ store.removeText(for: "task-1", generation: 1)
134
+ XCTAssertEqual(store.takeText(for: "task-1", generation: 2), "New")
135
+ }
136
+
137
+ func testTakeTextWithoutTaskGenerationUsesStoredEntry() {
138
+ store.setText("Ready", for: "task-1", generation: 3)
139
+ XCTAssertEqual(store.takeText(for: "task-1", generation: nil), "Ready")
140
+ }
141
+
142
+ func testConcurrentUpdatesDoNotDropEntries() {
143
+ let ids = (0..<50).map { "task-\($0)" }
144
+ DispatchQueue.concurrentPerform(iterations: ids.count) { index in
145
+ store.setText("Body \(index)", for: ids[index], generation: 1)
146
+ }
147
+
148
+ for (index, id) in ids.enumerated() {
149
+ XCTAssertEqual(store.takeText(for: id, generation: 1), "Body \(index)")
150
+ }
151
+ }
152
+ }
153
+
154
+ final class DownloadIdGenerationStoreTests: XCTestCase {
155
+ private var defaults: UserDefaults!
156
+ private var store: DownloadIdGenerationStore!
157
+
158
+ override func setUp() {
159
+ super.setUp()
160
+ defaults = UserDefaults(suiteName: "CapacitorDownloaderIdGenerationTests")!
161
+ defaults.removePersistentDomain(forName: "CapacitorDownloaderIdGenerationTests")
162
+ store = DownloadIdGenerationStore(defaults: defaults)
163
+ }
164
+
165
+ override func tearDown() {
166
+ defaults.removePersistentDomain(forName: "CapacitorDownloaderIdGenerationTests")
167
+ super.tearDown()
168
+ }
169
+
170
+ func testPersistedGenerationSurvivesRelaunch() {
171
+ store.setGeneration(3, forDownloadId: "episode-12")
172
+ let restored = DownloadIdGenerationStore(defaults: defaults)
173
+ XCTAssertEqual(restored.allGenerations()["episode-12"], 3)
174
+ let next = (restored.allGenerations()["episode-12"] ?? 0) &+ 1
175
+ XCTAssertEqual(next, 4)
176
+ }
177
+
178
+ func testNextGenerationAfterRelaunchDoesNotReuseStoredValue() {
179
+ store.setGeneration(1, forDownloadId: "shared-id")
180
+ let relaunched = DownloadIdGenerationStore(defaults: defaults)
181
+ let generations = relaunched.allGenerations()
182
+ let next = (generations["shared-id"] ?? 0) &+ 1
183
+ XCTAssertEqual(next, 2)
184
+ }
185
+ }
186
+
187
+ final class DownloadTaskGenerationStoreTests: XCTestCase {
188
+ private var defaults: UserDefaults!
189
+ private var store: DownloadTaskGenerationStore!
190
+
191
+ override func setUp() {
192
+ super.setUp()
193
+ defaults = UserDefaults(suiteName: "CapacitorDownloaderTaskGenerationTests")!
194
+ defaults.removePersistentDomain(forName: "CapacitorDownloaderTaskGenerationTests")
195
+ store = DownloadTaskGenerationStore(defaults: defaults)
196
+ }
197
+
198
+ override func tearDown() {
199
+ defaults.removePersistentDomain(forName: "CapacitorDownloaderTaskGenerationTests")
200
+ super.tearDown()
201
+ }
202
+
203
+ func testPersistAndRestoreGeneration() {
204
+ store.setGeneration(7, forTaskIdentifier: 42)
205
+ XCTAssertEqual(store.allGenerations()[42], 7)
206
+ store.removeGeneration(forTaskIdentifier: 42)
207
+ XCTAssertNil(store.allGenerations()[42])
208
+ }
209
+ }
210
+
90
211
  final class DownloadHTTPValidatorTests: XCTestCase {
91
212
  func testSuccessfulStatusCodes() {
92
213
  XCTAssertTrue(DownloadHTTPValidator.isSuccessfulStatusCode(200))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-downloader",
3
- "version": "8.3.21",
3
+ "version": "8.4.1",
4
4
  "description": "Download file in background or foreground",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",