@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 +29 -9
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java +91 -26
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationPresenter.java +91 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationStore.java +138 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationText.java +14 -0
- package/dist/docs.json +13 -1
- package/dist/esm/definitions.d.ts +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorDownloaderPlugin/CapacitorDownloaderPlugin.swift +86 -6
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationPresenter.swift +38 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationStore.swift +74 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationText.swift +11 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadIdGenerationStore.swift +39 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadTaskGenerationStore.swift +47 -0
- package/ios/Tests/CapacitorDownloaderPluginTests/CapacitorDownloaderPluginTests.swift +121 -0
- package/package.json +1 -1
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
|
|
315
|
-
|
|
|
316
|
-
| **`id`**
|
|
317
|
-
| **`url`**
|
|
318
|
-
| **`destination`**
|
|
319
|
-
| **`headers`**
|
|
320
|
-
| **`network`**
|
|
321
|
-
| **`priority`**
|
|
322
|
-
| **`notification`**
|
|
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
|
package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
() ->
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|