@capgo/capacitor-downloader 8.3.21 → 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.
Files changed (22) hide show
  1. package/README.md +66 -10
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/AndroidDownloadDestination.java +143 -0
  5. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/AndroidHttpEngineSelector.java +33 -0
  6. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java +357 -27
  7. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationPresenter.java +91 -0
  8. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationStore.java +138 -0
  9. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationText.java +14 -0
  10. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletionNotifier.java +70 -0
  11. package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/OkHttpDownloadExecutor.java +420 -0
  12. package/dist/docs.json +27 -3
  13. package/dist/esm/definitions.d.ts +51 -5
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/ios/Sources/CapacitorDownloaderPlugin/CapacitorDownloaderPlugin.swift +86 -6
  16. package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationPresenter.swift +38 -0
  17. package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationStore.swift +74 -0
  18. package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationText.swift +11 -0
  19. package/ios/Sources/CapacitorDownloaderPlugin/DownloadIdGenerationStore.swift +39 -0
  20. package/ios/Sources/CapacitorDownloaderPlugin/DownloadTaskGenerationStore.swift +47 -0
  21. package/ios/Tests/CapacitorDownloaderPluginTests/CapacitorDownloaderPluginTests.swift +121 -0
  22. package/package.json +1 -1
package/dist/docs.json CHANGED
@@ -27,7 +27,7 @@
27
27
  },
28
28
  {
29
29
  "name": "example",
30
- "text": "```typescript\nconst task = await Downloader.download({\n id: 'my-download',\n url: 'https://example.com/file.pdf',\n destination: 'downloads/file.pdf',\n notification: 'hidden', // Android: no system notification; use downloadProgress events\n});\n```"
30
+ "text": "```typescript\nconst task = await Downloader.download({\n id: 'my-download',\n url: 'https://example.com/file.pdf',\n destination: 'files/offline/package.zip',\n androidHttpEngine: 'auto',\n notification: 'hidden', // Android OkHttp: no notification during download; use downloadProgress events\n downloadCompletedNotificationText: 'Your file is ready', // opt-in completion alert\n});\n```"
31
31
  }
32
32
  ],
33
33
  "docs": "Start a new download task.",
@@ -364,7 +364,7 @@
364
364
  {
365
365
  "name": "destination",
366
366
  "tags": [],
367
- "docs": "Local file path where the download will be saved",
367
+ "docs": "Local file path where the download will be saved.\n\nOn Android, relative paths without `androidHttpEngine` behave like earlier releases: every relative path,\nincluding `cache/...` and `files/...`, is stored under app external files via DownloadManager.\n\nWhen you set `androidHttpEngine` to `'auto'` or `'okhttp'`, optional roots apply:\n- `cache/...` saves under app cache (OkHttp with `'auto'`).\n- `files/...` saves under app-private internal storage (OkHttp with `'auto'`).\n- `public-downloads/...` saves to the public Downloads folder (DownloadManager).\n- Any other relative path saves under app external files (DownloadManager with `'auto'`).\nAbsolute paths and `file://` URIs are supported; `'auto'` picks OkHttp when the path is under cache, data, or files dirs.",
368
368
  "complexTypes": [],
369
369
  "type": "string"
370
370
  },
@@ -397,9 +397,33 @@
397
397
  "name": "since"
398
398
  }
399
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.",
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 DownloadManager progress notification; use plugin progress events instead.\n `downloadCompletedNotificationText` can still post a completion notification when the transfer succeeds.\n\nOn the OkHttp Android path, notifications are not shown during the transfer. With `'completed'`, a single\ncompletion notification may be posted when the file finishes (Android 13+ requires runtime\n`POST_NOTIFICATIONS` in the host app). Ignored on iOS and Web.",
401
401
  "complexTypes": [],
402
402
  "type": "'completed' | 'progress' | 'hidden' | undefined"
403
+ },
404
+ {
405
+ "name": "androidHttpEngine",
406
+ "tags": [
407
+ {
408
+ "text": "next release",
409
+ "name": "since"
410
+ }
411
+ ],
412
+ "docs": "Android HTTP stack used for the download.\n\n- Omitted: always DownloadManager with legacy destination resolution (same as releases before this option).\n- `'auto'`: OkHttp for app-private destinations (`cache/...`, `files/...`, or an absolute path under\n cache/data/files when extended roots apply); DownloadManager for legacy relative paths under app external storage\n and `public-downloads/...`.\n- `'okhttp'`: always use OkHttp for extended-root destinations. Rejects `public-downloads/...` paths. Transfers run\n in the app process and do not continue after the process is killed (unlike DownloadManager on those paths).\n- `'downloadManager'`: always use the system DownloadManager with legacy destination resolution.\n\nIgnored on iOS and Web.",
413
+ "complexTypes": [],
414
+ "type": "'auto' | 'okhttp' | 'downloadManager' | undefined"
415
+ },
416
+ {
417
+ "name": "downloadCompletedNotificationText",
418
+ "tags": [
419
+ {
420
+ "text": "next release",
421
+ "name": "since"
422
+ }
423
+ ],
424
+ "docs": "Optional localized text for a system notification when the download finishes.\n\nWhen set, iOS and Android may show a local notification after a successful download\nif the user granted notification permission. If permission is denied, the download\nstill completes and no notification is shown. Tapping the notification reopens the app.\n\nOmitted or empty means no completion notification from the plugin (use\n`downloadCompleted` events while the app is open).",
425
+ "complexTypes": [],
426
+ "type": "string | undefined"
403
427
  }
404
428
  ]
405
429
  },
@@ -18,7 +18,19 @@ export interface DownloadOptions {
18
18
  id: string;
19
19
  /** URL of the file to download */
20
20
  url: string;
21
- /** Local file path where the download will be saved */
21
+ /**
22
+ * Local file path where the download will be saved.
23
+ *
24
+ * On Android, relative paths without `androidHttpEngine` behave like earlier releases: every relative path,
25
+ * including `cache/...` and `files/...`, is stored under app external files via DownloadManager.
26
+ *
27
+ * When you set `androidHttpEngine` to `'auto'` or `'okhttp'`, optional roots apply:
28
+ * - `cache/...` saves under app cache (OkHttp with `'auto'`).
29
+ * - `files/...` saves under app-private internal storage (OkHttp with `'auto'`).
30
+ * - `public-downloads/...` saves to the public Downloads folder (DownloadManager).
31
+ * - Any other relative path saves under app external files (DownloadManager with `'auto'`).
32
+ * Absolute paths and `file://` URIs are supported; `'auto'` picks OkHttp when the path is under cache, data, or files dirs.
33
+ */
22
34
  destination: string;
23
35
  /** Optional HTTP headers to include in the request */
24
36
  headers?: {
@@ -33,13 +45,45 @@ export interface DownloadOptions {
33
45
  *
34
46
  * - `'completed'` (default): show while downloading and keep the notification after completion.
35
47
  * - `'progress'`: show while downloading, remove the notification when finished.
36
- * - `'hidden'`: no system notification; use plugin progress events instead.
48
+ * - `'hidden'`: no DownloadManager progress notification; use plugin progress events instead.
49
+ * `downloadCompletedNotificationText` can still post a completion notification when the transfer succeeds.
37
50
  *
38
- * Ignored on iOS and Web.
51
+ * On the OkHttp Android path, notifications are not shown during the transfer. With `'completed'`, a single
52
+ * completion notification may be posted when the file finishes (Android 13+ requires runtime
53
+ * `POST_NOTIFICATIONS` in the host app). Ignored on iOS and Web.
39
54
  *
40
55
  * @since 8.2.0
41
56
  */
42
57
  notification?: 'completed' | 'progress' | 'hidden';
58
+ /**
59
+ * Android HTTP stack used for the download.
60
+ *
61
+ * - Omitted: always DownloadManager with legacy destination resolution (same as releases before this option).
62
+ * - `'auto'`: OkHttp for app-private destinations (`cache/...`, `files/...`, or an absolute path under
63
+ * cache/data/files when extended roots apply); DownloadManager for legacy relative paths under app external storage
64
+ * and `public-downloads/...`.
65
+ * - `'okhttp'`: always use OkHttp for extended-root destinations. Rejects `public-downloads/...` paths. Transfers run
66
+ * in the app process and do not continue after the process is killed (unlike DownloadManager on those paths).
67
+ * - `'downloadManager'`: always use the system DownloadManager with legacy destination resolution.
68
+ *
69
+ * Ignored on iOS and Web.
70
+ *
71
+ * @since next release
72
+ */
73
+ androidHttpEngine?: 'auto' | 'okhttp' | 'downloadManager';
74
+ /**
75
+ * Optional localized text for a system notification when the download finishes.
76
+ *
77
+ * When set, iOS and Android may show a local notification after a successful download
78
+ * if the user granted notification permission. If permission is denied, the download
79
+ * still completes and no notification is shown. Tapping the notification reopens the app.
80
+ *
81
+ * Omitted or empty means no completion notification from the plugin (use
82
+ * `downloadCompleted` events while the app is open).
83
+ *
84
+ * @since next release
85
+ */
86
+ downloadCompletedNotificationText?: string;
43
87
  }
44
88
  /**
45
89
  * Capacitor plugin for downloading files with background support.
@@ -56,8 +100,10 @@ export interface CapacitorDownloaderPlugin {
56
100
  * const task = await Downloader.download({
57
101
  * id: 'my-download',
58
102
  * url: 'https://example.com/file.pdf',
59
- * destination: 'downloads/file.pdf',
60
- * notification: 'hidden', // Android: no system notification; use downloadProgress events
103
+ * destination: 'files/offline/package.zip',
104
+ * androidHttpEngine: 'auto',
105
+ * notification: 'hidden', // Android OkHttp: no notification during download; use downloadProgress events
106
+ * downloadCompletedNotificationText: 'Your file is ready', // opt-in completion alert
61
107
  * });
62
108
  * ```
63
109
  */
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * Represents the current state and progress of a download task.\n */\nexport interface DownloadTask {\n /** Unique identifier for the download task */\n id: string;\n /** Download progress from 0 to 100 */\n progress: number;\n /** Current state of the download */\n state: 'PENDING' | 'RUNNING' | 'PAUSED' | 'DONE' | 'ERROR';\n}\n\n/**\n * Configuration options for starting a download.\n */\nexport interface DownloadOptions {\n /** Unique identifier for this download task */\n id: string;\n /** URL of the file to download */\n url: string;\n /** Local file path where the download will be saved */\n destination: string;\n /** Optional HTTP headers to include in the request */\n headers?: { [key: string]: string };\n /** Network type requirement for download */\n network?: 'cellular' | 'wifi-only';\n /** Download priority level */\n priority?: 'high' | 'normal' | 'low';\n /**\n * Android DownloadManager notification visibility.\n *\n * - `'completed'` (default): show while downloading and keep the notification after completion.\n * - `'progress'`: show while downloading, remove the notification when finished.\n * - `'hidden'`: no system notification; use plugin progress events instead.\n *\n * Ignored on iOS and Web.\n *\n * @since 8.2.0\n */\n notification?: 'completed' | 'progress' | 'hidden';\n}\n\n/**\n * Capacitor plugin for downloading files with background support.\n * Provides resumable downloads with progress tracking.\n */\nexport interface CapacitorDownloaderPlugin {\n /**\n * Start a new download task.\n *\n * @param options - Download configuration\n * @returns Promise with initial download task status\n * @example\n * ```typescript\n * const task = await Downloader.download({\n * id: 'my-download',\n * url: 'https://example.com/file.pdf',\n * destination: 'downloads/file.pdf',\n * notification: 'hidden', // Android: no system notification; use downloadProgress events\n * });\n * ```\n */\n download(options: DownloadOptions): Promise<DownloadTask>;\n\n /**\n * Pause an active download.\n * Download can be resumed later from the same position.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when paused\n */\n pause(options: { id: string }): Promise<void>;\n\n /**\n * Resume a paused download.\n * Continues from where it was paused.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when resumed\n */\n resume(options: { id: string }): Promise<void>;\n\n /**\n * Stop and cancel a download permanently.\n * Downloaded data will be deleted.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when stopped\n */\n stop(options: { id: string }): Promise<void>;\n\n /**\n * Check the current status of a download.\n *\n * @param options - Options containing the download task ID\n * @returns Promise with current download task status\n */\n checkStatus(options: { id: string }): Promise<DownloadTask>;\n\n /**\n * Get information about a downloaded file.\n *\n * @param options - Options containing the file path\n * @returns Promise with file size and MIME type\n */\n getFileInfo(options: { path: string }): Promise<{ size: number; type: string }>;\n\n /**\n * Listen for download progress updates.\n * Fired periodically as download progresses.\n *\n * @param eventName - Must be 'downloadProgress'\n * @param listenerFunc - Callback receiving progress updates\n * @returns Promise with listener handle for removal\n * @example\n * ```typescript\n * const listener = await Downloader.addListener('downloadProgress', (data) => {\n * console.log(`Download ${data.id}: ${data.progress}%`);\n * });\n * ```\n */\n addListener(\n eventName: 'downloadProgress',\n listenerFunc: (progress: {\n id: string;\n progress: number;\n /** Bytes written so far */\n bytesWritten?: number;\n /** Total bytes expected, or 0 when the server did not report a length */\n bytesTotal?: number;\n }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download completion.\n * Fired when a download finishes successfully.\n *\n * @param eventName - Must be 'downloadCompleted'\n * @param listenerFunc - Callback receiving completion notification\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadCompleted',\n listenerFunc: (result: { id: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download failures.\n * Fired when a download encounters an error.\n *\n * @param eventName - Must be 'downloadFailed'\n * @param listenerFunc - Callback receiving error information\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadFailed',\n listenerFunc: (error: { id: string; error: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Remove all event listeners.\n * Cleanup method to prevent memory leaks.\n *\n * @returns Promise that resolves when all listeners removed\n */\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the plugin version number.\n *\n * @returns Promise with version string\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * Represents the current state and progress of a download task.\n */\nexport interface DownloadTask {\n /** Unique identifier for the download task */\n id: string;\n /** Download progress from 0 to 100 */\n progress: number;\n /** Current state of the download */\n state: 'PENDING' | 'RUNNING' | 'PAUSED' | 'DONE' | 'ERROR';\n}\n\n/**\n * Configuration options for starting a download.\n */\nexport interface DownloadOptions {\n /** Unique identifier for this download task */\n id: string;\n /** URL of the file to download */\n url: string;\n /**\n * Local file path where the download will be saved.\n *\n * On Android, relative paths without `androidHttpEngine` behave like earlier releases: every relative path,\n * including `cache/...` and `files/...`, is stored under app external files via DownloadManager.\n *\n * When you set `androidHttpEngine` to `'auto'` or `'okhttp'`, optional roots apply:\n * - `cache/...` saves under app cache (OkHttp with `'auto'`).\n * - `files/...` saves under app-private internal storage (OkHttp with `'auto'`).\n * - `public-downloads/...` saves to the public Downloads folder (DownloadManager).\n * - Any other relative path saves under app external files (DownloadManager with `'auto'`).\n * Absolute paths and `file://` URIs are supported; `'auto'` picks OkHttp when the path is under cache, data, or files dirs.\n */\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 DownloadManager progress notification; use plugin progress events instead.\n * `downloadCompletedNotificationText` can still post a completion notification when the transfer succeeds.\n *\n * On the OkHttp Android path, notifications are not shown during the transfer. With `'completed'`, a single\n * completion notification may be posted when the file finishes (Android 13+ requires runtime\n * `POST_NOTIFICATIONS` in the host app). Ignored on iOS and Web.\n *\n * @since 8.2.0\n */\n notification?: 'completed' | 'progress' | 'hidden';\n /**\n * Android HTTP stack used for the download.\n *\n * - Omitted: always DownloadManager with legacy destination resolution (same as releases before this option).\n * - `'auto'`: OkHttp for app-private destinations (`cache/...`, `files/...`, or an absolute path under\n * cache/data/files when extended roots apply); DownloadManager for legacy relative paths under app external storage\n * and `public-downloads/...`.\n * - `'okhttp'`: always use OkHttp for extended-root destinations. Rejects `public-downloads/...` paths. Transfers run\n * in the app process and do not continue after the process is killed (unlike DownloadManager on those paths).\n * - `'downloadManager'`: always use the system DownloadManager with legacy destination resolution.\n *\n * Ignored on iOS and Web.\n *\n * @since next release\n */\n androidHttpEngine?: 'auto' | 'okhttp' | 'downloadManager';\n /**\n * Optional localized text for a system notification when the download finishes.\n *\n * When set, iOS and Android may show a local notification after a successful download\n * if the user granted notification permission. If permission is denied, the download\n * still completes and no notification is shown. Tapping the notification reopens the app.\n *\n * Omitted or empty means no completion notification from the plugin (use\n * `downloadCompleted` events while the app is open).\n *\n * @since next release\n */\n downloadCompletedNotificationText?: string;\n}\n\n/**\n * Capacitor plugin for downloading files with background support.\n * Provides resumable downloads with progress tracking.\n */\nexport interface CapacitorDownloaderPlugin {\n /**\n * Start a new download task.\n *\n * @param options - Download configuration\n * @returns Promise with initial download task status\n * @example\n * ```typescript\n * const task = await Downloader.download({\n * id: 'my-download',\n * url: 'https://example.com/file.pdf',\n * destination: 'files/offline/package.zip',\n * androidHttpEngine: 'auto',\n * notification: 'hidden', // Android OkHttp: no notification during download; use downloadProgress events\n * downloadCompletedNotificationText: 'Your file is ready', // opt-in completion alert\n * });\n * ```\n */\n download(options: DownloadOptions): Promise<DownloadTask>;\n\n /**\n * Pause an active download.\n * Download can be resumed later from the same position.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when paused\n */\n pause(options: { id: string }): Promise<void>;\n\n /**\n * Resume a paused download.\n * Continues from where it was paused.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when resumed\n */\n resume(options: { id: string }): Promise<void>;\n\n /**\n * Stop and cancel a download permanently.\n * Downloaded data will be deleted.\n *\n * @param options - Options containing the download task ID\n * @returns Promise that resolves when stopped\n */\n stop(options: { id: string }): Promise<void>;\n\n /**\n * Check the current status of a download.\n *\n * @param options - Options containing the download task ID\n * @returns Promise with current download task status\n */\n checkStatus(options: { id: string }): Promise<DownloadTask>;\n\n /**\n * Get information about a downloaded file.\n *\n * @param options - Options containing the file path\n * @returns Promise with file size and MIME type\n */\n getFileInfo(options: { path: string }): Promise<{ size: number; type: string }>;\n\n /**\n * Listen for download progress updates.\n * Fired periodically as download progresses.\n *\n * @param eventName - Must be 'downloadProgress'\n * @param listenerFunc - Callback receiving progress updates\n * @returns Promise with listener handle for removal\n * @example\n * ```typescript\n * const listener = await Downloader.addListener('downloadProgress', (data) => {\n * console.log(`Download ${data.id}: ${data.progress}%`);\n * });\n * ```\n */\n addListener(\n eventName: 'downloadProgress',\n listenerFunc: (progress: {\n id: string;\n progress: number;\n /** Bytes written so far */\n bytesWritten?: number;\n /** Total bytes expected, or 0 when the server did not report a length */\n bytesTotal?: number;\n }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download completion.\n * Fired when a download finishes successfully.\n *\n * @param eventName - Must be 'downloadCompleted'\n * @param listenerFunc - Callback receiving completion notification\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadCompleted',\n listenerFunc: (result: { id: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for download failures.\n * Fired when a download encounters an error.\n *\n * @param eventName - Must be 'downloadFailed'\n * @param listenerFunc - Callback receiving error information\n * @returns Promise with listener handle for removal\n */\n addListener(\n eventName: 'downloadFailed',\n listenerFunc: (error: { id: string; error: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Remove all event listeners.\n * Cleanup method to prevent memory leaks.\n *\n * @returns Promise that resolves when all listeners removed\n */\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the plugin version number.\n *\n * @returns Promise with version string\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
@@ -8,7 +8,7 @@ import Capacitor
8
8
 
9
9
  @objc(CapacitorDownloaderPlugin)
10
10
  public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
11
- private let pluginVersion: String = "8.3.21"
11
+ private let pluginVersion: String = "8.5.0"
12
12
  public let identifier = "CapacitorDownloaderPlugin"
13
13
  public let jsName = "CapacitorDownloader"
14
14
  public let pluginMethods: [CAPPluginMethod] = [
@@ -22,6 +22,8 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
22
22
  ]
23
23
 
24
24
  private var tasks: [String: URLSessionDownloadTask] = [:]
25
+ private var downloadGenerations: [String: UInt64] = [:]
26
+ private var taskGenerations: [Int: UInt64] = [:]
25
27
  private let tasksLock = NSLock()
26
28
  private lazy var session: URLSession = {
27
29
  let config = URLSessionConfiguration.background(withIdentifier: "CapacitorDownloader")
@@ -29,6 +31,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
29
31
  return URLSession(configuration: config, delegate: self, delegateQueue: nil)
30
32
  }()
31
33
 
34
+ public override func load() {
35
+ tasksLock.lock()
36
+ downloadGenerations = DownloadIdGenerationStore.shared.allGenerations()
37
+ taskGenerations = DownloadTaskGenerationStore.shared.allGenerations()
38
+ tasksLock.unlock()
39
+ }
40
+
32
41
  private func setTask(_ task: URLSessionDownloadTask, for id: String) {
33
42
  tasksLock.lock()
34
43
  defer { tasksLock.unlock() }
@@ -41,10 +50,48 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
41
50
  return tasks[id]
42
51
  }
43
52
 
44
- private func removeTask(for id: String) {
53
+ private func removeTask(for id: String, completing task: URLSessionDownloadTask) {
54
+ tasksLock.lock()
55
+ defer { tasksLock.unlock() }
56
+ if tasks[id] === task {
57
+ tasks.removeValue(forKey: id)
58
+ }
59
+ taskGenerations.removeValue(forKey: task.taskIdentifier)
60
+ }
61
+
62
+ private func nextGeneration(for id: String) -> UInt64 {
63
+ tasksLock.lock()
64
+ defer { tasksLock.unlock() }
65
+ let generation = (downloadGenerations[id] ?? 0) &+ 1
66
+ downloadGenerations[id] = generation
67
+ DownloadIdGenerationStore.shared.setGeneration(generation, forDownloadId: id)
68
+ return generation
69
+ }
70
+
71
+ private func generation(for id: String) -> UInt64? {
72
+ tasksLock.lock()
73
+ defer { tasksLock.unlock() }
74
+ return downloadGenerations[id]
75
+ }
76
+
77
+ private func setTaskGeneration(_ generation: UInt64, for task: URLSessionDownloadTask) {
78
+ tasksLock.lock()
79
+ defer { tasksLock.unlock() }
80
+ taskGenerations[task.taskIdentifier] = generation
81
+ DownloadTaskGenerationStore.shared.setGeneration(generation, forTaskIdentifier: task.taskIdentifier)
82
+ }
83
+
84
+ private func generation(for task: URLSessionTask) -> UInt64? {
45
85
  tasksLock.lock()
46
86
  defer { tasksLock.unlock() }
47
- tasks.removeValue(forKey: id)
87
+ return taskGenerations[task.taskIdentifier]
88
+ }
89
+
90
+ private func clearTaskGeneration(for task: URLSessionTask) {
91
+ tasksLock.lock()
92
+ defer { tasksLock.unlock() }
93
+ taskGenerations.removeValue(forKey: task.taskIdentifier)
94
+ DownloadTaskGenerationStore.shared.removeGeneration(forTaskIdentifier: task.taskIdentifier)
48
95
  }
49
96
 
50
97
  private func idForTask(_ task: URLSessionDownloadTask) -> String? {
@@ -86,7 +133,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
86
133
  }
87
134
 
88
135
  let destination = call.getString("destination") ?? ""
136
+ let generation = nextGeneration(for: id)
89
137
  DownloadDestinationStore.shared.setDestination(destination, for: id)
138
+ DownloadCompletedNotificationStore.shared.setText(
139
+ call.getString("downloadCompletedNotificationText"),
140
+ for: id,
141
+ generation: generation
142
+ )
90
143
 
91
144
  var request = URLRequest(url: url)
92
145
  if let headers = call.getObject("headers") as? [String: String] {
@@ -98,6 +151,7 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
98
151
  let task = session.downloadTask(with: request)
99
152
  task.taskDescription = id
100
153
  setTask(task, for: id)
154
+ setTaskGeneration(generation, for: task)
101
155
  task.resume()
102
156
 
103
157
  call.resolve([
@@ -136,9 +190,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
136
190
  return
137
191
  }
138
192
 
193
+ let generation = generation(for: id)
139
194
  task.cancel()
140
- removeTask(for: id)
195
+ removeTask(for: id, completing: task)
141
196
  DownloadDestinationStore.shared.removeDestination(for: id)
197
+ if let generation {
198
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
199
+ }
142
200
  call.resolve()
143
201
  }
144
202
 
@@ -203,7 +261,12 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
203
261
  }
204
262
 
205
263
  let storedDestination = DownloadDestinationStore.shared.takeDestination(for: id)
264
+ let generation = generation(for: downloadTask)
265
+
206
266
  guard let destinationURL = destinationURL(for: id, storedDestination: storedDestination) else {
267
+ if let generation {
268
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
269
+ }
207
270
  notifyListeners("downloadFailed", data: ["id": id, "error": "Unable to resolve destination"])
208
271
  return
209
272
  }
@@ -211,6 +274,9 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
211
274
  if let httpResponse = downloadTask.response as? HTTPURLResponse,
212
275
  !DownloadHTTPValidator.isSuccessfulStatusCode(httpResponse.statusCode) {
213
276
  try? FileManager.default.removeItem(at: location)
277
+ if let generation {
278
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
279
+ }
214
280
  notifyListeners(
215
281
  "downloadFailed",
216
282
  data: ["id": id, "error": "HTTP \(httpResponse.statusCode)"]
@@ -225,9 +291,17 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
225
291
  }
226
292
  try FileManager.default.moveItem(at: location, to: destinationURL)
227
293
  notifyListeners("downloadCompleted", data: ["id": id])
294
+ if let completionText = DownloadCompletedNotificationStore.shared.takeText(for: id, generation: generation) {
295
+ DownloadCompletedNotificationPresenter.presentIfAllowed(downloadId: id, body: completionText)
296
+ }
228
297
  } catch {
298
+ if let generation {
299
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
300
+ }
229
301
  notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
230
302
  }
303
+
304
+ clearTaskGeneration(for: downloadTask)
231
305
  }
232
306
 
233
307
  public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
@@ -236,15 +310,21 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
236
310
  return
237
311
  }
238
312
 
239
- removeTask(for: id)
313
+ let generation = generation(for: downloadTask)
314
+ removeTask(for: id, completing: downloadTask)
240
315
 
241
316
  if error != nil {
242
317
  _ = DownloadDestinationStore.shared.takeDestination(for: id)
243
318
  }
244
319
 
245
- if let error = error {
320
+ if let error {
321
+ if let generation {
322
+ DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
323
+ }
246
324
  notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
247
325
  }
326
+
327
+ clearTaskGeneration(for: downloadTask)
248
328
  }
249
329
 
250
330
  public func urlSession(
@@ -0,0 +1,38 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ enum DownloadCompletedNotificationPresenter {
5
+ static func presentIfAllowed(downloadId: String, body: String) {
6
+ guard let bodyText = DownloadCompletedNotificationText.normalize(body) else {
7
+ return
8
+ }
9
+
10
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
11
+ let allowed: Bool
12
+ switch settings.authorizationStatus {
13
+ case .authorized, .provisional, .ephemeral:
14
+ allowed = true
15
+ case .denied, .notDetermined:
16
+ allowed = false
17
+ @unknown default:
18
+ allowed = false
19
+ }
20
+
21
+ guard allowed else {
22
+ return
23
+ }
24
+
25
+ let content = UNMutableNotificationContent()
26
+ content.body = bodyText
27
+ content.sound = .default
28
+
29
+ let request = UNNotificationRequest(
30
+ identifier: "capacitor-downloader-completed-\(downloadId)",
31
+ content: content,
32
+ trigger: nil
33
+ )
34
+
35
+ UNUserNotificationCenter.current().add(request)
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,74 @@
1
+ import Foundation
2
+
3
+ final class DownloadCompletedNotificationStore {
4
+ static let shared = DownloadCompletedNotificationStore()
5
+
6
+ static let userDefaultsKey = "CapacitorDownloaderCompletedNotificationTexts"
7
+
8
+ private let defaults: UserDefaults
9
+ private let lock = NSLock()
10
+
11
+ init(defaults: UserDefaults = .standard) {
12
+ self.defaults = defaults
13
+ }
14
+
15
+ func setText(_ text: String?, for id: String, generation: UInt64) {
16
+ lock.lock()
17
+ defer { lock.unlock() }
18
+ var map = entriesMapUnlocked()
19
+ if let normalized = DownloadCompletedNotificationText.normalize(text) {
20
+ map[id] = Self.encodeEntry(text: normalized, generation: generation)
21
+ } else {
22
+ map.removeValue(forKey: id)
23
+ }
24
+ defaults.set(map, forKey: Self.userDefaultsKey)
25
+ }
26
+
27
+ func takeText(for id: String, generation: UInt64?) -> String? {
28
+ lock.lock()
29
+ defer { lock.unlock() }
30
+ var map = entriesMapUnlocked()
31
+ guard let entry = map[id] else {
32
+ return nil
33
+ }
34
+ if let generation, Self.generation(from: entry) != generation {
35
+ return nil
36
+ }
37
+ map.removeValue(forKey: id)
38
+ defaults.set(map, forKey: Self.userDefaultsKey)
39
+ return Self.text(from: entry)
40
+ }
41
+
42
+ func removeText(for id: String, generation: UInt64?) {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ var map = entriesMapUnlocked()
46
+ guard let entry = map[id] else {
47
+ return
48
+ }
49
+ if let generation, Self.generation(from: entry) != generation {
50
+ return
51
+ }
52
+ map.removeValue(forKey: id)
53
+ defaults.set(map, forKey: Self.userDefaultsKey)
54
+ }
55
+
56
+ private func entriesMapUnlocked() -> [String: [String: Any]] {
57
+ defaults.dictionary(forKey: Self.userDefaultsKey) as? [String: [String: Any]] ?? [:]
58
+ }
59
+
60
+ private static func encodeEntry(text: String, generation: UInt64) -> [String: Any] {
61
+ ["text": text, "generation": NSNumber(value: generation)]
62
+ }
63
+
64
+ private static func text(from entry: [String: Any]) -> String? {
65
+ entry["text"] as? String
66
+ }
67
+
68
+ private static func generation(from entry: [String: Any]) -> UInt64? {
69
+ if let number = entry["generation"] as? NSNumber {
70
+ return number.uint64Value
71
+ }
72
+ return entry["generation"] as? UInt64
73
+ }
74
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ enum DownloadCompletedNotificationText {
4
+ static func normalize(_ text: String?) -> String? {
5
+ guard let text else {
6
+ return nil
7
+ }
8
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
9
+ return trimmed.isEmpty ? nil : trimmed
10
+ }
11
+ }
@@ -0,0 +1,39 @@
1
+ import Foundation
2
+
3
+ final class DownloadIdGenerationStore {
4
+ static let shared = DownloadIdGenerationStore()
5
+
6
+ static let userDefaultsKey = "CapacitorDownloaderDownloadGenerations"
7
+
8
+ private let defaults: UserDefaults
9
+ private let lock = NSLock()
10
+
11
+ init(defaults: UserDefaults = .standard) {
12
+ self.defaults = defaults
13
+ }
14
+
15
+ func setGeneration(_ generation: UInt64, forDownloadId id: String) {
16
+ lock.lock()
17
+ defer { lock.unlock() }
18
+ var map = generationsMapUnlocked()
19
+ map[id] = NSNumber(value: generation)
20
+ defaults.set(map, forKey: Self.userDefaultsKey)
21
+ }
22
+
23
+ func allGenerations() -> [String: UInt64] {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ var result: [String: UInt64] = [:]
27
+ for (key, value) in generationsMapUnlocked() {
28
+ guard let number = value as? NSNumber else {
29
+ continue
30
+ }
31
+ result[key] = number.uint64Value
32
+ }
33
+ return result
34
+ }
35
+
36
+ private func generationsMapUnlocked() -> [String: Any] {
37
+ defaults.dictionary(forKey: Self.userDefaultsKey) ?? [:]
38
+ }
39
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+
3
+ final class DownloadTaskGenerationStore {
4
+ static let shared = DownloadTaskGenerationStore()
5
+
6
+ static let userDefaultsKey = "CapacitorDownloaderTaskGenerations"
7
+
8
+ private let defaults: UserDefaults
9
+ private let lock = NSLock()
10
+
11
+ init(defaults: UserDefaults = .standard) {
12
+ self.defaults = defaults
13
+ }
14
+
15
+ func setGeneration(_ generation: UInt64, forTaskIdentifier taskIdentifier: Int) {
16
+ lock.lock()
17
+ defer { lock.unlock() }
18
+ var map = generationsMapUnlocked()
19
+ map[String(taskIdentifier)] = NSNumber(value: generation)
20
+ defaults.set(map, forKey: Self.userDefaultsKey)
21
+ }
22
+
23
+ func removeGeneration(forTaskIdentifier taskIdentifier: Int) {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ var map = generationsMapUnlocked()
27
+ map.removeValue(forKey: String(taskIdentifier))
28
+ defaults.set(map, forKey: Self.userDefaultsKey)
29
+ }
30
+
31
+ func allGenerations() -> [Int: UInt64] {
32
+ lock.lock()
33
+ defer { lock.unlock() }
34
+ var result: [Int: UInt64] = [:]
35
+ for (key, value) in generationsMapUnlocked() {
36
+ guard let taskIdentifier = Int(key), let number = value as? NSNumber else {
37
+ continue
38
+ }
39
+ result[taskIdentifier] = number.uint64Value
40
+ }
41
+ return result
42
+ }
43
+
44
+ private func generationsMapUnlocked() -> [String: Any] {
45
+ defaults.dictionary(forKey: Self.userDefaultsKey) ?? [:]
46
+ }
47
+ }