@capgo/capacitor-downloader 8.4.1 → 8.5.0

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.
@@ -27,13 +27,15 @@ 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.4.1";
30
+ private final String pluginVersion = "8.5.0";
31
31
 
32
32
  private DownloadManager downloadManager;
33
33
  private final Map<String, Long> downloads = new ConcurrentHashMap<>();
34
+ private final Map<String, File> okHttpDestinationFiles = new ConcurrentHashMap<>();
34
35
  private final Map<String, Long> downloadGenerations = new ConcurrentHashMap<>();
35
36
  private final AtomicLong pendingDownloadSequence = new AtomicLong(0);
36
37
  private ExecutorService downloadManagerExecutor;
38
+ private OkHttpDownloadExecutor okHttpDownloadExecutor;
37
39
  private final Handler handler = new Handler(Looper.getMainLooper());
38
40
  private BroadcastReceiver downloadReceiver;
39
41
  private DownloadCompletedNotificationStore completedNotificationStore;
@@ -42,6 +44,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
42
44
  public void load() {
43
45
  downloadManager = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
44
46
  downloadManagerExecutor = Executors.newCachedThreadPool();
47
+ okHttpDownloadExecutor = new OkHttpDownloadExecutor(getContext());
45
48
  completedNotificationStore = new DownloadCompletedNotificationStore(getContext());
46
49
  registerDownloadReceiver();
47
50
  }
@@ -75,11 +78,15 @@ public class CapacitorDownloaderPlugin extends Plugin {
75
78
  }
76
79
 
77
80
  private static boolean isPendingDownload(long downloadId) {
78
- return downloadId < 0;
81
+ return downloadId < 0 && downloadId != OkHttpDownloadExecutor.TRACKED_DOWNLOAD_ID;
79
82
  }
80
83
 
81
84
  private long reservePendingDownload() {
82
- return -pendingDownloadSequence.incrementAndGet();
85
+ long token;
86
+ do {
87
+ token = -pendingDownloadSequence.incrementAndGet();
88
+ } while (token == OkHttpDownloadExecutor.TRACKED_DOWNLOAD_ID);
89
+ return token;
83
90
  }
84
91
 
85
92
  private boolean isTrackedDownload(String id, long systemDownloadId) {
@@ -87,6 +94,11 @@ public class CapacitorDownloaderPlugin extends Plugin {
87
94
  return trackedDownloadId != null && trackedDownloadId.longValue() == systemDownloadId;
88
95
  }
89
96
 
97
+ private boolean isCurrentOkHttpDownload(String id) {
98
+ Long trackedDownloadId = downloads.get(id);
99
+ return trackedDownloadId != null && isOkHttpDownload(trackedDownloadId);
100
+ }
101
+
90
102
  private long nextGeneration(String id) {
91
103
  while (true) {
92
104
  Long current = downloadGenerations.get(id);
@@ -105,6 +117,21 @@ public class CapacitorDownloaderPlugin extends Plugin {
105
117
  return downloadGenerations.get(id);
106
118
  }
107
119
 
120
+ private static boolean usesExtendedDestinationRoots(String androidHttpEngine) {
121
+ if (androidHttpEngine == null || androidHttpEngine.isEmpty()) {
122
+ return false;
123
+ }
124
+ if ("downloadManager".equalsIgnoreCase(androidHttpEngine) || "download_manager".equalsIgnoreCase(androidHttpEngine)) {
125
+ return false;
126
+ }
127
+ return true;
128
+ }
129
+
130
+ private boolean isCurrentOkHttpGeneration(String id, long expectedGeneration) {
131
+ Long current = generationFor(id);
132
+ return current != null && current.longValue() == expectedGeneration;
133
+ }
134
+
108
135
  @PluginMethod
109
136
  public void download(PluginCall call) {
110
137
  String id = call.getString("id");
@@ -121,7 +148,71 @@ public class CapacitorDownloaderPlugin extends Plugin {
121
148
  return;
122
149
  }
123
150
 
124
- final String notification = call.getString("notification");
151
+ final boolean extendedDestinationRoots = usesExtendedDestinationRoots(call.getString("androidHttpEngine"));
152
+
153
+ final AndroidDownloadDestination.ResolvedDestination resolvedDestination;
154
+ try {
155
+ resolvedDestination = AndroidDownloadDestination.resolve(getContext(), destination, extendedDestinationRoots);
156
+ } catch (IllegalArgumentException e) {
157
+ call.reject(e.getMessage(), e);
158
+ return;
159
+ }
160
+
161
+ final AndroidHttpEngineSelector.Engine httpEngine;
162
+ try {
163
+ httpEngine = AndroidHttpEngineSelector.resolve(call.getString("androidHttpEngine"), resolvedDestination.preferOkHttpInAutoMode);
164
+ } catch (IllegalArgumentException e) {
165
+ call.reject(e.getMessage(), e);
166
+ return;
167
+ }
168
+
169
+ if (
170
+ httpEngine == AndroidHttpEngineSelector.Engine.OKHTTP &&
171
+ extendedDestinationRoots &&
172
+ destination.trim().startsWith("public-downloads/")
173
+ ) {
174
+ call.reject("OkHttp cannot write to public-downloads/ paths. Use androidHttpEngine 'auto' or 'downloadManager'.");
175
+ return;
176
+ }
177
+
178
+ final String completionNotificationText = call.getString("downloadCompletedNotificationText");
179
+
180
+ if (httpEngine == AndroidHttpEngineSelector.Engine.OKHTTP) {
181
+ downloadWithOkHttp(
182
+ call,
183
+ id,
184
+ url,
185
+ resolvedDestination.file,
186
+ call.getObject("headers"),
187
+ call.getString("network"),
188
+ call.getString("notification"),
189
+ completionNotificationText
190
+ );
191
+ return;
192
+ }
193
+
194
+ downloadWithDownloadManager(
195
+ call,
196
+ id,
197
+ url,
198
+ resolvedDestination.file,
199
+ call.getObject("headers"),
200
+ call.getString("network"),
201
+ call.getString("notification"),
202
+ completionNotificationText
203
+ );
204
+ }
205
+
206
+ private void downloadWithDownloadManager(
207
+ PluginCall call,
208
+ String id,
209
+ String url,
210
+ File destinationFile,
211
+ JSObject headers,
212
+ String network,
213
+ String notification,
214
+ String completionNotificationText
215
+ ) {
125
216
  final DownloadManager.Request request;
126
217
  try {
127
218
  request = new DownloadManager.Request(Uri.parse(url))
@@ -129,11 +220,9 @@ public class CapacitorDownloaderPlugin extends Plugin {
129
220
  .setAllowedOverMetered(true)
130
221
  .setAllowedOverRoaming(true);
131
222
 
132
- File destinationFile = new File(getContext().getExternalFilesDir(null), destination);
133
223
  Uri destinationUri = Uri.fromFile(destinationFile);
134
224
  request.setDestinationUri(destinationUri);
135
225
 
136
- JSObject headers = call.getObject("headers");
137
226
  if (headers != null) {
138
227
  for (Iterator<String> it = headers.keys(); it.hasNext(); ) {
139
228
  String key = it.next();
@@ -141,7 +230,6 @@ public class CapacitorDownloaderPlugin extends Plugin {
141
230
  }
142
231
  }
143
232
 
144
- String network = call.getString("network");
145
233
  if ("wifi-only".equals(network)) {
146
234
  request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
147
235
  } else {
@@ -158,7 +246,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
158
246
  return;
159
247
  }
160
248
  final long downloadGeneration = nextGeneration(id);
161
- completedNotificationStore.setText(id, call.getString("downloadCompletedNotificationText"), downloadGeneration);
249
+ completedNotificationStore.setText(id, completionNotificationText, downloadGeneration);
162
250
  runDownloadManagerWork(
163
251
  call,
164
252
  () -> {
@@ -205,7 +293,6 @@ public class CapacitorDownloaderPlugin extends Plugin {
205
293
  result.put("status", DownloadManager.STATUS_PENDING);
206
294
  call.resolve(result);
207
295
 
208
- // Start a periodic progress check
209
296
  startProgressCheck(id, downloadId);
210
297
  },
211
298
  () -> {
@@ -215,6 +302,137 @@ public class CapacitorDownloaderPlugin extends Plugin {
215
302
  );
216
303
  }
217
304
 
305
+ private void downloadWithOkHttp(
306
+ PluginCall call,
307
+ String id,
308
+ String url,
309
+ File destinationFile,
310
+ JSObject headers,
311
+ String network,
312
+ String notification,
313
+ String completionNotificationText
314
+ ) {
315
+ if (notification == null) {
316
+ notification = "completed";
317
+ }
318
+
319
+ final long pendingToken = reservePendingDownload();
320
+ if (downloads.putIfAbsent(id, pendingToken) != null) {
321
+ call.reject("Download already exists");
322
+ return;
323
+ }
324
+ okHttpDestinationFiles.put(id, destinationFile);
325
+ final long downloadGeneration = nextGeneration(id);
326
+ completedNotificationStore.setText(id, completionNotificationText, downloadGeneration);
327
+
328
+ final String notificationMode = notification;
329
+ final long storedGeneration = downloadGeneration;
330
+ runDownloadManagerWork(
331
+ call,
332
+ () -> {
333
+ if (!isTrackedDownload(id, pendingToken)) {
334
+ okHttpDestinationFiles.remove(id);
335
+ completedNotificationStore.remove(id, storedGeneration);
336
+ call.reject("Download was cancelled");
337
+ return;
338
+ }
339
+
340
+ if (!okHttpDownloadExecutor.isWifiSatisfied(network)) {
341
+ downloads.remove(id, pendingToken);
342
+ okHttpDestinationFiles.remove(id);
343
+ completedNotificationStore.remove(id, storedGeneration);
344
+ call.reject("Wi-Fi required for this download");
345
+ return;
346
+ }
347
+
348
+ if (!okHttpDownloadExecutor.beginTransfer(id)) {
349
+ downloads.remove(id, pendingToken);
350
+ okHttpDestinationFiles.remove(id);
351
+ completedNotificationStore.remove(id, storedGeneration);
352
+ call.reject("Download already exists");
353
+ return;
354
+ }
355
+
356
+ if (!downloads.replace(id, pendingToken, OkHttpDownloadExecutor.TRACKED_DOWNLOAD_ID)) {
357
+ okHttpDownloadExecutor.abandonTransfer(id);
358
+ okHttpDestinationFiles.remove(id);
359
+ completedNotificationStore.remove(id, storedGeneration);
360
+ call.reject("Download was cancelled");
361
+ return;
362
+ }
363
+
364
+ JSObject result = new JSObject();
365
+ result.put("id", id);
366
+ result.put("status", DownloadManager.STATUS_PENDING);
367
+ call.resolve(result);
368
+
369
+ try {
370
+ okHttpDownloadExecutor.start(
371
+ id,
372
+ url,
373
+ destinationFile,
374
+ headers,
375
+ network,
376
+ (downloadId, progress) ->
377
+ handler.post(() -> {
378
+ if (!isCurrentOkHttpDownload(downloadId) || !isCurrentOkHttpGeneration(downloadId, storedGeneration)) {
379
+ return;
380
+ }
381
+ JSObject progressData = new JSObject();
382
+ progressData.put("id", downloadId);
383
+ progressData.put("progress", progress);
384
+ notifyListeners("downloadProgress", progressData);
385
+ }),
386
+ (downloadId, success, cancelled, errorMessage) ->
387
+ handler.post(() -> {
388
+ if (!isCurrentOkHttpDownload(downloadId) || !isCurrentOkHttpGeneration(downloadId, storedGeneration)) {
389
+ return;
390
+ }
391
+ if (cancelled) {
392
+ return;
393
+ }
394
+ if (success) {
395
+ JSObject completedData = new JSObject();
396
+ completedData.put("id", downloadId);
397
+ notifyListeners("downloadCompleted", completedData);
398
+ DownloadCompletionNotifier.notifyIfRequested(getContext(), downloadId, notificationMode, true);
399
+ String completionText = completedNotificationStore.takeText(downloadId, storedGeneration);
400
+ if (completionText != null) {
401
+ DownloadCompletedNotificationPresenter.showIfAllowed(getContext(), downloadId, completionText);
402
+ }
403
+ } else {
404
+ okHttpDestinationFiles.remove(downloadId, destinationFile);
405
+ completedNotificationStore.remove(downloadId, storedGeneration);
406
+ JSObject failedData = new JSObject();
407
+ failedData.put("id", downloadId);
408
+ failedData.put("error", errorMessage != null ? errorMessage : "Download failed");
409
+ notifyListeners("downloadFailed", failedData);
410
+ }
411
+ })
412
+ );
413
+ } catch (RuntimeException e) {
414
+ okHttpDownloadExecutor.abandonTransfer(id);
415
+ downloads.remove(id);
416
+ okHttpDestinationFiles.remove(id);
417
+ completedNotificationStore.remove(id, storedGeneration);
418
+ JSObject failedData = new JSObject();
419
+ failedData.put("id", id);
420
+ failedData.put("error", e.getMessage());
421
+ notifyListeners("downloadFailed", failedData);
422
+ }
423
+ },
424
+ () -> {
425
+ downloads.remove(id, pendingToken);
426
+ okHttpDestinationFiles.remove(id);
427
+ completedNotificationStore.remove(id, storedGeneration);
428
+ }
429
+ );
430
+ }
431
+
432
+ private boolean isOkHttpDownload(long trackedDownloadId) {
433
+ return trackedDownloadId == OkHttpDownloadExecutor.TRACKED_DOWNLOAD_ID;
434
+ }
435
+
218
436
  private void startProgressCheck(final String id, final long downloadId) {
219
437
  runProgressQuery(id, true, downloadId);
220
438
  }
@@ -232,13 +450,13 @@ public class CapacitorDownloaderPlugin extends Plugin {
232
450
  return;
233
451
  }
234
452
 
235
- final Long capturedGeneration = generationFor(id);
236
-
237
453
  ExecutorService executor = downloadManagerExecutor;
238
454
  if (executor == null) {
239
455
  return;
240
456
  }
241
457
 
458
+ final Long capturedGeneration = generationFor(id);
459
+
242
460
  try {
243
461
  executor.execute(() -> {
244
462
  ProgressSnapshot snapshot = queryProgressSnapshot(id, systemDownloadId);
@@ -362,6 +580,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
362
580
  }
363
581
 
364
582
  if (isPendingDownload(downloadId)) {
583
+ okHttpDestinationFiles.remove(id);
365
584
  if (stoppedGeneration != null) {
366
585
  completedNotificationStore.remove(id, stoppedGeneration);
367
586
  }
@@ -369,6 +588,17 @@ public class CapacitorDownloaderPlugin extends Plugin {
369
588
  return;
370
589
  }
371
590
 
591
+ if (isOkHttpDownload(downloadId)) {
592
+ File destinationFile = okHttpDestinationFiles.remove(id);
593
+ boolean cancelled = okHttpDownloadExecutor.cancel(id, destinationFile);
594
+ okHttpDownloadExecutor.clearCompleted(id);
595
+ if (stoppedGeneration != null) {
596
+ completedNotificationStore.remove(id, stoppedGeneration);
597
+ }
598
+ call.resolve(new JSObject().put("removed", cancelled));
599
+ return;
600
+ }
601
+
372
602
  final long systemDownloadId = downloadId;
373
603
  final String stoppedId = id;
374
604
  final long generation = stoppedGeneration != null ? stoppedGeneration.longValue() : 0L;
@@ -404,6 +634,25 @@ public class CapacitorDownloaderPlugin extends Plugin {
404
634
  return;
405
635
  }
406
636
 
637
+ if (isOkHttpDownload(trackedDownloadId)) {
638
+ OkHttpDownloadExecutor.ActiveDownload active = okHttpDownloadExecutor.getActive(id);
639
+ JSObject result = new JSObject();
640
+ result.put("id", id);
641
+ if (active == null) {
642
+ call.reject("Download not found");
643
+ return;
644
+ } else {
645
+ result.put("status", mapOkHttpStateToDownloadManagerStatus(active.state));
646
+ result.put("bytesDownloaded", active.bytesWritten);
647
+ result.put("bytesTotal", active.bytesTotal);
648
+ if (active.state == OkHttpDownloadExecutor.State.ERROR && active.errorMessage != null) {
649
+ result.put("reasonText", active.errorMessage);
650
+ }
651
+ }
652
+ call.resolve(result);
653
+ return;
654
+ }
655
+
407
656
  final long downloadId = trackedDownloadId;
408
657
  runDownloadManagerWork(call, () -> {
409
658
  DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
@@ -469,6 +718,22 @@ public class CapacitorDownloaderPlugin extends Plugin {
469
718
  return result;
470
719
  }
471
720
 
721
+ private int mapOkHttpStateToDownloadManagerStatus(OkHttpDownloadExecutor.State state) {
722
+ switch (state) {
723
+ case PENDING:
724
+ return DownloadManager.STATUS_PENDING;
725
+ case RUNNING:
726
+ return DownloadManager.STATUS_RUNNING;
727
+ case DONE:
728
+ return DownloadManager.STATUS_SUCCESSFUL;
729
+ case ERROR:
730
+ case CANCELLED:
731
+ return DownloadManager.STATUS_FAILED;
732
+ default:
733
+ return DownloadManager.STATUS_PENDING;
734
+ }
735
+ }
736
+
472
737
  private String getReasonText(int status, int reason) {
473
738
  if (status == DownloadManager.STATUS_FAILED) {
474
739
  switch (reason) {
@@ -0,0 +1,70 @@
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.content.Context;
7
+ import android.content.pm.PackageManager;
8
+ import android.os.Build;
9
+ import androidx.core.app.NotificationCompat;
10
+ import androidx.core.content.ContextCompat;
11
+
12
+ /**
13
+ * Optional completion-only notifications for OkHttp downloads (DownloadManager has its own).
14
+ */
15
+ public final class DownloadCompletionNotifier {
16
+
17
+ private static final String CHANNEL_ID = "capgo_capacitor_downloader";
18
+ private static final int NOTIFICATION_ID_BASE = 0x44554c44; // "DOWNL"
19
+
20
+ private DownloadCompletionNotifier() {}
21
+
22
+ public static void notifyIfRequested(Context context, String downloadId, String notificationMode, boolean success) {
23
+ if (context == null) {
24
+ return;
25
+ }
26
+ if (!"completed".equals(notificationMode)) {
27
+ return;
28
+ }
29
+
30
+ if (!canPostNotifications(context)) {
31
+ return;
32
+ }
33
+
34
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
35
+ if (notificationManager == null) {
36
+ return;
37
+ }
38
+
39
+ ensureChannel(context, notificationManager);
40
+
41
+ String title = success ? "Download complete" : "Download failed";
42
+ String text = success ? "Finished: " + downloadId : "Failed: " + downloadId;
43
+
44
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
45
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
46
+ .setContentTitle(title)
47
+ .setContentText(text)
48
+ .setAutoCancel(true)
49
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT);
50
+
51
+ int notificationId = NOTIFICATION_ID_BASE ^ (downloadId != null ? downloadId.hashCode() : 0);
52
+ notificationManager.notify(notificationId, builder.build());
53
+ }
54
+
55
+ private static boolean canPostNotifications(Context context) {
56
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
57
+ return true;
58
+ }
59
+ return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED;
60
+ }
61
+
62
+ private static void ensureChannel(Context context, NotificationManager notificationManager) {
63
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
64
+ return;
65
+ }
66
+ NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Capacitor Downloader", NotificationManager.IMPORTANCE_DEFAULT);
67
+ channel.setDescription("Download completion notifications");
68
+ notificationManager.createNotificationChannel(channel);
69
+ }
70
+ }