[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
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
)
]
)
@@ -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
}
}
@@ -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)
}
}
@@ -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)
}
})
}
}
@@ -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
}
@@ -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
}
}
@@ -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
}
@@ -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()!
}
}
@@ -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
}
}
@@ -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
}
@@ -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>
@@ -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
}
@@ -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")
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
}
}