diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 742251a..a9b4f3e 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -12,9 +12,7 @@ 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */; }; 437CD5102D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CD50D2D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift */; }; 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 */; }; - 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 */; }; @@ -41,8 +39,6 @@ 437CD50F2D94C62800A909A6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; - 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; }; @@ -109,8 +105,6 @@ isa = PBXGroup; children = ( 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */, - 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */, - 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegate.swift */, ); path = BackgroundDownload; sourceTree = ""; @@ -295,9 +289,7 @@ 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */, 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */, 437CD5102D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, - 4395E65E2DB00E4100637803 /* BackgroundDownloadMetaStore.swift in Sources */, 4395E65F2DB00E4100637803 /* BackgroundDownloadService.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 26b0d04..0307eb0 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { Task { - await BackgroundDownloadService.shared.saveBackgroundCompletionHandler(completionHandler) + await BackgroundDownloadService.shared.saveAppPreviewCompletionHandler(completionHandler) } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift deleted file mode 100644 index 1aaeb48..0000000 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// 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/BackgroundDownloadMetaStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift deleted file mode 100644 index fcb0330..0000000 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// BackgroundDownloadStore.swift -// BackgroundTransfer-Example -// -// Created by William Boles on 26/03/2025. -// Copyright © 2025 William Boles. All rights reserved. -// - -import Foundation - -struct BackgroundDownloadMetadata { - let toURL: URL - let continuation: CheckedContinuation? -} - -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, - key: String) { - 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, - 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) - } - } - - // MARK: - Remove - - func removeMetadata(key: String) { - 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 67c88c4..b0cb7e7 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -11,66 +11,190 @@ import OSLog import UIKit import SwiftUI -actor BackgroundDownloadService { - private let session: URLSession - private let metaStore: BackgroundDownloadMetaStore - private let logger: Logger +enum BackgroundDownloadError: Error { + case cancelled + case unknownDownload + case fileSystemError(_ underlyingError: Error) + case clientError(_ underlyingError: Error) + case serverError(_ underlyingResponse: URLResponse?) +} + +actor BackgroundDownloadService: NSObject { + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") + configuration.isDiscretionary = false + configuration.sessionSendsLaunchEvents = true + let session = URLSession(configuration: configuration, + delegate: self, + delegateQueue: nil) + + return session + }() - private var backgroundCompletionHandler: (() -> Void)? + private var activeDownloads = [String: URLSessionDownloadTask]() + private var inMemoryStore = [String: CheckedContinuation]() + private let persistentStore = UserDefaults.standard + private let logger = Logger(subsystem: "com.williamboles", + category: "background.download") + + private var appPreviewCompletionHandler: (() -> Void)? // MARK: - Singleton static let shared = BackgroundDownloadService() - // MARK: - Init - - private init() { - self.metaStore = BackgroundDownloadMetaStore() - self.logger = Logger(subsystem: "com.williamboles", - category: "background.download") - - 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: delegate, - delegateQueue: nil) + private override init() { + super.init() } - + // MARK: - Download func download(from fromURL: URL, to toURL: URL) async throws -> URL { + if activeDownloads[fromURL.absoluteString] != nil { + // cancel existing downloads for this URL + cancelDownload(forURL: fromURL) + } + return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") - let metadata = BackgroundDownloadMetadata(toURL: toURL, - continuation: continuation) - metaStore.storeMetadata(metadata, - key: fromURL.absoluteString) - + inMemoryStore[fromURL.absoluteString] = continuation + persistentStore.set(toURL, forKey: fromURL.absoluteString) + let downloadTask = session.downloadTask(with: fromURL) + activeDownloads[fromURL.absoluteString] = downloadTask downloadTask.earliestBeginDate = Date().addingTimeInterval(10) // Remove this in production, the delay was added for demonstration purposes only downloadTask.resume() } } - // MARK: - CompletionHandler + func cancelDownload(forURL url: URL) { + logger.info("Cancelling download for: \(url.absoluteString)") + + inMemoryStore[url.absoluteString]?.resume(throwing: BackgroundDownloadError.cancelled) + activeDownloads[url.absoluteString]?.cancel() + + cleanUpDownload(forURL: url) + } + + private func downloadFinished(task: URLSessionDownloadTask, + downloadedTo location: URL) { + guard let fromURL = task.originalRequest?.url else { + logger.error("Unexpected nil URL for download task.") + return + } + + logger.info("Download request completed for: \(fromURL.absoluteString)") + + defer { + cleanUpDownload(forURL: fromURL) + } + + guard let toURL = persistentStore.url(forKey: fromURL.absoluteString) else { + logger.error("Unable to find existing download for: \(fromURL.absoluteString)") + return + } + + let continuation = inMemoryStore[fromURL.absoluteString] + + guard let response = task.response as? HTTPURLResponse, + response.statusCode == 200 else { + logger.error("Unexpected response for: \(fromURL.absoluteString)") + continuation?.resume(throwing: BackgroundDownloadError.serverError(task.response)) + return + } + + logger.info("Download successful for: \(fromURL.absoluteString)") + + do { + try FileManager.default.moveItem(at: location, + to: toURL) + continuation?.resume(returning: toURL) + } catch { + logger.error("File system error while moving file: \(error.localizedDescription)") + continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) + } + } + + private func downloadComplete(task: URLSessionTask, + withError error: Error?) { + guard let error = error else { + return + } + + if let error = error as? URLError, + error.code == .cancelled { + 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)") + + let continuation = inMemoryStore[fromURL.absoluteString] + continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) + + cleanUpDownload(forURL: fromURL) + } + + // MARK: - AppPreview - func saveBackgroundCompletionHandler(_ backgroundCompletionHandler: @escaping (() -> Void)) { - self.backgroundCompletionHandler = backgroundCompletionHandler + func saveAppPreviewCompletionHandler(_ appPreviewCompletionHandler: @escaping (() -> Void)) { + self.appPreviewCompletionHandler = appPreviewCompletionHandler } private func backgroundDownloadsComplete() { - logger.info("Triggering background session completion handler") + logger.info("All background downloads completed") - backgroundCompletionHandler?() - backgroundCompletionHandler = nil + appPreviewCompletionHandler?() + appPreviewCompletionHandler = nil + } + + // MARK: - Cleanup + + private func cleanUpDownload(forURL url: URL) { + inMemoryStore.removeValue(forKey: url.absoluteString) + persistentStore.removeObject(forKey: url.absoluteString) + activeDownloads.removeValue(forKey: url.absoluteString) + } +} + +extension BackgroundDownloadService: URLSessionDownloadDelegate { + // MARK: - URLSessionDownloadDelegate + + nonisolated + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + // File needs moved before method exits, as file is only guaranteed to exist during this method + let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) + try? FileManager.default.moveItem(at: location, + to: tempLocation) + + Task { + await downloadFinished(task: downloadTask, + downloadedTo: tempLocation) + } + } + + nonisolated + func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error?) { + Task { + await downloadComplete(task: task, + withError: error) + } + } + + nonisolated + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + Task { + await backgroundDownloadsComplete() + } } }