@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.
- package/README.md +66 -10
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/AndroidDownloadDestination.java +143 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/AndroidHttpEngineSelector.java +33 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java +357 -27
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationPresenter.java +91 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationStore.java +138 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationText.java +14 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletionNotifier.java +70 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/OkHttpDownloadExecutor.java +420 -0
- package/dist/docs.json +27 -3
- package/dist/esm/definitions.d.ts +51 -5
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorDownloaderPlugin/CapacitorDownloaderPlugin.swift +86 -6
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationPresenter.swift +38 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationStore.swift +74 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationText.swift +11 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadIdGenerationStore.swift +39 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadTaskGenerationStore.swift +47 -0
- package/ios/Tests/CapacitorDownloaderPluginTests/CapacitorDownloaderPluginTests.swift +121 -0
- package/package.json +1 -1
package/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: '
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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: '
|
|
60
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
87
|
+
return taskGenerations[task.taskIdentifier]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func clearTaskGeneration(for task: URLSessionTask) {
|
|
91
|
+
tasksLock.lock()
|
|
92
|
+
defer { tasksLock.unlock() }
|
|
93
|
+
taskGenerations.removeValue(forKey: task.taskIdentifier)
|
|
94
|
+
DownloadTaskGenerationStore.shared.removeGeneration(forTaskIdentifier: task.taskIdentifier)
|
|
48
95
|
}
|
|
49
96
|
|
|
50
97
|
private func idForTask(_ task: URLSessionDownloadTask) -> String? {
|
|
@@ -86,7 +133,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
let destination = call.getString("destination") ?? ""
|
|
136
|
+
let generation = nextGeneration(for: id)
|
|
89
137
|
DownloadDestinationStore.shared.setDestination(destination, for: id)
|
|
138
|
+
DownloadCompletedNotificationStore.shared.setText(
|
|
139
|
+
call.getString("downloadCompletedNotificationText"),
|
|
140
|
+
for: id,
|
|
141
|
+
generation: generation
|
|
142
|
+
)
|
|
90
143
|
|
|
91
144
|
var request = URLRequest(url: url)
|
|
92
145
|
if let headers = call.getObject("headers") as? [String: String] {
|
|
@@ -98,6 +151,7 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
98
151
|
let task = session.downloadTask(with: request)
|
|
99
152
|
task.taskDescription = id
|
|
100
153
|
setTask(task, for: id)
|
|
154
|
+
setTaskGeneration(generation, for: task)
|
|
101
155
|
task.resume()
|
|
102
156
|
|
|
103
157
|
call.resolve([
|
|
@@ -136,9 +190,13 @@ public class CapacitorDownloaderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
136
190
|
return
|
|
137
191
|
}
|
|
138
192
|
|
|
193
|
+
let generation = generation(for: id)
|
|
139
194
|
task.cancel()
|
|
140
|
-
removeTask(for: id)
|
|
195
|
+
removeTask(for: id, completing: task)
|
|
141
196
|
DownloadDestinationStore.shared.removeDestination(for: id)
|
|
197
|
+
if let generation {
|
|
198
|
+
DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
|
|
199
|
+
}
|
|
142
200
|
call.resolve()
|
|
143
201
|
}
|
|
144
202
|
|
|
@@ -203,7 +261,12 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
|
|
|
203
261
|
}
|
|
204
262
|
|
|
205
263
|
let storedDestination = DownloadDestinationStore.shared.takeDestination(for: id)
|
|
264
|
+
let generation = generation(for: downloadTask)
|
|
265
|
+
|
|
206
266
|
guard let destinationURL = destinationURL(for: id, storedDestination: storedDestination) else {
|
|
267
|
+
if let generation {
|
|
268
|
+
DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
|
|
269
|
+
}
|
|
207
270
|
notifyListeners("downloadFailed", data: ["id": id, "error": "Unable to resolve destination"])
|
|
208
271
|
return
|
|
209
272
|
}
|
|
@@ -211,6 +274,9 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
|
|
|
211
274
|
if let httpResponse = downloadTask.response as? HTTPURLResponse,
|
|
212
275
|
!DownloadHTTPValidator.isSuccessfulStatusCode(httpResponse.statusCode) {
|
|
213
276
|
try? FileManager.default.removeItem(at: location)
|
|
277
|
+
if let generation {
|
|
278
|
+
DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
|
|
279
|
+
}
|
|
214
280
|
notifyListeners(
|
|
215
281
|
"downloadFailed",
|
|
216
282
|
data: ["id": id, "error": "HTTP \(httpResponse.statusCode)"]
|
|
@@ -225,9 +291,17 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
|
|
|
225
291
|
}
|
|
226
292
|
try FileManager.default.moveItem(at: location, to: destinationURL)
|
|
227
293
|
notifyListeners("downloadCompleted", data: ["id": id])
|
|
294
|
+
if let completionText = DownloadCompletedNotificationStore.shared.takeText(for: id, generation: generation) {
|
|
295
|
+
DownloadCompletedNotificationPresenter.presentIfAllowed(downloadId: id, body: completionText)
|
|
296
|
+
}
|
|
228
297
|
} catch {
|
|
298
|
+
if let generation {
|
|
299
|
+
DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
|
|
300
|
+
}
|
|
229
301
|
notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
|
|
230
302
|
}
|
|
303
|
+
|
|
304
|
+
clearTaskGeneration(for: downloadTask)
|
|
231
305
|
}
|
|
232
306
|
|
|
233
307
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
@@ -236,15 +310,21 @@ extension CapacitorDownloaderPlugin: URLSessionDownloadDelegate {
|
|
|
236
310
|
return
|
|
237
311
|
}
|
|
238
312
|
|
|
239
|
-
|
|
313
|
+
let generation = generation(for: downloadTask)
|
|
314
|
+
removeTask(for: id, completing: downloadTask)
|
|
240
315
|
|
|
241
316
|
if error != nil {
|
|
242
317
|
_ = DownloadDestinationStore.shared.takeDestination(for: id)
|
|
243
318
|
}
|
|
244
319
|
|
|
245
|
-
if let error
|
|
320
|
+
if let error {
|
|
321
|
+
if let generation {
|
|
322
|
+
DownloadCompletedNotificationStore.shared.removeText(for: id, generation: generation)
|
|
323
|
+
}
|
|
246
324
|
notifyListeners("downloadFailed", data: ["id": id, "error": error.localizedDescription])
|
|
247
325
|
}
|
|
326
|
+
|
|
327
|
+
clearTaskGeneration(for: downloadTask)
|
|
248
328
|
}
|
|
249
329
|
|
|
250
330
|
public func urlSession(
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UserNotifications
|
|
3
|
+
|
|
4
|
+
enum DownloadCompletedNotificationPresenter {
|
|
5
|
+
static func presentIfAllowed(downloadId: String, body: String) {
|
|
6
|
+
guard let bodyText = DownloadCompletedNotificationText.normalize(body) else {
|
|
7
|
+
return
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
11
|
+
let allowed: Bool
|
|
12
|
+
switch settings.authorizationStatus {
|
|
13
|
+
case .authorized, .provisional, .ephemeral:
|
|
14
|
+
allowed = true
|
|
15
|
+
case .denied, .notDetermined:
|
|
16
|
+
allowed = false
|
|
17
|
+
@unknown default:
|
|
18
|
+
allowed = false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
guard allowed else {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let content = UNMutableNotificationContent()
|
|
26
|
+
content.body = bodyText
|
|
27
|
+
content.sound = .default
|
|
28
|
+
|
|
29
|
+
let request = UNNotificationRequest(
|
|
30
|
+
identifier: "capacitor-downloader-completed-\(downloadId)",
|
|
31
|
+
content: content,
|
|
32
|
+
trigger: nil
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
UNUserNotificationCenter.current().add(request)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class DownloadCompletedNotificationStore {
|
|
4
|
+
static let shared = DownloadCompletedNotificationStore()
|
|
5
|
+
|
|
6
|
+
static let userDefaultsKey = "CapacitorDownloaderCompletedNotificationTexts"
|
|
7
|
+
|
|
8
|
+
private let defaults: UserDefaults
|
|
9
|
+
private let lock = NSLock()
|
|
10
|
+
|
|
11
|
+
init(defaults: UserDefaults = .standard) {
|
|
12
|
+
self.defaults = defaults
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func setText(_ text: String?, for id: String, generation: UInt64) {
|
|
16
|
+
lock.lock()
|
|
17
|
+
defer { lock.unlock() }
|
|
18
|
+
var map = entriesMapUnlocked()
|
|
19
|
+
if let normalized = DownloadCompletedNotificationText.normalize(text) {
|
|
20
|
+
map[id] = Self.encodeEntry(text: normalized, generation: generation)
|
|
21
|
+
} else {
|
|
22
|
+
map.removeValue(forKey: id)
|
|
23
|
+
}
|
|
24
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func takeText(for id: String, generation: UInt64?) -> String? {
|
|
28
|
+
lock.lock()
|
|
29
|
+
defer { lock.unlock() }
|
|
30
|
+
var map = entriesMapUnlocked()
|
|
31
|
+
guard let entry = map[id] else {
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
if let generation, Self.generation(from: entry) != generation {
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
37
|
+
map.removeValue(forKey: id)
|
|
38
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
39
|
+
return Self.text(from: entry)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func removeText(for id: String, generation: UInt64?) {
|
|
43
|
+
lock.lock()
|
|
44
|
+
defer { lock.unlock() }
|
|
45
|
+
var map = entriesMapUnlocked()
|
|
46
|
+
guard let entry = map[id] else {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if let generation, Self.generation(from: entry) != generation {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
map.removeValue(forKey: id)
|
|
53
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func entriesMapUnlocked() -> [String: [String: Any]] {
|
|
57
|
+
defaults.dictionary(forKey: Self.userDefaultsKey) as? [String: [String: Any]] ?? [:]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static func encodeEntry(text: String, generation: UInt64) -> [String: Any] {
|
|
61
|
+
["text": text, "generation": NSNumber(value: generation)]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static func text(from entry: [String: Any]) -> String? {
|
|
65
|
+
entry["text"] as? String
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static func generation(from entry: [String: Any]) -> UInt64? {
|
|
69
|
+
if let number = entry["generation"] as? NSNumber {
|
|
70
|
+
return number.uint64Value
|
|
71
|
+
}
|
|
72
|
+
return entry["generation"] as? UInt64
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum DownloadCompletedNotificationText {
|
|
4
|
+
static func normalize(_ text: String?) -> String? {
|
|
5
|
+
guard let text else {
|
|
6
|
+
return nil
|
|
7
|
+
}
|
|
8
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
9
|
+
return trimmed.isEmpty ? nil : trimmed
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class DownloadIdGenerationStore {
|
|
4
|
+
static let shared = DownloadIdGenerationStore()
|
|
5
|
+
|
|
6
|
+
static let userDefaultsKey = "CapacitorDownloaderDownloadGenerations"
|
|
7
|
+
|
|
8
|
+
private let defaults: UserDefaults
|
|
9
|
+
private let lock = NSLock()
|
|
10
|
+
|
|
11
|
+
init(defaults: UserDefaults = .standard) {
|
|
12
|
+
self.defaults = defaults
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func setGeneration(_ generation: UInt64, forDownloadId id: String) {
|
|
16
|
+
lock.lock()
|
|
17
|
+
defer { lock.unlock() }
|
|
18
|
+
var map = generationsMapUnlocked()
|
|
19
|
+
map[id] = NSNumber(value: generation)
|
|
20
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func allGenerations() -> [String: UInt64] {
|
|
24
|
+
lock.lock()
|
|
25
|
+
defer { lock.unlock() }
|
|
26
|
+
var result: [String: UInt64] = [:]
|
|
27
|
+
for (key, value) in generationsMapUnlocked() {
|
|
28
|
+
guard let number = value as? NSNumber else {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
result[key] = number.uint64Value
|
|
32
|
+
}
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private func generationsMapUnlocked() -> [String: Any] {
|
|
37
|
+
defaults.dictionary(forKey: Self.userDefaultsKey) ?? [:]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class DownloadTaskGenerationStore {
|
|
4
|
+
static let shared = DownloadTaskGenerationStore()
|
|
5
|
+
|
|
6
|
+
static let userDefaultsKey = "CapacitorDownloaderTaskGenerations"
|
|
7
|
+
|
|
8
|
+
private let defaults: UserDefaults
|
|
9
|
+
private let lock = NSLock()
|
|
10
|
+
|
|
11
|
+
init(defaults: UserDefaults = .standard) {
|
|
12
|
+
self.defaults = defaults
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func setGeneration(_ generation: UInt64, forTaskIdentifier taskIdentifier: Int) {
|
|
16
|
+
lock.lock()
|
|
17
|
+
defer { lock.unlock() }
|
|
18
|
+
var map = generationsMapUnlocked()
|
|
19
|
+
map[String(taskIdentifier)] = NSNumber(value: generation)
|
|
20
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func removeGeneration(forTaskIdentifier taskIdentifier: Int) {
|
|
24
|
+
lock.lock()
|
|
25
|
+
defer { lock.unlock() }
|
|
26
|
+
var map = generationsMapUnlocked()
|
|
27
|
+
map.removeValue(forKey: String(taskIdentifier))
|
|
28
|
+
defaults.set(map, forKey: Self.userDefaultsKey)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func allGenerations() -> [Int: UInt64] {
|
|
32
|
+
lock.lock()
|
|
33
|
+
defer { lock.unlock() }
|
|
34
|
+
var result: [Int: UInt64] = [:]
|
|
35
|
+
for (key, value) in generationsMapUnlocked() {
|
|
36
|
+
guard let taskIdentifier = Int(key), let number = value as? NSNumber else {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
result[taskIdentifier] = number.uint64Value
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private func generationsMapUnlocked() -> [String: Any] {
|
|
45
|
+
defaults.dictionary(forKey: Self.userDefaultsKey) ?? [:]
|
|
46
|
+
}
|
|
47
|
+
}
|