@capgo/capacitor-downloader 8.1.32 → 8.3.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 +14 -13
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java +249 -68
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadNotificationVisibility.java +20 -0
- package/dist/docs.json +15 -3
- package/dist/esm/definitions.d.ts +18 -1
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorDownloaderPlugin/CapacitorDownloaderPlugin.swift +73 -12
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadDestinationResolver.swift +24 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadDestinationStore.swift +36 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadHTTPValidator.swift +7 -0
- package/ios/Tests/CapacitorDownloaderPluginTests/CapacitorDownloaderPluginTests.swift +95 -8
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -187,16 +187,16 @@ Get information about a downloaded file.
|
|
|
187
187
|
### addListener('downloadProgress', ...)
|
|
188
188
|
|
|
189
189
|
```typescript
|
|
190
|
-
addListener(eventName: 'downloadProgress', listenerFunc: (progress: { id: string; progress: number; }) => void) => Promise<PluginListenerHandle>
|
|
190
|
+
addListener(eventName: 'downloadProgress', listenerFunc: (progress: { id: string; progress: number; bytesWritten?: number | undefined; bytesTotal?: number | undefined; }) => void) => Promise<PluginListenerHandle>
|
|
191
191
|
```
|
|
192
192
|
|
|
193
193
|
Listen for download progress updates.
|
|
194
194
|
Fired periodically as download progresses.
|
|
195
195
|
|
|
196
|
-
| Param | Type
|
|
197
|
-
| ------------------ |
|
|
198
|
-
| **`eventName`** | <code>'downloadProgress'</code>
|
|
199
|
-
| **`listenerFunc`** | <code>(progress: { id: string; progress: number; }) => void</code> | - Callback receiving progress updates |
|
|
196
|
+
| Param | Type | Description |
|
|
197
|
+
| ------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
198
|
+
| **`eventName`** | <code>'downloadProgress'</code> | - Must be 'downloadProgress' |
|
|
199
|
+
| **`listenerFunc`** | <code>(progress: { id: string; progress: number; bytesWritten?: number; bytesTotal?: number; }) => void</code> | - Callback receiving progress updates |
|
|
200
200
|
|
|
201
201
|
**Returns:** <code>Promise<<a href="#pluginlistenerhandle">PluginListenerHandle</a>></code>
|
|
202
202
|
|
|
@@ -284,14 +284,15 @@ Represents the current state and progress of a download task.
|
|
|
284
284
|
|
|
285
285
|
Configuration options for starting a download.
|
|
286
286
|
|
|
287
|
-
| Prop
|
|
288
|
-
|
|
|
289
|
-
| **`id`**
|
|
290
|
-
| **`url`**
|
|
291
|
-
| **`destination`**
|
|
292
|
-
| **`headers`**
|
|
293
|
-
| **`network`**
|
|
294
|
-
| **`priority`**
|
|
287
|
+
| Prop | Type | Description | Since |
|
|
288
|
+
| ------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
|
289
|
+
| **`id`** | <code>string</code> | Unique identifier for this download task | |
|
|
290
|
+
| **`url`** | <code>string</code> | URL of the file to download | |
|
|
291
|
+
| **`destination`** | <code>string</code> | Local file path where the download will be saved | |
|
|
292
|
+
| **`headers`** | <code>{ [key: string]: string; }</code> | Optional HTTP headers to include in the request | |
|
|
293
|
+
| **`network`** | <code>'cellular' \| 'wifi-only'</code> | Network type requirement for download | |
|
|
294
|
+
| **`priority`** | <code>'high' \| 'normal' \| 'low'</code> | Download priority level | |
|
|
295
|
+
| **`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 |
|
|
295
296
|
|
|
296
297
|
|
|
297
298
|
#### PluginListenerHandle
|
package/android/build.gradle
CHANGED
|
@@ -40,6 +40,11 @@ android {
|
|
|
40
40
|
sourceCompatibility JavaVersion.VERSION_21
|
|
41
41
|
targetCompatibility JavaVersion.VERSION_21
|
|
42
42
|
}
|
|
43
|
+
testOptions {
|
|
44
|
+
unitTests {
|
|
45
|
+
returnDefaultValues = true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
repositories {
|
|
@@ -53,6 +58,7 @@ dependencies {
|
|
|
53
58
|
annotationProcessor project(':capacitor-android')
|
|
54
59
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
|
55
60
|
testImplementation "junit:junit:$junitVersion"
|
|
61
|
+
testImplementation "org.mockito:mockito-core:5.14.2"
|
|
56
62
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
57
63
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
58
64
|
}
|
package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java
CHANGED
|
@@ -16,23 +16,30 @@ import com.getcapacitor.PluginCall;
|
|
|
16
16
|
import com.getcapacitor.PluginMethod;
|
|
17
17
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
18
18
|
import java.io.File;
|
|
19
|
-
import java.util.HashMap;
|
|
20
19
|
import java.util.Iterator;
|
|
21
20
|
import java.util.Map;
|
|
21
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
22
|
+
import java.util.concurrent.ExecutorService;
|
|
23
|
+
import java.util.concurrent.Executors;
|
|
24
|
+
import java.util.concurrent.RejectedExecutionException;
|
|
25
|
+
import java.util.concurrent.atomic.AtomicLong;
|
|
22
26
|
|
|
23
27
|
@CapacitorPlugin(name = "CapacitorDownloader")
|
|
24
28
|
public class CapacitorDownloaderPlugin extends Plugin {
|
|
25
29
|
|
|
26
|
-
private final String pluginVersion = "8.1
|
|
30
|
+
private final String pluginVersion = "8.3.1";
|
|
27
31
|
|
|
28
32
|
private DownloadManager downloadManager;
|
|
29
|
-
private final Map<String, Long> downloads = new
|
|
33
|
+
private final Map<String, Long> downloads = new ConcurrentHashMap<>();
|
|
34
|
+
private final AtomicLong pendingDownloadSequence = new AtomicLong(0);
|
|
35
|
+
private ExecutorService downloadManagerExecutor;
|
|
30
36
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
|
31
37
|
private BroadcastReceiver downloadReceiver;
|
|
32
38
|
|
|
33
39
|
@Override
|
|
34
40
|
public void load() {
|
|
35
41
|
downloadManager = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
|
|
42
|
+
downloadManagerExecutor = Executors.newCachedThreadPool();
|
|
36
43
|
registerDownloadReceiver();
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -57,13 +64,26 @@ public class CapacitorDownloaderPlugin extends Plugin {
|
|
|
57
64
|
|
|
58
65
|
private String getDownloadIdByValue(long value) {
|
|
59
66
|
for (Map.Entry<String, Long> entry : downloads.entrySet()) {
|
|
60
|
-
if (entry.getValue() == value) {
|
|
67
|
+
if (entry.getValue() == value && value > 0) {
|
|
61
68
|
return entry.getKey();
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
71
|
return null;
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
private static boolean isPendingDownload(long downloadId) {
|
|
75
|
+
return downloadId < 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private long reservePendingDownload() {
|
|
79
|
+
return -pendingDownloadSequence.incrementAndGet();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private boolean isTrackedDownload(String id, long systemDownloadId) {
|
|
83
|
+
Long trackedDownloadId = downloads.get(id);
|
|
84
|
+
return trackedDownloadId != null && trackedDownloadId.longValue() == systemDownloadId;
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
@PluginMethod
|
|
68
88
|
public void download(PluginCall call) {
|
|
69
89
|
String id = call.getString("id");
|
|
@@ -76,7 +96,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
|
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url))
|
|
79
|
-
.setNotificationVisibility(
|
|
99
|
+
.setNotificationVisibility(DownloadNotificationVisibility.resolve(call.getString("notification")))
|
|
80
100
|
.setAllowedOverMetered(true)
|
|
81
101
|
.setAllowedOverRoaming(true);
|
|
82
102
|
|
|
@@ -100,69 +120,161 @@ public class CapacitorDownloaderPlugin extends Plugin {
|
|
|
100
120
|
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE | DownloadManager.Request.NETWORK_WIFI);
|
|
101
121
|
}
|
|
102
122
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
final String notification = call.getString("notification");
|
|
124
|
+
final long pendingToken = reservePendingDownload();
|
|
125
|
+
if (downloads.putIfAbsent(id, pendingToken) != null) {
|
|
126
|
+
call.reject("Download already exists");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
runDownloadManagerWork(
|
|
130
|
+
call,
|
|
131
|
+
() -> {
|
|
132
|
+
if (!isTrackedDownload(id, pendingToken)) {
|
|
133
|
+
call.reject("Download was cancelled");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
110
136
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
long downloadId;
|
|
138
|
+
try {
|
|
139
|
+
downloadId = downloadManager.enqueue(request);
|
|
140
|
+
} catch (SecurityException e) {
|
|
141
|
+
downloads.remove(id, pendingToken);
|
|
142
|
+
if ("hidden".equals(notification)) {
|
|
143
|
+
call.reject("Hidden downloads require android.permission.DOWNLOAD_WITHOUT_NOTIFICATION in the app manifest", e);
|
|
144
|
+
} else {
|
|
145
|
+
call.reject("Download could not be enqueued due to missing permission", e);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
} catch (RuntimeException e) {
|
|
149
|
+
downloads.remove(id, pendingToken);
|
|
150
|
+
call.reject("Download could not be enqueued", e);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
114
153
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
handler.postDelayed(this, 1000); // Check every second
|
|
154
|
+
if (!downloads.replace(id, pendingToken, downloadId)) {
|
|
155
|
+
try {
|
|
156
|
+
downloadManager.remove(downloadId);
|
|
157
|
+
} catch (RuntimeException e) {
|
|
158
|
+
call.reject("Download was cancelled", e);
|
|
159
|
+
return;
|
|
122
160
|
}
|
|
161
|
+
call.reject("Download was cancelled");
|
|
162
|
+
return;
|
|
123
163
|
}
|
|
124
|
-
|
|
164
|
+
|
|
165
|
+
JSObject result = new JSObject();
|
|
166
|
+
result.put("id", id);
|
|
167
|
+
result.put("status", DownloadManager.STATUS_PENDING);
|
|
168
|
+
call.resolve(result);
|
|
169
|
+
|
|
170
|
+
// Start a periodic progress check
|
|
171
|
+
startProgressCheck(id, downloadId);
|
|
172
|
+
},
|
|
173
|
+
() -> downloads.remove(id, pendingToken)
|
|
125
174
|
);
|
|
126
175
|
}
|
|
127
176
|
|
|
128
|
-
private
|
|
129
|
-
|
|
177
|
+
private void startProgressCheck(final String id, final long downloadId) {
|
|
178
|
+
runProgressQuery(id, true, downloadId);
|
|
179
|
+
}
|
|
130
180
|
|
|
131
|
-
|
|
132
|
-
|
|
181
|
+
private void checkDownloadStatus(String id) {
|
|
182
|
+
Long trackedDownloadId = downloads.get(id);
|
|
183
|
+
if (trackedDownloadId == null || isPendingDownload(trackedDownloadId)) {
|
|
184
|
+
return;
|
|
133
185
|
}
|
|
186
|
+
runProgressQuery(id, false, trackedDownloadId);
|
|
187
|
+
}
|
|
134
188
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
189
|
+
private void runProgressQuery(final String id, final boolean scheduleNext, final long systemDownloadId) {
|
|
190
|
+
if (!isTrackedDownload(id, systemDownloadId)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ExecutorService executor = downloadManagerExecutor;
|
|
195
|
+
if (executor == null) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
executor.execute(() -> {
|
|
201
|
+
ProgressSnapshot snapshot = queryProgressSnapshot(id, systemDownloadId);
|
|
202
|
+
handler.post(() -> deliverProgressSnapshot(id, systemDownloadId, scheduleNext, snapshot));
|
|
203
|
+
});
|
|
204
|
+
} catch (RejectedExecutionException ignored) {
|
|
205
|
+
// Plugin is shutting down; stop polling quietly.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private ProgressSnapshot queryProgressSnapshot(String id, long systemDownloadId) {
|
|
210
|
+
if (!isTrackedDownload(id, systemDownloadId)) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
DownloadManager.Query query = new DownloadManager.Query().setFilterById(systemDownloadId);
|
|
215
|
+
Cursor cursor = downloadManager.query(query);
|
|
216
|
+
if (cursor == null) {
|
|
217
|
+
return ProgressSnapshot.notFound(id);
|
|
218
|
+
}
|
|
219
|
+
try (cursor) {
|
|
220
|
+
if (!cursor.moveToFirst()) {
|
|
221
|
+
return ProgressSnapshot.notFound(id);
|
|
163
222
|
}
|
|
223
|
+
|
|
224
|
+
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
|
225
|
+
long bytesDownloaded = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
|
|
226
|
+
long bytesTotal = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
|
|
227
|
+
float progress = bytesTotal > 0 ? (float) bytesDownloaded / bytesTotal : 0f;
|
|
228
|
+
return new ProgressSnapshot(id, progress, status, true);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private void deliverProgressSnapshot(String id, long systemDownloadId, boolean scheduleNext, ProgressSnapshot snapshot) {
|
|
233
|
+
if (snapshot == null || !snapshot.found || !isTrackedDownload(id, systemDownloadId)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
JSObject progressData = new JSObject();
|
|
238
|
+
progressData.put("id", snapshot.id);
|
|
239
|
+
progressData.put("progress", snapshot.progress);
|
|
240
|
+
notifyListeners("downloadProgress", progressData);
|
|
241
|
+
|
|
242
|
+
boolean shouldContinue = true;
|
|
243
|
+
if (snapshot.status == DownloadManager.STATUS_SUCCESSFUL) {
|
|
244
|
+
JSObject completedData = new JSObject();
|
|
245
|
+
completedData.put("id", snapshot.id);
|
|
246
|
+
notifyListeners("downloadCompleted", completedData);
|
|
247
|
+
shouldContinue = false;
|
|
248
|
+
} else if (snapshot.status == DownloadManager.STATUS_FAILED) {
|
|
249
|
+
JSObject failedData = new JSObject();
|
|
250
|
+
failedData.put("id", snapshot.id);
|
|
251
|
+
failedData.put("error", "Download failed");
|
|
252
|
+
notifyListeners("downloadFailed", failedData);
|
|
253
|
+
shouldContinue = false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (scheduleNext && shouldContinue) {
|
|
257
|
+
handler.postDelayed(() -> runProgressQuery(id, true, systemDownloadId), 1000);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private static final class ProgressSnapshot {
|
|
262
|
+
|
|
263
|
+
private final String id;
|
|
264
|
+
private final float progress;
|
|
265
|
+
private final int status;
|
|
266
|
+
private final boolean found;
|
|
267
|
+
|
|
268
|
+
private ProgressSnapshot(String id, float progress, int status, boolean found) {
|
|
269
|
+
this.id = id;
|
|
270
|
+
this.progress = progress;
|
|
271
|
+
this.status = status;
|
|
272
|
+
this.found = found;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private static ProgressSnapshot notFound(String id) {
|
|
276
|
+
return new ProgressSnapshot(id, 0f, -1, false);
|
|
164
277
|
}
|
|
165
|
-
return true; // Continue checking progress
|
|
166
278
|
}
|
|
167
279
|
|
|
168
280
|
@PluginMethod
|
|
@@ -180,31 +292,96 @@ public class CapacitorDownloaderPlugin extends Plugin {
|
|
|
180
292
|
@PluginMethod
|
|
181
293
|
public void stop(PluginCall call) {
|
|
182
294
|
String id = call.getString("id");
|
|
183
|
-
if (id == null
|
|
295
|
+
if (id == null) {
|
|
296
|
+
call.reject("Download not found");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
Long downloadId = downloads.remove(id);
|
|
301
|
+
if (downloadId == null) {
|
|
184
302
|
call.reject("Download not found");
|
|
185
303
|
return;
|
|
186
304
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
305
|
+
|
|
306
|
+
if (isPendingDownload(downloadId)) {
|
|
307
|
+
call.resolve(new JSObject().put("removed", false));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
final long systemDownloadId = downloadId;
|
|
312
|
+
final String stoppedId = id;
|
|
313
|
+
runDownloadManagerWork(
|
|
314
|
+
call,
|
|
315
|
+
() -> {
|
|
316
|
+
try {
|
|
317
|
+
int removedDownloads = downloadManager.remove(systemDownloadId);
|
|
318
|
+
call.resolve(new JSObject().put("removed", removedDownloads > 0));
|
|
319
|
+
} catch (RuntimeException e) {
|
|
320
|
+
downloads.putIfAbsent(stoppedId, systemDownloadId);
|
|
321
|
+
call.reject("Download could not be removed", e);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
() -> downloads.putIfAbsent(stoppedId, systemDownloadId)
|
|
325
|
+
);
|
|
190
326
|
}
|
|
191
327
|
|
|
192
328
|
@PluginMethod
|
|
193
329
|
public void checkStatus(PluginCall call) {
|
|
194
330
|
String id = call.getString("id");
|
|
195
|
-
if (id == null
|
|
331
|
+
if (id == null) {
|
|
196
332
|
call.reject("Download not found");
|
|
197
333
|
return;
|
|
198
334
|
}
|
|
199
335
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
336
|
+
Long trackedDownloadId = downloads.get(id);
|
|
337
|
+
if (trackedDownloadId == null || isPendingDownload(trackedDownloadId)) {
|
|
338
|
+
call.reject("Download not found");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
final long downloadId = trackedDownloadId;
|
|
343
|
+
runDownloadManagerWork(call, () -> {
|
|
344
|
+
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
|
|
345
|
+
try {
|
|
346
|
+
Cursor cursor = downloadManager.query(query);
|
|
347
|
+
if (cursor == null) {
|
|
348
|
+
call.reject("Download not found");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
try (cursor) {
|
|
352
|
+
if (cursor.moveToFirst()) {
|
|
353
|
+
call.resolve(getDownloadStatus(cursor));
|
|
354
|
+
} else {
|
|
355
|
+
call.reject("Download not found");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} catch (RuntimeException e) {
|
|
359
|
+
call.reject("Download status could not be read", e);
|
|
207
360
|
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private void runDownloadManagerWork(PluginCall call, Runnable work) {
|
|
365
|
+
runDownloadManagerWork(call, work, null);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private void runDownloadManagerWork(PluginCall call, Runnable work, Runnable onFailure) {
|
|
369
|
+
ExecutorService executor = downloadManagerExecutor;
|
|
370
|
+
if (executor == null) {
|
|
371
|
+
if (onFailure != null) {
|
|
372
|
+
onFailure.run();
|
|
373
|
+
}
|
|
374
|
+
call.reject("Download manager is not available");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
executor.execute(work);
|
|
380
|
+
} catch (RejectedExecutionException e) {
|
|
381
|
+
if (onFailure != null) {
|
|
382
|
+
onFailure.run();
|
|
383
|
+
}
|
|
384
|
+
call.reject("Download manager is shutting down", e);
|
|
208
385
|
}
|
|
209
386
|
}
|
|
210
387
|
|
|
@@ -274,6 +451,10 @@ public class CapacitorDownloaderPlugin extends Plugin {
|
|
|
274
451
|
if (downloadReceiver != null) {
|
|
275
452
|
getContext().unregisterReceiver(downloadReceiver);
|
|
276
453
|
}
|
|
454
|
+
if (downloadManagerExecutor != null) {
|
|
455
|
+
downloadManagerExecutor.shutdown();
|
|
456
|
+
downloadManagerExecutor = null;
|
|
457
|
+
}
|
|
277
458
|
}
|
|
278
459
|
|
|
279
460
|
@PluginMethod
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package ee.forgr.capacitor.plugin.downloader;
|
|
2
|
+
|
|
3
|
+
import android.app.DownloadManager;
|
|
4
|
+
|
|
5
|
+
final class DownloadNotificationVisibility {
|
|
6
|
+
|
|
7
|
+
private DownloadNotificationVisibility() {}
|
|
8
|
+
|
|
9
|
+
static int resolve(String notification) {
|
|
10
|
+
if ("progress".equals(notification)) {
|
|
11
|
+
return DownloadManager.Request.VISIBILITY_VISIBLE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if ("hidden".equals(notification)) {
|
|
15
|
+
return DownloadManager.Request.VISIBILITY_HIDDEN;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
|
|
19
|
+
}
|
|
20
|
+
}
|
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});\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});\n```"
|
|
31
31
|
}
|
|
32
32
|
],
|
|
33
33
|
"docs": "Start a new download task.",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
168
|
"name": "addListener",
|
|
169
|
-
"signature": "(eventName: 'downloadProgress', listenerFunc: (progress: { id: string; progress: number; }) => void) => Promise<PluginListenerHandle>",
|
|
169
|
+
"signature": "(eventName: 'downloadProgress', listenerFunc: (progress: { id: string; progress: number; bytesWritten?: number | undefined; bytesTotal?: number | undefined; }) => void) => Promise<PluginListenerHandle>",
|
|
170
170
|
"parameters": [
|
|
171
171
|
{
|
|
172
172
|
"name": "eventName",
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
{
|
|
177
177
|
"name": "listenerFunc",
|
|
178
178
|
"docs": "- Callback receiving progress updates",
|
|
179
|
-
"type": "(progress: { id: string; progress: number; }) => void"
|
|
179
|
+
"type": "(progress: { id: string; progress: number; bytesWritten?: number | undefined; bytesTotal?: number | undefined; }) => void"
|
|
180
180
|
}
|
|
181
181
|
],
|
|
182
182
|
"returns": "Promise<PluginListenerHandle>",
|
|
@@ -388,6 +388,18 @@
|
|
|
388
388
|
"docs": "Download priority level",
|
|
389
389
|
"complexTypes": [],
|
|
390
390
|
"type": "'high' | 'normal' | 'low' | undefined"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"name": "notification",
|
|
394
|
+
"tags": [
|
|
395
|
+
{
|
|
396
|
+
"text": "8.2.0",
|
|
397
|
+
"name": "since"
|
|
398
|
+
}
|
|
399
|
+
],
|
|
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
|
+
"complexTypes": [],
|
|
402
|
+
"type": "'completed' | 'progress' | 'hidden' | undefined"
|
|
391
403
|
}
|
|
392
404
|
]
|
|
393
405
|
},
|
|
@@ -28,6 +28,18 @@ export interface DownloadOptions {
|
|
|
28
28
|
network?: 'cellular' | 'wifi-only';
|
|
29
29
|
/** Download priority level */
|
|
30
30
|
priority?: 'high' | 'normal' | 'low';
|
|
31
|
+
/**
|
|
32
|
+
* Android DownloadManager notification visibility.
|
|
33
|
+
*
|
|
34
|
+
* - `'completed'` (default): show while downloading and keep the notification after completion.
|
|
35
|
+
* - `'progress'`: show while downloading, remove the notification when finished.
|
|
36
|
+
* - `'hidden'`: no system notification; use plugin progress events instead.
|
|
37
|
+
*
|
|
38
|
+
* Ignored on iOS and Web.
|
|
39
|
+
*
|
|
40
|
+
* @since 8.2.0
|
|
41
|
+
*/
|
|
42
|
+
notification?: 'completed' | 'progress' | 'hidden';
|
|
31
43
|
}
|
|
32
44
|
/**
|
|
33
45
|
* Capacitor plugin for downloading files with background support.
|
|
@@ -44,7 +56,8 @@ export interface CapacitorDownloaderPlugin {
|
|
|
44
56
|
* const task = await Downloader.download({
|
|
45
57
|
* id: 'my-download',
|
|
46
58
|
* url: 'https://example.com/file.pdf',
|
|
47
|
-
* destination: 'downloads/file.pdf'
|
|
59
|
+
* destination: 'downloads/file.pdf',
|
|
60
|
+
* notification: 'hidden', // Android: no system notification; use downloadProgress events
|
|
48
61
|
* });
|
|
49
62
|
* ```
|
|
50
63
|
*/
|
|
@@ -117,6 +130,10 @@ export interface CapacitorDownloaderPlugin {
|
|
|
117
130
|
addListener(eventName: 'downloadProgress', listenerFunc: (progress: {
|
|
118
131
|
id: string;
|
|
119
132
|
progress: number;
|
|
133
|
+
/** Bytes written so far */
|
|
134
|
+
bytesWritten?: number;
|
|
135
|
+
/** Total bytes expected, or 0 when the server did not report a length */
|
|
136
|
+
bytesTotal?: number;
|
|
120
137
|
}) => void): Promise<PluginListenerHandle>;
|
|
121
138
|
/**
|
|
122
139
|
* Listen for download completion.
|
|
@@ -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\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 * });\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: {
|
|
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"]}
|
|
@@ -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.1
|
|
11
|
+
private let pluginVersion: String = "8.3.1"
|
|
12
12
|
public let identifier = "CapacitorDownloaderPlugin"
|
|
13
13
|
public let jsName = "CapacitorDownloader"
|
|
14
14
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -56,15 +56,38 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
56
56
|
return tasks.first(where: { $0.value == task })?.key
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
private func documentsDirectoryURL() -> URL? {
|
|
60
|
+
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private func destinationURL(for id: String, storedDestination: String?) -> URL? {
|
|
64
|
+
guard let documentsDirectory = documentsDirectoryURL() else {
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return DownloadDestinationResolver.resolveDestinationURL(
|
|
69
|
+
destination: storedDestination,
|
|
70
|
+
id: id,
|
|
71
|
+
documentsDirectory: documentsDirectory
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func prepareDestinationDirectory(for destinationURL: URL) throws {
|
|
76
|
+
let directoryURL = destinationURL.deletingLastPathComponent()
|
|
77
|
+
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
@objc func download(_ call: CAPPluginCall) {
|
|
60
81
|
guard let id = call.getString("id"),
|
|
61
82
|
let urlString = call.getString("url"),
|
|
62
|
-
let destination = call.getString("destination"),
|
|
63
83
|
let url = URL(string: urlString) else {
|
|
64
84
|
call.reject("Invalid parameters")
|
|
65
85
|
return
|
|
66
86
|
}
|
|
67
87
|
|
|
88
|
+
let destination = call.getString("destination") ?? ""
|
|
89
|
+
DownloadDestinationStore.shared.setDestination(destination, for: id)
|
|
90
|
+
|
|
68
91
|
var request = URLRequest(url: url)
|
|
69
92
|
if let headers = call.getObject("headers") as? [String: String] {
|
|
70
93
|
for (key, value) in headers {
|
|
@@ -115,6 +138,7 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
115
138
|
|
|
116
139
|
task.cancel()
|
|
117
140
|
removeTask(for: id)
|
|
141
|
+
DownloadDestinationStore.shared.removeDestination(for: id)
|
|
118
142
|
call.resolve()
|
|
119
143
|
}
|
|
120
144
|
|
|
@@ -166,16 +190,39 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
166
190
|
"type": type
|
|
167
191
|
])
|
|
168
192
|
}
|
|
193
|
+
|
|
194
|
+
@objc func getPluginVersion(_ call: CAPPluginCall) {
|
|
195
|
+
call.resolve(["version": self.pluginVersion])
|
|
196
|
+
}
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
|
|
172
200
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
|
173
|
-
guard let id = idForTask(downloadTask)
|
|
174
|
-
|
|
201
|
+
guard let id = idForTask(downloadTask) else {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let storedDestination = DownloadDestinationStore.shared.takeDestination(for: id)
|
|
206
|
+
guard let destinationURL = destinationURL(for: id, storedDestination: storedDestination) else {
|
|
207
|
+
notifyListeners("downloadFailed", data: ["id": id, "error": "Unable to resolve destination"])
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if let httpResponse = downloadTask.response as? HTTPURLResponse,
|
|
212
|
+
!DownloadHTTPValidator.isSuccessfulStatusCode(httpResponse.statusCode) {
|
|
213
|
+
try? FileManager.default.removeItem(at: location)
|
|
214
|
+
notifyListeners(
|
|
215
|
+
"downloadFailed",
|
|
216
|
+
data: ["id": id, "error": "HTTP \(httpResponse.statusCode)"]
|
|
217
|
+
)
|
|
175
218
|
return
|
|
176
219
|
}
|
|
177
220
|
|
|
178
221
|
do {
|
|
222
|
+
try prepareDestinationDirectory(for: destinationURL)
|
|
223
|
+
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
|
224
|
+
try FileManager.default.removeItem(at: destinationURL)
|
|
225
|
+
}
|
|
179
226
|
try FileManager.default.moveItem(at: location, to: destinationURL)
|
|
180
227
|
notifyListeners("downloadCompleted", data: ["id": id])
|
|
181
228
|
} catch {
|
|
@@ -191,22 +238,36 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
|
|
|
191
238
|
|
|
192
239
|
removeTask(for: id)
|
|
193
240
|
|
|
241
|
+
if error != nil {
|
|
242
|
+
_ = DownloadDestinationStore.shared.takeDestination(for: id)
|
|
243
|
+
}
|
|
244
|
+
|
|
194
245
|
if let error = error {
|
|
195
246
|
notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
|
|
196
247
|
}
|
|
197
248
|
}
|
|
198
249
|
|
|
199
|
-
public func urlSession(
|
|
250
|
+
public func urlSession(
|
|
251
|
+
_ session: URLSession,
|
|
252
|
+
downloadTask: URLSessionDownloadTask,
|
|
253
|
+
didWriteData bytesWritten: Int64,
|
|
254
|
+
totalBytesWritten: Int64,
|
|
255
|
+
totalBytesExpectedToWrite: Int64
|
|
256
|
+
) {
|
|
200
257
|
guard let id = idForTask(downloadTask) else {
|
|
201
258
|
return
|
|
202
259
|
}
|
|
203
260
|
|
|
204
|
-
let
|
|
205
|
-
|
|
261
|
+
let bytesTotal = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : 0
|
|
262
|
+
let progress = bytesTotal > 0 ? Float(totalBytesWritten) / Float(bytesTotal) : 0
|
|
263
|
+
notifyListeners(
|
|
264
|
+
"downloadProgress",
|
|
265
|
+
data: [
|
|
266
|
+
"id": id,
|
|
267
|
+
"progress": progress,
|
|
268
|
+
"bytesWritten": totalBytesWritten,
|
|
269
|
+
"bytesTotal": bytesTotal
|
|
270
|
+
]
|
|
271
|
+
)
|
|
206
272
|
}
|
|
207
|
-
|
|
208
|
-
@objc func getPluginVersion(_ call: CAPPluginCall) {
|
|
209
|
-
call.resolve(["version": self.pluginVersion])
|
|
210
|
-
}
|
|
211
|
-
|
|
212
273
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum DownloadDestinationResolver {
|
|
4
|
+
static func resolveDestinationURL(
|
|
5
|
+
destination: String?,
|
|
6
|
+
id: String,
|
|
7
|
+
documentsDirectory: URL
|
|
8
|
+
) -> URL {
|
|
9
|
+
let trimmedDestination = destination?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
10
|
+
guard let destination = trimmedDestination, !destination.isEmpty else {
|
|
11
|
+
return documentsDirectory.appendingPathComponent(id)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if destination.hasPrefix("file://"), let url = URL(string: destination) {
|
|
15
|
+
return url
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if destination.hasPrefix("/") {
|
|
19
|
+
return URL(fileURLWithPath: destination)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return documentsDirectory.appendingPathComponent(destination)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class DownloadDestinationStore {
|
|
4
|
+
static let shared = DownloadDestinationStore()
|
|
5
|
+
|
|
6
|
+
static let userDefaultsKey = "CapacitorDownloaderDestinations"
|
|
7
|
+
|
|
8
|
+
private let defaults: UserDefaults
|
|
9
|
+
|
|
10
|
+
init(defaults: UserDefaults = .standard) {
|
|
11
|
+
self.defaults = defaults
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func setDestination(_ destination: String, for id: String) {
|
|
15
|
+
var map = destinationsMap()
|
|
16
|
+
map[id] = destination
|
|
17
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func takeDestination(for id: String) -> String? {
|
|
21
|
+
var map = destinationsMap()
|
|
22
|
+
let destination = map.removeValue(forKey: id)
|
|
23
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
24
|
+
return destination
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func removeDestination(for id: String) {
|
|
28
|
+
var map = destinationsMap()
|
|
29
|
+
map.removeValue(forKey: id)
|
|
30
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private func destinationsMap() -> [String: String] {
|
|
34
|
+
defaults.dictionary(forKey: Self.userDefaultsKey) as? [String: String] ?? [:]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,15 +1,102 @@
|
|
|
1
1
|
import XCTest
|
|
2
2
|
@testable import CapacitorDownloaderPlugin
|
|
3
3
|
|
|
4
|
-
class
|
|
5
|
-
|
|
6
|
-
// This is an example of a functional test case for a plugin.
|
|
7
|
-
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
|
4
|
+
final class DownloadDestinationResolverTests: XCTestCase {
|
|
5
|
+
private let documentsDirectory = URL(fileURLWithPath: "/var/mobile/Documents")
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
let
|
|
11
|
-
|
|
7
|
+
func testEmptyDestinationFallsBackToDocumentsPlusId() {
|
|
8
|
+
let url = DownloadDestinationResolver.resolveDestinationURL(
|
|
9
|
+
destination: nil,
|
|
10
|
+
id: "download-1",
|
|
11
|
+
documentsDirectory: documentsDirectory
|
|
12
|
+
)
|
|
12
13
|
|
|
13
|
-
XCTAssertEqual(
|
|
14
|
+
XCTAssertEqual(url, documentsDirectory.appendingPathComponent("download-1"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func testBlankDestinationFallsBackToDocumentsPlusId() {
|
|
18
|
+
let url = DownloadDestinationResolver.resolveDestinationURL(
|
|
19
|
+
destination: " ",
|
|
20
|
+
id: "download-1",
|
|
21
|
+
documentsDirectory: documentsDirectory
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
XCTAssertEqual(url, documentsDirectory.appendingPathComponent("download-1"))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func testRelativeDestinationUsesDocumentsDirectory() {
|
|
28
|
+
let url = DownloadDestinationResolver.resolveDestinationURL(
|
|
29
|
+
destination: "downloads/sample.zip",
|
|
30
|
+
id: "download-1",
|
|
31
|
+
documentsDirectory: documentsDirectory
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
XCTAssertEqual(url, documentsDirectory.appendingPathComponent("downloads/sample.zip"))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testAbsolutePathUsesFileURL() {
|
|
38
|
+
let url = DownloadDestinationResolver.resolveDestinationURL(
|
|
39
|
+
destination: "/tmp/custom-file.bin",
|
|
40
|
+
id: "download-1",
|
|
41
|
+
documentsDirectory: documentsDirectory
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
XCTAssertEqual(url, URL(fileURLWithPath: "/tmp/custom-file.bin"))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testFileURLDestinationIsUsedDirectly() {
|
|
48
|
+
let url = DownloadDestinationResolver.resolveDestinationURL(
|
|
49
|
+
destination: "file:///tmp/custom-file.bin",
|
|
50
|
+
id: "download-1",
|
|
51
|
+
documentsDirectory: documentsDirectory
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
XCTAssertEqual(url, URL(string: "file:///tmp/custom-file.bin"))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
final class DownloadDestinationStoreTests: XCTestCase {
|
|
59
|
+
private var defaults: UserDefaults!
|
|
60
|
+
private var store: DownloadDestinationStore!
|
|
61
|
+
|
|
62
|
+
override func setUp() {
|
|
63
|
+
super.setUp()
|
|
64
|
+
defaults = UserDefaults(suiteName: "CapacitorDownloaderPluginTests")!
|
|
65
|
+
defaults.removePersistentDomain(forName: "CapacitorDownloaderPluginTests")
|
|
66
|
+
store = DownloadDestinationStore(defaults: defaults)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override func tearDown() {
|
|
70
|
+
defaults.removePersistentDomain(forName: "CapacitorDownloaderPluginTests")
|
|
71
|
+
super.tearDown()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func testSetAndTakeDestination() {
|
|
75
|
+
store.setDestination("downloads/file.zip", for: "task-1")
|
|
76
|
+
|
|
77
|
+
XCTAssertEqual(store.takeDestination(for: "task-1"), "downloads/file.zip")
|
|
78
|
+
XCTAssertNil(store.takeDestination(for: "task-1"))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func testRemoveDestination() {
|
|
82
|
+
store.setDestination("downloads/file.zip", for: "task-1")
|
|
83
|
+
|
|
84
|
+
store.removeDestination(for: "task-1")
|
|
85
|
+
|
|
86
|
+
XCTAssertNil(store.takeDestination(for: "task-1"))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
final class DownloadHTTPValidatorTests: XCTestCase {
|
|
91
|
+
func testSuccessfulStatusCodes() {
|
|
92
|
+
XCTAssertTrue(DownloadHTTPValidator.isSuccessfulStatusCode(200))
|
|
93
|
+
XCTAssertTrue(DownloadHTTPValidator.isSuccessfulStatusCode(204))
|
|
94
|
+
XCTAssertTrue(DownloadHTTPValidator.isSuccessfulStatusCode(299))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func testNonSuccessfulStatusCodesAreRejected() {
|
|
98
|
+
XCTAssertFalse(DownloadHTTPValidator.isSuccessfulStatusCode(199))
|
|
99
|
+
XCTAssertFalse(DownloadHTTPValidator.isSuccessfulStatusCode(404))
|
|
100
|
+
XCTAssertFalse(DownloadHTTPValidator.isSuccessfulStatusCode(500))
|
|
14
101
|
}
|
|
15
102
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capgo/capacitor-downloader",
|
|
3
|
-
"version": "8.1
|
|
3
|
+
"version": "8.3.1",
|
|
4
4
|
"description": "Download file in background or foreground",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
"download file in foreground"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
-
"verify": "
|
|
40
|
-
"verify:ios": "
|
|
39
|
+
"verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
|
|
40
|
+
"verify:ios": "bun scripts/verify-ios.mjs",
|
|
41
41
|
"verify:android": "cd android && ./gradlew clean build test && cd ..",
|
|
42
|
-
"verify:web": "
|
|
43
|
-
"lint": "
|
|
44
|
-
"fmt": "
|
|
42
|
+
"verify:web": "bun run build",
|
|
43
|
+
"lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
|
|
44
|
+
"fmt": "bun run eslint -- --fix && bun run prettier -- --write && bun run swiftlint -- --fix --format",
|
|
45
45
|
"eslint": "eslint . --ext .ts",
|
|
46
46
|
"prettier": "prettier-pretty-check \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
|
|
47
47
|
"swiftlint": "node-swiftlint",
|
|
48
48
|
"docgen": "docgen --api CapacitorDownloaderPlugin --output-readme README.md --output-json dist/docs.json",
|
|
49
|
-
"build": "
|
|
49
|
+
"build": "bun run clean && bun run docgen && tsc && rollup -c rollup.config.mjs",
|
|
50
50
|
"clean": "rimraf ./dist",
|
|
51
51
|
"watch": "tsc --watch",
|
|
52
52
|
"prepublishOnly": "bun run build",
|