From f54b9feffd81f447826e2a3b4a06acb855f23826 Mon Sep 17 00:00:00 2001 From: William Boles Date: Mon, 21 Apr 2025 22:04:14 +0100 Subject: [PATCH 1/8] Removed delegate in favour of nonisolated methods --- .../project.pbxproj | 4 - .../BackgroundDownloadDelegate.swift | 117 ------------ .../BackgroundDownloadMetaStore.swift | 55 ++---- .../BackgroundDownloadService.swift | 174 +++++++++++++++--- 4 files changed, 161 insertions(+), 189 deletions(-) delete mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegate.swift diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 742251a..52f6fb0 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ 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 */; }; @@ -42,7 +41,6 @@ 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; }; @@ -110,7 +108,6 @@ children = ( 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */, 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */, - 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegate.swift */, ); path = BackgroundDownload; sourceTree = ""; @@ -297,7 +294,6 @@ 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/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 index fcb0330..5a2d319 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift @@ -13,56 +13,35 @@ struct BackgroundDownloadMetadata { 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) - } +actor BackgroundDownloadMetaStore { + private var inMemoryStore = [String: CheckedContinuation]() + private let persistentStore = UserDefaults.standard // 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) - } + inMemoryStore[key] = metadata.continuation + 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) + func retrieveMetadata(key: String) throws -> BackgroundDownloadMetadata { + guard let toURL = persistentStore.url(forKey: key) else { + throw BackgroundDownloadError.unknownDownload } + + let continuation = inMemoryStore[key] + + let metadata = BackgroundDownloadMetadata(toURL: toURL, + continuation: continuation) + + return metadata } // MARK: - Remove func removeMetadata(key: String) { - queue.async(flags: .barrier) { [weak self] in - self?.inMemoryStore.removeValue(forKey: key) - self?.persistentStore.removeObject(forKey: key) - } + inMemoryStore.removeValue(forKey: key) + persistentStore.removeObject(forKey: key) } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index 67c88c4..c133c98 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -11,10 +11,28 @@ import OSLog import UIKit import SwiftUI -actor BackgroundDownloadService { - private let session: URLSession - private let metaStore: BackgroundDownloadMetaStore - private let logger: Logger +enum BackgroundDownloadError: Error { + 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 let metaStore = BackgroundDownloadMetaStore() + private let logger = Logger(subsystem: "com.williamboles", + category: "background.download") private var backgroundCompletionHandler: (() -> Void)? @@ -22,27 +40,6 @@ actor BackgroundDownloadService { 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) - } - // MARK: - Download func download(from fromURL: URL, @@ -50,11 +47,14 @@ actor BackgroundDownloadService { 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) - + // TODO: Investigate removing metastore in favour of two properties + Task { + let metadata = BackgroundDownloadMetadata(toURL: toURL, + continuation: continuation) + await 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 downloadTask.resume() @@ -73,4 +73,118 @@ actor BackgroundDownloadService { backgroundCompletionHandler?() backgroundCompletionHandler = nil } + + // MARK: Download + + private func downloadFinished(task: URLSessionDownloadTask, + downloadedTo location: URL) async { + 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 { + Task { + await metaStore.removeMetadata(key: fromURL.absoluteString) + } + } + + do { + let metadata = try await metaStore.retrieveMetadata(key: fromURL.absoluteString) + + guard let response = task.response as? HTTPURLResponse, + response.statusCode == 200 else { + logger.error("Unexpected response for: \(fromURL.absoluteString)") + metadata.continuation?.resume(throwing: BackgroundDownloadError.serverError(task.response)) + return + } + + logger.info("Download successful for: \(fromURL.absoluteString)") + + do { + try FileManager.default.moveItem(at: location, + 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)) + } + } catch { + logger.error("Unable to find existing download for: \(fromURL.absoluteString)") + } + } + + private func downloadComplete(task: URLSessionTask, + withError error: Error?) async { + 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)") + + do { + defer { + Task { + await metaStore.removeMetadata(key: fromURL.absoluteString) + } + } + + let metadata = try await metaStore.retrieveMetadata(key: fromURL.absoluteString) + + metadata.continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) + + } catch { + logger.error("Unable to find existing download for: \(fromURL.absoluteString)") + } + } + + private func backgroundDownloadsComplete() async { + logger.info("All background downloads completed") + + backgroundCompletionHandler?() + backgroundCompletionHandler = nil + } +} + +extension BackgroundDownloadService: URLSessionDownloadDelegate { + // MARK: - URLSessionDownloadDelegate + + nonisolated + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + // File needs moved before method exist as it only guaranteed to exist until that point + 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() + } + } } From db6a1302a61b7d263c00f247ce93c8b8095e46e2 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 09:41:59 +0100 Subject: [PATCH 2/8] Removed metastore --- .../project.pbxproj | 4 - .../BackgroundDownloadMetaStore.swift | 47 ----------- .../BackgroundDownloadService.swift | 80 ++++++++----------- 3 files changed, 34 insertions(+), 97 deletions(-) delete mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 52f6fb0..a9b4f3e 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 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 */; }; 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 */; }; @@ -40,7 +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 = ""; }; 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; }; @@ -107,7 +105,6 @@ isa = PBXGroup; children = ( 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */, - 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */, ); path = BackgroundDownload; sourceTree = ""; @@ -292,7 +289,6 @@ 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 */, 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */, ); diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift deleted file mode 100644 index 5a2d319..0000000 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift +++ /dev/null @@ -1,47 +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? -} - -actor BackgroundDownloadMetaStore { - private var inMemoryStore = [String: CheckedContinuation]() - private let persistentStore = UserDefaults.standard - - // MARK: - Store - - func storeMetadata(_ metadata: BackgroundDownloadMetadata, - key: String) { - inMemoryStore[key] = metadata.continuation - persistentStore.set(metadata.toURL, forKey: key) - } - - func retrieveMetadata(key: String) throws -> BackgroundDownloadMetadata { - guard let toURL = persistentStore.url(forKey: key) else { - throw BackgroundDownloadError.unknownDownload - } - - 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) - } -} diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index c133c98..61cccf1 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -30,7 +30,8 @@ actor BackgroundDownloadService: NSObject { return session }() - private let metaStore = BackgroundDownloadMetaStore() + private var inMemoryStore = [String: CheckedContinuation]() + private let persistentStore = UserDefaults.standard private let logger = Logger(subsystem: "com.williamboles", category: "background.download") @@ -47,13 +48,8 @@ actor BackgroundDownloadService: NSObject { return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") - // TODO: Investigate removing metastore in favour of two properties - Task { - let metadata = BackgroundDownloadMetadata(toURL: toURL, - continuation: continuation) - await metaStore.storeMetadata(metadata, - key: fromURL.absoluteString) - } + inMemoryStore[fromURL.absoluteString] = continuation + persistentStore.set(toURL, forKey: 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 @@ -86,33 +82,33 @@ actor BackgroundDownloadService: NSObject { logger.info("Download request completed for: \(fromURL.absoluteString)") defer { - Task { - await metaStore.removeMetadata(key: fromURL.absoluteString) - } + inMemoryStore.removeValue(forKey: fromURL.absoluteString) + persistentStore.removeObject(forKey: fromURL.absoluteString) } + 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 { - let metadata = try await metaStore.retrieveMetadata(key: fromURL.absoluteString) - - guard let response = task.response as? HTTPURLResponse, - response.statusCode == 200 else { - logger.error("Unexpected response for: \(fromURL.absoluteString)") - metadata.continuation?.resume(throwing: BackgroundDownloadError.serverError(task.response)) - return - } - - logger.info("Download successful for: \(fromURL.absoluteString)") - - do { - try FileManager.default.moveItem(at: location, - 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)) - } + try FileManager.default.moveItem(at: location, + to: toURL) + continuation?.resume(returning: toURL) } catch { - logger.error("Unable to find existing download for: \(fromURL.absoluteString)") + logger.error("File system error while moving file: \(error.localizedDescription)") + continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) } } @@ -129,20 +125,12 @@ actor BackgroundDownloadService: NSObject { logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") - do { - defer { - Task { - await metaStore.removeMetadata(key: fromURL.absoluteString) - } - } - - let metadata = try await metaStore.retrieveMetadata(key: fromURL.absoluteString) - - metadata.continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) - - } catch { - logger.error("Unable to find existing download for: \(fromURL.absoluteString)") - } + let continuation = inMemoryStore[fromURL.absoluteString] + + continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) + + inMemoryStore.removeValue(forKey: fromURL.absoluteString) + persistentStore.removeObject(forKey: fromURL.absoluteString) } private func backgroundDownloadsComplete() async { @@ -160,7 +148,7 @@ extension BackgroundDownloadService: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - // File needs moved before method exist as it only guaranteed to exist until that point + // 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) From 9efc211c636836a5958f8e75d69e0d573652faea Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 10:42:15 +0100 Subject: [PATCH 3/8] Added duplicate request handling --- .../BackgroundDownloadService.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index 61cccf1..9d755bd 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -13,6 +13,7 @@ import SwiftUI enum BackgroundDownloadError: Error { case unknownDownload + case cancelled case fileSystemError(_ underlyingError: Error) case clientError(_ underlyingError: Error) case serverError(_ underlyingResponse: URLResponse?) @@ -45,6 +46,11 @@ actor BackgroundDownloadService: NSObject { func download(from fromURL: URL, to toURL: URL) async throws -> URL { + if let existingContinuation = inMemoryStore[fromURL.absoluteString] { + existingContinuation.resume(throwing: BackgroundDownloadError.cancelled) + cleanUpDownload(forURL: fromURL) + } + return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") @@ -82,8 +88,7 @@ actor BackgroundDownloadService: NSObject { logger.info("Download request completed for: \(fromURL.absoluteString)") defer { - inMemoryStore.removeValue(forKey: fromURL.absoluteString) - persistentStore.removeObject(forKey: fromURL.absoluteString) + cleanUpDownload(forURL: fromURL) } guard let toURL = persistentStore.url(forKey: fromURL.absoluteString) else { @@ -129,8 +134,7 @@ actor BackgroundDownloadService: NSObject { continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) - inMemoryStore.removeValue(forKey: fromURL.absoluteString) - persistentStore.removeObject(forKey: fromURL.absoluteString) + cleanUpDownload(forURL: fromURL) } private func backgroundDownloadsComplete() async { @@ -139,6 +143,11 @@ actor BackgroundDownloadService: NSObject { backgroundCompletionHandler?() backgroundCompletionHandler = nil } + + private func cleanUpDownload(forURL url: URL) { + inMemoryStore.removeValue(forKey: url.absoluteString) + persistentStore.removeObject(forKey: url.absoluteString) + } } extension BackgroundDownloadService: URLSessionDownloadDelegate { From 598fd7730f9d4039e3c3c08a74d28c87f6808106 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 11:11:04 +0100 Subject: [PATCH 4/8] Added ability to cancel --- .../BackgroundDownloadService.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index 9d755bd..130d16f 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -31,6 +31,7 @@ actor BackgroundDownloadService: NSObject { return session }() + private var activeDownloads = [String: URLSessionDownloadTask]() private var inMemoryStore = [String: CheckedContinuation]() private let persistentStore = UserDefaults.standard private let logger = Logger(subsystem: "com.williamboles", @@ -46,9 +47,9 @@ actor BackgroundDownloadService: NSObject { func download(from fromURL: URL, to toURL: URL) async throws -> URL { - if let existingContinuation = inMemoryStore[fromURL.absoluteString] { - existingContinuation.resume(throwing: BackgroundDownloadError.cancelled) - cleanUpDownload(forURL: fromURL) + if activeDownloads[fromURL.absoluteString] != nil { + // cancel existing downloads for this URL + cancelDownload(forURL: fromURL) } return try await withCheckedThrowingContinuation { continuation in @@ -58,11 +59,21 @@ actor BackgroundDownloadService: NSObject { 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() } } + 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) + } + // MARK: - CompletionHandler func saveBackgroundCompletionHandler(_ backgroundCompletionHandler: @escaping (() -> Void)) { @@ -123,6 +134,11 @@ actor BackgroundDownloadService: NSObject { 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 @@ -147,6 +163,9 @@ actor BackgroundDownloadService: NSObject { private func cleanUpDownload(forURL url: URL) { inMemoryStore.removeValue(forKey: url.absoluteString) persistentStore.removeObject(forKey: url.absoluteString) + activeDownloads.removeValue(forKey: url.absoluteString) + + persistentStore.synchronize() } } From 4a0e741ed6623aac737392c18e74a396056666d4 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 13:50:25 +0100 Subject: [PATCH 5/8] Private init --- .../BackgroundDownload/BackgroundDownloadService.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index 130d16f..c512817 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -12,8 +12,8 @@ import UIKit import SwiftUI enum BackgroundDownloadError: Error { - case unknownDownload case cancelled + case unknownDownload case fileSystemError(_ underlyingError: Error) case clientError(_ underlyingError: Error) case serverError(_ underlyingResponse: URLResponse?) @@ -33,7 +33,7 @@ actor BackgroundDownloadService: NSObject { private var activeDownloads = [String: URLSessionDownloadTask]() private var inMemoryStore = [String: CheckedContinuation]() - private let persistentStore = UserDefaults.standard + private let persistentStore = UserDefaults.standard private let logger = Logger(subsystem: "com.williamboles", category: "background.download") @@ -43,6 +43,10 @@ actor BackgroundDownloadService: NSObject { static let shared = BackgroundDownloadService() + private override init() { + super.init() + } + // MARK: - Download func download(from fromURL: URL, From 72fcef4bc51a0798cd36065806ae257331f9a162 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 15:03:21 +0100 Subject: [PATCH 6/8] Removed unneeded method --- .../Application/AppDelegate.swift | 2 +- .../BackgroundDownloadService.swift | 33 +++++++------------ 2 files changed, 13 insertions(+), 22 deletions(-) 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/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index c512817..0e793b3 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -37,7 +37,7 @@ actor BackgroundDownloadService: NSObject { private let logger = Logger(subsystem: "com.williamboles", category: "background.download") - private var backgroundCompletionHandler: (() -> Void)? + private var appPreviewCompletionHandler: (() -> Void)? // MARK: - Singleton @@ -78,21 +78,6 @@ actor BackgroundDownloadService: NSObject { cleanUpDownload(forURL: url) } - // MARK: - CompletionHandler - - func saveBackgroundCompletionHandler(_ backgroundCompletionHandler: @escaping (() -> Void)) { - self.backgroundCompletionHandler = backgroundCompletionHandler - } - - private func backgroundDownloadsComplete() { - logger.info("Triggering background session completion handler") - - backgroundCompletionHandler?() - backgroundCompletionHandler = nil - } - - // MARK: Download - private func downloadFinished(task: URLSessionDownloadTask, downloadedTo location: URL) async { guard let fromURL = task.originalRequest?.url else { @@ -157,19 +142,25 @@ actor BackgroundDownloadService: NSObject { cleanUpDownload(forURL: fromURL) } - private func backgroundDownloadsComplete() async { + // MARK: - AppPreview + + func saveAppPreviewCompletionHandler(_ appPreviewCompletionHandler: @escaping (() -> Void)) { + self.appPreviewCompletionHandler = appPreviewCompletionHandler + } + + private func backgroundDownloadsComplete() { 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) - - persistentStore.synchronize() } } From db9a62707214ce990674c7129711877913494ff6 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 15:09:35 +0100 Subject: [PATCH 7/8] Removed unneeded async attributes --- .../BackgroundDownload/BackgroundDownloadService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index 0e793b3..fdaaae8 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -79,7 +79,7 @@ actor BackgroundDownloadService: NSObject { } private func downloadFinished(task: URLSessionDownloadTask, - downloadedTo location: URL) async { + downloadedTo location: URL) { guard let fromURL = task.originalRequest?.url else { logger.error("Unexpected nil URL for download task.") return @@ -118,7 +118,7 @@ actor BackgroundDownloadService: NSObject { } private func downloadComplete(task: URLSessionTask, - withError error: Error?) async { + withError error: Error?) { guard let error = error else { return } From 26e02fcfed89036dff904f34a34c02e737857750 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 22 Apr 2025 15:41:01 +0100 Subject: [PATCH 8/8] Removed white spacing --- .../Network/BackgroundDownload/BackgroundDownloadService.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index fdaaae8..b0cb7e7 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -136,7 +136,6 @@ actor BackgroundDownloadService: NSObject { logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") let continuation = inMemoryStore[fromURL.absoluteString] - continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) cleanUpDownload(forURL: fromURL)