@capgo/capacitor-downloader 8.1.32 → 8.3.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.
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 | Description |
197
- | ------------------ | --------------------------------------------------------------------- | ------------------------------------- |
198
- | **`eventName`** | <code>'downloadProgress'</code> | - Must be 'downloadProgress' |
199
- | **`listenerFunc`** | <code>(progress: { id: string; progress: number; }) =&gt; 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; }) =&gt; void</code> | - Callback receiving progress updates |
200
200
 
201
201
  **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</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 | Type | Description |
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 |
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
@@ -1,2 +1,3 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
2
3
  </manifest>
@@ -23,7 +23,7 @@ import java.util.Map;
23
23
  @CapacitorPlugin(name = "CapacitorDownloader")
24
24
  public class CapacitorDownloaderPlugin extends Plugin {
25
25
 
26
- private final String pluginVersion = "8.1.32";
26
+ private final String pluginVersion = "8.3.0";
27
27
 
28
28
  private DownloadManager downloadManager;
29
29
  private final Map<String, Long> downloads = new HashMap<>();
@@ -76,7 +76,7 @@ public class CapacitorDownloaderPlugin extends Plugin {
76
76
  }
77
77
 
78
78
  DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url))
79
- .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
79
+ .setNotificationVisibility(DownloadNotificationVisibility.resolve(call.getString("notification")))
80
80
  .setAllowedOverMetered(true)
81
81
  .setAllowedOverRoaming(true);
82
82
 
@@ -100,7 +100,17 @@ public class CapacitorDownloaderPlugin extends Plugin {
100
100
  request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE | DownloadManager.Request.NETWORK_WIFI);
101
101
  }
102
102
 
103
- long downloadId = downloadManager.enqueue(request);
103
+ long downloadId;
104
+ try {
105
+ downloadId = downloadManager.enqueue(request);
106
+ } catch (SecurityException e) {
107
+ if ("hidden".equals(call.getString("notification"))) {
108
+ call.reject("Hidden downloads require android.permission.DOWNLOAD_WITHOUT_NOTIFICATION in the app manifest", e);
109
+ } else {
110
+ call.reject("Download could not be enqueued due to missing permission", e);
111
+ }
112
+ return;
113
+ }
104
114
  downloads.put(id, downloadId);
105
115
 
106
116
  JSObject result = new JSObject();
@@ -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: { id: string; progress: number }) => 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\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.32"
11
+ private let pluginVersion: String = "8.3.0"
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
- let destinationURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent(id) else {
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(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
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 progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
205
- notifyListeners("downloadProgress", data: ["id": id, "progress": progress])
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
+ }
@@ -0,0 +1,7 @@
1
+ import Foundation
2
+
3
+ enum DownloadHTTPValidator {
4
+ static func isSuccessfulStatusCode(_ statusCode: Int) -> Bool {
5
+ statusCode >= 200 && statusCode < 300
6
+ }
7
+ }
@@ -1,15 +1,102 @@
1
1
  import XCTest
2
2
  @testable import CapacitorDownloaderPlugin
3
3
 
4
- class CapacitorDownloaderTests: XCTestCase {
5
- func testEcho() {
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
- let implementation = CapacitorDownloader()
10
- let value = "Hello, World!"
11
- let result = implementation.echo(value)
7
+ func testEmptyDestinationFallsBackToDocumentsPlusId() {
8
+ let url = DownloadDestinationResolver.resolveDestinationURL(
9
+ destination: nil,
10
+ id: "download-1",
11
+ documentsDirectory: documentsDirectory
12
+ )
12
13
 
13
- XCTAssertEqual(value, result)
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.32",
3
+ "version": "8.3.0",
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": "npm run verify:ios && npm run verify:android && npm run verify:web",
40
- "verify:ios": "xcodebuild -scheme CapgoCapacitorDownloader -destination generic/platform=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": "npm run build",
43
- "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
44
- "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
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": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
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",