diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index f05f77c..742251a 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -14,8 +14,7 @@ 437CD5112D94C62800A909A6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CD50E2D94C62800A909A6 /* AppDelegate.swift */; }; 4395E65E2DB00E4100637803 /* BackgroundDownloadMetaStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */; }; 4395E65F2DB00E4100637803 /* BackgroundDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */; }; - 4395E6632DB00E8300637803 /* BackgroundDownloadTaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6622DB00E8300637803 /* BackgroundDownloadTaskStore.swift */; }; - 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.swift */; }; + 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegate.swift */; }; 43D1CF5A2D80860E00AC1ED9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43D1CF592D80860E00AC1ED9 /* Preview Assets.xcassets */; }; 43D1CF642D80860E00AC1ED9 /* BackgroundTransferRevised_ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF632D80860E00AC1ED9 /* BackgroundTransferRevised_ExampleTests.swift */; }; 43D1CF832D808A5000AC1ED9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43D1CF7E2D808A5000AC1ED9 /* Assets.xcassets */; }; @@ -43,8 +42,7 @@ 437CD5132D94C66C00A909A6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadService.swift; sourceTree = ""; }; 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadMetaStore.swift; sourceTree = ""; }; - 4395E6622DB00E8300637803 /* BackgroundDownloadTaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadTaskStore.swift; sourceTree = ""; }; - 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadDelegator.swift; sourceTree = ""; }; + 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadDelegate.swift; sourceTree = ""; }; 43D1CF4F2D80860C00AC1ED9 /* BackgroundTransferRevised-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BackgroundTransferRevised-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 43D1CF592D80860E00AC1ED9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 43D1CF5F2D80860E00AC1ED9 /* BackgroundTransferRevised-ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "BackgroundTransferRevised-ExampleTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -110,10 +108,9 @@ 4395E65D2DB00E4100637803 /* BackgroundDownload */ = { isa = PBXGroup; children = ( - 4395E6622DB00E8300637803 /* BackgroundDownloadTaskStore.swift */, 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */, 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */, - 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.swift */, + 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegate.swift */, ); path = BackgroundDownload; sourceTree = ""; @@ -300,8 +297,7 @@ 437CD5102D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, 4395E65E2DB00E4100637803 /* BackgroundDownloadMetaStore.swift in Sources */, 4395E65F2DB00E4100637803 /* BackgroundDownloadService.swift in Sources */, - 4395E6632DB00E8300637803 /* BackgroundDownloadTaskStore.swift in Sources */, - 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegator.swift in Sources */, + 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegate.swift in Sources */, 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/BackgroundTransferRevised-Example/Application/AppDelegate.swift b/BackgroundTransferRevised-Example/Application/AppDelegate.swift index 59958e6..26b0d04 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -9,24 +9,13 @@ import UIKit import OSLog class AppDelegate: NSObject, UIApplicationDelegate { - static var shared: AppDelegate? - - private var backgroundCompletionHandler: (() -> Void)? - private let logger = Logger(subsystem: "com.williamboles", - category: "appDelegate") - - // MARK: - Background + // MARK: - UIApplicationDelegate func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { - self.backgroundCompletionHandler = completionHandler - } - - func backgroundDownloadsComplete() { - logger.info("Triggering background session completion handler") - - backgroundCompletionHandler?() - backgroundCompletionHandler = nil + Task { + await BackgroundDownloadService.shared.saveBackgroundCompletionHandler(completionHandler) + } } } diff --git a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift index 61b5077..c60761a 100644 --- a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift +++ b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift @@ -16,13 +16,6 @@ struct BackgroundTransferRevised_ExampleApp: App { private let logger = Logger(subsystem: "com.williamboles", category: "app") - - // MARK: - Init - - init() { - AppDelegate.shared = appDelegate - } - // MARK: - Scene var body: some Scene { @@ -37,12 +30,12 @@ struct BackgroundTransferRevised_ExampleApp: App { logger.info("Files will be downloaded to: \(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].absoluteString)") -// //Exit app to test restoring app from a terminated state. Comment out to test restoring app from a suspended state. -// Task { -// logger.info("Simulating app termination by exit(0)") -// -// exit(0) -// } + //Exit app to test restoring app from a terminated state. + Task { + logger.info("Simulating app termination by exit(0)") + + exit(0) + } } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift new file mode 100644 index 0000000..1aaeb48 --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift @@ -0,0 +1,117 @@ +// +// BackgroundDownloadDelegator.swift +// BackgroundTransferRevised-Example +// +// Created by William Boles on 16/04/2025. +// + +import Foundation +import OSLog + +enum BackgroundDownloadError: Error { + case fileSystemError(_ underlyingError: Error) + case clientError(_ underlyingError: Error) + case serverError(_ underlyingResponse: URLResponse?) +} + +final class BackgroundDownloadDelegate: NSObject, URLSessionDownloadDelegate { + private let metaStore: BackgroundDownloadMetaStore + private let logger: Logger + private let processingGroup: DispatchGroup + private let urlSessionDidFinishEventsCompletionHandler: (@Sendable () -> Void) + + // MARK: - Init + + init(metaStore: BackgroundDownloadMetaStore, + urlSessionDidFinishEventsCompletionHandler: @escaping (@Sendable () -> Void)) { + self.metaStore = metaStore + self.logger = Logger(subsystem: "com.williamboles", + category: "background.download.delegate") + self.processingGroup = DispatchGroup() + self.urlSessionDidFinishEventsCompletionHandler = urlSessionDidFinishEventsCompletionHandler + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + guard let fromURL = downloadTask.originalRequest?.url else { + logger.error("Unexpected nil URL for download task.") + return + } + + logger.info("Download request completed for: \(fromURL.absoluteString)") + + let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) + try? FileManager.default.moveItem(at: location, + to: tempLocation) + + processingGroup.enter() + metaStore.retrieveMetadata(key: fromURL.absoluteString) { [weak self] metadata in + defer { + self?.metaStore.removeMetadata(key: fromURL.absoluteString) + self?.processingGroup.leave() + } + + guard let metadata else { + self?.logger.error("Unable to find existing download item for: \(fromURL.absoluteString)") + return + } + + guard let response = downloadTask.response as? HTTPURLResponse, + response.statusCode == 200 else { + self?.logger.error("Unexpected response for: \(fromURL.absoluteString)") + metadata.continuation?.resume(throwing: BackgroundDownloadError.serverError(downloadTask.response)) + return + } + + self?.logger.info("Download successful for: \(fromURL.absoluteString)") + + do { + try FileManager.default.moveItem(at: tempLocation, + to: metadata.toURL) + metadata.continuation?.resume(returning: metadata.toURL) + } catch { + self?.logger.error("File system error while moving file: \(error.localizedDescription)") + metadata.continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) + } + } + } + + func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let error = error else { + return + } + + guard let fromURL = task.originalRequest?.url else { + logger.error("Unexpected nil URL for task.") + return + } + + logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") + + processingGroup.enter() + metaStore.retrieveMetadata(key: fromURL.absoluteString) { [weak self] metadata in + defer { + self?.metaStore.removeMetadata(key: fromURL.absoluteString) + self?.processingGroup.leave() + } + + metadata?.continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + logger.info("Did finish events for background session") + + processingGroup.notify(queue: .global()) { [weak self] in + self?.logger.info("Processing group has finished") + + self?.urlSessionDidFinishEventsCompletionHandler() + } + } +} + diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift deleted file mode 100644 index 7b42998..0000000 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// BackgroundDownloadDelegator.swift -// BackgroundTransferRevised-Example -// -// Created by William Boles on 16/04/2025. -// - -import Foundation -import OSLog - -final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { - private let metaStore: BackgroundDownloadMetaStore - private let taskStore: BackgroundDownloadTaskStore - private let logger: Logger - - // MARK: - Init - - init(metaStore: BackgroundDownloadMetaStore, - logger: Logger) { - self.metaStore = metaStore - self.logger = logger - self.taskStore = BackgroundDownloadTaskStore() - } - - // MARK: - URLSessionDownloadDelegate - - func urlSession(_ session: URLSession, - downloadTask: URLSessionDownloadTask, - didFinishDownloadingTo location: URL) { - guard let fromURL = downloadTask.originalRequest?.url else { - logger.error("Unexpected nil URL for download task.") - return - } - - logger.info("Download request completed for: \(fromURL.absoluteString)") - - let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) - try? FileManager.default.moveItem(at: location, - to: tempLocation) - - let processingTask = Task { - defer { - cleanUpDownload(forURL: fromURL) - } - - let metaData = await metaStore.retrieveMetadata(key: fromURL.absoluteString) - guard let metaData else { - logger.error("Unable to find existing download item for: \(fromURL.absoluteString)") - return - } - - guard let response = downloadTask.response as? HTTPURLResponse, response.statusCode == 200 else { - logger.error("Unexpected response for: \(fromURL.absoluteString)") - metaData.continuation?.resume(throwing: BackgroundDownloadError.serverError(downloadTask.response)) - return - } - - logger.info("Download successful for: \(fromURL.absoluteString)") - - do { - try FileManager.default.moveItem(at: tempLocation, - to: metaData.toURL) - metaData.continuation?.resume(returning: metaData.toURL) - } catch { - logger.error("File system error while moving file: \(error.localizedDescription)") - metaData.continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) - } - } - - // TODO: Is this a race condition, where processing task could finish before this Task block is ran? - Task { - await taskStore.storeTask(processingTask, - key: fromURL.absoluteString) - } - } - - func urlSession(_ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error?) { - guard let error = error, - let fromURL = task.originalRequest?.url else { - return - } - - logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") - - let processingTask = Task { - defer { - cleanUpDownload(forURL: fromURL) - } - - let metaData = await metaStore.retrieveMetadata(key: fromURL.absoluteString) - metaData?.continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) - } - - // TODO: Is this a race condition, where processing task could finish before this Task block is ran? - Task { - await taskStore.storeTask(processingTask, - key: fromURL.absoluteString) - } - } - - func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - logger.info("Did finish events for background session") - - Task { - await withTaskGroup(of: Void.self) { group in - for task in await taskStore.retrieveAll() { - group.addTask { - await task.value - } - } - - await group.waitForAll() - - logger.info("All tasks in group completed") - - await MainActor.run { - guard let appDelegate = AppDelegate.shared else { - logger.error("App delegate is nil") - return - } - - appDelegate.backgroundDownloadsComplete() - } - } - } - } - - private func cleanUpDownload(forURL url: URL) { - Task { - let key = url.absoluteString - - await metaStore.removeMetadata(key: key) - await taskStore.removeTask(key: key) - } - } -} - diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift index 2888e2a..fcb0330 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift @@ -8,49 +8,61 @@ import Foundation -struct BackgroundDownloadMetaData { +struct BackgroundDownloadMetadata { let toURL: URL let continuation: CheckedContinuation? } -actor BackgroundDownloadMetaStore { +final class BackgroundDownloadMetaStore: @unchecked Sendable { private var inMemoryStore: [String: CheckedContinuation] private let persistentStore: UserDefaults + private let queue: DispatchQueue // MARK: - Init init() { self.inMemoryStore = [String: CheckedContinuation]() self.persistentStore = UserDefaults.standard + self.queue = DispatchQueue(label: "com.williamboles.background.download.service", + qos: .userInitiated, + attributes: .concurrent) } // MARK: - Store - func storeMetadata(_ metaData: BackgroundDownloadMetaData, + func storeMetadata(_ metadata: BackgroundDownloadMetadata, key: String) { - inMemoryStore[key] = metaData.continuation - persistentStore.set(metaData.toURL, forKey: key) + queue.async(flags: .barrier) { [weak self] in + self?.inMemoryStore[key] = metadata.continuation + self?.persistentStore.set(metadata.toURL, forKey: key) + } } // MARK: - Retrieve - func retrieveMetadata(key: String) -> BackgroundDownloadMetaData? { - guard let toURL = persistentStore.url(forKey: key) else { - return nil + func retrieveMetadata(key: String, + completionHandler: @escaping (@Sendable (BackgroundDownloadMetadata?) -> ())) { + return queue.async { [weak self] in + guard let toURL = self?.persistentStore.url(forKey: key) else { + completionHandler(nil) + return + } + + let continuation = self?.inMemoryStore[key] + + let metadata = BackgroundDownloadMetadata(toURL: toURL, + continuation: continuation) + + completionHandler(metadata) } - - let continuation = inMemoryStore[key] - - let metaData = BackgroundDownloadMetaData(toURL: toURL, - continuation: continuation) - - return metaData } // MARK: - Remove func removeMetadata(key: String) { - inMemoryStore.removeValue(forKey: key) - persistentStore.removeObject(forKey: key) + queue.async(flags: .barrier) { [weak self] in + self?.inMemoryStore.removeValue(forKey: key) + self?.persistentStore.removeObject(forKey: key) + } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index 6bbcf24..67c88c4 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -11,17 +11,13 @@ import OSLog import UIKit import SwiftUI -enum BackgroundDownloadError: Error { - case fileSystemError(_ underlyingError: Error) - case clientError(_ underlyingError: Error) - case serverError(_ underlyingResponse: URLResponse?) -} - actor BackgroundDownloadService { private let session: URLSession private let metaStore: BackgroundDownloadMetaStore private let logger: Logger + private var backgroundCompletionHandler: (() -> Void)? + // MARK: - Singleton static let shared = BackgroundDownloadService() @@ -33,14 +29,17 @@ actor BackgroundDownloadService { self.logger = Logger(subsystem: "com.williamboles", category: "background.download") - let delegator = BackgroundDownloadDelegator(metaStore: metaStore, - logger: logger) + let delegate = BackgroundDownloadDelegate(metaStore: metaStore) { + Task { + await BackgroundDownloadService.shared.backgroundDownloadsComplete() + } + } let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") configuration.isDiscretionary = false configuration.sessionSendsLaunchEvents = true self.session = URLSession(configuration: configuration, - delegate: delegator, + delegate: delegate, delegateQueue: nil) } @@ -51,9 +50,10 @@ actor BackgroundDownloadService { return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") - storeMetadata(from: fromURL, - to: toURL, - continuation: continuation) + let metadata = BackgroundDownloadMetadata(toURL: toURL, + continuation: continuation) + metaStore.storeMetadata(metadata, + key: fromURL.absoluteString) let downloadTask = session.downloadTask(with: fromURL) downloadTask.earliestBeginDate = Date().addingTimeInterval(10) // Remove this in production, the delay was added for demonstration purposes only @@ -61,14 +61,16 @@ actor BackgroundDownloadService { } } - private func storeMetadata(from fromURL: URL, - to toURL: URL, - continuation: CheckedContinuation) { - Task { - let metaData = BackgroundDownloadMetaData(toURL: toURL, - continuation: continuation) - await metaStore.storeMetadata(metaData, - key: fromURL.absoluteString) - } + // MARK: - CompletionHandler + + func saveBackgroundCompletionHandler(_ backgroundCompletionHandler: @escaping (() -> Void)) { + self.backgroundCompletionHandler = backgroundCompletionHandler + } + + private func backgroundDownloadsComplete() { + logger.info("Triggering background session completion handler") + + backgroundCompletionHandler?() + backgroundCompletionHandler = nil } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift deleted file mode 100644 index 59400fe..0000000 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// BackgroundDownloadTaskStore.swift -// BackgroundTransferRevised-Example -// -// Created by William Boles on 16/04/2025. -// - -import Foundation - -actor BackgroundDownloadTaskStore { - private var tasks = [String: Task]() - - // MARK: - Add - - func storeTask(_ task: Task, - key: String) { - tasks[key] = task - } - - // MARK: - Retrieve - - func retrieveAll() -> [Task] { - Array(tasks.values) - } - - // MARK: - Remove - - func removeTask(key: String) { - tasks[key] = nil - } -}