[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint background_downloader.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'background_downloader'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'background_downloader/Sources/background_downloader/**/*.swift'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '14.0'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.resource_bundles = {'background_downloader_privacy' => ['background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy']}
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import Foundation
|
||||
import PackageDescription
|
||||
|
||||
var swiftSettings: [SwiftSetting] = []
|
||||
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_NOTIFICATIONS"] == "1" {
|
||||
swiftSettings.append(.define("BYPASS_PERMISSION_NOTIFICATIONS"))
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"] == "1" {
|
||||
swiftSettings.append(.define("BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"))
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"] == "1" {
|
||||
swiftSettings.append(.define("BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"))
|
||||
}
|
||||
|
||||
let package = Package(
|
||||
name: "background_downloader",
|
||||
platforms: [
|
||||
.iOS("14.0")
|
||||
],
|
||||
products: [
|
||||
.library(name: "background-downloader", targets: ["background_downloader"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "FlutterFramework", path: "../FlutterFramework")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "background_downloader",
|
||||
dependencies: [
|
||||
.product(name: "FlutterFramework", package: "FlutterFramework")
|
||||
],
|
||||
resources: [
|
||||
.process("PrivacyInfo.xcprivacy"),
|
||||
],
|
||||
swiftSettings: swiftSettings.isEmpty ? nil : swiftSettings
|
||||
)
|
||||
]
|
||||
)
|
||||
third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Auth.swift
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// Auth.swift
|
||||
//
|
||||
// Created by Bram on 11/8/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Auth: Codable, Hashable {
|
||||
var accessToken: String?
|
||||
var accessHeaders: [String: String] = [:]
|
||||
var accessQueryParams: [String: String] = [:]
|
||||
var accessTokenExpiryTime: Int64?
|
||||
var refreshToken: String?
|
||||
var refreshHeaders: [String: String] = [:]
|
||||
var refreshQueryParams: [String: String] = [:]
|
||||
var refreshUrl: String?
|
||||
var onAuthRawHandle: Int64?
|
||||
|
||||
/**
|
||||
* Returns the headers specified for the access request, with the
|
||||
* template `{accessToken}` and `{refreshToken}` replaced.
|
||||
*/
|
||||
func getExpandedAccessHeaders() -> [String: String] {
|
||||
return expandMap(accessHeaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query parameters specified for the access request, with the
|
||||
* template `{accessToken}` and `{refreshToken}` replaced.
|
||||
*/
|
||||
func getExpandedAccessQueryParams() -> [String: String] {
|
||||
return expandMap(accessQueryParams)
|
||||
}
|
||||
|
||||
/// Add/update the query parameters in this `url` with `queryParams`
|
||||
/// and return the new URI.
|
||||
func addOrUpdateQueryParams(
|
||||
url: String,
|
||||
queryParams: [String: String] = [:]
|
||||
) -> URL {
|
||||
guard let startUri = URL(string: url) else {
|
||||
// Handle invalid URL gracefully, e.g., return the original URL or throw an error
|
||||
return URL(string: url)!
|
||||
}
|
||||
guard !queryParams.isEmpty else { return startUri }
|
||||
|
||||
var components = URLComponents(url: startUri, resolvingAgainstBaseURL: false)!
|
||||
var updatedQueryParams = components.queryItems?.reduce(into: [String: String]()) { (result, item) in
|
||||
result[item.name] = item.value
|
||||
} ?? [:]
|
||||
|
||||
for (key, value) in queryParams {
|
||||
updatedQueryParams[key] = value
|
||||
}
|
||||
|
||||
components.queryItems = updatedQueryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
return components.url!
|
||||
}
|
||||
|
||||
/// Returns true if the `accessTokenExpiryTime` is after now plus
|
||||
/// the `bufferTime`, otherwise returns false.
|
||||
func isTokenExpired(bufferTime: Int64 = 10000) -> Bool {
|
||||
guard let expiry = accessTokenExpiryTime else { return false }
|
||||
let expiryTimeWithBuffer = Int64(Date().timeIntervalSince1970 * 1000) + bufferTime
|
||||
return expiryTimeWithBuffer > expiry
|
||||
}
|
||||
|
||||
/// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
|
||||
///
|
||||
/// Returns the expanded map, without changing the original
|
||||
private func expandMap(_ mapToExpand: [String: String]) -> [String: String] {
|
||||
var newMap: [String: String] = [:]
|
||||
for (key, value) in mapToExpand {
|
||||
var newValue = value
|
||||
if let accessToken = accessToken {
|
||||
newValue = newValue.replacingOccurrences(of: "{accessToken}", with: accessToken)
|
||||
}
|
||||
if let refreshToken = refreshToken {
|
||||
newValue = newValue.replacingOccurrences(of: "{refreshToken}", with: refreshToken)
|
||||
}
|
||||
newMap[key] = newValue
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
/// True if this `Auth` object has an 'onAuth' callback that can be called to refresh the
|
||||
/// access token.
|
||||
func hasOnAuthCallback() -> Bool {
|
||||
return onAuthRawHandle != nil
|
||||
}
|
||||
}
|
||||
+1044
File diff suppressed because it is too large.
Load diff
+16
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// BackgroundDownloaderPlugin.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 1/9/25.
|
||||
//
|
||||
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@objc(BackgroundDownloaderPlugin) // Keep the Objective-C name for compatibility
|
||||
public class BackgroundDownloaderPlugin: NSObject, FlutterPlugin {
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
BDPlugin.register(with: registrar)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Callbacks.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 10/26/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Flutter
|
||||
import os.log
|
||||
|
||||
import UIKit
|
||||
|
||||
/// Helper function to get the callback channel with retry
|
||||
private func getCallbackChannel() async -> FlutterMethodChannel? {
|
||||
var retries = 0
|
||||
while BDPlugin.callbackChannel == nil && retries < 5 {
|
||||
try? await _Concurrency.Task.sleep(nanoseconds: 100_000_000) // 0.1 second
|
||||
retries += 1
|
||||
}
|
||||
return BDPlugin.callbackChannel
|
||||
}
|
||||
|
||||
/// Invoke a callback in Dart (via the method channel) and return the returned Task value or nil
|
||||
private func invokeCallback(withMethod methodName: String, forTask task: Task) async -> Any? {
|
||||
guard let callbackChannel = await getCallbackChannel() else {
|
||||
os_log("Could not invoke %{public}@ for taskId %@: callbackChannel not set", log: log, type: .error, methodName, task.taskId)
|
||||
return nil
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
os_log("Invoking %{public}@ for taskId %@", log: log, type: .info, methodName, task.taskId)
|
||||
callbackChannel.invokeMethod(methodName, arguments: jsonStringFor(task: task), result: { result in
|
||||
guard let jsonString = result as? String else {
|
||||
if let error = result as? FlutterError {
|
||||
os_log("Error invoking %{public}@: %@", log: log, type: .error, methodName, error.message ?? "nil")
|
||||
}
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
if methodName == "beforeTaskStartCallback" {
|
||||
continuation.resume(returning: try? JSONDecoder().decode(TaskStatusUpdate.self, from: (jsonString).data(using: .utf8)!))
|
||||
}
|
||||
else {
|
||||
continuation.resume(returning: taskFrom(jsonString: jsonString))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil
|
||||
func invokeBeforeTaskStartCallback(task: Task) async -> TaskStatusUpdate? {
|
||||
return await invokeCallback(withMethod: "beforeTaskStartCallback", forTask: task) as? TaskStatusUpdate
|
||||
}
|
||||
|
||||
/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil
|
||||
func invokeOnTaskStartCallback(task: Task) async -> Task? {
|
||||
return await invokeCallback(withMethod: "onTaskStartCallback", forTask: task) as? Task
|
||||
}
|
||||
|
||||
/// Invoke the onAuthCallback in Dart (via the method channel) and return the returned Task value or nil
|
||||
func invokeOnAuthCallback(task: Task) async -> Task? {
|
||||
return await invokeCallback(withMethod: "onAuthCallback", forTask: task) as? Task
|
||||
}
|
||||
|
||||
/// Invoke the onTaskFinishedCallback in Dart (via the method channel) and return true if successful
|
||||
func invokeOnTaskFinishedCallback(taskStatusUpdate: TaskStatusUpdate) async -> Bool {
|
||||
guard let callbackChannel = await getCallbackChannel() else {
|
||||
os_log("Could not invoke onTaskFinishedCallback: callbackChannel not set", log: log, type: .error)
|
||||
return false
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
callbackChannel.invokeMethod("onTaskFinishedCallback", arguments: jsonStringFor(taskStatusUpdate: taskStatusUpdate), result: { result in
|
||||
if let error = result as? FlutterError {
|
||||
os_log("Error invoking onTaskFinishedCallback: %@", log: log, type: .error, error.message ?? "nil")
|
||||
continuation.resume(returning: false)
|
||||
} else {
|
||||
continuation.resume(returning: true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// Helpers.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 12/31/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import os.log
|
||||
|
||||
extension URL {
|
||||
/// Uses .appending for iOS 16 and up, and .appendingPathComponent
|
||||
/// for earlier versions
|
||||
func appendingPath(_ component: String, isDirectory: Bool = false) -> URL {
|
||||
if #available(iOS 16.0, *) {
|
||||
return appending(path: component, directoryHint: isDirectory ? .isDirectory : .notDirectory)
|
||||
} else {
|
||||
return appendingPathComponent(component, isDirectory: isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
/// Excludes URL from backup
|
||||
mutating func setCloudBackup(exclude: Bool) throws {
|
||||
var resource = URLResourceValues()
|
||||
resource.isExcludedFromBackup = exclude
|
||||
try self.setResourceValues(resource)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the task's URL if it can be parsed, otherwise null
|
||||
func validateUrl(_ task: Task) -> URL? {
|
||||
let url: URL?
|
||||
// encodingInvalidCharacters is only available when compiling with Xcode 15, which uses Swift version 5.9
|
||||
#if swift(>=5.9)
|
||||
if #available(iOS 17.0, *) {
|
||||
url = URL(string: task.url, encodingInvalidCharacters: false)
|
||||
} else {
|
||||
url = URL(string: task.url)
|
||||
}
|
||||
#else
|
||||
url = URL(string: task.url)
|
||||
#endif
|
||||
return url
|
||||
}
|
||||
|
||||
/// Converts the [map] to a [String:String] map with lowercased keys
|
||||
func lowerCasedStringStringMap(_ map: [AnyHashable: Any]?) -> [String: String]? {
|
||||
if map == nil {return nil}
|
||||
var result: [String: String] = [:]
|
||||
for (key, value) in map! {
|
||||
if let stringKey = key as? String, let stringValue = value as? String {
|
||||
result[stringKey.lowercased()] = stringValue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Create a temp file that contains a subset of the [fileURL] based on [start] and [contentLength]
|
||||
///
|
||||
/// Returns the URL of the temp file, or nil if there was a problem (problem is logged)
|
||||
func createTempFileWithRange(from fileURL: URL, start: UInt64, contentLength: UInt64) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.temporaryDirectory
|
||||
let tempFileURL = tempDir.appendingPathComponent(UUID().uuidString) // Create a unique temporary file
|
||||
|
||||
// Create the temporary file
|
||||
fileManager.createFile(atPath: tempFileURL.path, contents: nil, attributes: nil)
|
||||
guard let inputHandle = try? FileHandle(forReadingFrom: fileURL),
|
||||
let outputHandle = try? FileHandle(forWritingTo: tempFileURL) else {
|
||||
os_log("Cannot open file handles for partial upload temporary file creation", log: log, type: .error)
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
try? inputHandle.close()
|
||||
try? outputHandle.close()
|
||||
}
|
||||
let bufferSize = 1024 * 1024 // 1MB chunks
|
||||
var remainingBytes = contentLength
|
||||
do {
|
||||
// Seek to the start position
|
||||
try inputHandle.seek(toOffset: start)
|
||||
while remainingBytes > 0 {
|
||||
let bytesToRead = Int(min(UInt64(bufferSize), remainingBytes))
|
||||
guard let data = try inputHandle.read(upToCount: bytesToRead), !data.isEmpty else {
|
||||
break // EOF
|
||||
}
|
||||
try outputHandle.write(contentsOf: data)
|
||||
remainingBytes -= UInt64(data.count)
|
||||
}
|
||||
} catch {
|
||||
os_log("Cannot create temporary file for partial upload: %@", log: log, type: .error,
|
||||
error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
return tempFileURL
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Returns mimetype of a filename based on its extension, or application/octet-stream
|
||||
*/
|
||||
func getMimeType(fromFilename filename: String) -> String {
|
||||
// Extract the file extension from the filename
|
||||
let fileExtension = (filename as NSString).pathExtension
|
||||
if let type = UTType(filenameExtension: fileExtension) {
|
||||
return type.preferredMIMEType ?? "application/octet-stream"
|
||||
}
|
||||
// Default MIME type if unable to determine
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
/// Returns the root view controller of the app, or nil if it cannot be found
|
||||
/// Handles both the old AppDelegate (window) and the new SceneDelegate (connectedScenes) ways of getting the window
|
||||
func getRootViewController() -> UIViewController? {
|
||||
if #available(iOS 13, *) {
|
||||
let window = UIApplication.shared.connectedScenes
|
||||
.filter { $0.activationState == .foregroundActive }
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow }
|
||||
if let root = window?.rootViewController {
|
||||
return root
|
||||
}
|
||||
}
|
||||
return UIApplication.shared.keyWindow?.rootViewController ?? UIApplication.shared.delegate?.window??.rootViewController
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
//
|
||||
// HoldingQueue.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 3/16/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
/**
|
||||
* Queue that holds [EnqueueItem] items before they are actually enqueued as a URLSessionTask
|
||||
*
|
||||
* Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items
|
||||
* can be enqueued simultaneously.
|
||||
*
|
||||
* Call:
|
||||
* [add] to add an [EnqueueItem]
|
||||
* [taskFinished] for all tasks that finish, so we may start a new one
|
||||
* [cancelAllTasks] to empty the queue (sends status updates)
|
||||
* [cancelTasksWithIds] to remove specific tasks (sends status updates)
|
||||
* [allTasks] for a list of [Task] matching a group
|
||||
* [taskForId] to get the task for a specific taskId
|
||||
*/
|
||||
class HoldingQueue {
|
||||
var maxConcurrent: Int = 1000000
|
||||
var maxConcurrentByHost: Int = 1000000
|
||||
var maxConcurrentByGroup: Int = 1000000
|
||||
var enqueuedTaskIds = [String]()
|
||||
|
||||
private var concurrent = 0
|
||||
private var concurrentByHost = [String: Int]()
|
||||
private var concurrentByGroup = [String: Int]()
|
||||
|
||||
private var queue = [EnqueueItem]() // Using an array as a substitute for a priority queue
|
||||
|
||||
let stateLock = AsyncLock()
|
||||
private var job: DispatchWorkItem? = nil // for advanceQueue in future
|
||||
|
||||
/**
|
||||
* Add [EnqueueItem] [item] to the queue and advance the queue if possible
|
||||
*/
|
||||
func add(item: EnqueueItem) async {
|
||||
await stateLock.lock()
|
||||
queue.append(item)
|
||||
queue.sort()
|
||||
enqueuedTaskIds.append(item.task.taskId)
|
||||
await registerEnqueue(task: item.task,
|
||||
notificationConfigJsonString: item.notificationConfigJsonString,
|
||||
success: true) // for accurate groupnotification count
|
||||
advanceQueue()
|
||||
await stateLock.unlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to the holdingQueue that a [task] has finished
|
||||
*
|
||||
* Adjusts the state variables and advances the queue
|
||||
*
|
||||
* To prevent deadlock when called when the lock is already obtained, set [reEntry]
|
||||
*/
|
||||
func taskFinished(_ task: Task, reEntry: Bool = false) async {
|
||||
if !reEntry {
|
||||
await stateLock.lock()
|
||||
}
|
||||
let host = getHost(task)
|
||||
concurrent -= 1
|
||||
concurrentByHost[host]? -= 1
|
||||
concurrentByGroup[task.group]? -= 1
|
||||
if let index = enqueuedTaskIds.firstIndex(of: task.taskId) {
|
||||
enqueuedTaskIds.remove(at: index)
|
||||
}
|
||||
advanceQueue()
|
||||
if (!reEntry) {
|
||||
await stateLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all [EnqueueItem] where their taskId is in [taskIds], sends a
|
||||
* [TaskStatus.canceled] update and returns a list of
|
||||
* taskIds that were cancelled this way.
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func cancelTasksWithIds(_ taskIds: [String]) -> [String] {
|
||||
let toRemove = queue.filter( { taskIds.contains($0.task.taskId) } )
|
||||
toRemove.forEach { item in
|
||||
processStatusUpdate(task: item.task, status: .canceled)
|
||||
if let configString = item.notificationConfigJsonString,
|
||||
let config = notificationConfigFrom(jsonString: configString) {
|
||||
_Concurrency.Task {
|
||||
await updateGroupNotification(task: item.task, notificationType: .canceled, notificationConfig: config)
|
||||
}
|
||||
}
|
||||
os_log("Canceled task with id %@", log: log, type: .info, item.task.taskId)
|
||||
}
|
||||
queue.removeAll(where: { taskIds.contains($0.task.taskId)})
|
||||
return toRemove.map { $0.task.taskId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (delete) all [EnqueueItem] matching [group], send a
|
||||
* [TaskStatus.canceled] for each and return the number of items cancelled
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func cancelAllTasks(group: String) -> Int {
|
||||
let taskIds = queue.filter({ $0.task.group == group }).map { $0.task.taskId }
|
||||
return cancelTasksWithIds(taskIds).count
|
||||
}
|
||||
|
||||
/**
|
||||
* Return task matching [taskId], or null
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func taskForId(_ taskId: String) -> Task? {
|
||||
let tasks = queue.filter( { $0.task.taskId == taskId } ).map { $0.task }
|
||||
if !tasks.isEmpty {
|
||||
return tasks.first
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of [Task] for this [group]. If [group] is nil al tasks are returned
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func allTasks(group: String?) -> [Task] {
|
||||
return queue.filter( { group == nil || $0.task.group == group } ).map { $0.task }
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the queue by signalling the queue processing coroutine
|
||||
*
|
||||
* Also restarts a timer that will advance the queue in 10 seconds, in case
|
||||
* it dries up
|
||||
*/
|
||||
private func advanceQueue() {
|
||||
DispatchQueue.global().async {
|
||||
_Concurrency.Task {
|
||||
await self.processQueue()
|
||||
}
|
||||
}
|
||||
advanceQueueInFuture()
|
||||
}
|
||||
|
||||
private func advanceQueueInFuture() {
|
||||
job?.cancel()
|
||||
job = DispatchWorkItem {
|
||||
_Concurrency.Task {
|
||||
await self.calculateState()
|
||||
self.advanceQueue()
|
||||
}
|
||||
}
|
||||
guard let job = job else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 10, execute: job)
|
||||
|
||||
}
|
||||
|
||||
/// Processes items in the queue, if possible
|
||||
private func processQueue() async {
|
||||
await stateLock.lock()
|
||||
var itemsToEnqueue = [EnqueueItem]()
|
||||
while concurrent < maxConcurrent && !queue.isEmpty {
|
||||
var mustWait = [EnqueueItem]()
|
||||
var foundItem: EnqueueItem? = nil
|
||||
while !queue.isEmpty {
|
||||
let item = queue.removeFirst()
|
||||
let host = getHost(item.task)
|
||||
if (concurrentByHost[host] ?? 0) < maxConcurrentByHost &&
|
||||
(concurrentByGroup[item.task.group] ?? 0) < maxConcurrentByGroup {
|
||||
concurrent += 1
|
||||
concurrentByHost[host, default: 0] += 1
|
||||
concurrentByGroup[item.task.group, default: 0] += 1
|
||||
foundItem = item
|
||||
break
|
||||
} else {
|
||||
mustWait.append(item)
|
||||
}
|
||||
}
|
||||
queue.append(contentsOf: mustWait)
|
||||
queue.sort()
|
||||
if let item = foundItem {
|
||||
itemsToEnqueue.append(item)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
await stateLock.unlock()
|
||||
|
||||
// Enqueue items outside the stateLock to avoid blocking during network HEAD requests
|
||||
for item in itemsToEnqueue {
|
||||
await item.enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values
|
||||
*
|
||||
* This is expensive, so is only done initially and when the [advanceQueueInFuture] timer
|
||||
* fires
|
||||
*/
|
||||
private func calculateState() async {
|
||||
await stateLock.lock()
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else {
|
||||
await stateLock.unlock()
|
||||
return
|
||||
}
|
||||
let tasks: [Task] = urlSessionTasks.filter({ $0.state != .completed }).compactMap({ getTaskFrom(urlSessionTask: $0)})
|
||||
concurrent = tasks.count
|
||||
concurrentByHost.removeAll()
|
||||
concurrentByGroup.removeAll()
|
||||
for task in tasks {
|
||||
let host = getHost(task)
|
||||
concurrentByHost[host, default: 0] += 1
|
||||
concurrentByGroup[task.group, default: 0] += 1
|
||||
}
|
||||
await stateLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Holds data related to enqueueing a task
|
||||
*
|
||||
* Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued)
|
||||
* and in the context of the [HoldingQueue]
|
||||
*/
|
||||
struct EnqueueItem : Comparable {
|
||||
let task: Task
|
||||
let notificationConfigJsonString: String?
|
||||
let resumeDataAsBase64String: String
|
||||
let created = Date()
|
||||
|
||||
// Comparable implementation to sort based on task priority and creation time
|
||||
static func < (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool {
|
||||
return lhs.task.priority == rhs.task.priority ? lhs.task.creationTime < rhs.task.creationTime : lhs.task.priority < rhs.task.priority
|
||||
}
|
||||
|
||||
static func == (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool {
|
||||
return lhs.task.priority == rhs.task.priority && lhs.task.creationTime == rhs.task.creationTime
|
||||
}
|
||||
|
||||
func enqueue() async {
|
||||
let success = await BDPlugin.instance.doEnqueue(taskJsonString: jsonStringFor(task: task) ?? "", notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: resumeDataAsBase64String)
|
||||
if !success {
|
||||
os_log("Delayed or retried enqueue failed for taskId %@", log: log, type: .info, task.taskId)
|
||||
processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .general, description: "Delayed or retried enqueue failed"))
|
||||
await BDPlugin.holdingQueue?.taskFinished(task, reEntry: true)
|
||||
// register the failure with the notification service (for accurate groupnotification count)
|
||||
await registerEnqueue(task: task, notificationConfigJsonString: notificationConfigJsonString, success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Traditional lock for asyn/await environment
|
||||
actor AsyncLock {
|
||||
private var isLocked = false
|
||||
|
||||
func lock() async {
|
||||
while isLocked {
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
isLocked = true
|
||||
}
|
||||
|
||||
func unlock() async {
|
||||
isLocked = false
|
||||
}
|
||||
}
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
//
|
||||
// Notifications.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 3/19/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
|
||||
/// NotificationContents
|
||||
struct NotificationContents : Codable {
|
||||
let title: String
|
||||
let body: String
|
||||
|
||||
init(json: [String:Any]) {
|
||||
title = json["title"] as? String ?? ""
|
||||
body = json["body"] as? String ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationConfig : Codable {
|
||||
let running: NotificationContents?
|
||||
let complete: NotificationContents?
|
||||
let error: NotificationContents?
|
||||
let paused: NotificationContents?
|
||||
let canceled: NotificationContents?
|
||||
let progressBar: Bool
|
||||
let tapOpensFile: Bool
|
||||
let groupNotificationId: String
|
||||
}
|
||||
|
||||
enum NotificationType : Int {
|
||||
case running,
|
||||
complete,
|
||||
error,
|
||||
paused,
|
||||
canceled
|
||||
}
|
||||
|
||||
/// Data and methods related to a notification for a group of tasks
|
||||
actor GroupNotification {
|
||||
private static var registry: [String : GroupNotification] = [:]
|
||||
private static let registryLock = NSLock()
|
||||
|
||||
/// Atomically gets (or creates and registers) the notification for this
|
||||
/// group. Serialised via a lock: this is called concurrently from
|
||||
/// [updateGroupNotification] for every task status update, and
|
||||
/// unsynchronised access to the shared dictionary corrupts it
|
||||
/// (EXC_BAD_ACCESS).
|
||||
static func groupNotification(named name: String, notificationConfig: NotificationConfig) -> GroupNotification {
|
||||
registryLock.lock()
|
||||
defer { registryLock.unlock() }
|
||||
if let existing = registry[name] {
|
||||
return existing
|
||||
}
|
||||
let created = GroupNotification(name: name, notificationConfig: notificationConfig)
|
||||
registry[name] = created
|
||||
return created
|
||||
}
|
||||
|
||||
/// Removes the notification for this group from the registry
|
||||
static func remove(named name: String) {
|
||||
registryLock.lock()
|
||||
defer { registryLock.unlock() }
|
||||
registry.removeValue(forKey: name)
|
||||
}
|
||||
|
||||
let name: String
|
||||
let notificationConfig: NotificationConfig
|
||||
|
||||
private var notifications: [Task : NotificationType]
|
||||
|
||||
init(name: String, notificationConfig: NotificationConfig) {
|
||||
self.name = name
|
||||
self.notificationConfig = notificationConfig
|
||||
self.notifications = [:]
|
||||
}
|
||||
|
||||
/// NotificationId derived from group name
|
||||
var notificationId: String {
|
||||
get {
|
||||
return "groupNotification:\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of notifications in this group
|
||||
var numTotal: Int {
|
||||
get {
|
||||
return notifications.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress expressed as [numFinished]/[numTotal], except
|
||||
/// return 2.0 if numTotal is 0, to suggest that progress
|
||||
/// is undetermined
|
||||
var progress: Double {
|
||||
get {
|
||||
if numTotal == 0 {
|
||||
return 2.0
|
||||
} else {
|
||||
return Double(numFinished) / Double(numTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of "finished" notifications in this group.
|
||||
///
|
||||
/// A "finished" notification is one that is not .running,
|
||||
/// so includes .complete, .error, .paused
|
||||
///
|
||||
var numFinished: Int {
|
||||
get {
|
||||
return notifications.filter { (_, v) in v != NotificationType.running }.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of "failed" notifications in this group.
|
||||
///
|
||||
/// A "failed" notification is one of type .error
|
||||
///
|
||||
var numFailed: Int {
|
||||
get {
|
||||
return notifications.filter { (_, v) in v == NotificationType.error }.count
|
||||
}
|
||||
}
|
||||
|
||||
/// True if all tasks finished, regardless of outcome
|
||||
var isFinished: Bool {
|
||||
get {
|
||||
return numFinished == numTotal
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Return true if this group has an error
|
||||
///
|
||||
var hasError: Bool {
|
||||
get {
|
||||
return numFailed > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a Set of running tasks in this notificationGroup
|
||||
var runningTasks: Set<Task> {
|
||||
get {
|
||||
return Set(notifications.filter { (_, notificationType) in
|
||||
notificationType == NotificationType.running
|
||||
}.keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// Int representing this group's state. If this number
|
||||
/// does not change, the group state did not change.
|
||||
///
|
||||
/// State is determined by the number of finished notifications
|
||||
/// and the number of total notifications
|
||||
private var groupState: Int {
|
||||
get {
|
||||
return 1000 * numTotal + numFinished
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a [task] and [notificationType] to this group,
|
||||
/// and return True if this led to change in [groupState]
|
||||
func update(task: Task, notificationType: NotificationType) -> Bool {
|
||||
guard notifications[task] != notificationType else {
|
||||
return false
|
||||
}
|
||||
let priorState = groupState
|
||||
notifications[task] = notificationType
|
||||
return priorState != groupState
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum NotificationCategory : String, CaseIterable {
|
||||
case runningWithPause = "running_with_pause";
|
||||
case runningWithoutPause = "running_without_pause";
|
||||
case paused = "paused"
|
||||
case complete = "complete"
|
||||
case error = "error"
|
||||
case canceled = "canceled"
|
||||
}
|
||||
|
||||
/// List of all category identifiers
|
||||
let ourCategories = NotificationCategory.allCases.map { $0.rawValue }
|
||||
|
||||
/// Update notification for this [task], based on [notificationType] and [notificationConfig]
|
||||
func updateNotification(task: Task, notificationType: NotificationType, notificationConfig: NotificationConfig?) {
|
||||
_Concurrency.Task { @MainActor in // run using concurrency on main thread
|
||||
if (await getPermissionStatus(for: .notifications)) != .granted {
|
||||
return
|
||||
}
|
||||
if !BDPlugin.haveregisteredNotificationCategories {
|
||||
registerNotificationCategories()
|
||||
BDPlugin.haveregisteredNotificationCategories = true
|
||||
}
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
if notificationConfig?.groupNotificationId.isEmpty ?? true {
|
||||
// regular notification
|
||||
var notification: NotificationContents?
|
||||
switch notificationType {
|
||||
case .running:
|
||||
notification = notificationConfig?.running
|
||||
case .complete:
|
||||
notification = notificationConfig?.complete
|
||||
case .error:
|
||||
notification = notificationConfig?.error
|
||||
case .paused:
|
||||
notification = notificationConfig?.paused
|
||||
case .canceled:
|
||||
notification = notificationConfig?.canceled
|
||||
}
|
||||
if notification == nil {
|
||||
// remove a previous notification for this task (e.g. 'running'), as on Android
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: [task.taskId])
|
||||
return
|
||||
}
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = await replaceTokens(input: notification!.title, task: task)
|
||||
content.body = await replaceTokens(input: notification!.body, task: task)
|
||||
content.userInfo = [
|
||||
"task": jsonStringFor(task: task) ?? "",
|
||||
"notificationConfig": jsonStringFor(notificationConfig: notificationConfig!) ?? "",
|
||||
"notificationType": notificationType.rawValue
|
||||
]
|
||||
addNotificationActions(task: task, notificationType: notificationType, content: content, notificationConfig: notificationConfig!)
|
||||
let request = UNNotificationRequest(identifier: task.taskId,
|
||||
content: content, trigger: nil)
|
||||
do {
|
||||
try await notificationCenter.add(request)
|
||||
} catch {
|
||||
os_log("Notification error %@", log: log, type: .info, error.localizedDescription)
|
||||
}
|
||||
} else {
|
||||
// group notification
|
||||
await updateGroupNotification(task: task, notificationType: notificationType, notificationConfig: notificationConfig!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update notification for this [taskWorker] in group
|
||||
* [notificationGroupName] and type [notificationType].
|
||||
*
|
||||
* A group notification aggregates the state of all tasks in a group and
|
||||
* presents a notification based on that value
|
||||
*/
|
||||
func updateGroupNotification(
|
||||
task: Task,
|
||||
notificationType: NotificationType,
|
||||
notificationConfig: NotificationConfig
|
||||
) async {
|
||||
let groupNotificationId = notificationConfig.groupNotificationId
|
||||
let groupNotification = GroupNotification.groupNotification(named: groupNotificationId, notificationConfig: notificationConfig)
|
||||
let stateChange = await groupNotification.update(task: task, notificationType: notificationType)
|
||||
if stateChange {
|
||||
// need to update the group notification
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let hasError = await groupNotification.hasError
|
||||
let isFinished = await groupNotification.isFinished
|
||||
var notification: NotificationContents?
|
||||
if isFinished {
|
||||
if hasError {
|
||||
notification = groupNotification.notificationConfig.error
|
||||
} else {
|
||||
notification = groupNotification.notificationConfig.complete
|
||||
}
|
||||
} else {
|
||||
notification = groupNotification.notificationConfig.running
|
||||
}
|
||||
let notificationId = await groupNotification.notificationId
|
||||
if let notification = notification {
|
||||
// need to show a notification
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = await replaceTokens(input: notification.title, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification)
|
||||
content.body = await replaceTokens(input: notification.body, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification)
|
||||
// check if the notification title or body have changed relative to what may
|
||||
// already be delivered, to avoid flashing notifications without change
|
||||
var shouldPost = isFinished
|
||||
if !shouldPost {
|
||||
let existingNotifications = await notificationCenter.deliveredNotifications()
|
||||
let previousNotification = existingNotifications.filter {
|
||||
$0.request.identifier == notificationId
|
||||
}
|
||||
shouldPost = previousNotification.isEmpty || previousNotification.first?.request.content.title != content.title || previousNotification.first?.request.content.body != content.body
|
||||
}
|
||||
if shouldPost
|
||||
{
|
||||
content.categoryIdentifier = !isFinished
|
||||
? NotificationCategory.runningWithoutPause.rawValue
|
||||
: (hasError ? NotificationCategory.error.rawValue : NotificationCategory.complete.rawValue)
|
||||
let request = UNNotificationRequest(identifier: notificationId,
|
||||
content: content, trigger: nil)
|
||||
do {
|
||||
try await notificationCenter.add(request)
|
||||
} catch {
|
||||
os_log("Notification error %@", log: log, type: .info, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// remove notification
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: [notificationId])
|
||||
}
|
||||
if isFinished {
|
||||
// remove only if not re-activated within 5 seconds
|
||||
try? await _Concurrency.Task.sleep(nanoseconds: 5_000_000_000)
|
||||
if await groupNotification.isFinished {
|
||||
GroupNotification.remove(named: groupNotificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Register that [task] was enqueued, with [success] or failure
|
||||
///
|
||||
/// This is only relevant for tasks that are part of a group notification, so that the
|
||||
/// 'numTotal' count is based on enqueued tasks, not on running tasks (which may be limited
|
||||
/// by holdingQueue or OS restrictions).
|
||||
/// [notificationConfigJsonString] is only used to detect if this task has a group notification
|
||||
func registerEnqueue(task: Task, notificationConfigJsonString: String?, success: Bool) async {
|
||||
guard let notificationConfigJsonString = notificationConfigJsonString,
|
||||
let notificationConfig = notificationConfigFrom(jsonString: notificationConfigJsonString),
|
||||
!notificationConfig.groupNotificationId.isEmpty
|
||||
else { return }
|
||||
await updateGroupNotification(task: task, notificationType: success ? .running : .error, notificationConfig: notificationConfig)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Add action buttons to the notification
|
||||
///
|
||||
/// Which button(s) depends on the [notificationType]. Action buttons are defined when defining the notification categories
|
||||
func addNotificationActions(task: Task, notificationType: NotificationType, content: UNMutableNotificationContent, notificationConfig: NotificationConfig) {
|
||||
BDPlugin.propertyLock.withLock( {
|
||||
switch notificationType {
|
||||
case .running:
|
||||
content.categoryIdentifier = BDPlugin.taskIdsThatCanResume.contains(task.taskId) && notificationConfig.paused != nil ? NotificationCategory.runningWithPause.rawValue : NotificationCategory.runningWithoutPause.rawValue
|
||||
case .paused:
|
||||
content.categoryIdentifier = NotificationCategory.paused.rawValue
|
||||
case .complete:
|
||||
content.categoryIdentifier = NotificationCategory.complete.rawValue
|
||||
case .error:
|
||||
content.categoryIdentifier = NotificationCategory.error.rawValue
|
||||
case .canceled:
|
||||
content.categoryIdentifier = NotificationCategory.canceled.rawValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the notificationType related to this [status]
|
||||
func notificationTypeForTaskStatus(status: TaskStatus) -> NotificationType {
|
||||
switch status {
|
||||
case .enqueued, .running: return NotificationType.running
|
||||
case .complete: return NotificationType.complete
|
||||
case .paused: return NotificationType.paused
|
||||
case .canceled: return NotificationType.canceled
|
||||
default: return NotificationType.error
|
||||
}
|
||||
}
|
||||
|
||||
let displayNameRegEx = try! NSRegularExpression(pattern: "\\{displayName\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let fileNameRegEx = try! NSRegularExpression(pattern: "\\{filename\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let metaDataRegEx = try! NSRegularExpression(pattern: "\\{metadata\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let progressRegEx = try! NSRegularExpression(pattern: "\\{progress\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let networkSpeedRegEx = try! NSRegularExpression(pattern: "\\{networkSpeed\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let timeRemainingRegEx = try! NSRegularExpression(pattern: "\\{timeRemaining\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let numFinishedRegEx = try! NSRegularExpression(pattern: "\\{numFinished\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let numFailedRegEx = try! NSRegularExpression(pattern: "\\{numFailed\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let numTotalRegEx = try! NSRegularExpression(pattern: "\\{numTotal\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
|
||||
/// Replace special tokens {filename} and {metadata} with their respective values
|
||||
func replaceTokens(input: String, task: Task, progress: Double? = nil, notificationGroup: GroupNotification? = nil) async -> String {
|
||||
let inputString = NSMutableString()
|
||||
inputString.append(input)
|
||||
|
||||
let unpacked = unpack(packedString: task.filename)
|
||||
let safeFilename = unpacked.filename ?? task.filename
|
||||
|
||||
displayNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.displayName)
|
||||
fileNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: safeFilename)
|
||||
metaDataRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.metaData)
|
||||
if (progress == nil) {
|
||||
progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "")}
|
||||
else {
|
||||
progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(Int(progress! * 100))%")
|
||||
}
|
||||
networkSpeedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "-- MB/s")
|
||||
timeRemainingRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "--:--")
|
||||
if (notificationGroup != nil) {
|
||||
let numFinished = await notificationGroup!.numFinished
|
||||
let numFailed = await notificationGroup!.numFailed
|
||||
let numTotal = await notificationGroup!.numTotal
|
||||
numFinishedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFinished)")
|
||||
numFailedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFailed)")
|
||||
numTotalRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numTotal)")
|
||||
}
|
||||
return inputString as String
|
||||
}
|
||||
|
||||
/// Registers notification categories and actions for the different notification types
|
||||
func registerNotificationCategories() {
|
||||
// get values from shared preferences
|
||||
let defaults = UserDefaults.standard
|
||||
let localize = defaults.dictionary(forKey: BDPlugin.keyConfigLocalize)
|
||||
|
||||
// define the actions
|
||||
let cancelAction = UNNotificationAction(identifier: "cancel_action",
|
||||
title: localize?["Cancel"] as? String ?? "Cancel",
|
||||
options: [])
|
||||
let cancelInactiveAction = UNNotificationAction(identifier: "cancel_inactive_action",
|
||||
title: localize?["Cancel"] as? String ?? "Cancel",
|
||||
options: [])
|
||||
let pauseAction = UNNotificationAction(identifier: "pause_action",
|
||||
title: localize?["Pause"] as? String ?? "Pause",
|
||||
options: [])
|
||||
let resumeAction = UNNotificationAction(identifier: "resume_action",
|
||||
title: localize?["Resume"] as? String ?? "Resume",
|
||||
options: [])
|
||||
// Define the notification categories using these actions
|
||||
let runningWithPauseCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.runningWithPause.rawValue,
|
||||
actions: [cancelAction, pauseAction],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let runningWithoutPauseCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.runningWithoutPause.rawValue,
|
||||
actions: [cancelAction],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let pausedCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.paused.rawValue,
|
||||
actions: [cancelInactiveAction, resumeAction],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let completeCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.complete.rawValue,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let errorCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.error.rawValue,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let canceledCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.canceled.rawValue,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
// Register the notification type.
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.setNotificationCategories([runningWithPauseCategory, runningWithoutPauseCategory, pausedCategory, completeCategory, errorCategory, canceledCategory])
|
||||
}
|
||||
|
||||
/// Returns a JSON string for this NotificationConfig, or nil
|
||||
func jsonStringFor(notificationConfig: NotificationConfig) -> String? {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
guard let jsonResultData = try? jsonEncoder.encode(notificationConfig)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(data: jsonResultData, encoding: .utf8)
|
||||
|
||||
}
|
||||
|
||||
/// Returns a NotificationConfig from the supplied jsonString, or nil
|
||||
func notificationConfigFrom(jsonString: String) -> NotificationConfig? {
|
||||
let decoder = JSONDecoder()
|
||||
let notificationConfig: NotificationConfig? = try? decoder.decode(NotificationConfig.self, from: (jsonString).data(using: .utf8)!)
|
||||
return notificationConfig
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// OpenFile.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 4/27/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MobileCoreServices
|
||||
import UIKit
|
||||
#if canImport(UniformTypeIdentifiers)
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
///
|
||||
func doOpenFile(filePath: String, mimeType: String?) -> Bool {
|
||||
let fileUrl = URL(fileURLWithPath: filePath)
|
||||
let documentInteractionController = UIDocumentInteractionController(url: fileUrl)
|
||||
let delegate = DocumentInteractionControllerDelegate()
|
||||
documentInteractionController.delegate = delegate
|
||||
if (mimeType != nil) {
|
||||
if #available(iOS 14.0, *) {
|
||||
if let type = UTType(mimeType: mimeType!) {
|
||||
documentInteractionController.uti = type.identifier
|
||||
}
|
||||
} else {
|
||||
if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType! as NSString, nil)?.takeRetainedValue()
|
||||
{
|
||||
documentInteractionController.uti = uti as String
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let view = getRootViewController()?.view
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if !documentInteractionController.presentPreview(animated: true) {
|
||||
documentInteractionController.presentOpenInMenu(from: view.bounds, in: view, animated: true)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class DocumentInteractionControllerDelegate: NSObject, UIDocumentInteractionControllerDelegate {
|
||||
|
||||
func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
|
||||
return getRootViewController()!
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
//
|
||||
// ParallelDownloader.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 9/18/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
let chunkGroup = "chunk"
|
||||
|
||||
func scheduleParallelDownload(task: Task, taskDescription: String, baseRequest: URLRequest, resumeData: String) async -> Bool
|
||||
{
|
||||
let isResume = !resumeData.isEmpty
|
||||
let parallelDownload = ParallelDownloader(task: task)
|
||||
if !isResume {
|
||||
return await withCheckedContinuation { continuation in
|
||||
let dataTask = URLSession.shared.dataTask(with: baseRequest) { (data, response, error) in
|
||||
if let httpResponse = response as? HTTPURLResponse, error == nil {
|
||||
if httpResponse.statusCode == 404 {
|
||||
os_log("URL not found for taskId %@", log: log, type: .info, task.taskId)
|
||||
}
|
||||
else if !parallelDownload.start(responseStatusCode: httpResponse.statusCode, contentLengthFromHeader: Int64(httpResponse.value(forHTTPHeaderField: "Content-Length") ?? "-1") ?? -1, responseHeaders: httpResponse.allHeaderFields ) {
|
||||
os_log("Cannot chunk or enqueue download", log: log, type: .info)
|
||||
} else {
|
||||
if BDPlugin.holdingQueue?.enqueuedTaskIds.contains(task.taskId) != true {
|
||||
// only send .enqueued if we didn't already do that when
|
||||
// enqueueing with the holdingQueue
|
||||
processStatusUpdate(task: task, status: TaskStatus.enqueued)
|
||||
}
|
||||
continuation.resume(returning: true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
os_log("Error making HEAD request for taskId %@", log: log, type: .info, task.taskId)
|
||||
}
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
dataTask.priority = 1 - Float(task.priority) / 10
|
||||
dataTask.resume()
|
||||
}
|
||||
} else {
|
||||
// resume
|
||||
return parallelDownload.resume(resumeData: resumeData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/ Manages a parallel download
|
||||
/
|
||||
/ A ParallelDownloadTask pings the server to get the content-length of the
|
||||
/ download, then creates a list of [Chunk]s, each representing a portion
|
||||
/ of the download. Each chunk-task has its group set to 'chunk' and
|
||||
/ has the taskId of the parent ParallelDownloadTask in its
|
||||
/ [Task.metaData] field.
|
||||
/ The ParallelDownloader sends 'enqueue' messages back to the NativeDownloader to
|
||||
/ start each chunk-task, just like any other download task.
|
||||
/ Messages with group 'chunk' are intercepted in the NativeDownloader,
|
||||
/ and the update is sent to the ParallelDownloader.
|
||||
/ In the ParallelDownloader, the update is processed and the new status/progress
|
||||
/ of the ParallelDownloadTask is determined. If the status/progress has
|
||||
/ changed, an update is sent and the status is processed (e.g., a complete
|
||||
/ status triggers the piecing together of the downloaded file from
|
||||
/ its chunk pieces).
|
||||
/
|
||||
/ Similarly, pause and cancel commands are sent to all chunk tasks before
|
||||
/ updating the status of the parent ParallelDownloadTask
|
||||
*/
|
||||
public class ParallelDownloader: NSObject {
|
||||
// downloads is the list of active parallel downloads, used to route child
|
||||
// status and progress updates
|
||||
static var downloads: [String : ParallelDownloader] = [:] // keyed by parentTask.taskId
|
||||
var parentTask: Task
|
||||
var chunks: [Chunk] = []
|
||||
var parallelDownloadContentLength: Int64 = 0
|
||||
var lastTaskStatus: TaskStatus = .enqueued
|
||||
var lastProgress: Double = 0
|
||||
var nextProgressUpdateTime = Date()
|
||||
var taskException: TaskException? = nil
|
||||
var responseBody: String? = nil
|
||||
var responseHeaders: [AnyHashable : Any]? = nil
|
||||
var initialResponseStatusCode: Int? = nil
|
||||
|
||||
/// Create a new ParallelDownloader
|
||||
init(task:Task) {
|
||||
self.parentTask = task
|
||||
}
|
||||
|
||||
/// Start the parallel download by creating and enqueueing chunks based on
|
||||
/// the content length and header fields
|
||||
///
|
||||
/// Returns false if start was unsuccessful
|
||||
public func start(responseStatusCode: Int, contentLengthFromHeader: Int64, responseHeaders: [AnyHashable: Any]) -> Bool {
|
||||
if !(200...206).contains(responseStatusCode) {
|
||||
os_log("TaskId %@ returned response code %d", log: log, type: .info, parentTask.taskId, responseStatusCode)
|
||||
return false
|
||||
}
|
||||
initialResponseStatusCode = responseStatusCode // may change due to chunk errors
|
||||
self.responseHeaders = responseHeaders
|
||||
// get suggested filename if needed
|
||||
if parentTask.filename == "?" {
|
||||
let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: parentTask, responseHeaders: responseHeaders)
|
||||
if newTask.filename != parentTask.filename {
|
||||
// store for future replacement, and replace now
|
||||
storeModifiedTask(task: newTask)
|
||||
parentTask = newTask
|
||||
}
|
||||
}
|
||||
parallelDownloadContentLength = contentLengthFromHeader > 0
|
||||
? contentLengthFromHeader
|
||||
: getContentLength(responseHeaders: responseHeaders, task: self.parentTask)
|
||||
if parallelDownloadContentLength <= 0 {
|
||||
os_log("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header", log: log, type: .info)
|
||||
return false
|
||||
}
|
||||
if responseHeaders.first(where: { entry in (entry.key as! String).lowercased() == "accept-ranges" && entry.value as! String == "bytes"}) == nil {
|
||||
os_log("Server does not accept ranges - cannot chunk download", log: log, type: .info)
|
||||
return false
|
||||
}
|
||||
extractContentType(responseHeaders: responseHeaders, task: self.parentTask)
|
||||
ParallelDownloader.downloads[parentTask.taskId] = self
|
||||
chunks = createChunks(task: parentTask, contentLength: parallelDownloadContentLength)
|
||||
let success = !chunks.isEmpty && enqueueChunkTasks()
|
||||
if !success {
|
||||
ParallelDownloader.downloads.removeValue(forKey: parentTask.taskId)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/// resume: reconstruct [chunks] and wait for all chunk tasks to complete.
|
||||
/// The Dart side will resume each chunk task, so we just wait for the
|
||||
/// completer to complete
|
||||
func resume(resumeData: String) -> Bool {
|
||||
ParallelDownloader.downloads[parentTask.taskId] = self
|
||||
let decoder = JSONDecoder()
|
||||
guard
|
||||
let chunkList = try? decoder.decode([Chunk].self, from: resumeData.data(using: .utf8)!)
|
||||
else {
|
||||
os_log("Could not decode resumeData for taskid %@", log: log, type: .info, parentTask.taskId)
|
||||
return false
|
||||
}
|
||||
chunks = chunkList
|
||||
parallelDownloadContentLength = chunks.reduce(0, { partialResult, chunk in
|
||||
partialResult + chunk.toByte - chunk.fromByte + 1
|
||||
})
|
||||
lastTaskStatus = .paused
|
||||
return true
|
||||
}
|
||||
|
||||
/// Returns a list of chunk information for this task, and sets
|
||||
/// [parallelDownloadContentLength] to the total length of the download
|
||||
///
|
||||
/// Throws a StateError if any information is missing, which should lead
|
||||
/// to a failure of the [ParallelDownloadTask]
|
||||
func createChunks(task: Task, contentLength: Int64) -> [Chunk] {
|
||||
let numChunks = task.urls!.count * task.chunks!
|
||||
if contentLength <= 0 {
|
||||
os_log("Server does not provide content length - cannot chunk download", log: log, type: .info)
|
||||
return []
|
||||
}
|
||||
parallelDownloadContentLength = contentLength
|
||||
let chunkSize = (contentLength / Int64(numChunks)) + 1
|
||||
var chunksList: [Chunk] = []
|
||||
for i in 0..<numChunks {
|
||||
chunksList.append(Chunk(
|
||||
parentTask: task,
|
||||
url: task.urls![i % task.urls!.count],
|
||||
filename: "\(Int.random(in: 1..<1 << 32))",
|
||||
fromByte: Int64(i) * chunkSize,
|
||||
toByte: min(Int64(i) * chunkSize + chunkSize - 1, contentLength - 1)))
|
||||
}
|
||||
return chunksList
|
||||
}
|
||||
|
||||
/// Enqueues all chunk tasks and returns true if successful
|
||||
///
|
||||
/// Enqueue request is posted to Dart side
|
||||
func enqueueChunkTasks() -> Bool {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
for chunk in chunks {
|
||||
guard let taskAsJsonData = try? jsonEncoder.encode(chunk.task)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "enqueueChild", task: parentTask, arg: String(data: taskAsJsonData, encoding: .utf8) as Any) {
|
||||
os_log("Could not enqueue child task for chunk", log: log, type: .info)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Process incoming [status] update for a chunk with [chunkTaskId]
|
||||
///
|
||||
/// If status is failure, may include [taskException] and [responseBody]
|
||||
func chunkStatusUpdate(chunkTaskId: String, status: TaskStatus, taskException: TaskException?, responseBody: String?) {
|
||||
// Confirm chunk is part of this parent task
|
||||
guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else { return }
|
||||
// first check for fail -> retry
|
||||
if status == .failed && chunk.task.retriesRemaining > 0 {
|
||||
chunk.task.retriesRemaining -= 1
|
||||
let waitTimeSeconds = 2 << min(chunk.task.retries - chunk.task.retriesRemaining - 1, 8)
|
||||
os_log("Chunk with taskId %@ failed, waiting %d seconds to retry; %d retries remaining", log: log, type: .info, chunk.task.taskId, waitTimeSeconds, chunk.task.retriesRemaining)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(waitTimeSeconds)) {
|
||||
if !postOnBackgroundChannel(
|
||||
method: "enqueueChild",
|
||||
task: self.parentTask,
|
||||
arg: jsonStringFor(task: chunk.task)!
|
||||
) {
|
||||
self.chunkStatusUpdate(chunkTaskId: chunkTaskId, status: .failed, taskException: taskException, responseBody: responseBody)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no retry
|
||||
let newStatusUpdate = updateChunkStatus(chunk: chunk, status: status)
|
||||
if let newStatusUpdate = newStatusUpdate {
|
||||
switch newStatusUpdate {
|
||||
case .running:
|
||||
processStatusUpdate(task: parentTask, status: .running)
|
||||
case .complete:
|
||||
let stitchResult = stitchChunks()
|
||||
if stitchResult == TaskStatus.complete {
|
||||
os_log("Finished task with id %@", log: log, type: .info, parentTask.taskId)
|
||||
}
|
||||
finishTask(status: stitchResult)
|
||||
case .failed:
|
||||
self.taskException = taskException
|
||||
cancelAllChunkTasks()
|
||||
finishTask(status: .failed)
|
||||
case .notFound:
|
||||
self.responseBody = responseBody
|
||||
cancelAllChunkTasks()
|
||||
finishTask(status: .notFound)
|
||||
default:
|
||||
// ignore all other status updates
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process incoming [progress] update for a chunk with [chunkTaskId].
|
||||
///
|
||||
/// Recalculates overall task progress (based on the average of the chunk
|
||||
/// task progress) and sends an updatre to the Dart isde and updates the
|
||||
/// notification at the appropriate interval
|
||||
func chunkProgressUpdate(chunkTaskId: String, progress: Double) {
|
||||
guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else {
|
||||
return // chunk is not part of this parent task
|
||||
}
|
||||
if progress > 0 && progress < 1 {
|
||||
let parentProgress = updateChunkProgress(chunk: chunk, progress: progress)
|
||||
let totalBytesDone = Int64(parentProgress * Double(parallelDownloadContentLength))
|
||||
updateProgress(task: parentTask, totalBytesExpected: parallelDownloadContentLength, totalBytesDone: totalBytesDone)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status for this chunk, and return the status for the parent task
|
||||
/// as derived from the sum of the child tasks, or null if undefined
|
||||
///
|
||||
/// The updates are received from the NativeDownloader, which intercepts
|
||||
/// status updates for the chunkGroup
|
||||
private func updateChunkStatus(chunk: Chunk, status: TaskStatus) -> TaskStatus? {
|
||||
chunk.status = status
|
||||
let parentStatus = parentTaskStatus()
|
||||
if let parentStatus = parentStatus, parentStatus != lastTaskStatus {
|
||||
lastTaskStatus = parentStatus
|
||||
return parentStatus
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the [TaskStatus] for the parent of this chunk, as derived from
|
||||
/// the 'sum' of the child tasks, or nil if undetermined
|
||||
///
|
||||
/// The updates are received from the NativeDownloader, which intercepts
|
||||
/// status updates for the chunkGroup
|
||||
private func parentTaskStatus() -> TaskStatus? {
|
||||
if chunks.first(where: { $0.status == .failed }) != nil {
|
||||
return .failed
|
||||
}
|
||||
if chunks.first(where: { $0.status == .notFound }) != nil {
|
||||
return .notFound
|
||||
}
|
||||
if (chunks.reduce(into: 0) { previousValue, chunk in
|
||||
if chunk.status == .running {
|
||||
previousValue += 1
|
||||
}
|
||||
}) == 1 {
|
||||
return .running // if exactly one chunk task is running
|
||||
}
|
||||
if chunks.allSatisfy({ $0.status == .complete }) {
|
||||
return .complete
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Updates the chunk's progress and returns the average progress
|
||||
///
|
||||
/// Returns the [progress] for the parent of this chunk, as derived from
|
||||
/// its children by averaging
|
||||
private func updateChunkProgress(chunk: Chunk, progress: Double) -> Double {
|
||||
chunk.progress = progress
|
||||
return chunks.reduce(into: 0.0) { previousValue, chunk in
|
||||
previousValue += chunk.progress
|
||||
} / Double(chunks.count)
|
||||
}
|
||||
|
||||
/// Stitch all chunks together into one file, per the [parentTask]
|
||||
private func stitchChunks() -> TaskStatus {
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
let outputFilePath = getFilePath(for: parentTask)!
|
||||
let outputFile = URL(fileURLWithPath: outputFilePath)
|
||||
if fileManager.fileExists(atPath: outputFilePath) {
|
||||
try? fileManager.removeItem(at: outputFile)
|
||||
}
|
||||
fileManager.createFile(atPath: outputFilePath, contents: nil)
|
||||
let outputFileHandle = try FileHandle(forWritingTo: outputFile)
|
||||
defer {
|
||||
outputFileHandle.closeFile()
|
||||
for chunk in chunks {
|
||||
let inputFilePath = getFilePath(for: chunk.task)!
|
||||
let inputFileURL = URL(fileURLWithPath: inputFilePath)
|
||||
if fileManager.fileExists(atPath: inputFilePath) {
|
||||
try? FileManager.default.removeItem(at: inputFileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
for chunk in chunks.sorted(by: { $0.fromByte < $1.fromByte }) {
|
||||
let filePath = getFilePath(for: chunk.task)!
|
||||
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath))
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
}
|
||||
while true {
|
||||
let data = fileHandle.readData(ofLength: 2 << 13)
|
||||
if data.isEmpty {
|
||||
break
|
||||
}
|
||||
outputFileHandle.write(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
os_log("Error stitching chunks: %@", log: log, type: .info, error.localizedDescription)
|
||||
taskException = TaskException(type: .fileSystem, description: "Error stitching chunks: \(error.localizedDescription)")
|
||||
return .failed
|
||||
}
|
||||
return .complete
|
||||
}
|
||||
|
||||
/// Cancel this task
|
||||
///
|
||||
/// Cancels all chunk tasks and completes the task with [TaskStatus.canceled]
|
||||
func cancelTask() {
|
||||
cancelAllChunkTasks()
|
||||
finishTask(status: .canceled)
|
||||
}
|
||||
|
||||
/// Pause this task
|
||||
///
|
||||
/// Pauses all chunk tasks
|
||||
func pauseTask() async -> Bool {
|
||||
let encoder = JSONEncoder()
|
||||
guard
|
||||
let chunkTasksData = try? encoder.encode(chunks.map({ chunk in
|
||||
chunk.task })),
|
||||
let chunksData = try? encoder.encode(chunks)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "pauseTasks", task: parentTask, arg: String(data: chunkTasksData, encoding: .utf8)!)
|
||||
{
|
||||
os_log("Could not pause chunk tasks for taskId %@", log: log, type: .info, parentTask.taskId)
|
||||
return false
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "resumeData", task: parentTask, arg: String(data: chunksData, encoding: .utf8)!) {
|
||||
os_log("Could not post resume data for taskId %@", log: log, type: .info, parentTask.taskId)
|
||||
// because we already paused the
|
||||
cancelAllChunkTasks()
|
||||
processStatusUpdate(task: parentTask, status: .failed)
|
||||
return false
|
||||
}
|
||||
processStatusUpdate(task: parentTask, status: .paused)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Cancel the tasks associated with each chunk
|
||||
///
|
||||
/// Accomplished by sending list of taskIds to cancel to the NativeDownloader
|
||||
private func cancelAllChunkTasks() {
|
||||
let encoder = JSONEncoder()
|
||||
guard let data = try? encoder.encode(chunks.map({ $0.task.taskId })) else {
|
||||
os_log("Could not encode chunk ids", log: log, type: .error)
|
||||
return
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "cancelTasksWithId", task: parentTask, arg: String(data: data, encoding: .utf8)!) {
|
||||
os_log("Could not cancel chunk tasks related to taskId %@", log: log, type: .info, parentTask.taskId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the [ParallelDownloadTask] by posting a statusUpdate and clearning up
|
||||
private func finishTask(status: TaskStatus) {
|
||||
let taskId = parentTask.taskId
|
||||
let (mimeType, charSet) = BDPlugin.propertyLock.withLock({
|
||||
(BDPlugin.mimeTypes[taskId], BDPlugin.charSets[taskId])
|
||||
})
|
||||
var responseStatusCode: Int? = nil
|
||||
switch status {
|
||||
case .complete:
|
||||
responseStatusCode = initialResponseStatusCode
|
||||
|
||||
case .notFound:
|
||||
responseStatusCode = 404
|
||||
|
||||
default:
|
||||
responseStatusCode = nil
|
||||
}
|
||||
processStatusUpdate(task: parentTask, status: status, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode, mimeType: mimeType, charSet: charSet)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.mimeTypes.removeValue(forKey: taskId)
|
||||
BDPlugin.charSets.removeValue(forKey: taskId)
|
||||
BDPlugin.tasksWithModifications.removeValue(forKey: taskId)
|
||||
ParallelDownloader.downloads.removeValue(forKey: taskId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents one chunk of a parallel download
|
||||
///
|
||||
/// Initialized to a specific range of the parent task.
|
||||
/// Holds state (status and progress) of the chunk
|
||||
public class Chunk: NSObject, Codable {
|
||||
let parentTaskId: String
|
||||
let url: String
|
||||
let filename: String
|
||||
let fromByte: Int64
|
||||
let toByte: Int64
|
||||
var task: Task
|
||||
var status: TaskStatus = TaskStatus.enqueued
|
||||
var progress = 0.0
|
||||
|
||||
|
||||
init(parentTask: Task, url: String, filename: String, fromByte: Int64, toByte: Int64) {
|
||||
self.parentTaskId = parentTask.taskId
|
||||
self.url = url
|
||||
self.filename = filename
|
||||
self.fromByte = fromByte
|
||||
self.toByte = toByte
|
||||
var headers = parentTask.headers
|
||||
headers["Range"] = "bytes=\(fromByte)-\(toByte)"
|
||||
let jsonEncoder = JSONEncoder()
|
||||
let data = try? jsonEncoder.encode(["parentTaskId": self.parentTaskId, "from": String(fromByte), "to": String(toByte)])
|
||||
let metaData = data != nil ? String(data: data!, encoding: .utf8) ?? "" : ""
|
||||
self.task = Task(url: url, filename: filename, headers: headers, baseDirectory: BaseDirectory.temporary.rawValue, group: chunkGroup, updates: Chunk.updatesBasedOnParent(parentTask), retries: parentTask.retries, retriesRemaining: parentTask.retries, allowPause: parentTask.allowPause, priority: parentTask.priority, metaData: metaData, taskType: "DownloadTask")
|
||||
}
|
||||
|
||||
/// Returns [Updates] enum rawValue based on its parent
|
||||
static func updatesBasedOnParent(_ parentTask: Task) -> Int {
|
||||
return parentTask.updates == Updates.none.rawValue || parentTask.updates == Updates.statusChange.rawValue ? Updates.statusChange.rawValue : Updates.statusChangeAndProgressUpdates.rawValue
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// Permissions.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 11/30/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import UserNotifications
|
||||
import os.log
|
||||
|
||||
public enum PermissionType: Int {
|
||||
case notifications,
|
||||
androidSharedStorage,
|
||||
iosAddToPhotoLibrary,
|
||||
iosChangePhotoLibrary
|
||||
}
|
||||
|
||||
public enum PermissionStatus: Int {
|
||||
case undetermined,
|
||||
denied,
|
||||
granted,
|
||||
partial,
|
||||
requestError }
|
||||
|
||||
/// Get current permission status for this [request]
|
||||
///
|
||||
/// Unknown permissions resturn .granted
|
||||
public func getPermissionStatus(for permissionType: PermissionType) async -> PermissionStatus {
|
||||
if permissionType == .notifications {
|
||||
#if BYPASS_PERMISSION_NOTIFICATIONS
|
||||
return .denied
|
||||
#else
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = (await center.notificationSettings()).authorizationStatus
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
|
||||
case .denied:
|
||||
return .denied
|
||||
|
||||
case .notDetermined:
|
||||
return .undetermined
|
||||
|
||||
default:
|
||||
return .partial
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) {
|
||||
let status: PHAuthorizationStatus
|
||||
if permissionType == .iosAddToPhotoLibrary {
|
||||
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
} else {
|
||||
status = PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
#endif
|
||||
} else { // readwrite
|
||||
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
} else {
|
||||
status = PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
|
||||
case .denied:
|
||||
return .denied
|
||||
|
||||
case .notDetermined:
|
||||
return .undetermined
|
||||
|
||||
default:
|
||||
return .partial
|
||||
}
|
||||
}
|
||||
return .granted // default return if irrelevant permission for iOS
|
||||
}
|
||||
|
||||
/// Request permission from user and return the [PermissionResult]
|
||||
///
|
||||
/// Unknown permissions are granted
|
||||
public func requestPermission(for permissionType: PermissionType) async -> PermissionStatus {
|
||||
if permissionType == .notifications {
|
||||
#if BYPASS_PERMISSION_NOTIFICATIONS
|
||||
return .denied
|
||||
#else
|
||||
let center = UNUserNotificationCenter.current()
|
||||
guard let granted = try? await center.requestAuthorization(options: [.alert]) else {
|
||||
return .requestError
|
||||
}
|
||||
return granted ? .granted : .denied
|
||||
#endif
|
||||
}
|
||||
if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) {
|
||||
let status: PHAuthorizationStatus
|
||||
if permissionType == .iosAddToPhotoLibrary {
|
||||
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
} else {
|
||||
status = await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.requestAuthorization {status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else { // readwrite
|
||||
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||||
} else {
|
||||
status = await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.requestAuthorization {status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
|
||||
case .denied:
|
||||
return .denied
|
||||
|
||||
case .notDetermined:
|
||||
return .undetermined
|
||||
|
||||
default:
|
||||
return .partial
|
||||
}
|
||||
}
|
||||
return .granted
|
||||
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// SharedStorage.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 3/26/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Photos
|
||||
import Combine
|
||||
|
||||
public enum SharedStorage: Int {
|
||||
case downloads,
|
||||
images,
|
||||
video,
|
||||
audio,
|
||||
files,
|
||||
external
|
||||
}
|
||||
|
||||
/// Check if photos permissions have been granted
|
||||
///
|
||||
/// if [addOnly] is true, checks .addOnly permission, otherwise checks
|
||||
/// full .readWrite permission.
|
||||
///
|
||||
/// Returns true if granted
|
||||
|
||||
/// .addOnly requires the "NSPhotoLibraryAddUsageDescription" to be set in Info.plist
|
||||
/// .readWrite requires the "NSPhotoLibraryUsageDescription" to be set in Info.plist
|
||||
/// Prior to iOS 14, use NSPhotoLibraryUsageDescription
|
||||
public func photosAccessAuthorized(addOnly: Bool) async -> Bool {
|
||||
let permissionType: PermissionType = addOnly ? .iosAddToPhotoLibrary : .iosChangePhotoLibrary
|
||||
var authStatus = await getPermissionStatus(for: permissionType)
|
||||
if (authStatus == .granted) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Move file at [filePath] to shared storage [destination] with optional [directory]
|
||||
public func moveToSharedStorage(filePathOrUriString: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? {
|
||||
let fileUrl = URL(string: filePathOrUriString)
|
||||
let filePath = fileUrl?.scheme == "file" ? fileUrl!.path : filePathOrUriString
|
||||
guard FileManager().fileExists(atPath: filePath)
|
||||
else {
|
||||
os_log("Cannot move to shared storage: file %@ does not exist", log: log, type: .error, filePathOrUriString)
|
||||
return nil
|
||||
}
|
||||
switch destination {
|
||||
case .images, .video:
|
||||
return await moveToPhotoLibrary(filePath: filePath, destination: destination)
|
||||
default:
|
||||
return moveToFakeSharedStorage(filePath: filePath, destination: destination, directory: directory, asUriString: asUriString)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the file at [filePath] to the photolibrary [destination] and removes the original
|
||||
///
|
||||
/// The returned path is the 'localId'
|
||||
private func moveToPhotoLibrary(filePath: String, destination: SharedStorage) async -> String? {
|
||||
var localId: String?
|
||||
guard await photosAccessAuthorized(addOnly: true) else {
|
||||
os_log("Cannot move to shared storage: permission to add to photos library denied by user", log: log, type: .info)
|
||||
return nil
|
||||
}
|
||||
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
|
||||
return nil
|
||||
#else
|
||||
guard let fileURL = URL(string: filePath) else {
|
||||
os_log("filePath invalid: %@", log: log, type: .info, filePath)
|
||||
return nil
|
||||
}
|
||||
let result = await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
let assetChangeRequest = destination == .video ? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL) :
|
||||
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
|
||||
localId = assetChangeRequest?.placeholderForCreatedAsset?.localIdentifier
|
||||
})
|
||||
{ (success, error: Error?) in
|
||||
guard success, let unwrappedId = localId else {
|
||||
if let unwrappedError = error {
|
||||
os_log("Could not save to Photos Library: %@", log: log, type: .info, String(describing: unwrappedError))
|
||||
}
|
||||
continuation.resume(returning: "")
|
||||
return
|
||||
}
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
continuation.resume(returning: unwrappedId)
|
||||
}
|
||||
}
|
||||
return result.isEmpty ? nil : result
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Returns the path to the file at [filePath] in shared storage [destination] subdir [directory], or null
|
||||
public func pathInSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? {
|
||||
if destination == .images || destination == .video {
|
||||
return await pathInPhotoLibrary(localId: filePath, destination: destination)
|
||||
}
|
||||
// regular 'faked' sharedStorage
|
||||
guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else {
|
||||
os_log("Cannot determine path in shared storage: no permission for directory %@", log: log, type: .info, directory)
|
||||
return nil
|
||||
}
|
||||
let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent)
|
||||
return asUriString ? destUrl.absoluteString : destUrl.path
|
||||
}
|
||||
|
||||
/// Returns the file path associated with the [localId] in the Photos library
|
||||
///
|
||||
/// This requires .readWrite PhotoLibrary access, and therefore the filepath is NOT returned
|
||||
/// when moving a photo or video to the PhotoLibrary using [moveToSharedStorage].
|
||||
/// Instead, the local identifier is returned, and if the full file path is required, this identifier needs to
|
||||
/// be passed to the [pathInSharedStorage] call
|
||||
///
|
||||
/// Returns the path to the photo/video asset, or nil if unsuccessful
|
||||
public func pathInPhotoLibrary(localId: String, destination: SharedStorage) async -> String? {
|
||||
guard await photosAccessAuthorized(addOnly: false) else {
|
||||
os_log("Cannot get path in shared storage: permission to access photos library denied by user", log: log, type: .info)
|
||||
return nil
|
||||
}
|
||||
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
|
||||
return nil
|
||||
#else
|
||||
let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: [localId], options: nil)
|
||||
guard let asset = assetResult.firstObject else {
|
||||
os_log("Photos asset not found", log: log, type: .info)
|
||||
return nil
|
||||
}
|
||||
let result = await withCheckedContinuation { continuation in
|
||||
asset.requestContentEditingInput(with: nil, completionHandler: { input, _ in
|
||||
if (destination == .video) {
|
||||
if let urlAsset = input?.audiovisualAsset as? AVURLAsset {
|
||||
continuation.resume(returning: urlAsset.url.path)
|
||||
} else {
|
||||
continuation.resume(returning: "")
|
||||
}
|
||||
} else {
|
||||
continuation.resume(returning: input?.fullSizeImageURL?.path ?? "")
|
||||
}
|
||||
})
|
||||
}
|
||||
return result.isEmpty ? nil : result
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
/// Returns the URL of the directory associated with the [destination] and [directory], or nil
|
||||
public func directoryForSharedStorage(destination: SharedStorage, directory: String) throws -> URL? {
|
||||
var dir: String
|
||||
switch destination {
|
||||
case .downloads:
|
||||
dir = "Downloads"
|
||||
case .audio:
|
||||
dir = "Music"
|
||||
case .images, .video:
|
||||
os_log("Destination .images and .video should use PhotoLibrary functions", log: log, type: .error)
|
||||
return nil
|
||||
case .files, .external:
|
||||
os_log("Destination .files and .external are not supported on iOS", log: log, type: .info)
|
||||
return nil
|
||||
|
||||
}
|
||||
let documentsURL =
|
||||
try? FileManager.default.url(for: FileManager.SearchPathDirectory.documentDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true)
|
||||
return directory.isEmpty
|
||||
? documentsURL?.appendingPath(dir, isDirectory: true)
|
||||
: documentsURL?.appendingPath(dir, isDirectory: true).appendingPath(directory, isDirectory: true)
|
||||
}
|
||||
|
||||
private func moveToFakeSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) -> String? {
|
||||
let fileUrl = NSURL(fileURLWithPath: filePath)
|
||||
guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else {
|
||||
os_log("Cannot move to shared storage: no permission for directory %@", log: log, type: .error, directory)
|
||||
return nil
|
||||
}
|
||||
if !FileManager.default.fileExists(atPath: directory.path) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
os_log("Cannot move to shared storage: failed to create directory %@: %@", log: log, type: .error, directory.path, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: destUrl.path) {
|
||||
try? FileManager.default.removeItem(at: destUrl)
|
||||
}
|
||||
do {
|
||||
try FileManager.default.moveItem(at: fileUrl as URL, to: destUrl)
|
||||
} catch {
|
||||
os_log("Failed to move file %@ to %@: %@", log: log, type: .error, filePath, destUrl.path, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
return asUriString ? destUrl.absoluteString : destUrl.path
|
||||
}
|
||||
third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Task.swift
Vendored
+328
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// Task.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 3/4/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
/// Partial version of the Dart side DownloadTask, only used for background loading
|
||||
public struct Task : Codable, Hashable {
|
||||
public var taskId: String = "\(Int.random(in: 1..<(1 << 32)))"
|
||||
public var url: String
|
||||
public var urls: [String]? = []
|
||||
public var filename: String
|
||||
public var headers: [String:String] = [:]
|
||||
public var httpRequestMethod: String = "GET"
|
||||
public var chunks: Int? = 1
|
||||
public var post: String?
|
||||
public var postIsBinary: Bool?
|
||||
public var fileField: String?
|
||||
public var mimeType: String?
|
||||
public var fields: [String:String]?
|
||||
public var directory: String = ""
|
||||
public var baseDirectory: Int
|
||||
public var group: String
|
||||
public var updates: Int
|
||||
public var requiresWiFi: Bool = false
|
||||
public var retries: Int = 0
|
||||
public var retriesRemaining: Int = 0
|
||||
public var allowPause: Bool = false
|
||||
public var priority: Int = 5
|
||||
public var metaData: String = ""
|
||||
public var displayName: String = ""
|
||||
public var creationTime: Int64 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
|
||||
var options: TaskOptions?
|
||||
var transferHints: [Int]?
|
||||
var stallTimeout: Int64?
|
||||
public var taskType: String
|
||||
}
|
||||
|
||||
extension Task {
|
||||
func copyWith(taskId: String? = nil,
|
||||
url: String? = nil,
|
||||
urls: [String]? = nil,
|
||||
filename: String? = nil,
|
||||
headers: [String:String]? = nil,
|
||||
httpRequestMethod: String? = nil,
|
||||
chunks: Int? = nil,
|
||||
post: String? = nil,
|
||||
fileField: String? = nil,
|
||||
mimeType: String? = nil,
|
||||
fields: [String:String]? = nil,
|
||||
directory: String? = nil,
|
||||
baseDirectory: Int? = nil,
|
||||
group: String? = nil,
|
||||
updates: Int? = nil,
|
||||
requiresWiFi: Bool? = nil,
|
||||
retries: Int? = nil,
|
||||
retriesRemaining: Int? = nil,
|
||||
allowPause: Bool? = nil,
|
||||
priority: Int? = nil,
|
||||
metaData: String? = nil,
|
||||
displayName: String? = nil,
|
||||
creationTime: Int64? = nil,
|
||||
options: TaskOptions? = nil,
|
||||
transferHints: [Int]? = nil,
|
||||
stallTimeout: Int64? = nil,
|
||||
taskType: String? = nil) -> Task {
|
||||
|
||||
var copiedTask = self
|
||||
|
||||
if let taskId = taskId {
|
||||
copiedTask.taskId = taskId
|
||||
}
|
||||
|
||||
if let url = url {
|
||||
copiedTask.url = url
|
||||
}
|
||||
|
||||
if let urls = urls {
|
||||
copiedTask.urls = urls
|
||||
}
|
||||
|
||||
if let filename = filename {
|
||||
copiedTask.filename = filename
|
||||
}
|
||||
|
||||
if let headers = headers {
|
||||
copiedTask.headers = headers
|
||||
}
|
||||
|
||||
if let httpRequestMethod = httpRequestMethod {
|
||||
copiedTask.httpRequestMethod = httpRequestMethod
|
||||
}
|
||||
|
||||
if let chunks = chunks {
|
||||
copiedTask.chunks = chunks
|
||||
}
|
||||
|
||||
if let post = post {
|
||||
copiedTask.post = post
|
||||
}
|
||||
|
||||
if let fileField = fileField {
|
||||
copiedTask.fileField = fileField
|
||||
}
|
||||
|
||||
if let mimeType = mimeType {
|
||||
copiedTask.mimeType = mimeType
|
||||
}
|
||||
|
||||
if let fields = fields {
|
||||
copiedTask.fields = fields
|
||||
}
|
||||
|
||||
if let directory = directory {
|
||||
copiedTask.directory = directory
|
||||
}
|
||||
|
||||
if let baseDirectory = baseDirectory {
|
||||
copiedTask.baseDirectory = baseDirectory
|
||||
}
|
||||
|
||||
if let group = group {
|
||||
copiedTask.group = group
|
||||
}
|
||||
|
||||
if let updates = updates {
|
||||
copiedTask.updates = updates
|
||||
}
|
||||
|
||||
if let requiresWiFi = requiresWiFi {
|
||||
copiedTask.requiresWiFi = requiresWiFi
|
||||
}
|
||||
|
||||
if let retries = retries {
|
||||
copiedTask.retries = retries
|
||||
}
|
||||
|
||||
if let retriesRemaining = retriesRemaining {
|
||||
copiedTask.retriesRemaining = retriesRemaining
|
||||
}
|
||||
|
||||
if let allowPause = allowPause {
|
||||
copiedTask.allowPause = allowPause
|
||||
}
|
||||
|
||||
if let priority = priority {
|
||||
copiedTask.priority = priority
|
||||
}
|
||||
|
||||
if let metaData = metaData {
|
||||
copiedTask.metaData = metaData
|
||||
}
|
||||
|
||||
if let displayName = displayName {
|
||||
copiedTask.displayName = displayName
|
||||
}
|
||||
|
||||
if let creationTime = creationTime {
|
||||
copiedTask.creationTime = creationTime
|
||||
}
|
||||
|
||||
if let options = options {
|
||||
copiedTask.options = options
|
||||
}
|
||||
|
||||
if let transferHints = transferHints {
|
||||
copiedTask.transferHints = transferHints
|
||||
}
|
||||
|
||||
if let stallTimeout = stallTimeout {
|
||||
copiedTask.stallTimeout = stallTimeout
|
||||
}
|
||||
|
||||
if let taskType = taskType {
|
||||
copiedTask.taskType = taskType
|
||||
}
|
||||
|
||||
return copiedTask
|
||||
}
|
||||
}
|
||||
|
||||
/// Base directory in which files will be stored, based on their relative
|
||||
/// path.
|
||||
///
|
||||
/// These correspond to the directories provided by the path_provider package
|
||||
enum BaseDirectory: Int {
|
||||
case applicationDocuments, // getApplicationDocumentsDirectory()
|
||||
temporary, // getTemporaryDirectory()
|
||||
applicationSupport, // getApplicationSupportDirectory()
|
||||
applicationLibrary, // getLibraryDirectory()
|
||||
root // system root
|
||||
}
|
||||
|
||||
/// Type of updates requested for a group of downloads
|
||||
enum Updates: Int {
|
||||
case none, // no status or progress updates
|
||||
statusChange, // only calls upon change in DownloadTaskStatus
|
||||
progressUpdates, // only calls for progress
|
||||
statusChangeAndProgressUpdates // calls also for progress along the way
|
||||
}
|
||||
|
||||
/// Holds various options related to the task that are not included in the
|
||||
/// task's properties, as they are rare
|
||||
struct TaskOptions : Codable, Hashable {
|
||||
var beforeTaskStartRawHandle: Int64?
|
||||
var onTaskStartRawHandle: Int64?
|
||||
var onTaskFinishedRawHandle: Int64?
|
||||
var auth: Auth?
|
||||
|
||||
/// True if [TaskOptions] contains a callback for beforeTaskStart
|
||||
func hasBeforeStartCallback() -> Bool {
|
||||
beforeTaskStartRawHandle != nil
|
||||
}
|
||||
|
||||
/// True if [TaskOptions] contains a callback for onTaskStart
|
||||
func hasOnStartCallback() -> Bool {
|
||||
onTaskStartRawHandle != nil
|
||||
}
|
||||
|
||||
/// True if [TaskOptions] contains a callback that must be called after the task finishes
|
||||
func hasOnFinishedCallback() -> Bool {
|
||||
onTaskFinishedRawHandle != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a set of possible states which a [DownloadTask] can be in.
|
||||
public enum TaskStatus: Int, Codable {
|
||||
case enqueued,
|
||||
running,
|
||||
complete,
|
||||
notFound,
|
||||
failed,
|
||||
canceled,
|
||||
waitingToRetry,
|
||||
paused
|
||||
}
|
||||
|
||||
/** Holds data associated with a task status update, for local storage */
|
||||
public struct TaskStatusUpdate: Codable {
|
||||
public var task: Task
|
||||
public var taskStatus: TaskStatus
|
||||
public var exception: TaskException?
|
||||
public var responseBody: String?
|
||||
public var responseStatusCode: Int?
|
||||
public var responseHeaders: [String: String]?
|
||||
public var mimeType: String?
|
||||
public var charSet: String?
|
||||
|
||||
func argList() -> [Any?] {
|
||||
let finalState = isFinalState(status: taskStatus)
|
||||
return taskStatus == .failed
|
||||
? [taskStatus.rawValue,
|
||||
exception?.type.rawValue,
|
||||
exception?.description,
|
||||
exception?.httpResponseCode,
|
||||
responseBody]
|
||||
: [taskStatus.rawValue,
|
||||
finalState ? responseBody : nil,
|
||||
finalState ? responseHeaders : nil,
|
||||
(taskStatus == .complete || taskStatus == .notFound) ? responseStatusCode : nil,
|
||||
finalState ? mimeType : nil,
|
||||
finalState ? charSet : nil]
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds data associated with a task progress update, for local storage */
|
||||
struct TaskProgressUpdate: Encodable {
|
||||
var task: Task
|
||||
var progress: Double
|
||||
var expectedFileSize: Int64
|
||||
}
|
||||
|
||||
/** Holds data associated with a resume, for local storage */
|
||||
struct ResumeData: Encodable {
|
||||
var task: Task
|
||||
var data: String
|
||||
}
|
||||
|
||||
/// The type of [TaskException]
|
||||
public enum ExceptionType: String, Codable {
|
||||
case
|
||||
|
||||
// General error
|
||||
general = "TaskException",
|
||||
|
||||
|
||||
// Could not save or find file, or create directory
|
||||
fileSystem = "TaskFileSystemException",
|
||||
|
||||
// URL incorrect
|
||||
url = "TaskUrlException",
|
||||
|
||||
// Connection problem, eg host not found, timeout
|
||||
connection = "TaskConnectionException",
|
||||
|
||||
// Could not resume or pause task
|
||||
resume = "TaskResumeException",
|
||||
|
||||
// Invalid HTTP response
|
||||
httpResponse = "TaskHttpException"
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains error information associated with a failed [Task]
|
||||
*
|
||||
* The [type] categorizes the error
|
||||
* The [httpResponseCode] is only valid if >0 and may offer details about the
|
||||
* nature of the error
|
||||
* The [description] is typically taken from the platform-generated
|
||||
* error message, or from the plugin. The localization is undefined
|
||||
*/
|
||||
public struct TaskException : Codable {
|
||||
public var type: ExceptionType
|
||||
public var httpResponseCode: Int = -1
|
||||
public var description: String
|
||||
}
|
||||
|
||||
func taskException(jsonString: String) -> TaskException {
|
||||
if let jsonMap = try! JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: []) as? [String: Any] {
|
||||
return TaskException(type: ExceptionType(rawValue: jsonMap["type"] as! String) ?? ExceptionType.general
|
||||
, httpResponseCode: jsonMap["httpResponseCode"] as? Int ?? -1, description: jsonMap["description"] as! String )
|
||||
}
|
||||
return TaskException(type: .general, description: "Unknown error")
|
||||
}
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
//
|
||||
// TaskFunctions.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 2/11/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Flutter
|
||||
import os.log
|
||||
|
||||
let updatesQueue = DispatchQueue(label: "updatesProcessingQueue")
|
||||
let separatorString = "***<<<|>>>***"
|
||||
|
||||
/// True if this task expects to provide progress updates
|
||||
func providesProgressUpdates(task: Task) -> Bool {
|
||||
return task.updates == Updates.progressUpdates.rawValue || task.updates == Updates.statusChangeAndProgressUpdates.rawValue
|
||||
}
|
||||
|
||||
/// True if this task expects to provide status updates
|
||||
func providesStatusUpdates(downloadTask: Task) -> Bool {
|
||||
return downloadTask.updates == Updates.statusChange.rawValue || downloadTask.updates == Updates.statusChangeAndProgressUpdates.rawValue
|
||||
}
|
||||
|
||||
/// True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask
|
||||
func isDownloadTask(task: Task) -> Bool {
|
||||
return task.taskType == "DownloadTask" || task.taskType == "UriDownloadTask" || task.taskType == "ParallelDownloadTask"
|
||||
}
|
||||
|
||||
/// True if this task is a ParallelDownloadTask, false if not
|
||||
func isParallelDownloadTask(task: Task) -> Bool
|
||||
{
|
||||
return task.taskType == "ParallelDownloadTask"
|
||||
}
|
||||
|
||||
/// True if this task is an UploadTask, false if not
|
||||
///
|
||||
/// A MultiUploadTask is also an UploadTask
|
||||
func isUploadTask(task: Task) -> Bool {
|
||||
return task.taskType == "UploadTask" || task.taskType == "UriUploadTask" || task.taskType == "MultiUploadTask"
|
||||
}
|
||||
|
||||
/// True if this task is a MultiUploadTask, false if not
|
||||
func isMultiUploadTask(task: Task) -> Bool {
|
||||
return task.taskType == "MultiUploadTask"
|
||||
}
|
||||
|
||||
/// True if this task is a binary UploadTask
|
||||
func isBinaryUploadTask(task: Task) -> Bool {
|
||||
return isUploadTask(task: task) && task.post?.lowercased() == "binary"
|
||||
}
|
||||
|
||||
/// True if this is a DataTask
|
||||
func isDataTask(task: Task) -> Bool {
|
||||
return task.taskType == "DataTask"
|
||||
}
|
||||
|
||||
/// True if this state is not a final state (i.e. more changes may happen)
|
||||
func isNotFinalState(status: TaskStatus) -> Bool {
|
||||
return status == .enqueued || status == .running || status == .waitingToRetry || status == .paused
|
||||
}
|
||||
|
||||
/// True if this state is a final state (i.e. no more changes will happen)
|
||||
func isFinalState(status: TaskStatus) -> Bool {
|
||||
return !isNotFinalState(status: status)
|
||||
}
|
||||
|
||||
/// Returns the absolute path to the file represented by this task
|
||||
/// based on the [Task.filename] (default) or [withFilename]
|
||||
///
|
||||
/// If the task is a MultiUploadTask and no [withFilename] is given,
|
||||
/// returns the empty string, as there is no single path that can be
|
||||
/// returned
|
||||
func getFilePath(for task: Task, withFilename: String? = nil) -> String? {
|
||||
if isMultiUploadTask(task: task) && withFilename == nil {
|
||||
return ""
|
||||
}
|
||||
guard let directory = try? directoryForTask(task: task)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return directory.appendingPath(withFilename ?? task.filename).path
|
||||
}
|
||||
|
||||
func stripFileExtension ( _ filename: String ) -> String {
|
||||
var components = filename.components(separatedBy: ".")
|
||||
guard components.count > 1 else { return filename }
|
||||
components.removeLast()
|
||||
return components.joined(separator: ".")
|
||||
}
|
||||
|
||||
/// Suggests a filename based on response headers and a URL.
|
||||
/// If none can be derived, returns an empty string.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - responseHeaders: The response headers dictionary.
|
||||
/// - urlString: The URL string the file would be downloaded from.
|
||||
/// - Returns: A suggested filename, derived from the headers or the URL.
|
||||
func suggestFilename(responseHeaders: [AnyHashable: Any], urlString: String) -> String {
|
||||
do {
|
||||
if let disposition = responseHeaders["Content-Disposition"] as? String ?? responseHeaders["content-disposition"] as? String {
|
||||
// Try filename*=UTF-8'language'"encodedFilename"
|
||||
let encodedFilenameRegEx = try NSRegularExpression(pattern: #"filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?"#, options: .caseInsensitive)
|
||||
let range = NSRange(location: 0, length: disposition.utf16.count)
|
||||
if let match = encodedFilenameRegEx.firstMatch(in: disposition, options: [], range: range) {
|
||||
if let encodingRange = Range(match.range(at: 1), in: disposition),
|
||||
let filenameRange = Range(match.range(at: 3), in: disposition) {
|
||||
let encoding = String(disposition[encodingRange]).uppercased()
|
||||
let filename = String(disposition[filenameRange])
|
||||
if encoding == "UTF-8" {
|
||||
if let decodedFilename = filename.removingPercentEncoding {
|
||||
return decodedFilename
|
||||
} else {
|
||||
os_log("Could not interpret suggested filename (UTF-8 url encoded) %@", log: log, type: .debug, filename)
|
||||
}
|
||||
} else {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try filename="filename"
|
||||
let plainFilenameRegEx = try NSRegularExpression(pattern: #"filename=\s*"?([^"]+)"?.*$"#, options: .caseInsensitive)
|
||||
if let match = plainFilenameRegEx.firstMatch(in: disposition, options: [], range: range) {
|
||||
if let filenameRange = Range(match.range(at: 1), in: disposition) {
|
||||
return String(disposition[filenameRange])
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
os_log("Error parsing suggested filename from headers: %@", log: log, type: .error, error.localizedDescription)
|
||||
}
|
||||
|
||||
os_log("Could not determine suggested filename from server", log: log, type: .debug)
|
||||
|
||||
// Try filename derived from last path segment of the url
|
||||
if let url = URL(string: urlString), !url.lastPathComponent.isEmpty {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
|
||||
os_log("Could not parse URL pathSegment for suggested filename", log: log, type: .debug)
|
||||
return "" // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the task with the [Task.filename] property changed
|
||||
* to the filename suggested by the server, or derived from the url, or
|
||||
* unchanged.
|
||||
*
|
||||
* If [unique] is true, the filename is guaranteed not to already exist. This
|
||||
* is accomplished by adding a suffix to the suggested filename with a number,
|
||||
* e.g. "data (2).txt"
|
||||
*
|
||||
* The server-suggested filename is obtained from the [responseHeaders] entry
|
||||
* "Content-Disposition"
|
||||
*/
|
||||
func taskWithSuggestedFilenameFromResponseHeaders(
|
||||
task: Task,
|
||||
responseHeaders: [AnyHashable: Any],
|
||||
unique: Bool = false
|
||||
) -> Task {
|
||||
let suggestedFilenameValue = suggestFilename(responseHeaders: responseHeaders, urlString: task.url)
|
||||
|
||||
if !suggestedFilenameValue.isEmpty {
|
||||
return uniqueFilename(task: task.copyWith(filename: suggestedFilenameValue), unique: unique)
|
||||
}
|
||||
|
||||
// If everything fails, return the task with an unchanged filename
|
||||
// except for possibly making it unique
|
||||
return uniqueFilename(task: task, unique: unique)
|
||||
}
|
||||
|
||||
/// Returns [Task] with a filename similar to the one
|
||||
/// supplied, but unused.
|
||||
///
|
||||
/// If [unique], filename will sequence up in "filename (8).txt" format,
|
||||
/// otherwise returns the [task]
|
||||
func uniqueFilename(task: Task, unique: Bool) -> Task {
|
||||
if !unique {
|
||||
return task
|
||||
}
|
||||
let sequenceRegEx = try! NSRegularExpression(pattern: #"\((\d+)\)\.?[^.]*$"#)
|
||||
let extensionRegEx = try! NSRegularExpression(pattern: #"\.[^.]*$"#)
|
||||
var newTask = task
|
||||
guard let filePath = getFilePath(for: task) else {
|
||||
return task
|
||||
}
|
||||
var exists = FileManager.default.fileExists(atPath: filePath)
|
||||
while exists {
|
||||
let range = NSMakeRange(0, newTask.filename.utf16.count)
|
||||
let extMatch = extensionRegEx.firstMatch(in: newTask.filename, options: [], range: range)
|
||||
let extString = extMatch != nil ? String(newTask.filename[Range(extMatch!.range, in: newTask.filename)!]) : ""
|
||||
let seqMatch = sequenceRegEx.firstMatch(in: newTask.filename, options: [], range: range)
|
||||
let seqString = seqMatch != nil ? String(newTask.filename[Range(seqMatch!.range(at: 1), in: newTask.filename)!]) : ""
|
||||
let newSequence = seqString.isEmpty ? 1 : Int(seqString)! + 1
|
||||
let newFilename: String
|
||||
if seqMatch == nil {
|
||||
let baseNameWithoutExtension = stripFileExtension(newTask.filename)
|
||||
newFilename = "\(baseNameWithoutExtension) (\(newSequence))\(extString)"
|
||||
} else {
|
||||
let startOfSeq = seqMatch!.range.location
|
||||
let index = newTask.filename.index(newTask.filename.startIndex, offsetBy: startOfSeq)
|
||||
newFilename = "\(newTask.filename.prefix(upTo: index))(\(newSequence))\(extString)"
|
||||
}
|
||||
newTask = newTask.copyWith(filename: newFilename)
|
||||
guard let filePath = getFilePath(for: newTask) else {
|
||||
return task
|
||||
}
|
||||
exists = FileManager.default.fileExists(atPath: filePath)
|
||||
}
|
||||
return newTask
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the range in a Range header, and returns a Pair representing
|
||||
* the range. The format needs to be "bytes=10-20"
|
||||
*
|
||||
* A missing lower range is substituted with 0L, and a missing upper
|
||||
* range with null. If the string cannot be parsed, returns (0L, null)
|
||||
*/
|
||||
func parseRange(rangeStr: String) -> (Int64, Int64?) {
|
||||
let regex = try! NSRegularExpression(pattern: #"bytes=(\d*)-(\d*)"#)
|
||||
let range = NSMakeRange(0, rangeStr.utf16.count)
|
||||
if let match = regex.firstMatch(in: rangeStr, options: [], range: range) {
|
||||
let start = Int64(String(rangeStr[Range(match.range(at: 1), in: rangeStr)!]))
|
||||
let end = Int64(String(rangeStr[Range(match.range(at: 2), in: rangeStr)!]))
|
||||
return (start!, end!)
|
||||
} else {
|
||||
return (0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the content length extracted from the [responseHeaders], or from
|
||||
/// the [task] headers
|
||||
func getContentLength(responseHeaders: [AnyHashable: Any], task: Task) -> Int64 {
|
||||
// On iOS, the header has already been parsed for Content-Length so we don't need to
|
||||
// repeat that here (actually, we use the bytesExpectedToSend which is for some reason
|
||||
// not always set even whe a Content-Length is set)
|
||||
// Try extracting content length from Range header
|
||||
let taskRangeHeader = task.headers["Range"] ?? ""
|
||||
let taskRange = parseRange(rangeStr: taskRangeHeader)
|
||||
if let end = taskRange.1 {
|
||||
let rangeLength = end - taskRange.0 + 1
|
||||
os_log("TaskId %@ contentLength set to %d based on Range header", log: log, type: .info, task.taskId, rangeLength)
|
||||
return rangeLength
|
||||
}
|
||||
|
||||
// try extracting it from a special "Known-Content-Length" header
|
||||
let knownLength = Int64(task.headers["Known-Content-Length"] ?? "-1") ?? -1
|
||||
if knownLength != -1 {
|
||||
os_log("TaskId %@ contentLength set to %d based on Known-Content-Length header", log: log, type: .info, task.taskId, knownLength)
|
||||
} else {
|
||||
os_log("TaskId %@ contentLength undetermined", log: log, type: .info, task.taskId)
|
||||
}
|
||||
return knownLength
|
||||
}
|
||||
|
||||
/// Sets the mimeType and charSet extracted from the Content-Type header
|
||||
/// in [responseHeaders] and stores in static maps keyed by [task.taskId]
|
||||
func extractContentType(responseHeaders: [AnyHashable: Any], task: Task) {
|
||||
guard let contentType = responseHeaders["Content-Type"] as? String else { return }
|
||||
let regEx = try! NSRegularExpression(pattern: #"(.*);\s*charset\s*=(.*)"#)
|
||||
let range = NSMakeRange(0, contentType.utf16.count)
|
||||
let match = regEx.firstMatch(in: contentType, options: [], range: range)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
if let match = match {
|
||||
let mimeType = String(contentType[Range(match.range(at: 1), in: contentType)!])
|
||||
let charSet = String(contentType[Range(match.range(at: 2), in: contentType)!])
|
||||
BDPlugin.mimeTypes[task.taskId] = mimeType
|
||||
BDPlugin.charSets[task.taskId] = charSet
|
||||
} else {
|
||||
BDPlugin.mimeTypes[task.taskId] = contentType
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Returns a list of fileData elements, one for each file to upload.
|
||||
/// Each element is a triple containing fileField, full filePath, mimeType
|
||||
///
|
||||
/// The lists are stored in the similarly named String fields as a JSON list,
|
||||
/// with each list the same length. For the filenames list, if a filename refers
|
||||
/// to a file that exists (i.e. it is a full path) then that is the filePath used,
|
||||
/// otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory]
|
||||
/// to form a full file path
|
||||
func extractFilesData(task: Task) -> [((String, String, String))] {
|
||||
let decoder = JSONDecoder()
|
||||
guard
|
||||
let fileFields = try? decoder.decode([String].self, from: task.fileField!.data(using: .utf8)!),
|
||||
let filenames = try? decoder.decode([String].self, from: task.filename.data(using: .utf8)!),
|
||||
let mimeTypes = try? decoder.decode([String].self, from: task.mimeType!.data(using: .utf8)!)
|
||||
else {
|
||||
os_log("Could not parse filesData from field=%@, filename=%@ and mimeType=%@", log: log, type: .error, task.fileField!, task.filename, task.mimeType!)
|
||||
return []
|
||||
}
|
||||
var result = [(String, String, String)]()
|
||||
for i in 0 ..< fileFields.count {
|
||||
let fileUrl = URL(string: filenames[i])
|
||||
let decodedFileUrl = fileUrl != nil ? decodeToFileUrl(uri: fileUrl!) : nil
|
||||
if decodedFileUrl?.scheme == "file" {
|
||||
// URI mode, return path
|
||||
result.append((fileFields[i], decodedFileUrl!.path, mimeTypes[i]))
|
||||
} else {
|
||||
// file path mode
|
||||
if FileManager.default.fileExists(atPath: filenames[i]) {
|
||||
result.append((fileFields[i], filenames[i], mimeTypes[i]))
|
||||
} else {
|
||||
result.append((
|
||||
fileFields[i],
|
||||
getFilePath(for: task, withFilename: filenames[i]) ?? "",
|
||||
mimeTypes[i]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Return the host name for the task's url or ""
|
||||
func getHost(_ task: Task) -> String {
|
||||
return URL(string: task.url)?.host ?? ""
|
||||
}
|
||||
|
||||
/// Calculate progress, network speed and time remaining, and send this at an appropriate
|
||||
/// interval to the Dart side
|
||||
func updateProgress(task: Task, totalBytesExpected: Int64, totalBytesDone: Int64) {
|
||||
let info = BDPlugin.propertyLock.withLock({
|
||||
return BDPlugin.progressInfo[task.taskId] ?? (lastProgressUpdateTime: 0.0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
|
||||
})
|
||||
let now = Date().timeIntervalSince1970
|
||||
if totalBytesExpected != NSURLSessionTransferSizeUnknown && now > info.lastProgressUpdateTime + 0.5 {
|
||||
let progress = min(Double(totalBytesDone) / Double(totalBytesExpected), 0.999)
|
||||
if (progress - info.lastProgressValue > 0.02) || (progress > info.lastProgressValue && now > info.lastProgressUpdateTime + 2.5) {
|
||||
// calculate network speed and time remaining
|
||||
let now = Date().timeIntervalSince1970
|
||||
let timeSinceLastUpdate = now - info.lastProgressUpdateTime
|
||||
let bytesSinceLastUpdate = totalBytesDone - info.lastTotalBytesDone
|
||||
let currentNetworkSpeed: Double = timeSinceLastUpdate > 3600 ? -1.0 : Double(bytesSinceLastUpdate) / timeSinceLastUpdate / 1000000.0
|
||||
let newNetworkSpeed = info.lastNetworkSpeed == -1.0 ? currentNetworkSpeed : (info.lastNetworkSpeed * 3.0 + currentNetworkSpeed) / 4.0
|
||||
let remainingBytes = (1.0 - progress) * Double(totalBytesExpected)
|
||||
let timeRemaining: TimeInterval = newNetworkSpeed == -1.0 ? -1.0 : (remainingBytes / newNetworkSpeed / 1000000.0)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: now, lastProgressValue: progress, lastTotalBytesDone: totalBytesDone, lastNetworkSpeed: newNetworkSpeed)
|
||||
})
|
||||
processProgressUpdate(task: task, progress: progress, expectedFileSize: totalBytesExpected, networkSpeed: newNetworkSpeed, timeRemaining: timeRemaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Processes a change in status for the task
|
||||
///
|
||||
/// Sends status update via the background channel to Dart, if requested
|
||||
/// If the task is finished, processes a final progressUpdate update and removes
|
||||
/// task from persistent storage
|
||||
func processStatusUpdate(task: Task, status: TaskStatus, taskException: TaskException? = nil, responseBody: String? = nil, responseHeaders: [AnyHashable:Any]? = nil, responseStatusCode: Int? = nil, mimeType: String? = nil, charSet: String? = nil) {
|
||||
// Intercept status updates resulting from re-enqueue requests, which
|
||||
// themselves are triggered by a change in WiFi requirement
|
||||
let intercepted = BDPlugin.propertyLock.withLock {
|
||||
if BDPlugin.tasksToReEnqueue.remove(task) != nil {
|
||||
defer {
|
||||
if BDPlugin.tasksToReEnqueue.isEmpty {
|
||||
WiFiQueue.shared.reEnqueue(nil) // signal end of batch
|
||||
}
|
||||
}
|
||||
if [TaskStatus.paused, TaskStatus.canceled, TaskStatus.failed].contains(status) {
|
||||
_ = BDPlugin.progressInfo.removeValue(forKey: task.taskId) // ensure .running update on re-enqueue
|
||||
BDPlugin.initialResponseDataProcessed.remove(task.taskId)
|
||||
let resumeDataAsBase64String = status == .paused ? (BDPlugin.localResumeData[task.taskId] ?? "") : ""
|
||||
if status != .paused {
|
||||
BDPlugin.localResumeData.removeValue(forKey: task.taskId)
|
||||
}
|
||||
let reEnqueueData = EnqueueItem(task: task, notificationConfigJsonString: BDPlugin.notificationConfigJsonStrings[task.taskId], resumeDataAsBase64String: resumeDataAsBase64String)
|
||||
WiFiQueue.shared.reEnqueue(reEnqueueData)
|
||||
return true // intercepted
|
||||
}
|
||||
}
|
||||
return false // not intercepted
|
||||
}
|
||||
if intercepted {
|
||||
return
|
||||
}
|
||||
|
||||
// Normal status update
|
||||
|
||||
// Post update if task expects one, or if failed and retry is needed
|
||||
let retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0
|
||||
// if task is in final state, process a final progressUpdate
|
||||
// A 'failed' progress update is only provided if
|
||||
// a retry is not needed: if it is needed, a `waitingToRetry` progress update
|
||||
// will be generated on the Dart side
|
||||
switch (status) {
|
||||
case .complete:
|
||||
processProgressUpdate(task: task, progress: 1.0)
|
||||
case .failed:
|
||||
if !retryNeeded {
|
||||
processProgressUpdate(task: task, progress: -1.0)
|
||||
}
|
||||
case .canceled:
|
||||
processProgressUpdate(task: task, progress: -2.0)
|
||||
case .notFound:
|
||||
processProgressUpdate(task: task, progress: -3.0)
|
||||
case .paused:
|
||||
processProgressUpdate(task: task, progress: -5.0)
|
||||
default:
|
||||
break
|
||||
}
|
||||
// determine the TaskStatusUpdate
|
||||
let finalResponseStatusCode = status == .complete || status == .notFound
|
||||
? responseStatusCode
|
||||
: nil
|
||||
let finalTaskException = taskException == nil
|
||||
? TaskException(type: .general, httpResponseCode: -1, description: "")
|
||||
: taskException
|
||||
let statusUpdate = isFinalState(status: status)
|
||||
? TaskStatusUpdate(task: task,
|
||||
taskStatus: status,
|
||||
exception: status == .failed ? finalTaskException : nil,
|
||||
responseBody: responseBody,
|
||||
responseStatusCode: (status == .complete || status == .notFound) ? finalResponseStatusCode : nil,
|
||||
responseHeaders: lowerCasedStringStringMap(responseHeaders),
|
||||
mimeType: mimeType,
|
||||
charSet: charSet)
|
||||
: TaskStatusUpdate(task: task, taskStatus: status)
|
||||
if let onNativeTaskStatusChange = BDPlugin.onNativeTaskStatusChange {
|
||||
onNativeTaskStatusChange(task, statusUpdate)
|
||||
}
|
||||
if providesStatusUpdates(downloadTask: task) || retryNeeded {
|
||||
let arg = statusUpdate.argList()
|
||||
if !postOnBackgroundChannel(method: "statusUpdate", task: task, arg: arg) {
|
||||
// store update locally as a merged task/status JSON string, without error info
|
||||
guard let jsonData = try? JSONEncoder().encode(statusUpdate)
|
||||
else {
|
||||
os_log("Could not store status update locally", log: log, type: .debug)
|
||||
return }
|
||||
storeLocally(prefsKey: BDPlugin.keyStatusUpdateMap, taskId: task.taskId, item: jsonData)
|
||||
}
|
||||
}
|
||||
if isFinalState(status: status) {
|
||||
// remove references to this task that are no longer needed
|
||||
BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.progressInfo.removeValue(forKey: task.taskId)
|
||||
BDPlugin.localResumeData.removeValue(forKey: task.taskId)
|
||||
BDPlugin.remainingBytesToDownload.removeValue(forKey: task.taskId)
|
||||
BDPlugin.tasksWithModifications.removeValue(forKey: task.taskId)
|
||||
BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: task.taskId)
|
||||
BDPlugin.responseBodyData.removeValue(forKey: task.taskId)
|
||||
BDPlugin.notificationConfigJsonStrings.removeValue(forKey: task.taskId)
|
||||
BDPlugin.taskIdsThatCanResume.remove(task.taskId)
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(task.taskId)
|
||||
}
|
||||
// invoke onTaskFinished callback if needed
|
||||
if task.options?.hasOnFinishedCallback() == true {
|
||||
_Concurrency.Task {
|
||||
if !(await invokeOnTaskFinishedCallback(taskStatusUpdate: statusUpdate)) {
|
||||
os_log("Could not invoke onTaskFinishedCallback", log: log, type: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Processes a progress update for the task
|
||||
///
|
||||
/// Sends progress update via the background channel to Dart, if requested
|
||||
func processProgressUpdate(task: Task, progress: Double, expectedFileSize: Int64 = -1, networkSpeed: Double = -1.0, timeRemaining: TimeInterval = -1.0) {
|
||||
if let onNativeTaskProgressChange = BDPlugin.onNativeTaskProgressChange {
|
||||
onNativeTaskProgressChange(task, progress)
|
||||
}
|
||||
if providesProgressUpdates(task: task) {
|
||||
if (!postOnBackgroundChannel(method: "progressUpdate", task: task, arg: [progress, expectedFileSize, networkSpeed, Int(timeRemaining * 1000.0)] as [Any])) {
|
||||
// store update locally as a merged task/progress JSON string
|
||||
guard let jsonData = try? JSONEncoder().encode(TaskProgressUpdate(task: task, progress: progress, expectedFileSize: expectedFileSize))
|
||||
else {
|
||||
os_log("Could not store progress update locally", log: log, type: .info)
|
||||
return }
|
||||
storeLocally(prefsKey: BDPlugin.keyProgressUpdateMap, taskId: task.taskId, item: jsonData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a 'canResume' message for the task
|
||||
///
|
||||
/// Sends the data via the background channel to Dart
|
||||
func processCanResume(task: Task, taskCanResume: Bool) {
|
||||
if !postOnBackgroundChannel(method: "canResume", task: task, arg: taskCanResume) {
|
||||
os_log("Could not post CanResume", log: log, type: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Post resume data for this task
|
||||
///
|
||||
/// Returns true if successful.
|
||||
/// Sends the data via the background channel to Dart
|
||||
func processResumeData(task: Task, resumeData: Data) -> Bool {
|
||||
let resumeDataAsBase64String = resumeData.base64EncodedString()
|
||||
BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.localResumeData[task.taskId] = resumeDataAsBase64String
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "resumeData", task: task, arg: resumeDataAsBase64String) {
|
||||
// store resume data locally
|
||||
guard let jsonData = try? JSONEncoder().encode(ResumeData(task: task, data: resumeDataAsBase64String))
|
||||
else {
|
||||
os_log("Could not store resume data locally", log: log, type: .info)
|
||||
return false}
|
||||
storeLocally(prefsKey: BDPlugin.keyResumeDataMap, taskId: task.taskId, item: jsonData)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Return the background channel for cummincation to Dart side, or nil
|
||||
func getBackgroundChannel() -> FlutterMethodChannel? {
|
||||
guard let channel = BDPlugin.backgroundChannel else {
|
||||
os_log("Could not find background channel", log: log, type: .error)
|
||||
return nil
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
/// Post method message on backgroundChannel with arguments and return true if this was successful
|
||||
///
|
||||
/// [arg] can be a list or a single variable
|
||||
func postOnBackgroundChannel(method: String, task:Task, arg: Any) -> Bool {
|
||||
guard let channel = BDPlugin.backgroundChannel else {
|
||||
os_log("Could not find background channel", log: log, type: .error)
|
||||
return false
|
||||
}
|
||||
guard let jsonString = jsonStringFor(task: task) else {
|
||||
os_log("Could not convert task to JSON", log: log, type: .error)
|
||||
return false
|
||||
}
|
||||
var argsList: [Any?] = [jsonString]
|
||||
if arg is [Any?] {
|
||||
argsList.append(contentsOf: arg as! [Any?])
|
||||
} else {
|
||||
argsList.append(arg)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
DispatchQueue.main.async {
|
||||
channel.invokeMethod(method, arguments: argsList)
|
||||
}
|
||||
return true
|
||||
}
|
||||
var success = false
|
||||
updatesQueue.sync {
|
||||
let dispatchGroup = DispatchGroup()
|
||||
dispatchGroup.enter()
|
||||
DispatchQueue.main.async {
|
||||
channel.invokeMethod(method, arguments: argsList, result: {(r: Any?) -> () in
|
||||
success = !(r is FlutterError)
|
||||
if BDPlugin.forceFailPostOnBackgroundChannel {
|
||||
success = false
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
})
|
||||
}
|
||||
dispatchGroup.wait()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/// Store the [item] in preferences under [prefsKey], keyed by [taskId]
|
||||
///
|
||||
/// [item] is a JsonEncoded Data object
|
||||
func storeLocally(prefsKey: String, taskId: String,
|
||||
item: Data) {
|
||||
let defaults = UserDefaults.standard
|
||||
var map = defaults.dictionary(forKey: prefsKey) ?? [:]
|
||||
map[taskId] = String(data: item, encoding: .utf8)
|
||||
defaults.set(map, forKey: prefsKey)
|
||||
}
|
||||
|
||||
/// Returns a JSON string for this Task, or nil
|
||||
func jsonStringFor(task: Task) -> String? {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
guard let jsonResultData = try? jsonEncoder.encode(task)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(data: jsonResultData, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Returns a JSON string for this TaskStatusUpdate, or nil
|
||||
func jsonStringFor(taskStatusUpdate: TaskStatusUpdate) -> String? {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
guard let jsonResultData = try? jsonEncoder.encode(taskStatusUpdate)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(data: jsonResultData, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Returns a Task from the supplied jsonString, or nil
|
||||
func taskFrom(jsonString: String) -> Task? {
|
||||
let decoder = JSONDecoder()
|
||||
let task: Task? = try? decoder.decode(Task.self, from: (jsonString).data(using: .utf8)!)
|
||||
return task
|
||||
}
|
||||
|
||||
/// Return the task corresponding to the URLSessionTask, or nil if it cannot be matched
|
||||
///
|
||||
/// May return a modified version of the task instead of the original stored with the URLSessionTask, e.g.
|
||||
/// if the filename has changed
|
||||
func getTaskFrom(urlSessionTask: URLSessionTask) -> Task? {
|
||||
guard let jsonData = getTaskJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
if let task = try? decoder.decode(Task.self, from: jsonData) {
|
||||
let modifiedTask = BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.tasksWithModifications[task.taskId]
|
||||
})
|
||||
return modifiedTask ?? task
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Marks the task as modified such that [getTaskFrom] a UrlSession wlll get the modified task, not the one'
|
||||
/// immutably stored with the urlSession
|
||||
func storeModifiedTask(task: Task) {
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.tasksWithModifications[task.taskId] = task
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the taskJsonString contained in the urlSessionTask
|
||||
func getTaskJsonStringFrom(urlSessionTask: URLSessionTask) -> String? {
|
||||
guard let taskDescription = urlSessionTask.taskDescription else {
|
||||
return nil
|
||||
}
|
||||
if taskDescription.contains(separatorString) {
|
||||
return taskDescription.components(separatedBy: separatorString)[0]
|
||||
}
|
||||
return taskDescription
|
||||
}
|
||||
|
||||
/// Return the notificationConfig corresponding to the URLSessionTask, or nil if it cannot be matched
|
||||
func getNotificationConfigFrom(urlSessionTask: URLSessionTask) -> NotificationConfig? {
|
||||
guard let jsonData = getNotificationConfigJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
return try? decoder.decode(NotificationConfig.self, from: jsonData)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the notificationConfigJsonString contained in the urlSessionTask
|
||||
func getNotificationConfigJsonStringFrom(urlSessionTask: URLSessionTask) -> String? {
|
||||
guard let taskDescription = urlSessionTask.taskDescription else {
|
||||
return nil
|
||||
}
|
||||
if taskDescription.contains(separatorString) {
|
||||
return taskDescription.components(separatedBy: separatorString)[1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the URL of the directory where the file for this task is stored
|
||||
///
|
||||
/// This is made up of the baseDirectory and the directory fields of the Task
|
||||
func directoryForTask(task: Task) throws -> URL {
|
||||
let documentsURL: URL
|
||||
if task.baseDirectory != BaseDirectory.root.rawValue {
|
||||
var dir: FileManager.SearchPathDirectory
|
||||
switch task.baseDirectory {
|
||||
case 0:
|
||||
dir = .documentDirectory
|
||||
case 1:
|
||||
dir = .cachesDirectory
|
||||
case 2:
|
||||
dir = .applicationSupportDirectory
|
||||
case 3:
|
||||
dir = .libraryDirectory
|
||||
default:
|
||||
dir = .documentDirectory
|
||||
}
|
||||
documentsURL =
|
||||
try FileManager.default.url(for: dir,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true)
|
||||
} else {
|
||||
documentsURL = URL(fileURLWithPath: "/")
|
||||
}
|
||||
return task.directory.isEmpty
|
||||
? documentsURL
|
||||
: documentsURL.appendingPath(task.directory, isDirectory: true)
|
||||
|
||||
}
|
||||
|
||||
/// True if task requires WiFi, based on global and task-specific setting
|
||||
func taskRequiresWiFi(task: Task) -> Bool {
|
||||
return BDPlugin.requireWiFi == .forAllTasks || (BDPlugin.requireWiFi == .asSetByTask && task.requiresWiFi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is insufficient space to store a file of length
|
||||
* [contentLength]
|
||||
*
|
||||
* Returns false if [contentLength] <= 0
|
||||
* Returns false if configCheckAvailableSpace has not been set, or if available
|
||||
* space is greater than that setting
|
||||
* Returns true otherwise
|
||||
*/
|
||||
func insufficientSpace(contentLength: Int64) -> Bool {
|
||||
guard contentLength > 0 else {
|
||||
return false
|
||||
}
|
||||
let checkValue = UserDefaults.standard.integer(forKey: BDPlugin.keyConfigCheckAvailableSpace)
|
||||
guard
|
||||
// Check if the configCheckAvailableSpace preference is set and is positive
|
||||
checkValue > 0,
|
||||
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first,
|
||||
let available = try? URL(fileURLWithPath: path).resourceValues(forKeys: [URLResourceKey.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage
|
||||
else {
|
||||
return false
|
||||
}
|
||||
// Calculate the total remaining bytes to download
|
||||
let remainingBytesToDownload = BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.remainingBytesToDownload.values.reduce(0, +)
|
||||
})
|
||||
// Return true if there is insufficient space to store the file
|
||||
return available - (remainingBytesToDownload + contentLength) < checkValue << 20
|
||||
}
|
||||
|
||||
/// Post result [value] on FlutterResult completer
|
||||
func postResult(result: FlutterResult?, value: Any) {
|
||||
if result != nil {
|
||||
result!(value)
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//
|
||||
// Uploader.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 2/11/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
enum StreamError: Error {
|
||||
case Closed
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Uploader associated with one URLSessionUploadTask
|
||||
public class Uploader : NSObject, URLSessionTaskDelegate, StreamDelegate {
|
||||
|
||||
var task: Task
|
||||
let outputFilename: String
|
||||
var contentDispositionString = ""
|
||||
var contentTypeString = ""
|
||||
var fieldsString = ""
|
||||
var totalBytesWritten: Int64 = 0
|
||||
static let boundary = "-----background_downloader-akjhfw281onqciyhnIk"
|
||||
let lineFeed = "\r\n"
|
||||
let asciiOnly = try! NSRegularExpression(pattern: "^[\\x00-\\x7F]+$")
|
||||
let jsonString = try! NSRegularExpression(pattern: "^\\s*(\\{.*\\}|\\[.*\\])\\s*$")
|
||||
let newlineRegExp = try! NSRegularExpression(pattern: "\r\n|\r|\n")
|
||||
let bufferSize = 2 << 13
|
||||
|
||||
|
||||
/// Initialize an Uploader for this Task
|
||||
init(task: Task) {
|
||||
self.task = task
|
||||
outputFilename = NSUUID().uuidString
|
||||
}
|
||||
|
||||
|
||||
/// Creates the multipart file so it can be uploaded
|
||||
///
|
||||
/// Returns true if successful, false otherwise (cleaning up any partial file on failure).
|
||||
public func createMultipartFile() -> Bool {
|
||||
if writeMultipartFile() {
|
||||
return true
|
||||
}
|
||||
try? FileManager.default.removeItem(at: outputFileUrl())
|
||||
return false
|
||||
}
|
||||
|
||||
/// Writes the multipart file, returning true if successful
|
||||
private func writeMultipartFile() -> Bool {
|
||||
// create the output file
|
||||
FileManager.default.createFile(atPath: outputFileUrl().path, contents:Data(" ".utf8), attributes: nil)
|
||||
guard let fileHandle = try? FileHandle(forWritingTo: outputFileUrl()) else {
|
||||
os_log("Could not open temporary file %@", log: log, type: .error, outputFileUrl().path)
|
||||
return false
|
||||
}
|
||||
// field portion of the multipart, all in one string
|
||||
// multiple values should be encoded as '"value1", "value2", ...'
|
||||
let multiValueRegEx = try! NSRegularExpression(pattern: #"^(?:"[^"]+"\s*,\s*)+"[^"]+"$"#, options: [])
|
||||
for entry in task.fields ?? [:] {
|
||||
let value = entry.value
|
||||
let fullRange = NSRange(location: 0, length: value.utf16.count)
|
||||
// Check if the value matches the multi-value format
|
||||
if multiValueRegEx.firstMatch(in: value, options: [], range: fullRange) != nil {
|
||||
// Extract multiple values from entry.value
|
||||
let valueMatchRegEx = try! NSRegularExpression(pattern: #""([^"]+)""#, options: [])
|
||||
valueMatchRegEx.enumerateMatches(in: value, options: [], range: fullRange) { match, _, _ in
|
||||
if let matchRange = match?.range(at: 1), // Capture group 1
|
||||
let swiftRange = Range(matchRange, in: value) {
|
||||
let matchedString = String(value[swiftRange])
|
||||
fieldsString += fieldEntry(name: entry.key, value: matchedString)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle single value for key
|
||||
fieldsString += fieldEntry(name: entry.key, value: entry.value)
|
||||
}
|
||||
}
|
||||
if (!writeFields(fileHandle: fileHandle)) {
|
||||
os_log("Could not write to temporary file %@", log: log, type: .error, outputFileUrl().path)
|
||||
return false
|
||||
}
|
||||
// File portion of the multi-part
|
||||
// Assumes list of files. If only one file, that becomes a list of length one.
|
||||
// For each file, determine contentDispositionString, contentTypeString
|
||||
// and file length, so that we can calculate total size of upload
|
||||
let separator = "\(lineFeed)--\(Uploader.boundary)\(lineFeed)" // between files
|
||||
let terminator = "\(lineFeed)--\(Uploader.boundary)--\(lineFeed)" // after last file
|
||||
let maybeFileUri = uriFromStringValue(maybePacked: task.filename)
|
||||
let maybeDecodedFileUri = maybeFileUri != nil ? decodeToFileUrl(uri: maybeFileUri!) : nil
|
||||
guard let filePath = getFilePath(for: task) else {return false}
|
||||
let filesData = filePath.isEmpty
|
||||
? extractFilesData(task: task) // MultiUpload case
|
||||
: [(task.fileField!,maybeDecodedFileUri?.path ?? filePath, task.mimeType!)] // one file Upload case
|
||||
for (fileField, path, mimeType) in filesData {
|
||||
if !FileManager.default.fileExists(atPath: path) {
|
||||
os_log("File to upload does not exist at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
let derivedFilename = path.components(separatedBy: "/").last!
|
||||
if filesData.count == 1 && task.taskType != "MultiUploadTask" {
|
||||
// only for single file uploads do we set the task's filename property
|
||||
let newTask = task.copyWith(filename: maybeFileUri != nil
|
||||
? pack(filename: derivedFilename, uri: maybeFileUri!)
|
||||
: derivedFilename)
|
||||
storeModifiedTask(task: newTask)
|
||||
}
|
||||
let contentDispositionString =
|
||||
"Content-Disposition: form-data; name=\"\(browserEncode(fileField))\"; "
|
||||
+ "filename=\"\(browserEncode(derivedFilename))\"\(lineFeed)"
|
||||
let resolvedMimeType = mimeType.isEmpty ? getMimeType(fromFilename: path) : mimeType
|
||||
let contentTypeString = "Content-Type: \(resolvedMimeType)\(lineFeed)\(lineFeed)"
|
||||
let fileUrl = URL(fileURLWithPath: path)
|
||||
guard let inputStream = InputStream(url: fileUrl) else {
|
||||
os_log("Could not open file to upload at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
if (!writeFileBytes(fileHandle: fileHandle, inputStream: inputStream, contentDisposition: contentDispositionString, contentType: contentTypeString)) {
|
||||
os_log("Could not upload file at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
if (!writeText(fileHandle: fileHandle, text: path == filesData.last!.1 ?
|
||||
terminator : separator)) {
|
||||
os_log("Could not write separator for file at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Return the URL of the generated outputfile with multipart data
|
||||
///
|
||||
/// Callers outside this class should only use this after calling
|
||||
/// createMultipartFile, and only when that returns true
|
||||
func outputFileUrl() -> URL {
|
||||
return FileManager.default.temporaryDirectory.appendingPath(outputFilename)
|
||||
}
|
||||
|
||||
/// Write form fields
|
||||
///
|
||||
/// Returns true if successful, false otherwise
|
||||
private func writeFields(fileHandle: FileHandle) -> Bool {
|
||||
// construct the preamble
|
||||
return writeText(fileHandle: fileHandle, text: "\(fieldsString)--\(Uploader.boundary)\(lineFeed)")
|
||||
}
|
||||
|
||||
/// Write the data bytes from the provided inputStream to the fileHandle
|
||||
///
|
||||
/// Retruns true if successful, false otherwise
|
||||
private func writeFileBytes(fileHandle: FileHandle, inputStream: InputStream, contentDisposition: String, contentType: String) -> Bool {
|
||||
// multipart file header
|
||||
if !(writeText(fileHandle: fileHandle, text: contentDisposition) &&
|
||||
writeText(fileHandle: fileHandle, text: contentType)) {
|
||||
return false
|
||||
}
|
||||
// file bytes
|
||||
inputStream.open()
|
||||
while inputStream.hasBytesAvailable {
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||
defer {
|
||||
buffer.deallocate()
|
||||
}
|
||||
let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
|
||||
if bytesRead < 0 {
|
||||
// Stream error occured
|
||||
os_log("Error reading from file for taskId %@", log: log, type: .error, task.taskId)
|
||||
inputStream.close()
|
||||
return false
|
||||
} else if bytesRead == 0 {
|
||||
inputStream.close()
|
||||
return true
|
||||
}
|
||||
let data = Data.init(bytesNoCopy: buffer, count: bytesRead, deallocator: .none)
|
||||
do {
|
||||
try fileHandle.write(contentsOf: data)
|
||||
} catch {
|
||||
os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription)
|
||||
inputStream.close()
|
||||
return false
|
||||
}
|
||||
}
|
||||
inputStream.close()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Write text to the [fileHandle] and return true if successful
|
||||
private func writeText(fileHandle: FileHandle, text: String) -> Bool {
|
||||
guard let epilogue = text.data(using: .utf8) else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try fileHandle.write(contentsOf: epilogue)
|
||||
} catch {
|
||||
os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
totalBytesWritten += Int64(epilogue.count)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/// Returns the multipart entry for one field name/value pair
|
||||
private func fieldEntry(name: String, value: String) -> String {
|
||||
return "--\(Uploader.boundary)\(lineFeed)\(headerForField(name: name, value: value))\(value)\(lineFeed)"
|
||||
}
|
||||
|
||||
/// Returns the header string for a field
|
||||
///
|
||||
/// The return value is guaranteed to contain only ASCII characters
|
||||
private func headerForField(name: String, value: String) -> String {
|
||||
var header = "Content-Disposition: form-data; name=\"\(browserEncode(name))\""
|
||||
if isJsonString(value) {
|
||||
header = "\(header)\r\n" +
|
||||
"Content-Type: application/json; charset=utf-8"
|
||||
} else if !isPlainAscii(value) {
|
||||
header = "\(header)\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: binary"
|
||||
}
|
||||
return "\(header)\r\n\r\n"
|
||||
}
|
||||
|
||||
|
||||
/// Returns whether [string] is composed entirely of ASCII-compatible characters
|
||||
private func isPlainAscii(_ string: String)-> Bool {
|
||||
let result = asciiOnly.matches(in: string,
|
||||
range: NSMakeRange(0, (string as NSString).length))
|
||||
return !result.isEmpty
|
||||
}
|
||||
|
||||
/// Returns whether [string] is a JSON formatted string
|
||||
private func isJsonString(_ string: String)-> Bool {
|
||||
let result = jsonString.matches(in: string,
|
||||
range: NSMakeRange(0, (string as NSString).length))
|
||||
return !result.isEmpty
|
||||
}
|
||||
|
||||
/// Encode [value] in the same way browsers do
|
||||
private func browserEncode(_ value: String)-> String {
|
||||
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
|
||||
// field names and file names, but in practice user agents seem not to
|
||||
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
|
||||
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
|
||||
// characters). We follow their behavior.
|
||||
let newlinesReplaced = newlineRegExp.stringByReplacingMatches(
|
||||
in: value,
|
||||
options: [],
|
||||
range: NSMakeRange(0, (value as NSString).length), withTemplate: "%0D%0A")
|
||||
return newlinesReplaced.replacingOccurrences(of: "\"", with: "%22")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+762
@@ -0,0 +1,762 @@
|
||||
//
|
||||
// UriUtils.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 1/18/25.
|
||||
//
|
||||
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import MobileCoreServices
|
||||
import PhotosUI
|
||||
import os.log
|
||||
|
||||
|
||||
|
||||
public class UriUtilsMethodCallHelper: NSObject,
|
||||
FlutterPlugin,
|
||||
UIDocumentPickerDelegate,
|
||||
PHPickerViewControllerDelegate {
|
||||
private var flutterResult: FlutterResult?
|
||||
private var persistedUriPermission: Bool = false
|
||||
private var multipleAllowed: Bool = false
|
||||
private var allowVideos = false
|
||||
private var localFileUrls: [String] = [] // for media picker
|
||||
private var accessedSecurityScopedUrls: Set<URL> = Set()
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "com.bbflight.background_downloader.uriutils", binaryMessenger: registrar.messenger())
|
||||
let instance = UriUtilsMethodCallHelper()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "pickDirectory":
|
||||
pickDirectory(call, result: result)
|
||||
case "pickFiles":
|
||||
pickFiles(call, result: result)
|
||||
case "createDirectory":
|
||||
createDirectory(call, result: result)
|
||||
case "activateUri":
|
||||
activateUri(call, result: result)
|
||||
case "getFileBytes":
|
||||
getFileBytes(call, result: result)
|
||||
case "copyFile":
|
||||
copyFile(call, result: result)
|
||||
case "moveFile":
|
||||
moveFile(call, result: result)
|
||||
case "deleteFile":
|
||||
deleteFile(call, result: result)
|
||||
case "openFile":
|
||||
openFile(call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func pickDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let persistedUriPermission = args[2] as? Bool else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickDirectory", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.persistedUriPermission = persistedUriPermission
|
||||
self.flutterResult = result
|
||||
|
||||
var startLocation: URL? = nil
|
||||
|
||||
if let startLocationUriString = args[1] as? String,
|
||||
let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) {
|
||||
startLocation = startLocationUri
|
||||
} else if let startLocationOrdinal = args[0] as? Int,
|
||||
let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) {
|
||||
startLocation = getInitialDirectoryUrl(location: sharedStorage)
|
||||
}
|
||||
|
||||
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder], asCopy: false)
|
||||
documentPicker.delegate = self
|
||||
documentPicker.allowsMultipleSelection = false
|
||||
|
||||
// Set the initial directory if available
|
||||
if #available(iOS 14.0, *), let startLocation = startLocation {
|
||||
documentPicker.directoryURL = startLocation
|
||||
}
|
||||
|
||||
// Present the document picker
|
||||
if let rootViewController = getRootViewController() {
|
||||
rootViewController.present(documentPicker, animated: true, completion: nil)
|
||||
} else {
|
||||
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func pickFiles(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let multipleAllowed = args[3] as? Bool,
|
||||
let persistedUriPermission = args[4] as? Bool else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickFiles", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.persistedUriPermission = persistedUriPermission
|
||||
self.multipleAllowed = multipleAllowed
|
||||
self.flutterResult = result
|
||||
|
||||
var startLocation: URL? = nil
|
||||
|
||||
if let startLocationUriString = args[1] as? String,
|
||||
let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) {
|
||||
startLocation = startLocationUri
|
||||
} else if let startLocationOrdinal = args[0] as? Int,
|
||||
let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) {
|
||||
if sharedStorage == .images || sharedStorage == .video {
|
||||
pickMedia(startLocation: sharedStorage)
|
||||
return
|
||||
}
|
||||
startLocation = getInitialDirectoryUrl(location: sharedStorage)
|
||||
}
|
||||
|
||||
// Convert allowed extensions to UTTypes
|
||||
var allowedContentTypes: [UTType] = []
|
||||
if let allowedExtensions = args[2] as? [String] {
|
||||
for ext in allowedExtensions {
|
||||
if let utType = UTType(filenameExtension: ext) {
|
||||
allowedContentTypes.append(utType)
|
||||
}
|
||||
}
|
||||
}
|
||||
if allowedContentTypes.isEmpty {
|
||||
allowedContentTypes = [UTType.item] // Allow all types if no valid extensions are provided
|
||||
}
|
||||
|
||||
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: allowedContentTypes, asCopy: false)
|
||||
documentPicker.delegate = self
|
||||
documentPicker.allowsMultipleSelection = multipleAllowed
|
||||
|
||||
// Set the initial directory if available
|
||||
if let startLocation = startLocation {
|
||||
documentPicker.directoryURL = startLocation
|
||||
}
|
||||
|
||||
// Present the document picker
|
||||
if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {
|
||||
rootViewController.present(documentPicker, animated: true, completion: nil)
|
||||
} else {
|
||||
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the photo picker to select one or more media files (images or videos).
|
||||
*
|
||||
* Called only from [pickFiles] where argument
|
||||
* parsing has already happened
|
||||
*
|
||||
* @param startLocation The shared storage location to start the picker in (images or video).
|
||||
*
|
||||
* The result posted back is either a list of URIs as a String, or null (if the user has
|
||||
* cancelled the picker) or an error
|
||||
*/
|
||||
private func pickMedia(startLocation: SharedStorage) {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = startLocation == .images ? .images : .videos
|
||||
configuration.selectionLimit = multipleAllowed ? 0 : 1 // 0 means unlimited
|
||||
let pickerViewController = PHPickerViewController(configuration: configuration)
|
||||
pickerViewController.delegate = self
|
||||
|
||||
if let rootViewController = getRootViewController() {
|
||||
rootViewController.present(pickerViewController, animated: true, completion: nil)
|
||||
} else {
|
||||
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new directory at the specified path within the given parent directory URI.
|
||||
* The parent directory URI must be resolvable to a file:// URI. Supports creating intermediate directories.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call. The arguments are expected to be a list
|
||||
* containing: the parent directory URI (String), the new directory name (String),
|
||||
* and a boolean indicating whether to request persisted URI permission.
|
||||
* - result: The Flutter result callback. On success, the URI of the newly
|
||||
* created directory (String) is returned. On failure, a `FlutterError` is returned.
|
||||
*/
|
||||
private func createDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let parentDirectoryUriString = args[0] as? String,
|
||||
let newDirectoryName = args[1] as? String,
|
||||
let persistedUriPermission = args[2] as? Bool,
|
||||
let parentDirectoryUri = decodeToFileUrl(uriString: parentDirectoryUriString)
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for createDirectory", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let newDirectoryURL = parentDirectoryUri.appendingPathComponent(newDirectoryName, isDirectory: true)
|
||||
|
||||
// Ensure we have access to the parent directory before attempting to create a subdirectory.
|
||||
if !parentDirectoryUri.startAccessingSecurityScopedResource() {
|
||||
result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access parent directory: \(parentDirectoryUri)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(parentDirectoryUri)
|
||||
|
||||
// Create the directory
|
||||
try FileManager.default.createDirectory(at: newDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
// If persistence is requested, we will also need to stop accessing and deallocate the URL when done.
|
||||
if persistedUriPermission {
|
||||
accessedSecurityScopedUrls.insert(newDirectoryURL)
|
||||
// Return the persisted URI (bookmark)
|
||||
let bookmarkUri = try createBookmarkUriFromUrl(fileURL: newDirectoryURL)
|
||||
result(bookmarkUri.absoluteString)
|
||||
} else {
|
||||
// Return the regular file URI
|
||||
result(newDirectoryURL.absoluteString)
|
||||
}
|
||||
|
||||
} catch {
|
||||
result(FlutterError(code: "CREATE_DIRECTORY_FAILED", message: "Failed to create directory: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a previously accessed directory or file (represented by a URI string)
|
||||
* by calling `startAccessingSecurityScopedResource`. This is necessary on iOS
|
||||
* to regain access to resources outside the app's sandbox that were previously
|
||||
* granted access (e.g., through a document picker). Also decodes `urlbookmark://`
|
||||
* and `media://` URIs to `file://` URIs.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call. The argument is expected to be the URI string.
|
||||
* - result: The Flutter result callback. On success, the (potentially decoded)
|
||||
* `file://` URI string is returned. On failure (e.g., invalid URI, access denied),
|
||||
* a `FlutterError` is returned. If the URI is decoded, it is also added to
|
||||
* `accessedSecurityScopedUrls`
|
||||
*/
|
||||
private func activateUri(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let uriString = call.arguments as? String,
|
||||
let uri = decodeToFileUrl(uriString: uriString)
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for activateUri", details: nil))
|
||||
return
|
||||
}
|
||||
// Start accessing the security-scoped resource.
|
||||
if !uri.startAccessingSecurityScopedResource() {
|
||||
completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "activateUri failed to access security-scoped resource: \(uri)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(uri)
|
||||
result(uri.absoluteString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the file data (bytes) for a given URI string.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The argument is
|
||||
* expected to be the URI string. This string will be decoded using `decodeToFileUrl`.
|
||||
* - result: The Flutter result callback to be invoked with the resul---t. On success
|
||||
* the file data as a `FlutterStandardTypedData` is returned. On failure,
|
||||
* a `FlutterError` is returned.
|
||||
*/
|
||||
private func getFileBytes(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let uriString = call.arguments as? String,
|
||||
let fileUrl = decodeToFileUrl(uriString: uriString) else {
|
||||
result(FlutterError(code: "INVALID_URI", message: "Invalid or unresolvable URI: \(call.arguments)", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a bookmark URI, we need to start accessing the security-scoped resource.
|
||||
if uriString.starts(with: "urlbookmark://") {
|
||||
if !fileUrl.startAccessingSecurityScopedResource() {
|
||||
result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(fileUrl)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(fileUrl)
|
||||
}
|
||||
|
||||
do {
|
||||
let fileData = try Data(contentsOf: fileUrl)
|
||||
result(FlutterStandardTypedData(bytes: fileData))
|
||||
} catch {
|
||||
result(FlutterError(code: "FILE_READ_ERROR", message: "Failed to read file data: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the file at the given source URI to the destination URI.
|
||||
* Both the source and destination URIs must be resolvable to file:// URIs.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The arguments
|
||||
* are expected to be a list containing two strings: the source URI and
|
||||
* the destination URI.
|
||||
* - result: The Flutter result callback to be invoked with the result
|
||||
* of the copy operation. On success, the destination URI string is
|
||||
* returned. On failure, a `FlutterError` is returned.
|
||||
*/
|
||||
private func copyFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let sourceUriString = args[0] as? String,
|
||||
let destinationUriString = args[1] as? String,
|
||||
let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode to file URL
|
||||
let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode to file URL
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for copyFile", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Ensure destination directory exists
|
||||
let destinationDirectory = destinationUrl.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
try FileManager.default.copyItem(at: sourceUrl, to: destinationUrl)
|
||||
result(destinationUrl.absoluteString)
|
||||
} catch {
|
||||
result(FlutterError(code: "COPY_FAILED", message: "Failed to copy file: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the file at the given source URI to the destination URI.
|
||||
* Both the source and destination URIs must be resolvable to file:// URIs.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The arguments
|
||||
* are expected to be a list containing two strings: the source URI and
|
||||
* the destination URI.
|
||||
* - result: The Flutter result callback to be invoked with the result
|
||||
* of the move operation. On success, the destination URI string is
|
||||
* returned. On failure, a `FlutterError` is returned.
|
||||
*/
|
||||
private func moveFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let sourceUriString = args[0] as? String,
|
||||
let destinationUriString = args[1] as? String,
|
||||
let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode!
|
||||
let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode!
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for moveFile", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Ensure destination directory exists
|
||||
let destinationDirectory = destinationUrl.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
try FileManager.default.moveItem(at: sourceUrl, to: destinationUrl)
|
||||
result(destinationUrl.absoluteString)
|
||||
} catch {
|
||||
result(FlutterError(code: "MOVE_FAILED", message: "Failed to move file: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file at the given URI. The URI must be resolvable to a file:// URI.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The argument
|
||||
* is expected to be a string representing the file URI.
|
||||
* - result: The Flutter result callback. `true` is returned on success,
|
||||
* and a `FlutterError` is returned on failure.
|
||||
*/
|
||||
private func deleteFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let uriString = call.arguments as? String,
|
||||
let fileUrl = decodeToFileUrl(uriString: uriString),
|
||||
fileUrl.scheme == "file" else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid or non-file URI provided for deleteFile", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
try FileManager.default.removeItem(at: fileUrl)
|
||||
result(true)
|
||||
} catch {
|
||||
result(FlutterError(code: "DELETE_FILE_FAILED", message: "Failed to delete file: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file at the given URI using the system's default application for the file type.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The arguments are
|
||||
* expected to be a list where the first element is the URI string, and the
|
||||
* optional second element is the MIME type (as a String).
|
||||
* - result: The Flutter result callback. `true` is returned on successful launch,
|
||||
* and a `FlutterError` is returned on failure.
|
||||
*/
|
||||
private func openFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let uriString = args[0] as? String,
|
||||
let mimeType = args[1] as? String?,
|
||||
let fileUrl = decodeToFileUrl(uriString: uriString)
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for openFile", details: nil))
|
||||
return
|
||||
}
|
||||
let filePath = fileUrl.path
|
||||
if !FileManager.default.fileExists(atPath: fileUrl.path) {
|
||||
os_log("File does not exist at %@", log: log, type: .info, filePath)
|
||||
result(FlutterError(code: "FILE_NOT_FOUND", message: "File does not exist at \(filePath)", details: nil))
|
||||
return
|
||||
}
|
||||
result(doOpenFile(filePath: filePath, mimeType: mimeType != nil ? mimeType : getMimeType(fromFilename: filePath)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the flutter result callback and destroy the result object
|
||||
*/
|
||||
private func completeFlutterResult(_ result: Any?) {
|
||||
self.flutterResult?(result)
|
||||
self.flutterResult = nil
|
||||
}
|
||||
|
||||
// MARK: - UIDocumentPickerDelegate
|
||||
|
||||
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
|
||||
if multipleAllowed {
|
||||
var resultUrls: [String] = []
|
||||
|
||||
for url in urls {
|
||||
// Start accessing the security-scoped resource.
|
||||
if !url.startAccessingSecurityScopedResource() {
|
||||
// Handle access error (e.g., by skipping this URL and logging an error message)
|
||||
os_log("Failed to access security-scoped resource for %@", log: log, type: .info, url.absoluteString)
|
||||
continue
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(url)
|
||||
|
||||
let pickedUrl: URL
|
||||
if persistedUriPermission {
|
||||
do {
|
||||
pickedUrl = try createBookmarkUriFromUrl(fileURL: url)
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
pickedUrl = url
|
||||
}
|
||||
|
||||
resultUrls.append(pickedUrl.absoluteString)
|
||||
}
|
||||
|
||||
completeFlutterResult(resultUrls)
|
||||
} else {
|
||||
// Single selection
|
||||
guard let url = urls.first else {
|
||||
completeFlutterResult(nil) // User cancelled
|
||||
return
|
||||
}
|
||||
|
||||
// Start accessing the security-scoped resource.
|
||||
if !url.startAccessingSecurityScopedResource() {
|
||||
completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(url)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(url)
|
||||
|
||||
let pickedUrl: URL
|
||||
if persistedUriPermission {
|
||||
do {
|
||||
pickedUrl = try createBookmarkUriFromUrl(fileURL: url)
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
pickedUrl = url
|
||||
}
|
||||
completeFlutterResult(pickedUrl.absoluteString)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
completeFlutterResult(nil)
|
||||
}
|
||||
|
||||
|
||||
// MARK: PHickerViewControllerelegate
|
||||
|
||||
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
guard !results.isEmpty else {
|
||||
completeFlutterResult(nil)
|
||||
return
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
for result in results {
|
||||
group.enter()
|
||||
let itemProvider = result.itemProvider
|
||||
|
||||
// Determine the preferred order based on what is allowed
|
||||
var typeIdentifiers: [String] = []
|
||||
if allowVideos {
|
||||
typeIdentifiers.append(UTType.movie.identifier)
|
||||
}
|
||||
typeIdentifiers.append(UTType.image.identifier)
|
||||
|
||||
var handled = false
|
||||
for typeIdentifier in typeIdentifiers {
|
||||
if itemProvider.hasRepresentationConforming(toTypeIdentifier: typeIdentifier) {
|
||||
handleLoadedFile(for: itemProvider, typeIdentifier: typeIdentifier, group: group)
|
||||
handled = true
|
||||
break // Break after handling the first matching type
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the group if no suitable representation was found
|
||||
if !handled {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
// after all files have been copied, post the result
|
||||
group.notify(queue: .main) {
|
||||
picker.dismiss(animated: true)
|
||||
if self.localFileUrls.isEmpty {
|
||||
self.completeFlutterResult(nil)
|
||||
} else {
|
||||
self.completeFlutterResult(self.localFileUrls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the loading of a file representation from an `NSItemProvider`.
|
||||
///
|
||||
/// This function attempts to load a file of the specified `typeIdentifier` from the given `itemProvider`.
|
||||
/// If successful, it copies the file to the app's local storage and appends the local file URL to the `localFileUrls` array.
|
||||
/// This function is designed to be used within the `PHPickerViewControllerDelegate`'s `didFinishPicking` method to process selected assets.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - itemProvider: The `NSItemProvider` representing the asset to load.
|
||||
/// - typeIdentifier: The UTI (Uniform Type Identifier) representing the desired file type (e.g., `public.image`, `public.movie`).
|
||||
/// - group: The `DispatchGroup` to which the asynchronous file loading operation belongs. `group.leave()` is called when the operation is completed (either successfully or with an error).
|
||||
///
|
||||
/// - Important: This function uses an escaping closure to handle the asynchronous loading of the file.
|
||||
/// The `localFileUrls` array is a class property and is modified within the escaping closure.
|
||||
private func handleLoadedFile(for itemProvider: NSItemProvider, typeIdentifier: String, group: DispatchGroup) {
|
||||
itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in
|
||||
guard let self = self, let url = url, error == nil else {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
let localUrl = self.copyFileToLocalStorage(url: url)
|
||||
if let localUrl = localUrl {
|
||||
self.localFileUrls.append(localUrl.absoluteString)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies a file from the provided URL into the app's temporary storage directory, returning the new URL relative to the storage root. This URL will have the "support" scheme and
|
||||
/// itspath is the last pathsegment of the file URL. It will be converted to a proper file:// URL using `decodeToFileUrl`
|
||||
///
|
||||
/// This function creates a unique filename for the copied file to avoid collisions. The file is stored in a location that is:
|
||||
/// 1. **Private to the application:** Other apps cannot access this directory.
|
||||
/// 2. **Temporary:** The system may delete files in this directory when the app is not running to free up space. Your app should be prepared to recreate these files as needed.
|
||||
/// 3. **Not backed up:** Files in this directory are not backed up to iCloud or other backup services.
|
||||
/// 4. **Persistent across app launches:** The relative file paths are consistent between app launches.
|
||||
///
|
||||
/// - Parameter url: The source URL of the file to copy. This should be a file URL (e.g., obtained from a `PHPickerResult`).
|
||||
/// - Returns: The relative path (as a String) to the copied file within the temporary storage directory if the copy is successful, otherwise `nil`.
|
||||
/// You can reconstruct the full URL using `constructPersistentFileURL(for:)`.
|
||||
private func copyFileToLocalStorage(url: URL) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// Use the Application Support directory, which is private to the app but persistent.
|
||||
// Create a custom subdirectory for temporary files.
|
||||
guard let storageDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else {
|
||||
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Could not find Application Support directory", details: nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure the storage directory exists
|
||||
do {
|
||||
try fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error creating storage directory: \(error)", details: nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a unique filename
|
||||
let uniqueFilename = "\(UUID().uuidString).\(url.pathExtension)"
|
||||
let destinationURL = storageDirectory.appendingPathComponent(uniqueFilename)
|
||||
|
||||
do {
|
||||
// If a file with the same name already exists, remove it. This should not normally happen because of the UUID, but it's good practice.
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
// Copy the file from source to destination, and return the media URI to this location
|
||||
try fileManager.copyItem(at: url, to: destinationURL)
|
||||
return URL(string: "media://support/\(destinationURL.lastPathComponent)")
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error copying file: \(error)", details: nil))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func getInitialDirectoryUrl(location: SharedStorage) -> URL? {
|
||||
switch location {
|
||||
case SharedStorage.downloads:
|
||||
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.images:
|
||||
return FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.video:
|
||||
return FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.audio:
|
||||
return FileManager.default.urls(for: .musicDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.files:
|
||||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stop accessing security-scoped resources when appropriate.
|
||||
deinit {
|
||||
// Iterate through all stored URLs and stop accessing the security-scoped resources.
|
||||
for url in accessedSecurityScopedUrls {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Packs `filename` and `uri` into a single String
|
||||
///
|
||||
/// use `unpack` to retrieve the filename and uri from the packed String
|
||||
func pack(filename: String, uri: URL) -> String {
|
||||
return ":::\(filename)::::::\(uri.absoluteString):::"
|
||||
}
|
||||
|
||||
/// Unpacks `packedString` into a (filename, uri) tuple. If this is not a packed
|
||||
/// string, returns the original `packedString` as (filename, nil) or,
|
||||
/// if it is a Uri as (nil, the uri)
|
||||
func unpack(packedString: String) -> (filename: String?, uri: URL?) {
|
||||
let regex = try! NSRegularExpression(pattern: ":::([\\s\\S]*?)::::::([\\s\\S]*?):::")
|
||||
let range = NSRange(packedString.startIndex..<packedString.endIndex, in: packedString)
|
||||
|
||||
if let match = regex.firstMatch(in: packedString, range: range) {
|
||||
let filenameRange = match.range(at: 1)
|
||||
let uriStringRange = match.range(at: 2)
|
||||
|
||||
if filenameRange.location != NSNotFound && uriStringRange.location != NSNotFound {
|
||||
let filename = String(packedString[Range(filenameRange, in: packedString)!])
|
||||
let uriString = String(packedString[Range(uriStringRange, in: packedString)!])
|
||||
|
||||
if let uri = URL(string: uriString), uri.scheme != nil {
|
||||
return (filename: filename, uri: uri)
|
||||
} else {
|
||||
return (filename: filename, uri: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let uri = URL(string: packedString), uri.scheme != nil {
|
||||
return (filename: nil, uri: uri)
|
||||
}
|
||||
}
|
||||
return (filename: packedString, uri: nil)
|
||||
}
|
||||
|
||||
/// Returns the Uri represented by `maybePacked`, or nil if the String is not a
|
||||
/// valid Uri or packed Uri string.
|
||||
///
|
||||
/// `maybePacked` should be a full Uri string, or a packed String containing
|
||||
/// a Uri (see `pack`)
|
||||
func uriFromStringValue(maybePacked: String) -> URL? {
|
||||
let unpacked = unpack(packedString: maybePacked)
|
||||
return unpacked.uri
|
||||
}
|
||||
|
||||
/// Returns true if `maybePacked` is a valid Uri or packed Uri string.
|
||||
///
|
||||
/// `maybePacked` should be a full Uri string, or a packed String containing
|
||||
/// a Uri (see `pack`)
|
||||
func containsUri(maybePacked: String) -> Bool {
|
||||
return uriFromStringValue(maybePacked: maybePacked) != nil
|
||||
}
|
||||
|
||||
/// Creates a urlboomark://authority/ URL containing encoded data representing the bookmark for this URL, which
|
||||
/// can then be used later
|
||||
private func createBookmarkUriFromUrl(fileURL: URL) throws -> URL {
|
||||
let bookmarkData = try fileURL.bookmarkData(options: .suitableForBookmarkFile, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||
let bookmarkDataBase64 = bookmarkData.base64EncodedString()
|
||||
let bookmarkUriString = "urlbookmark://authority/\(bookmarkDataBase64)"
|
||||
guard let bookmarkUri = URL(string: bookmarkUriString) else {
|
||||
throw NSError(domain: "UriUtilsMethodCallHelper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark URI from '\(bookmarkUriString)'"])
|
||||
}
|
||||
return bookmarkUri
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a URI parameter on iOS.
|
||||
*
|
||||
* If the URI string starts with `urlbookmark://authority/`, it decodes the base64 encoded bookmark data to a file URL
|
||||
* If the URI string starts with `media://support/` it will convert it to a file URL relateive to the applicationSupport directory
|
||||
* Otherwise, it returns the original URI as a URL.
|
||||
*
|
||||
* - Parameter uriString: The URI string to decode.
|
||||
* - Returns: The decoded `URL` or `nil` if the URI string is invalid or the bookmark data cannot be resolved.
|
||||
*/
|
||||
private func decodeToFileUrl(uriString: String) -> URL? {
|
||||
guard let uri = URL(string: uriString) else { return nil }
|
||||
if uri.scheme == "urlbookmark" {
|
||||
// Decode base64 bookmark data
|
||||
let base64String = String(uri.path.dropFirst())
|
||||
guard let bookmarkData = Data(base64Encoded: base64String) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
var isStale = false
|
||||
let resolvedUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
||||
|
||||
if isStale {
|
||||
os_log("Warning: Bookmark data is stale for %@", log: log, type: .info, resolvedUrl.absoluteString)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Access to resolved URLs that are not persisted, is started when they are actually used
|
||||
// (i.e. when creating a subdirectory in it, or picking it)
|
||||
// And stopped in the deinit
|
||||
return resolvedUrl
|
||||
} catch {
|
||||
os_log("Error resolving bookmark data: %@", log: log, type: .info, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
} else if uri.scheme == "media" {
|
||||
guard let storageDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else {
|
||||
os_log("Could not determine application support directory - required to decode support:// scheme URL", log: log, type: .error)
|
||||
return nil
|
||||
}
|
||||
return storageDirectory.appendingPathComponent(uri.lastPathComponent, isDirectory: false)
|
||||
} else {
|
||||
// Regular file URL
|
||||
return uri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a file URI, or nil if not possible.
|
||||
|
||||
Decodes a bookmark URI or a media scheme uri
|
||||
*/
|
||||
func decodeToFileUrl(uri: URL) -> URL? {
|
||||
return uri.scheme == "file" ? uri : decodeToFileUrl(uriString: uri.absoluteString)
|
||||
}
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
//
|
||||
// UrlSessionDelegate.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 11/12/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
|
||||
public class UrlSessionDelegate : NSObject, URLSessionDelegate, URLSessionDownloadDelegate, URLSessionDataDelegate {
|
||||
|
||||
static let instance = UrlSessionDelegate()
|
||||
static var urlSession: URLSession?
|
||||
public static var sessionIdentifier = "com.bbflight.background_downloader.Downloader"
|
||||
public static var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
//MARK: URLSessionTaskDelegate
|
||||
|
||||
/// Called before the task starts, and may continue, cancel or modify the original request, based on native callbacks in the [TaskOptions] of the task
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest) async -> (URLSession.DelayedRequestDisposition, URLRequest?) {
|
||||
guard let bgdTask = getTaskFrom(urlSessionTask: task)
|
||||
else {
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
// check & process beforeTaskStartCallback and cancel the task if returned value is not nil
|
||||
if bgdTask.options?.hasBeforeStartCallback() == true {
|
||||
if let statusUpdate = await invokeBeforeTaskStartCallback(task: bgdTask) {
|
||||
os_log("TaskId %@ interrupted by beforeTaskStart callback", log: log, type: .info, bgdTask.taskId)
|
||||
BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.insert(bgdTask.taskId)
|
||||
})
|
||||
processStatusUpdate(task: bgdTask,
|
||||
status: statusUpdate.taskStatus,
|
||||
taskException: statusUpdate.exception,
|
||||
responseBody: statusUpdate.responseBody,
|
||||
responseHeaders: statusUpdate.responseHeaders,
|
||||
responseStatusCode: statusUpdate.responseStatusCode)
|
||||
return (.cancel, nil)
|
||||
}
|
||||
}
|
||||
// check & process onStartCalback and onAuthCallback
|
||||
guard bgdTask.options?.hasOnStartCallback() == true || bgdTask.options?.auth?.hasOnAuthCallback() == true
|
||||
else {
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
let (newTask, taskWasModified) = await getModifiedTask(task: bgdTask) // processes both onStart and onAuth
|
||||
if !taskWasModified {
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
// modify the request, copying the unmodified data from the original request
|
||||
guard let url = validateUrl(newTask)
|
||||
else {
|
||||
os_log("Invalid url in modified task", log: log, type: .info)
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
var newRequest = URLRequest(url: url)
|
||||
// all original request headers are copied, then the newTask headers are set:
|
||||
// you can change or add a header, but not remove one
|
||||
if request.allHTTPHeaderFields != nil {
|
||||
for (key, value) in request.allHTTPHeaderFields! {
|
||||
newRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}}
|
||||
for (key, value) in newTask.headers {
|
||||
newRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
newRequest.httpMethod = request.httpMethod
|
||||
newRequest.httpBody = request.httpBody
|
||||
newRequest.allowsCellularAccess = request.allowsCellularAccess
|
||||
return (URLSession.DelayedRequestDisposition.useNewRequest, newRequest)
|
||||
}
|
||||
|
||||
/// Handle task completion
|
||||
///
|
||||
/// DownloadTasks handle task completion in the :didFinishDownloadingTo function, so for download tasks
|
||||
/// we only process status updates if there was an error.
|
||||
/// For other tasks we handle error, or send .complete
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
guard let bgdTask = getTaskFrom(urlSessionTask: task) else { // refers to "our" task, not a URLSessionTask
|
||||
os_log("Could not find task related to urlSessionTask %d", log: log, type: .error, task.taskIdentifier)
|
||||
return
|
||||
}
|
||||
if BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.remove(bgdTask.taskId) != nil
|
||||
}) {
|
||||
// task was canceled before start and the
|
||||
// status update already generated, so simply return without any further processing
|
||||
return
|
||||
}
|
||||
let multipartUploader = BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.uploaderForUrlSessionTaskIdentifier.removeValue(forKey: task.taskIdentifier)
|
||||
}
|
||||
if let uploader = multipartUploader {
|
||||
try? FileManager.default.removeItem(at: uploader.outputFileUrl())
|
||||
}
|
||||
let responseStatusCode = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let responseStatusDescription = HTTPURLResponse.localizedString(forStatusCode: responseStatusCode)
|
||||
let responseHeaders = (task.response as? HTTPURLResponse)?.allHeaderFields
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: task)
|
||||
if let tempUploadUrl = BDPlugin.propertyLock.withLock({ BDPlugin.tasksWithTempUploadFile.removeValue(forKey: bgdTask.taskId) }) {
|
||||
try? FileManager.default.removeItem(at: tempUploadUrl)
|
||||
}
|
||||
if BDPlugin.holdingQueue != nil {
|
||||
_Concurrency.Task {
|
||||
await BDPlugin.holdingQueue?.taskFinished(bgdTask)
|
||||
}
|
||||
}
|
||||
let responseBody = getResponseBody(taskId: bgdTask.taskId)
|
||||
let taskWasProgramaticallyCanceledAfterStart = BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(bgdTask.taskId) != nil
|
||||
})
|
||||
guard error == nil else {
|
||||
var notificationType = taskWasProgramaticallyCanceledAfterStart ? nil : NotificationType.error
|
||||
// handle the error if this task wasn't programatically cancelled (in which
|
||||
// case the error has been handled already)
|
||||
if !taskWasProgramaticallyCanceledAfterStart {
|
||||
// check if we have resume data, and if so, process it
|
||||
let userInfo = (error! as NSError).userInfo
|
||||
let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
||||
let canResume = resumeData != nil && processResumeData(task: bgdTask, resumeData: resumeData!)
|
||||
// check different error codes
|
||||
if (error! as NSError).code == NSURLErrorTimedOut {
|
||||
os_log("Task with id %@ timed out", log: log, type: .info, bgdTask.taskId)
|
||||
processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .connection, httpResponseCode: -1, description: error!.localizedDescription))
|
||||
// replace the 'running' notification, unless the task will be retried
|
||||
notificationType = bgdTask.retriesRemaining > 0 ? nil : .error
|
||||
}
|
||||
else if (error! as NSError).code == NSURLErrorCancelled {
|
||||
// cancelled with resumedata implies 'pause'
|
||||
if canResume {
|
||||
os_log("Paused task with id %@", log: log, type: .info, bgdTask.taskId)
|
||||
processStatusUpdate(task: bgdTask, status: .paused)
|
||||
if isDownloadTask(task: bgdTask) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
updateNotification(task: bgdTask, notificationType: .paused, notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
BDPlugin.propertyLock.withLock({
|
||||
_ = BDPlugin.progressInfo.removeValue(forKey: bgdTask.taskId) // ensure .running update on resume
|
||||
BDPlugin.initialResponseDataProcessed.remove(bgdTask.taskId)
|
||||
})
|
||||
return
|
||||
}
|
||||
// cancelled without resumedata implies 'cancel'
|
||||
let isReEnqueuing = BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.tasksToReEnqueue.contains(bgdTask)
|
||||
}
|
||||
os_log("Canceled task with id %@", log: log, type: .info, bgdTask.taskId)
|
||||
processStatusUpdate(task: bgdTask, status: .canceled)
|
||||
notificationType = isReEnqueuing ? nil : .canceled
|
||||
}
|
||||
else {
|
||||
os_log("Error for taskId %@: %@", log: log, type: .error, bgdTask.taskId, error!.localizedDescription)
|
||||
processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .general, httpResponseCode: -1, description: error!.localizedDescription))
|
||||
}
|
||||
}
|
||||
if isDownloadTask(task: bgdTask) && notificationType != nil {
|
||||
updateNotification(task: bgdTask, notificationType: notificationType!, notificationConfig: notificationConfig)
|
||||
}
|
||||
return
|
||||
}
|
||||
// there was no error
|
||||
os_log("Finished task with id %@", log: log, type: .info, bgdTask.taskId)
|
||||
// if this is an upload task, send final TaskStatus (based on HTTP status code).
|
||||
// for download tasks, this is done in urlSession(downloadTask:, didFinishDownloadingTo:)
|
||||
if isUploadTask(task: bgdTask) {
|
||||
let taskException = TaskException(type: .httpResponse, httpResponseCode: responseStatusCode, description: responseStatusDescription)
|
||||
let finalStatus = (200...206).contains(responseStatusCode)
|
||||
? TaskStatus.complete
|
||||
: responseStatusCode == 404
|
||||
? TaskStatus.notFound
|
||||
: TaskStatus.failed
|
||||
let notificationType = finalStatus == .complete ? NotificationType.complete : NotificationType.error
|
||||
updateNotification(task: bgdTask, notificationType: notificationType, notificationConfig: notificationConfig)
|
||||
processStatusUpdate(task: bgdTask, status: finalStatus, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: URLSessionDownloadTaskDelegate
|
||||
|
||||
|
||||
|
||||
/// Process taskdelegate progress update for download task
|
||||
///
|
||||
/// If the task requires progress updates, provides these at a reasonable interval
|
||||
/// If this is the first update for this file, also emits a 'running' status update
|
||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
// task is var because the filename can be changed on the first 'didWriteData' call
|
||||
guard var task = getTaskFrom(urlSessionTask: downloadTask) else { return }
|
||||
let progressInfo = BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId]
|
||||
})
|
||||
if progressInfo == nil {
|
||||
// first 'didWriteData' call, so send 'running' status update and show notification
|
||||
os_log("Starting/resuming taskId %@", log: log, type: .info, task.taskId)
|
||||
processStatusUpdate(task: task, status: TaskStatus.running)
|
||||
processProgressUpdate(task: task, progress: 0.0)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
|
||||
})
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask)
|
||||
if (notificationConfig != nil) {
|
||||
updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
if !BDPlugin.propertyLock.withLock({ BDPlugin.initialResponseDataProcessed.contains(task.taskId) }) {
|
||||
// response can be nil in cases when progressInfo is nil due to app resuming in foreground
|
||||
if let response = downloadTask.response as? HTTPURLResponse {
|
||||
BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.initialResponseDataProcessed.insert(task.taskId)
|
||||
})
|
||||
// get suggested filename if needed
|
||||
let (filename, uri) = unpack(packedString: task.filename)
|
||||
if filename == "?" {
|
||||
if (uri == nil) {
|
||||
let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: task, responseHeaders: response.allHeaderFields)
|
||||
if newTask.filename != task.filename {
|
||||
storeModifiedTask(task: newTask)
|
||||
task = newTask
|
||||
}
|
||||
} else {
|
||||
let suggestedFilename = suggestFilename(responseHeaders: response.allHeaderFields, urlString: task.url)
|
||||
let packed = pack(filename: suggestedFilename.isEmpty ? "unknown" : suggestedFilename, uri: uri!)
|
||||
let newTask = task.copyWith(filename: packed)
|
||||
storeModifiedTask(task: newTask)
|
||||
task = newTask
|
||||
}
|
||||
}
|
||||
extractContentType(responseHeaders: response.allHeaderFields, task: task)
|
||||
// obtain content length override, if needed and available
|
||||
if totalBytesExpectedToWrite == -1 {
|
||||
let contentLength = getContentLength(responseHeaders: response.allHeaderFields, task: task)
|
||||
if contentLength != -1 {
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.tasksWithContentLengthOverride[task.taskId] = contentLength
|
||||
})
|
||||
}
|
||||
}
|
||||
// Check if there is enough space
|
||||
if insufficientSpace(contentLength: totalBytesExpectedToWrite) {
|
||||
let notProgrammaticallyCancelled = BDPlugin.propertyLock.withLock({
|
||||
!BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.contains(task.taskId)
|
||||
})
|
||||
if notProgrammaticallyCancelled {
|
||||
os_log("Error for taskId %@: Insufficient space to store the file to be downloaded", log: log, type: .error, task.taskId)
|
||||
processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .fileSystem, httpResponseCode: -1, description: "Insufficient space to store the file to be downloaded for taskId \(task.taskId)"))
|
||||
BDPlugin.propertyLock.withLock({
|
||||
_ = BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.insert(task.taskId)
|
||||
})
|
||||
downloadTask.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// check if the task is resumable
|
||||
if task.allowPause {
|
||||
// header lookup must be case-insensitive, and a partial
|
||||
// response also implies the server accepts ranges
|
||||
let httpResponse = downloadTask.response as? HTTPURLResponse
|
||||
let acceptRangesHeader = httpResponse?.value(forHTTPHeaderField: "Accept-Ranges")
|
||||
let taskCanResume = acceptRangesHeader == "bytes" || httpResponse?.statusCode == 206
|
||||
processCanResume(task: task, taskCanResume: taskCanResume)
|
||||
if taskCanResume {
|
||||
BDPlugin.propertyLock.withLock({
|
||||
_ = BDPlugin.taskIdsThatCanResume.insert(task.taskId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let contentLength = BDPlugin.propertyLock.withLock({
|
||||
let l = totalBytesExpectedToWrite != -1 ? totalBytesExpectedToWrite : BDPlugin.tasksWithContentLengthOverride[task.taskId] ?? -1
|
||||
BDPlugin.remainingBytesToDownload[task.taskId] = l - totalBytesWritten
|
||||
return l
|
||||
})
|
||||
updateProgress(task: task, totalBytesExpected: contentLength, totalBytesDone: totalBytesWritten)
|
||||
}
|
||||
|
||||
/// Process taskdelegate progress update for upload task
|
||||
///
|
||||
/// If the task requires progress updates, provide these at a reasonable interval
|
||||
/// If this is the first update for this file, also emit a 'running' status update
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
|
||||
let urlSessionTask = task
|
||||
guard let task = getTaskFrom(urlSessionTask: task) else {return}
|
||||
let taskId = task.taskId
|
||||
let progressInfo = BDPlugin.propertyLock.withLock({
|
||||
return BDPlugin.progressInfo[taskId]
|
||||
})
|
||||
if progressInfo == nil {
|
||||
// first call to this method: send 'running' status update
|
||||
processStatusUpdate(task: task, status: TaskStatus.running)
|
||||
processProgressUpdate(task: task, progress: 0.0)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
|
||||
})
|
||||
// notify if needed
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: urlSessionTask)
|
||||
if (notificationConfig != nil) {
|
||||
updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
updateProgress(task: task, totalBytesExpected: totalBytesExpectedToSend, totalBytesDone: totalBytesSent)
|
||||
}
|
||||
|
||||
/// Process end of downloadTask sent by the urlSession.
|
||||
///
|
||||
/// If successful, (over)write file to final destination per Task info
|
||||
public func urlSession(_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
guard var task = getTaskFrom(urlSessionTask: downloadTask),
|
||||
let response = downloadTask.response as? HTTPURLResponse
|
||||
else {
|
||||
os_log("Could not find task associated urlSessionTask %d, or did not get HttpResponse", log: log, type: .info, downloadTask.taskIdentifier)
|
||||
return}
|
||||
let responseHeaders = response.allHeaderFields
|
||||
let taskId = task.taskId
|
||||
let (mimeType, charSet) = BDPlugin.propertyLock.withLock({
|
||||
let mimeType = BDPlugin.mimeTypes.removeValue(forKey: taskId)
|
||||
let charSet = BDPlugin.charSets.removeValue(forKey: taskId)
|
||||
BDPlugin.tasksWithModifications.removeValue(forKey: taskId)
|
||||
BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: taskId)
|
||||
BDPlugin.initialResponseDataProcessed.remove(taskId)
|
||||
return (mimeType, charSet)
|
||||
})
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask)
|
||||
if response.statusCode == 404 {
|
||||
let responseBody = readFile(url: location)
|
||||
processStatusUpdate(task: task, status: TaskStatus.notFound, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: response.statusCode)
|
||||
updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig)
|
||||
return
|
||||
}
|
||||
if !(200...206).contains(response.statusCode) {
|
||||
os_log("TaskId %@ returned response code %d", log: log, type: .info, task.taskId, response.statusCode)
|
||||
let responseBody = readFile(url: location)
|
||||
processStatusUpdate(task: task, status: TaskStatus.failed, taskException: TaskException(type: .httpResponse, httpResponseCode: response.statusCode, description: responseBody?.isEmpty == false ? responseBody! : HTTPURLResponse.localizedString(forStatusCode: response.statusCode)))
|
||||
if task.retriesRemaining == 0 {
|
||||
// update notification only if no retries remaining
|
||||
updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig)
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
var finalStatus = TaskStatus.failed
|
||||
var taskException: TaskException? = nil
|
||||
var responseBody: String? = nil
|
||||
defer {
|
||||
processStatusUpdate(task: task,
|
||||
status: finalStatus,
|
||||
taskException: taskException,
|
||||
responseBody: responseBody,
|
||||
responseHeaders: responseHeaders,
|
||||
responseStatusCode: response.statusCode,
|
||||
mimeType: mimeType,
|
||||
charSet: charSet)
|
||||
if finalStatus != TaskStatus.failed || task.retriesRemaining == 0 {
|
||||
// update notification only if not failed, or no retries remaining
|
||||
updateNotification(task: task, notificationType: notificationTypeForTaskStatus(status: finalStatus), notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
if (isDownloadTask(task: task)) {
|
||||
// Determine directoryUri and fileUrl based on URI mode or filepath mode
|
||||
var directoryUrl: URL?
|
||||
var fileUrl: URL?
|
||||
|
||||
// filename field can contain filename and/or a file url (if already started)
|
||||
let unpackedFilename = unpack(packedString: task.filename)
|
||||
let filename = unpackedFilename.filename ?? "unknown"
|
||||
fileUrl = unpackedFilename.uri
|
||||
// directory may contain a path or a Uri representing the full destination directory
|
||||
if let directoryUri = uriFromStringValue(maybePacked: task.directory) {
|
||||
// URI mode
|
||||
let uri = decodeToFileUrl(uri: directoryUri)
|
||||
guard let uri = uri else {
|
||||
os_log("Invalid directory URI (could not convert bookmark): %@", log: log, type: .error, directoryUri.absoluteString)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
|
||||
description: "Invalid directory URI (could not convert bookmark): %@ \(directoryUri.absoluteString)")
|
||||
return
|
||||
}
|
||||
guard uri.isFileURL else {
|
||||
os_log("Invalid directory URI (not a file URL): %@", log: log, type: .error, uri.absoluteString)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
|
||||
description: "Invalid directory URI (not a file URL): \(uri.absoluteString)")
|
||||
return
|
||||
}
|
||||
directoryUrl = uri
|
||||
fileUrl = uri.appendingPathComponent(filename)
|
||||
// store the full Uri in the task.filename so it can be retrieved
|
||||
let newTask = task.copyWith(filename: pack(filename: filename, uri: fileUrl!))
|
||||
task = newTask
|
||||
} else {
|
||||
// Filepath mode
|
||||
directoryUrl = try directoryForTask(task: task)
|
||||
fileUrl = directoryUrl?.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
// Guard against directoryUri or fileUrl being nil
|
||||
guard let directoryUri = directoryUrl, var fileUrl = fileUrl else {
|
||||
os_log("Could not determine directory Uri or file Uri", log: log, type: .error)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
|
||||
description: "Could not determine directory or file Uri")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if !FileManager.default.fileExists(atPath: directoryUri.path) {
|
||||
try FileManager.default.createDirectory(at: directoryUri, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: fileUrl.path) {
|
||||
try FileManager.default.removeItem(at: fileUrl)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: location, to: fileUrl)
|
||||
|
||||
do {
|
||||
if UserDefaults.standard.bool(forKey: BDPlugin.keyConfigExcludeFromCloudBackup)
|
||||
{
|
||||
try fileUrl.setCloudBackup(exclude: true)
|
||||
os_log("Excluded from iCloud backup: %@", log: log, type: .info, fileUrl.path)
|
||||
}
|
||||
} catch {
|
||||
os_log("Could not exclude from iCloud backup: %@ - %@", log: log, type: .info, fileUrl.path, error.localizedDescription)
|
||||
}
|
||||
finalStatus = TaskStatus.complete
|
||||
|
||||
} catch {
|
||||
os_log("File operation failed: %@", log: log, type: .error, error.localizedDescription)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1, description: "File operation failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// this is a DataTask, so we read the file as the responseBody
|
||||
responseBody = readFile(url: location)
|
||||
try? FileManager.default.removeItem(at: location);
|
||||
finalStatus = .complete
|
||||
}
|
||||
} catch {
|
||||
os_log("Uncaught file download error for taskId %@ and file %@: %@", log: log, type: .error, task.taskId, task.filename, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: URLSessionDataDelegate
|
||||
|
||||
/// Collects incoming data following a file upload, by appending the data block to a static dictionary keyed by taskId
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive data: Data
|
||||
)
|
||||
{
|
||||
guard let task = getTaskFrom(urlSessionTask: dataTask)
|
||||
else {
|
||||
os_log("Could not find task associated urlSessionTask %d", log: log, type: .info, dataTask.taskIdentifier)
|
||||
return
|
||||
}
|
||||
BDPlugin.propertyLock.withLock({
|
||||
var dataList = BDPlugin.responseBodyData[task.taskId] ?? []
|
||||
dataList.append(data)
|
||||
BDPlugin.responseBodyData[task.taskId] = dataList
|
||||
})
|
||||
}
|
||||
|
||||
//MARK: URLSessionDelegate
|
||||
|
||||
/// Upon completion of download of all files, call the completion handler
|
||||
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
guard
|
||||
let handler = UrlSessionDelegate.backgroundCompletionHandler,
|
||||
session.configuration.identifier == UrlSessionDelegate.sessionIdentifier
|
||||
else {
|
||||
os_log("No handler or no identifier match in urlSessionDidFinishEvents", log: log, type: .info)
|
||||
return
|
||||
}
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: Helpers
|
||||
|
||||
/// Read the contents of a file to a String, or nil if unable
|
||||
private func readFile(url: URL) -> String? {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let string = String(data: data, encoding: .utf8)
|
||||
return string
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Get response body for upload task with this [taskId]
|
||||
private func getResponseBody(taskId: String) -> String? {
|
||||
guard let dataList = BDPlugin.propertyLock.withLock( { BDPlugin.responseBodyData[taskId] }) else {
|
||||
return nil
|
||||
}
|
||||
var allData: Data? = nil
|
||||
dataList.forEach { data in
|
||||
if allData == nil {
|
||||
allData = data
|
||||
} else {
|
||||
allData?.append(data)
|
||||
}
|
||||
}
|
||||
if (allData == nil) {
|
||||
return nil
|
||||
}
|
||||
return String(data: allData!, encoding: .utf8)!
|
||||
}
|
||||
|
||||
//MARK: UrlSession and related Task helpers
|
||||
|
||||
/// Creates a urlSession
|
||||
///
|
||||
/// Configures defaultResourceTimeout, defaultRequestTimeout and proxy based on configuration parameters,
|
||||
/// or defaults
|
||||
static func createUrlSession() -> Void {
|
||||
if UrlSessionDelegate.urlSession != nil {
|
||||
return
|
||||
}
|
||||
let config = URLSessionConfiguration.background(withIdentifier: UrlSessionDelegate.sessionIdentifier)
|
||||
let defaults = UserDefaults.standard
|
||||
let storedTimeoutIntervalForResource = defaults.double(forKey: BDPlugin.keyConfigResourceTimeout) // seconds
|
||||
let timeOutIntervalForResource = storedTimeoutIntervalForResource > 0 ? storedTimeoutIntervalForResource : BDPlugin.defaultResourceTimeout
|
||||
os_log("timeoutIntervalForResource = %d seconds", log: log, type: .info, Int(timeOutIntervalForResource))
|
||||
config.timeoutIntervalForResource = timeOutIntervalForResource
|
||||
let storedTimeoutIntervalForRequest = defaults.double(forKey: BDPlugin.keyConfigRequestTimeout) // seconds
|
||||
let timeoutIntervalForRequest = storedTimeoutIntervalForRequest > 0 ? storedTimeoutIntervalForRequest : BDPlugin.defaultRequestTimeout
|
||||
os_log("timeoutIntervalForRequest = %d seconds", log: log, type: .info, Int(timeoutIntervalForRequest))
|
||||
config.timeoutIntervalForRequest = timeoutIntervalForRequest
|
||||
let proxyAddress = defaults.string(forKey: BDPlugin.keyConfigProxyAdress)
|
||||
let proxyPort = defaults.integer(forKey: BDPlugin.keyConfigProxyPort)
|
||||
if (proxyAddress != nil && proxyPort != 0) {
|
||||
config.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable: true,
|
||||
kCFNetworkProxiesHTTPProxy: proxyAddress!,
|
||||
kCFNetworkProxiesHTTPPort: proxyPort,
|
||||
"HTTPSEnable": true,
|
||||
"HTTPSProxy": proxyAddress!,
|
||||
"HTTPSPort": proxyPort
|
||||
]
|
||||
os_log("Using proxy %@:%d for all tasks", log: log, type: .info, proxyAddress!, proxyPort)
|
||||
} else {
|
||||
os_log("Not using proxy for any task", log: log, type: .info)
|
||||
}
|
||||
UrlSessionDelegate.urlSession = URLSession(configuration: config, delegate: UrlSessionDelegate.instance, delegateQueue: nil)
|
||||
}
|
||||
|
||||
/// Return all tasks in this urlSession
|
||||
static func getAllTasks() async -> [Task] {
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
|
||||
return urlSessionTasks.compactMap({ getTaskFrom(urlSessionTask: $0) })
|
||||
}
|
||||
|
||||
/// Return the active task with this taskId, or nil
|
||||
static func getTaskWithId(taskId: String) async -> Task? {
|
||||
return await getAllTasks().first(where: { $0.taskId == taskId })
|
||||
}
|
||||
|
||||
/// Return all urlSessionsTasks in this urlSession
|
||||
static func getAllUrlSessionTasks(group: String? = nil) async -> [URLSessionTask] {
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
if (group == nil) {
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
|
||||
return urlSessionTasks
|
||||
}
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
|
||||
let urlSessionTasksInGroup = urlSessionTasks.filter({
|
||||
guard let task = getTaskFrom(urlSessionTask: $0) else { return false }
|
||||
return task.group == group
|
||||
})
|
||||
return urlSessionTasksInGroup
|
||||
}
|
||||
|
||||
/// Return the urlSessionTask matching this taskId, or nil
|
||||
static func getUrlSessionTaskWithId(taskId: String) async -> URLSessionTask? {
|
||||
guard let urlSessionTask = await getAllUrlSessionTasks().first(where: {
|
||||
guard let task = getTaskFrom(urlSessionTask: $0) else { return false }
|
||||
return task.taskId == taskId
|
||||
}) else { return nil }
|
||||
return urlSessionTask
|
||||
}
|
||||
|
||||
/// Returns a `task` that may be modified through callbacks, and a bool to indicate that the task was modified.
|
||||
///
|
||||
/// Callbacks would be attached to the task via its `Task.options` property, and if
|
||||
/// present will be invoked by starting a taskDispatcher on a background isolate, then
|
||||
/// sending the callback request via the MethodChannel.
|
||||
///
|
||||
/// First test is for auth refresh (the onAuth callback), then the onStart callback. Both
|
||||
/// callbacks run in a Dart isolate, and may return a modified task, which will be used
|
||||
/// for the actual task execution.
|
||||
func getModifiedTask(task: Task) async -> (Task, Bool) {
|
||||
var authTask: Task?
|
||||
var taskWasModified: Bool = false
|
||||
if let auth = task.options?.auth {
|
||||
// Refresh token if needed
|
||||
if auth.isTokenExpired() && auth.hasOnAuthCallback() == true {
|
||||
authTask = await invokeOnAuthCallback(task: task)
|
||||
}
|
||||
taskWasModified = authTask != nil
|
||||
authTask = authTask ?? task // Either original or newly authorized
|
||||
guard let newAuth = authTask?.options?.auth else { return (authTask ?? task, taskWasModified) }
|
||||
// Insert query parameters and headers
|
||||
taskWasModified = true
|
||||
let uri = newAuth.addOrUpdateQueryParams(
|
||||
url: authTask!.url,
|
||||
queryParams: newAuth.getExpandedAccessQueryParams()
|
||||
)
|
||||
var headers = authTask!.headers
|
||||
headers.merge(newAuth.getExpandedAccessHeaders()) { (_, new) in new }
|
||||
authTask = authTask!.copyWith(url: uri.absoluteString, headers: headers)
|
||||
}
|
||||
authTask = authTask ?? task
|
||||
guard task.options?.hasOnStartCallback() == true else { return (authTask!, taskWasModified) }
|
||||
// onStart callback
|
||||
let modifiedTask = await invokeOnTaskStartCallback(task: authTask!)
|
||||
taskWasModified = taskWasModified || modifiedTask != nil
|
||||
return (modifiedTask ?? authTask!, taskWasModified)
|
||||
}
|
||||
}
|
||||
third_party/background_downloader/ios/background_downloader/Sources/background_downloader/WiFi.swift
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// WiFi.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 1/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
/// WiFi requirement modes at the application level
|
||||
enum RequireWiFi: Int {
|
||||
case
|
||||
asSetByTask,
|
||||
forAllTasks,
|
||||
forNoTasks
|
||||
}
|
||||
|
||||
/// Manages changes to WiFi requirement, by re-enqueuing tasks if needed
|
||||
class WiFiQueue {
|
||||
static let shared = WiFiQueue()
|
||||
private let requireWiFiChangeQueue = DispatchQueue(label: "com.bbflight.background_downloader.requireWiFiChangeQueue", target: DispatchQueue.global())
|
||||
private let reEnqueueQueue = DispatchQueue(label: "com.bbflight.background_downloader.reEnqueueQueue", target: DispatchQueue.global())
|
||||
private let requireWiFiChangeSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
// Private initializer to prevent creating new instances
|
||||
private init() {}
|
||||
|
||||
// Change the application level WiFi requirement and re-enqueue tasks as necessary
|
||||
func requireWiFiChange(requireWiFi: RequireWiFi, rescheduleRunningTasks: Bool, alsoRestartUploads: Bool) {
|
||||
requireWiFiChangeQueue.async {
|
||||
_Concurrency.Task {
|
||||
BDPlugin.requireWiFi = requireWiFi
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.setValue(requireWiFi.rawValue, forKey: BDPlugin.keyRequireWiFi)
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks
|
||||
else {
|
||||
self.reEnqueuesDone()
|
||||
return
|
||||
}
|
||||
var haveReEnqueued = false
|
||||
urlSessionTasks.forEach { urlSessionTask in
|
||||
if ((urlSessionTask is URLSessionDownloadTask || urlSessionTask is URLSessionUploadTask) && (urlSessionTask.state == .running || urlSessionTask.state == .suspended)) {
|
||||
guard let task = getTaskFrom(urlSessionTask: urlSessionTask) else {
|
||||
return
|
||||
}
|
||||
// compare with the request the task was created with, which the URLSession
|
||||
// keeps across app launches, so tasks created by an earlier launch are included
|
||||
let urlSessionTaskRequiresWiFi = urlSessionTask.originalRequest?.allowsCellularAccess == false
|
||||
BDPlugin.propertyLock.withLock {
|
||||
if taskRequiresWiFi(task: task) != urlSessionTaskRequiresWiFi {
|
||||
// requirement differs, so we need to re-enqueue
|
||||
if BDPlugin.progressInfo[task.taskId] == nil {
|
||||
// enqueued only, so ensure it is re-enqueued and cancel
|
||||
haveReEnqueued = true
|
||||
BDPlugin.tasksToReEnqueue.insert(task)
|
||||
urlSessionTask.cancel()
|
||||
} else {
|
||||
if (rescheduleRunningTasks && isDownloadTask(task: task)) || (alsoRestartUploads && isUploadTask(task: task)) {
|
||||
// cancel without resume data so the new URLRequest (with the updated
|
||||
// allowsCellularAccess setting) is used; downloadTask(withResumeData:)
|
||||
// would reuse the request embedded in the resume data
|
||||
haveReEnqueued = true
|
||||
BDPlugin.tasksToReEnqueue.insert(task)
|
||||
urlSessionTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !haveReEnqueued {
|
||||
self.reEnqueuesDone() // nothing left to do
|
||||
}
|
||||
}
|
||||
self.requireWiFiChangeSemaphore.wait() // wait for re-enqueues to complete
|
||||
}
|
||||
}
|
||||
|
||||
func reEnqueuesDone() {
|
||||
self.requireWiFiChangeSemaphore.signal()
|
||||
}
|
||||
|
||||
|
||||
/// Re-enqueue this task and associated data. Nil signals end of batch
|
||||
func reEnqueue(_ enqueueItem: EnqueueItem?) {
|
||||
reEnqueueQueue.async {
|
||||
_Concurrency.Task {
|
||||
guard let reEnqueueData = enqueueItem else {
|
||||
// nil value indicates end of batch of re-enqueues
|
||||
self.reEnqueuesDone()
|
||||
return
|
||||
}
|
||||
let timeSinceCreated = Date().timeIntervalSince(reEnqueueData.created)
|
||||
if timeSinceCreated < 0.3 {
|
||||
try await _Concurrency.Task.sleep(nanoseconds: UInt64((0.3 - timeSinceCreated) * 1_000_000_000))
|
||||
}
|
||||
await enqueueItem?.enqueue()
|
||||
try await _Concurrency.Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user