From 09b7fa28241d554aaa1cbd13c22d9c0867eb1c7a Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 26 Mar 2025 17:47:20 +0000 Subject: [PATCH 01/18] Basic implementation of cat retrieval --- .../project.pbxproj | 92 ++++++++-- .../AccentColor.colorset/Contents.json | 11 -- .../AppIcon.appiconset/Contents.json | 13 -- ...BackgroundTransferRevised_ExampleApp.swift | 3 +- .../ContentView.swift | 24 --- .../Extensions/GridItem+Layout.swift | 22 +++ .../Features/Cats/CatsView.swift | 70 ++++++++ .../Features/Cats/CatsViewModel.swift | 82 +++++++++ .../Network/BackgroundDownloadService.swift | 161 ++++++++++++++++++ .../Network/BackgroundDownloadStore.swift | 45 +++++ .../Network/ImageLoader.swift | 62 +++++++ .../Network/NetworkService.swift | 71 ++++++++ .../AppIcon.appiconset/Contents.json | 98 +++++++++++ .../Images}/Assets.xcassets/Contents.json | 0 14 files changed, 695 insertions(+), 59 deletions(-) delete mode 100644 BackgroundTransferRevised-Example/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 BackgroundTransferRevised-Example/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 BackgroundTransferRevised-Example/ContentView.swift create mode 100644 BackgroundTransferRevised-Example/Extensions/GridItem+Layout.swift create mode 100644 BackgroundTransferRevised-Example/Features/Cats/CatsView.swift create mode 100644 BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift create mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift create mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift create mode 100644 BackgroundTransferRevised-Example/Network/ImageLoader.swift create mode 100644 BackgroundTransferRevised-Example/Network/NetworkService.swift create mode 100644 BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AppIcon.appiconset/Contents.json rename BackgroundTransferRevised-Example/{ => Resources/Images}/Assets.xcassets/Contents.json (100%) diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 849bc75..d700f07 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -7,11 +7,17 @@ objects = { /* Begin PBXBuildFile section */ + 436CC0972D808CC500F9E4E2 /* CatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */; }; + 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC0962D808CC400F9E4E2 /* CatsView.swift */; }; + 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */; }; 43D1CF532D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */; }; - 43D1CF552D80860C00AC1ED9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF542D80860C00AC1ED9 /* ContentView.swift */; }; - 43D1CF572D80860E00AC1ED9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43D1CF562D80860E00AC1ED9 /* Assets.xcassets */; }; 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 */; }; + 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF802D808A5000AC1ED9 /* ImageLoader.swift */; }; + 43D1CF852D808A5000AC1ED9 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF812D808A5000AC1ED9 /* NetworkService.swift */; }; + 43E208BA2D942444006B7E12 /* BackgroundDownloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E208B82D942444006B7E12 /* BackgroundDownloadStore.swift */; }; + 43E208BB2D942444006B7E12 /* BackgroundDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E208B92D942444006B7E12 /* BackgroundDownloadService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -25,13 +31,19 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsViewModel.swift; sourceTree = ""; }; + 436CC0962D808CC400F9E4E2 /* CatsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsView.swift; sourceTree = ""; }; + 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GridItem+Layout.swift"; sourceTree = ""; }; 43D1CF4F2D80860C00AC1ED9 /* BackgroundTransferRevised-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BackgroundTransferRevised-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransferRevised_ExampleApp.swift; sourceTree = ""; }; - 43D1CF542D80860C00AC1ED9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 43D1CF562D80860E00AC1ED9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 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; }; 43D1CF632D80860E00AC1ED9 /* BackgroundTransferRevised_ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransferRevised_ExampleTests.swift; sourceTree = ""; }; + 43D1CF7E2D808A5000AC1ED9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 43D1CF802D808A5000AC1ED9 /* ImageLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + 43D1CF812D808A5000AC1ED9 /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + 43E208B82D942444006B7E12 /* BackgroundDownloadStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadStore.swift; sourceTree = ""; }; + 43E208B92D942444006B7E12 /* BackgroundDownloadService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,6 +64,31 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 436CC0932D808CC400F9E4E2 /* Features */ = { + isa = PBXGroup; + children = ( + 436CC0942D808CC400F9E4E2 /* Cats */, + ); + path = Features; + sourceTree = ""; + }; + 436CC0942D808CC400F9E4E2 /* Cats */ = { + isa = PBXGroup; + children = ( + 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */, + 436CC0962D808CC400F9E4E2 /* CatsView.swift */, + ); + path = Cats; + sourceTree = ""; + }; + 436CC0992D8091D300F9E4E2 /* Extensions */ = { + isa = PBXGroup; + children = ( + 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 43D1CF462D80860C00AC1ED9 = { isa = PBXGroup; children = ( @@ -73,9 +110,11 @@ 43D1CF512D80860C00AC1ED9 /* BackgroundTransferRevised-Example */ = { isa = PBXGroup; children = ( + 436CC0992D8091D300F9E4E2 /* Extensions */, + 436CC0932D808CC400F9E4E2 /* Features */, + 43D1CF7F2D808A5000AC1ED9 /* Network */, + 43D1CF7C2D808A5000AC1ED9 /* Resources */, 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */, - 43D1CF542D80860C00AC1ED9 /* ContentView.swift */, - 43D1CF562D80860E00AC1ED9 /* Assets.xcassets */, 43D1CF582D80860E00AC1ED9 /* Preview Content */, ); path = "BackgroundTransferRevised-Example"; @@ -97,6 +136,33 @@ path = "BackgroundTransferRevised-ExampleTests"; sourceTree = ""; }; + 43D1CF7C2D808A5000AC1ED9 /* Resources */ = { + isa = PBXGroup; + children = ( + 43D1CF7D2D808A5000AC1ED9 /* Images */, + ); + path = Resources; + sourceTree = ""; + }; + 43D1CF7D2D808A5000AC1ED9 /* Images */ = { + isa = PBXGroup; + children = ( + 43D1CF7E2D808A5000AC1ED9 /* Assets.xcassets */, + ); + path = Images; + sourceTree = ""; + }; + 43D1CF7F2D808A5000AC1ED9 /* Network */ = { + isa = PBXGroup; + children = ( + 43D1CF802D808A5000AC1ED9 /* ImageLoader.swift */, + 43D1CF812D808A5000AC1ED9 /* NetworkService.swift */, + 43E208B92D942444006B7E12 /* BackgroundDownloadService.swift */, + 43E208B82D942444006B7E12 /* BackgroundDownloadStore.swift */, + ); + path = Network; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -179,7 +245,7 @@ buildActionMask = 2147483647; files = ( 43D1CF5A2D80860E00AC1ED9 /* Preview Assets.xcassets in Resources */, - 43D1CF572D80860E00AC1ED9 /* Assets.xcassets in Resources */, + 43D1CF832D808A5000AC1ED9 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -197,8 +263,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43D1CF552D80860C00AC1ED9 /* ContentView.swift in Sources */, 43D1CF532D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, + 436CC0972D808CC500F9E4E2 /* CatsViewModel.swift in Sources */, + 43D1CF852D808A5000AC1ED9 /* NetworkService.swift in Sources */, + 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */, + 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */, + 43E208BA2D942444006B7E12 /* BackgroundDownloadStore.swift in Sources */, + 43E208BB2D942444006B7E12 /* BackgroundDownloadService.swift in Sources */, + 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -273,7 +345,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -330,7 +402,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/BackgroundTransferRevised-Example/Assets.xcassets/AccentColor.colorset/Contents.json b/BackgroundTransferRevised-Example/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/BackgroundTransferRevised-Example/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BackgroundTransferRevised-Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/BackgroundTransferRevised-Example/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3..0000000 --- a/BackgroundTransferRevised-Example/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift index 42170d8..34ffebe 100644 --- a/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift +++ b/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift @@ -11,7 +11,8 @@ import SwiftUI struct BackgroundTransferRevised_ExampleApp: App { var body: some Scene { WindowGroup { - ContentView() + let catsViewModel = CatsViewModel() + CatsView(viewModel: catsViewModel) } } } diff --git a/BackgroundTransferRevised-Example/ContentView.swift b/BackgroundTransferRevised-Example/ContentView.swift deleted file mode 100644 index 080de83..0000000 --- a/BackgroundTransferRevised-Example/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// BackgroundTransferRevised-Example -// -// Created by William Boles on 11/03/2025. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/BackgroundTransferRevised-Example/Extensions/GridItem+Layout.swift b/BackgroundTransferRevised-Example/Extensions/GridItem+Layout.swift new file mode 100644 index 0000000..3b8aaa1 --- /dev/null +++ b/BackgroundTransferRevised-Example/Extensions/GridItem+Layout.swift @@ -0,0 +1,22 @@ +// +// GridItem+Layout.swift +// BackgroundTransfer-Example +// +// Created by William Boles on 11/03/2025. +// + +import Foundation +import SwiftUI + +extension GridItem { + + static func threeFlexibleColumns() -> [GridItem] { + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + ] + + return columns + } +} diff --git a/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift b/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift new file mode 100644 index 0000000..3c3ec08 --- /dev/null +++ b/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift @@ -0,0 +1,70 @@ +// +// ContentView.swift +// BackgroundTransferRevised-Example +// +// Created by William Boles on 11/03/2025. +// + +import SwiftUI + +struct CatsView: View { + @StateObject var viewModel: CatsViewModel + + // MARK: - View + + var body: some View { + NavigationStack { + VStack { + switch viewModel.state { + case .empty: + Text("We have no cats to show you! 🙀") + case .retrieving: + ProgressView("Retrieving Cats! 😺") + case .retrieved(let cats): + GeometryReader { geometryReader in + let columns = GridItem.threeFlexibleColumns() + let sideLength = geometryReader.size.width / CGFloat(columns.count) + ScrollView { + LazyVGrid(columns: columns, alignment: .center, spacing: 4) { + ForEach(cats) { catViewModel in + CatImageCell(viewModel: catViewModel) + .frame(width: sideLength, height: sideLength) + .task { + catViewModel.loadImage() + } + } + } + } + } + case .failed: + Text("Failed to retrieve Cats! 😿") + } + } + .padding() + .navigationTitle("Cats 😻") + } + .task { + await viewModel.retrieveCats() + } + } +} + +struct CatImageCell: View { + @StateObject var viewModel: CatViewModel + + // MARK: - View + + var body: some View { + switch viewModel.state { + case .empty: + Image(systemName: "photo") + case .retrieving: + ProgressView() + case .retrieved(let image): + image.resizable() + .aspectRatio(contentMode: .fill) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .clipped() + } + } +} diff --git a/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift b/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift new file mode 100644 index 0000000..4b55e88 --- /dev/null +++ b/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift @@ -0,0 +1,82 @@ +// +// ViewModelProvider.swift +// BackgroundTransfer-Example +// +// Created by William Boles on 31/01/2023. +// + +import Foundation +import SwiftUI +import UIKit +import OSLog + +@MainActor +class CatsViewModel: ObservableObject { + enum CatsState { + case empty + case retrieving + case retrieved(_ cats: [CatViewModel]) + case failed + } + + @Published var state: CatsState = .empty + + private let networkService = NetworkService() + + // MARK: - Retrieval + + func retrieveCats() async { + state = .retrieving + + do { + let cats = try await networkService.retrieveCats() + + let viewModels = cats.map { CatViewModel(cat: $0) } + state = .retrieved(viewModels) + } catch { + os_log(.error, "Error when retrieving json: %{public}@", error.localizedDescription) + state = .failed + } + } +} + +@MainActor +class CatViewModel: ObservableObject, Identifiable { + enum CatState { + case empty + case retrieving + case retrieved(_ image: Image) + } + + @Published var state: CatState = .empty + + private let imageLoader = ImageLoader() + private let cat: Cat + + let id: String + + // MARK: - Init + + init(cat: Cat) { + self.cat = cat + self.id = cat.id + } + + // MARK: - Image + + func loadImage() { + state = .retrieving + + Task { + let uiImage = try? await imageLoader.loadImage(name: cat.id, + url: cat.url) + + guard let uiImage else { + state = .empty + return + } + + state = .retrieved(Image(uiImage: uiImage)) + } + } +} diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift new file mode 100644 index 0000000..d21f41a --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -0,0 +1,161 @@ +// +// BackgroundDownloadService.swift +// BackgroundTransfer-Example +// +// Created by William Boles on 02/05/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation +import os + +enum BackgroundDownloadError: Error { + case missingInstructionsError + case fileSystemError(_ underlyingError: Error) + case clientError(_ underlyingError: Error) + case serverError(_ underlyingResponse: URLResponse?) +} + +class BackgroundDownloadService: NSObject, URLSessionDelegate { + var backgroundCompletionHandler: (() -> Void)? + + private var session: URLSession! + private let store = BackgroundDownloadStore() + + // MARK: - Singleton + + static let shared = BackgroundDownloadService() + + // MARK: - Init + + override init() { + super.init() + + configureSession() + } + + private func configureSession() { + let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") + configuration.sessionSendsLaunchEvents = true + let session = URLSession(configuration: configuration, + delegate: self, + delegateQueue: nil) + self.session = session + } + + // MARK: - Download + + func download(from fromURL: URL, + to toURL: URL) async throws -> URL { + return try await withCheckedThrowingContinuation { continuation in + Task { + os_log(.info, "Scheduling to download: %{public}@", fromURL.absoluteString) + + await store.storeMetadata(from: fromURL, + to: toURL) { result in + os_log(.info, "Calling continuation on %{public}@", fromURL.absoluteString) + + switch result { + case let .success(url): + continuation.resume(returning: url) + case let .failure(error): + continuation.resume(throwing: error) + } + } + + // can i use func download(for request: URLRequest, delegate: (URLSessionTaskDelegate)? = nil) async throws -> (URL, URLResponse) instead? + + let downloadTask = session.downloadTask(with: fromURL) + downloadTask.earliestBeginDate = Date().addingTimeInterval(2) // Remove this in production, the delay was added for demonstration purposes only + downloadTask.resume() + } + } + } +} + +// MARK: - URLSessionDownloadDelegate + +extension BackgroundDownloadService: URLSessionDownloadDelegate { + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + guard let fromURL = downloadTask.originalRequest?.url else { + os_log(.error, "Unexpected nil URL") + // Unable to call the closure here as we use fromURL as the key to retrieve the closure + return + } + + let fromURLAsString = fromURL.absoluteString + + os_log(.info, "Download request completed for: %{public}@", fromURLAsString) + + let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) + try? FileManager.default.moveItem(at: location, + to: tempLocation) + + Task { + defer { + Task { + await store.removeMetadata(for: fromURL) + } + } + + let (toURL, completionHandler) = await store.retrieveMetadata(for: fromURL) + guard let toURL else { + os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString) + completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError)) + return + } + + guard let response = downloadTask.response as? HTTPURLResponse, + response.statusCode == 200 else { + os_log(.error, "Unexpected response for: %{public}@", fromURLAsString) + completionHandler?(.failure(BackgroundDownloadError.serverError(downloadTask.response))) + return + } + + os_log(.info, "Download successful for: %{public}@", fromURLAsString) + + do { + try FileManager.default.moveItem(at: tempLocation, + to: toURL) + + completionHandler?(.success(toURL)) + } catch { + completionHandler?(.failure(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 { + os_log(.error, "Unexpected nil URL") + return + } + + let fromURLAsString = fromURL.absoluteString + + os_log(.info, "Download failed for: %{public}@", fromURLAsString) + + Task { + let (_, completionHandler) = await store.retrieveMetadata(for: fromURL) + completionHandler?(.failure(BackgroundDownloadError.clientError(error))) + + await store.removeMetadata(for: fromURL) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + // needs to be called on the main queue + self.backgroundCompletionHandler?() + self.backgroundCompletionHandler = nil + } + } +} diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift new file mode 100644 index 0000000..fd744ca --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift @@ -0,0 +1,45 @@ +// +// BackgroundDownloadStore.swift +// BackgroundTransfer-Example +// +// Created by William Boles on 02/05/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation + +typealias BackgroundDownloadCompletion = (_ result: Result) -> () + +actor BackgroundDownloadStore { + private var inMemoryStore = [String: BackgroundDownloadCompletion]() + private let persistentStore = UserDefaults.standard + + // MARK: - Store + + func storeMetadata(from fromURL: URL, + to toURL: URL, + completionHandler: @escaping BackgroundDownloadCompletion) { + inMemoryStore[fromURL.absoluteString] = completionHandler + persistentStore.set(toURL, forKey: fromURL.absoluteString) + } + + // MARK: - Retrieve + + func retrieveMetadata(for forURL: URL) -> (URL?, BackgroundDownloadCompletion?) { + let key = forURL.absoluteString + + let toURL = persistentStore.url(forKey: key) + let completionHandler = inMemoryStore[key] + + return (toURL, completionHandler) + } + + // MARK: - Remove + + func removeMetadata(for forURL: URL) { + let key = forURL.absoluteString + + inMemoryStore[key] = nil + persistentStore.removeObject(forKey: key) + } +} diff --git a/BackgroundTransferRevised-Example/Network/ImageLoader.swift b/BackgroundTransferRevised-Example/Network/ImageLoader.swift new file mode 100644 index 0000000..79632f2 --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/ImageLoader.swift @@ -0,0 +1,62 @@ +// +// ImageLoader.swift +// BackgroundTransferRevised-Example +// +// Created by William Boles on 11/03/2025. +// Copyright © 2025 William Boles. All rights reserved. +// + +import Foundation +import UIKit + +enum ImageLoaderError: Error { + case missingData + case invalidImageData +} + +class ImageLoader { + private let backgroundDownloader = BackgroundDownloadService.shared + + // MARK: - Load + + func loadImage(name: String, + url: URL) async throws -> UIImage { + let fileManager = FileManager.default + let paths = fileManager.urls(for: .documentDirectory, in: .userDomainMask) + let documentsDirectoryURL = paths[0] + let localImageURL = documentsDirectoryURL.appendingPathComponent(name) + + if fileManager.fileExists(atPath: localImageURL.path) { + let image = try await loadLocalImage(localImageURL: localImageURL) + + return image + } else { + let image = try await loadRemoteImage(remoteImageURL: url, + localImageURL: localImageURL) + + return image + } + } + + + private func loadLocalImage(localImageURL: URL) async throws -> UIImage { + guard let imageData = try? Data(contentsOf: localImageURL) else { + throw ImageLoaderError.missingData + } + + guard let image = UIImage(data: imageData) else { + throw ImageLoaderError.invalidImageData + } + + return image + } + + private func loadRemoteImage(remoteImageURL: URL, + localImageURL: URL) async throws -> UIImage { + let url = try await backgroundDownloader.download(from: remoteImageURL, + to: localImageURL) + let image = try await loadLocalImage(localImageURL: url) + + return image + } +} diff --git a/BackgroundTransferRevised-Example/Network/NetworkService.swift b/BackgroundTransferRevised-Example/Network/NetworkService.swift new file mode 100644 index 0000000..574d908 --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/NetworkService.swift @@ -0,0 +1,71 @@ +// +// NetworkService.swift +// BackgroundTransferRevised-Example +// +// Created by William Boles on 11/03/2025. +// Copyright © 2025 William Boles. All rights reserved. +// + +import Foundation +import os + +struct Cat: Decodable, Equatable { + let id: String + let url: URL +} + +enum NetworkServiceError: Error { + case networkError + case decodingErrror +} + +class NetworkService { + // MARK: - Cats + + func retrieveCats() async throws -> [Cat] { + let APIKey = "live_yzNvM2rsrxvWpSwtsAWzbSiGoGW175yNLmnO1u5Fh5GMFxbZ9l4C01t9BcP2v6WQ" + + assert(!APIKey.isEmpty, "Replace this empty string with your API key from: https://thecatapi.com/") + + let limitQueryItem = URLQueryItem(name: "limit", value: "50") + let sizeQueryItem = URLQueryItem(name: "size", value: "thumb") + + let queryItems = [limitQueryItem, sizeQueryItem] + + var components = URLComponents() + components.scheme = "https" + components.host = "api.thecatapi.com" + components.path = "/v1/images/search" + components.queryItems = queryItems + + guard let url = components.url else { + throw NetworkServiceError.networkError + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "GET" + urlRequest.addValue(APIKey, forHTTPHeaderField: "x-api-key") + + os_log(.error, "Retrieving cats...") + + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NetworkServiceError.networkError + } + + guard let cats = try? JSONDecoder().decode([Cat].self, from: data) else { + throw NetworkServiceError.decodingErrror + } + + os_log(.error, "Cats successfully retrieved!") + + return cats + } catch let error as NetworkServiceError { + throw error + } catch { + throw NetworkServiceError.networkError + } + } +} diff --git a/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AppIcon.appiconset/Contents.json b/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/BackgroundTransferRevised-Example/Assets.xcassets/Contents.json b/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/Contents.json similarity index 100% rename from BackgroundTransferRevised-Example/Assets.xcassets/Contents.json rename to BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/Contents.json From 875bf64bb73fd6312968e3f7dbf5db777f6cd2f4 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 26 Mar 2025 18:24:33 +0000 Subject: [PATCH 02/18] Using continuation in place of completion handler --- .../Network/BackgroundDownloadService.swift | 28 ++++++------------- .../Network/BackgroundDownloadStore.swift | 12 ++++---- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index d21f41a..3e15edf 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -52,18 +52,8 @@ class BackgroundDownloadService: NSObject, URLSessionDelegate { os_log(.info, "Scheduling to download: %{public}@", fromURL.absoluteString) await store.storeMetadata(from: fromURL, - to: toURL) { result in - os_log(.info, "Calling continuation on %{public}@", fromURL.absoluteString) - - switch result { - case let .success(url): - continuation.resume(returning: url) - case let .failure(error): - continuation.resume(throwing: error) - } - } - - // can i use func download(for request: URLRequest, delegate: (URLSessionTaskDelegate)? = nil) async throws -> (URL, URLResponse) instead? + to: toURL, + continuation: continuation) let downloadTask = session.downloadTask(with: fromURL) downloadTask.earliestBeginDate = Date().addingTimeInterval(2) // Remove this in production, the delay was added for demonstration purposes only @@ -100,17 +90,17 @@ extension BackgroundDownloadService: URLSessionDownloadDelegate { } } - let (toURL, completionHandler) = await store.retrieveMetadata(for: fromURL) + let (toURL, continuation) = await store.retrieveMetadata(for: fromURL) guard let toURL else { os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString) - completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError)) + continuation?.resume(throwing: BackgroundDownloadError.missingInstructionsError) return } guard let response = downloadTask.response as? HTTPURLResponse, response.statusCode == 200 else { os_log(.error, "Unexpected response for: %{public}@", fromURLAsString) - completionHandler?(.failure(BackgroundDownloadError.serverError(downloadTask.response))) + continuation?.resume(throwing: BackgroundDownloadError.serverError(downloadTask.response)) return } @@ -120,9 +110,9 @@ extension BackgroundDownloadService: URLSessionDownloadDelegate { try FileManager.default.moveItem(at: tempLocation, to: toURL) - completionHandler?(.success(toURL)) + continuation?.resume(returning: toURL) } catch { - completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error))) + continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) } } } @@ -144,8 +134,8 @@ extension BackgroundDownloadService: URLSessionDownloadDelegate { os_log(.info, "Download failed for: %{public}@", fromURLAsString) Task { - let (_, completionHandler) = await store.retrieveMetadata(for: fromURL) - completionHandler?(.failure(BackgroundDownloadError.clientError(error))) + let (_, continuation) = await store.retrieveMetadata(for: fromURL) + continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) await store.removeMetadata(for: fromURL) } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift index fd744ca..5e9390a 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift @@ -11,27 +11,27 @@ import Foundation typealias BackgroundDownloadCompletion = (_ result: Result) -> () actor BackgroundDownloadStore { - private var inMemoryStore = [String: BackgroundDownloadCompletion]() + private var inMemoryStore = [String: CheckedContinuation]() private let persistentStore = UserDefaults.standard // MARK: - Store func storeMetadata(from fromURL: URL, to toURL: URL, - completionHandler: @escaping BackgroundDownloadCompletion) { - inMemoryStore[fromURL.absoluteString] = completionHandler + continuation: CheckedContinuation) { + inMemoryStore[fromURL.absoluteString] = continuation persistentStore.set(toURL, forKey: fromURL.absoluteString) } // MARK: - Retrieve - func retrieveMetadata(for forURL: URL) -> (URL?, BackgroundDownloadCompletion?) { + func retrieveMetadata(for forURL: URL) -> (URL?, CheckedContinuation?) { let key = forURL.absoluteString let toURL = persistentStore.url(forKey: key) - let completionHandler = inMemoryStore[key] + let continuation = inMemoryStore[key] - return (toURL, completionHandler) + return (toURL, continuation) } // MARK: - Remove From 912beda1aa0e78c854cfa0d5aae72a5caa2b4b40 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 26 Mar 2025 23:18:58 +0000 Subject: [PATCH 03/18] Added AppDelegate --- .../project.pbxproj | 8 ++ .../AppDelegate.swift | 18 +++ ...BackgroundTransferRevised_ExampleApp.swift | 21 ++++ BackgroundTransferRevised-Example/Info.plist | 11 ++ .../Network/BackgroundDownloadService.swift | 118 +++++++----------- .../Network/BackgroundDownloadStore.swift | 12 +- .../Network/NetworkService.swift | 9 +- 7 files changed, 117 insertions(+), 80 deletions(-) create mode 100644 BackgroundTransferRevised-Example/AppDelegate.swift create mode 100644 BackgroundTransferRevised-Example/Info.plist diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index d700f07..ce9ebbf 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 436CC0972D808CC500F9E4E2 /* CatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */; }; 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC0962D808CC400F9E4E2 /* CatsView.swift */; }; 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */; }; + 437CD50A2D94BA8E00A909A6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CD5092D94BA8E00A909A6 /* AppDelegate.swift */; }; 43D1CF532D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.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 */; }; @@ -34,6 +35,8 @@ 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsViewModel.swift; sourceTree = ""; }; 436CC0962D808CC400F9E4E2 /* CatsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsView.swift; sourceTree = ""; }; 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GridItem+Layout.swift"; sourceTree = ""; }; + 437CD5092D94BA8E00A909A6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 437CD50B2D94BB3A00A909A6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 43D1CF4F2D80860C00AC1ED9 /* BackgroundTransferRevised-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BackgroundTransferRevised-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransferRevised_ExampleApp.swift; sourceTree = ""; }; 43D1CF592D80860E00AC1ED9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -110,12 +113,14 @@ 43D1CF512D80860C00AC1ED9 /* BackgroundTransferRevised-Example */ = { isa = PBXGroup; children = ( + 437CD50B2D94BB3A00A909A6 /* Info.plist */, 436CC0992D8091D300F9E4E2 /* Extensions */, 436CC0932D808CC400F9E4E2 /* Features */, 43D1CF7F2D808A5000AC1ED9 /* Network */, 43D1CF7C2D808A5000AC1ED9 /* Resources */, 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */, 43D1CF582D80860E00AC1ED9 /* Preview Content */, + 437CD5092D94BA8E00A909A6 /* AppDelegate.swift */, ); path = "BackgroundTransferRevised-Example"; sourceTree = ""; @@ -266,6 +271,7 @@ 43D1CF532D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, 436CC0972D808CC500F9E4E2 /* CatsViewModel.swift in Sources */, 43D1CF852D808A5000AC1ED9 /* NetworkService.swift in Sources */, + 437CD50A2D94BA8E00A909A6 /* AppDelegate.swift in Sources */, 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */, 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */, 43E208BA2D942444006B7E12 /* BackgroundDownloadStore.swift in Sources */, @@ -422,6 +428,7 @@ DEVELOPMENT_ASSET_PATHS = "\"BackgroundTransferRevised-Example/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "BackgroundTransferRevised-Example/Info.plist"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -450,6 +457,7 @@ DEVELOPMENT_ASSET_PATHS = "\"BackgroundTransferRevised-Example/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "BackgroundTransferRevised-Example/Info.plist"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/BackgroundTransferRevised-Example/AppDelegate.swift b/BackgroundTransferRevised-Example/AppDelegate.swift new file mode 100644 index 0000000..f712b0b --- /dev/null +++ b/BackgroundTransferRevised-Example/AppDelegate.swift @@ -0,0 +1,18 @@ +// +// AppDelegate.swift +// BackgroundTransferRevised-Example +// +// Created by William Boles on 26/03/2025. +// + +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + // MARK: - Background + + func application(_ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void) { + BackgroundDownloadService().backgroundCompletionHandler = completionHandler + } +} diff --git a/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift index 34ffebe..3d53461 100644 --- a/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift +++ b/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift @@ -6,13 +6,34 @@ // import SwiftUI +import OSLog @main struct BackgroundTransferRevised_ExampleApp: App { + @Environment(\.scenePhase) private var scenePhase + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + private let logger = Logger(subsystem: "com.williamboles", category: "app") + + var body: some Scene { WindowGroup { let catsViewModel = CatsViewModel() CatsView(viewModel: catsViewModel) } + .onChange(of: scenePhase) { (_, newPhase) in + guard newPhase == .background else { + return + } + + logger.info("Scheduling download: \(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) + } + } } } diff --git a/BackgroundTransferRevised-Example/Info.plist b/BackgroundTransferRevised-Example/Info.plist new file mode 100644 index 0000000..1a1bf62 --- /dev/null +++ b/BackgroundTransferRevised-Example/Info.plist @@ -0,0 +1,11 @@ + + + + + UIBackgroundModes + + fetch + processing + + + diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 3e15edf..900a8c5 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -2,12 +2,12 @@ // BackgroundDownloadService.swift // BackgroundTransfer-Example // -// Created by William Boles on 02/05/2018. -// Copyright © 2018 William Boles. All rights reserved. +// Created by William Boles on 26/03/2025. +// Copyright © 2025 William Boles. All rights reserved. // import Foundation -import os +import OSLog enum BackgroundDownloadError: Error { case missingInstructionsError @@ -18,45 +18,40 @@ enum BackgroundDownloadError: Error { class BackgroundDownloadService: NSObject, URLSessionDelegate { var backgroundCompletionHandler: (() -> Void)? - + + static let identifier = "com.williamboles.background.download.session" + private var session: URLSession! private let store = BackgroundDownloadStore() - + + private let logger = Logger(subsystem: "com.williamboles", category: "BackgroundDownloadService") + // MARK: - Singleton - static let shared = BackgroundDownloadService() - + // MARK: - Init - override init() { super.init() - configureSession() } - + private func configureSession() { - let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") + let configuration = URLSessionConfiguration.background(withIdentifier: BackgroundDownloadService.identifier) + configuration.isDiscretionary = false configuration.sessionSendsLaunchEvents = true - let session = URLSession(configuration: configuration, - delegate: self, - delegateQueue: nil) - self.session = session + self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) } - + // MARK: - Download - - func download(from fromURL: URL, - to toURL: URL) async throws -> URL { + func download(from fromURL: URL, to toURL: URL) async throws -> URL { return try await withCheckedThrowingContinuation { continuation in Task { - os_log(.info, "Scheduling to download: %{public}@", fromURL.absoluteString) - - await store.storeMetadata(from: fromURL, - to: toURL, - continuation: continuation) - + logger.info("Scheduling download: \(fromURL.absoluteString)") + + await store.storeMetadata(from: fromURL, to: toURL, continuation: continuation) + let downloadTask = session.downloadTask(with: fromURL) - downloadTask.earliestBeginDate = Date().addingTimeInterval(2) // Remove this in production, the delay was added for demonstration purposes only + downloadTask.earliestBeginDate = Date().addingTimeInterval(10) // Demonstration delay downloadTask.resume() } } @@ -64,88 +59,69 @@ class BackgroundDownloadService: NSObject, URLSessionDelegate { } // MARK: - URLSessionDownloadDelegate - extension BackgroundDownloadService: URLSessionDownloadDelegate { - func urlSession(_ session: URLSession, - downloadTask: URLSessionDownloadTask, - didFinishDownloadingTo location: URL) { + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard let fromURL = downloadTask.originalRequest?.url else { - os_log(.error, "Unexpected nil URL") - // Unable to call the closure here as we use fromURL as the key to retrieve the closure + logger.error("Unexpected nil URL for download task.") return } - - let fromURLAsString = fromURL.absoluteString - - os_log(.info, "Download request completed for: %{public}@", fromURLAsString) - + + logger.info("Download request completed for: \(fromURL.absoluteString)") + let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) - try? FileManager.default.moveItem(at: location, - to: tempLocation) - + try? FileManager.default.moveItem(at: location, to: tempLocation) + Task { defer { Task { await store.removeMetadata(for: fromURL) } } - + let (toURL, continuation) = await store.retrieveMetadata(for: fromURL) guard let toURL else { - os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString) + logger.error("Unable to find existing download item for: \(fromURL.absoluteString)") continuation?.resume(throwing: BackgroundDownloadError.missingInstructionsError) return } - - guard let response = downloadTask.response as? HTTPURLResponse, - response.statusCode == 200 else { - os_log(.error, "Unexpected response for: %{public}@", fromURLAsString) + + guard let response = downloadTask.response as? HTTPURLResponse, response.statusCode == 200 else { + logger.error("Unexpected response for: \(fromURL.absoluteString)") continuation?.resume(throwing: BackgroundDownloadError.serverError(downloadTask.response)) return } - - os_log(.info, "Download successful for: %{public}@", fromURLAsString) - + + logger.info("Download successful for: \(fromURL.absoluteString)") + do { - try FileManager.default.moveItem(at: tempLocation, - to: toURL) - + try FileManager.default.moveItem(at: tempLocation, to: toURL) continuation?.resume(returning: toURL) } catch { + logger.error("File system error while moving file: \(error.localizedDescription)") 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 { - os_log(.error, "Unexpected nil URL") + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error = error, let fromURL = task.originalRequest?.url else { return } - - let fromURLAsString = fromURL.absoluteString - - os_log(.info, "Download failed for: %{public}@", fromURLAsString) - + + logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") + Task { let (_, continuation) = await store.retrieveMetadata(for: fromURL) continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) - await store.removeMetadata(for: fromURL) } } - + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - DispatchQueue.main.async { - // needs to be called on the main queue + Task { @MainActor in self.backgroundCompletionHandler?() self.backgroundCompletionHandler = nil } } } + diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift index 5e9390a..4c273cc 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift @@ -2,14 +2,12 @@ // BackgroundDownloadStore.swift // BackgroundTransfer-Example // -// Created by William Boles on 02/05/2018. -// Copyright © 2018 William Boles. All rights reserved. +// Created by William Boles on 26/03/2025. +// Copyright © 2025 William Boles. All rights reserved. // import Foundation -typealias BackgroundDownloadCompletion = (_ result: Result) -> () - actor BackgroundDownloadStore { private var inMemoryStore = [String: CheckedContinuation]() private let persistentStore = UserDefaults.standard @@ -19,8 +17,10 @@ actor BackgroundDownloadStore { func storeMetadata(from fromURL: URL, to toURL: URL, continuation: CheckedContinuation) { - inMemoryStore[fromURL.absoluteString] = continuation - persistentStore.set(toURL, forKey: fromURL.absoluteString) + let key = fromURL.absoluteString + + inMemoryStore[key] = continuation + persistentStore.set(toURL, forKey: key) } // MARK: - Retrieve diff --git a/BackgroundTransferRevised-Example/Network/NetworkService.swift b/BackgroundTransferRevised-Example/Network/NetworkService.swift index 574d908..ce21b61 100644 --- a/BackgroundTransferRevised-Example/Network/NetworkService.swift +++ b/BackgroundTransferRevised-Example/Network/NetworkService.swift @@ -7,7 +7,7 @@ // import Foundation -import os +import OSLog struct Cat: Decodable, Equatable { let id: String @@ -20,6 +20,8 @@ enum NetworkServiceError: Error { } class NetworkService { + private let logger = Logger(subsystem: "com.williamboles", category: "NetworkService") + // MARK: - Cats func retrieveCats() async throws -> [Cat] { @@ -46,7 +48,8 @@ class NetworkService { urlRequest.httpMethod = "GET" urlRequest.addValue(APIKey, forHTTPHeaderField: "x-api-key") - os_log(.error, "Retrieving cats...") + + logger.info("Retrieving cats...") do { let (data, response) = try await URLSession.shared.data(for: urlRequest) @@ -59,7 +62,7 @@ class NetworkService { throw NetworkServiceError.decodingErrror } - os_log(.error, "Cats successfully retrieved!") + logger.info("Cats successfully retrieved!") return cats } catch let error as NetworkServiceError { From 9f2dbe9e94204bb7c1c50dc5f7f383a4dcdfaa0f Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 26 Mar 2025 23:43:14 +0000 Subject: [PATCH 04/18] Added info.plist --- .../project.pbxproj | 68 +++++++++----- ...BackgroundTransferRevised-Example.xcscheme | 90 +++++++++++++++++++ .../{ => Application}/AppDelegate.swift | 0 ...BackgroundTransferRevised_ExampleApp.swift | 0 .../{ => Application}/Info.plist | 0 ...ckgroundTransferRevised_ExampleTests.swift | 8 -- .../Info.plist | 11 +++ Gemfile | 2 +- Gemfile.lock | 14 +-- 9 files changed, 158 insertions(+), 35 deletions(-) create mode 100644 BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme rename BackgroundTransferRevised-Example/{ => Application}/AppDelegate.swift (100%) rename BackgroundTransferRevised-Example/{ => Application}/BackgroundTransferRevised_ExampleApp.swift (100%) rename BackgroundTransferRevised-Example/{ => Application}/Info.plist (100%) create mode 100644 BackgroundTransferRevised-ExampleTests/Info.plist diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index ce9ebbf..e71dcff 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -10,8 +10,8 @@ 436CC0972D808CC500F9E4E2 /* CatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */; }; 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC0962D808CC400F9E4E2 /* CatsView.swift */; }; 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */; }; - 437CD50A2D94BA8E00A909A6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CD5092D94BA8E00A909A6 /* AppDelegate.swift */; }; - 43D1CF532D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.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 */; }; 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 */; }; @@ -35,10 +35,11 @@ 436CC0952D808CC400F9E4E2 /* CatsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsViewModel.swift; sourceTree = ""; }; 436CC0962D808CC400F9E4E2 /* CatsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsView.swift; sourceTree = ""; }; 436CC09A2D8091D300F9E4E2 /* GridItem+Layout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GridItem+Layout.swift"; sourceTree = ""; }; - 437CD5092D94BA8E00A909A6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 437CD50B2D94BB3A00A909A6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 437CD50D2D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTransferRevised_ExampleApp.swift; sourceTree = ""; }; + 437CD50E2D94C62800A909A6 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 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 = ""; }; 43D1CF4F2D80860C00AC1ED9 /* BackgroundTransferRevised-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BackgroundTransferRevised-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransferRevised_ExampleApp.swift; sourceTree = ""; }; 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; }; 43D1CF632D80860E00AC1ED9 /* BackgroundTransferRevised_ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransferRevised_ExampleTests.swift; sourceTree = ""; }; @@ -92,6 +93,16 @@ path = Extensions; sourceTree = ""; }; + 437CD50C2D94C62800A909A6 /* Application */ = { + isa = PBXGroup; + children = ( + 437CD50D2D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift */, + 437CD50E2D94C62800A909A6 /* AppDelegate.swift */, + 437CD50F2D94C62800A909A6 /* Info.plist */, + ); + path = Application; + sourceTree = ""; + }; 43D1CF462D80860C00AC1ED9 = { isa = PBXGroup; children = ( @@ -113,14 +124,12 @@ 43D1CF512D80860C00AC1ED9 /* BackgroundTransferRevised-Example */ = { isa = PBXGroup; children = ( - 437CD50B2D94BB3A00A909A6 /* Info.plist */, + 437CD50C2D94C62800A909A6 /* Application */, 436CC0992D8091D300F9E4E2 /* Extensions */, 436CC0932D808CC400F9E4E2 /* Features */, 43D1CF7F2D808A5000AC1ED9 /* Network */, 43D1CF7C2D808A5000AC1ED9 /* Resources */, - 43D1CF522D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift */, 43D1CF582D80860E00AC1ED9 /* Preview Content */, - 437CD5092D94BA8E00A909A6 /* AppDelegate.swift */, ); path = "BackgroundTransferRevised-Example"; sourceTree = ""; @@ -136,6 +145,7 @@ 43D1CF622D80860E00AC1ED9 /* BackgroundTransferRevised-ExampleTests */ = { isa = PBXGroup; children = ( + 437CD5132D94C66C00A909A6 /* Info.plist */, 43D1CF632D80860E00AC1ED9 /* BackgroundTransferRevised_ExampleTests.swift */, ); path = "BackgroundTransferRevised-ExampleTests"; @@ -268,14 +278,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43D1CF532D80860C00AC1ED9 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, + 437CD5112D94C62800A909A6 /* AppDelegate.swift in Sources */, 436CC0972D808CC500F9E4E2 /* CatsViewModel.swift in Sources */, 43D1CF852D808A5000AC1ED9 /* NetworkService.swift in Sources */, - 437CD50A2D94BA8E00A909A6 /* AppDelegate.swift in Sources */, 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */, 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */, 43E208BA2D942444006B7E12 /* BackgroundDownloadStore.swift in Sources */, 43E208BB2D942444006B7E12 /* BackgroundDownloadService.swift in Sources */, + 437CD5102D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -428,7 +438,7 @@ DEVELOPMENT_ASSET_PATHS = "\"BackgroundTransferRevised-Example/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "BackgroundTransferRevised-Example/Info.plist"; + INFOPLIST_FILE = "BackgroundTransferRevised-Example/Application/Info.plist"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -441,9 +451,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransferRevised-Example"; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -457,7 +471,7 @@ DEVELOPMENT_ASSET_PATHS = "\"BackgroundTransferRevised-Example/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "BackgroundTransferRevised-Example/Info.plist"; + INFOPLIST_FILE = "BackgroundTransferRevised-Example/Application/Info.plist"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -470,9 +484,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransferRevised-Example"; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -483,14 +501,19 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "BackgroundTransferRevised-ExampleTests/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransferRevised-ExampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BackgroundTransferRevised-Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BackgroundTransferRevised-Example"; }; name = Debug; @@ -502,14 +525,19 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "BackgroundTransferRevised-ExampleTests/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransferRevised-ExampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BackgroundTransferRevised-Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BackgroundTransferRevised-Example"; }; name = Release; diff --git a/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme b/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme new file mode 100644 index 0000000..34bbc39 --- /dev/null +++ b/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BackgroundTransferRevised-Example/AppDelegate.swift b/BackgroundTransferRevised-Example/Application/AppDelegate.swift similarity index 100% rename from BackgroundTransferRevised-Example/AppDelegate.swift rename to BackgroundTransferRevised-Example/Application/AppDelegate.swift diff --git a/BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift similarity index 100% rename from BackgroundTransferRevised-Example/BackgroundTransferRevised_ExampleApp.swift rename to BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift diff --git a/BackgroundTransferRevised-Example/Info.plist b/BackgroundTransferRevised-Example/Application/Info.plist similarity index 100% rename from BackgroundTransferRevised-Example/Info.plist rename to BackgroundTransferRevised-Example/Application/Info.plist diff --git a/BackgroundTransferRevised-ExampleTests/BackgroundTransferRevised_ExampleTests.swift b/BackgroundTransferRevised-ExampleTests/BackgroundTransferRevised_ExampleTests.swift index 45e8427..a8b9eb4 100644 --- a/BackgroundTransferRevised-ExampleTests/BackgroundTransferRevised_ExampleTests.swift +++ b/BackgroundTransferRevised-ExampleTests/BackgroundTransferRevised_ExampleTests.swift @@ -25,12 +25,4 @@ final class BackgroundTransferRevised_ExampleTests: XCTestCase { // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - } diff --git a/BackgroundTransferRevised-ExampleTests/Info.plist b/BackgroundTransferRevised-ExampleTests/Info.plist new file mode 100644 index 0000000..1a1bf62 --- /dev/null +++ b/BackgroundTransferRevised-ExampleTests/Info.plist @@ -0,0 +1,11 @@ + + + + + UIBackgroundModes + + fetch + processing + + + diff --git a/Gemfile b/Gemfile index 628d7a1..6f0518a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "fastlane", "2.226.0" +gem "fastlane", "2.227.0" diff --git a/Gemfile.lock b/Gemfile.lock index 210bf3b..cf51386 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,13 +10,14 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.2) - aws-partitions (1.1064.0) - aws-sdk-core (3.220.1) + aws-partitions (1.1075.0) + aws-sdk-core (3.221.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) + logger aws-sdk-kms (1.99.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) @@ -69,7 +70,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.226.0) + fastlane (2.227.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -156,9 +157,10 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.10.1) + json (2.10.2) jwt (2.10.1) base64 + logger (1.6.6) mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) @@ -209,7 +211,7 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.4.0) + xcpretty (0.4.1) rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) @@ -219,7 +221,7 @@ PLATFORMS x86_64-darwin-22 DEPENDENCIES - fastlane (= 2.226.0) + fastlane (= 2.227.0) BUNDLED WITH 2.6.5 From 0f9c58a3eeb3d767e6470d8364f9e5e1ea1bfe21 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Apr 2025 20:47:55 +0100 Subject: [PATCH 05/18] Moved resume out of Task --- .../project.pbxproj | 6 ++--- .../Network/BackgroundDownloadService.swift | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index e71dcff..fb8e18c 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -369,6 +369,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -424,6 +425,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; name = Release; @@ -456,7 +458,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -489,7 +490,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -512,7 +512,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BackgroundTransferRevised-Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BackgroundTransferRevised-Example"; }; @@ -536,7 +535,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BackgroundTransferRevised-Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BackgroundTransferRevised-Example"; }; diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 900a8c5..456b5d1 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -16,7 +16,7 @@ enum BackgroundDownloadError: Error { case serverError(_ underlyingResponse: URLResponse?) } -class BackgroundDownloadService: NSObject, URLSessionDelegate { +final class BackgroundDownloadService: NSObject { var backgroundCompletionHandler: (() -> Void)? static let identifier = "com.williamboles.background.download.session" @@ -39,21 +39,28 @@ class BackgroundDownloadService: NSObject, URLSessionDelegate { let configuration = URLSessionConfiguration.background(withIdentifier: BackgroundDownloadService.identifier) configuration.isDiscretionary = false configuration.sessionSendsLaunchEvents = true - self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + self.session = URLSession(configuration: configuration, + delegate: self, + delegateQueue: nil) } // MARK: - Download func download(from fromURL: URL, to toURL: URL) async throws -> URL { return try await withCheckedThrowingContinuation { continuation in + logger.info("Scheduling download: \(fromURL.absoluteString)") + Task { - logger.info("Scheduling download: \(fromURL.absoluteString)") - - await store.storeMetadata(from: fromURL, to: toURL, continuation: continuation) - - let downloadTask = session.downloadTask(with: fromURL) - downloadTask.earliestBeginDate = Date().addingTimeInterval(10) // Demonstration delay - downloadTask.resume() + await store.storeMetadata(from: fromURL, + to: toURL, + continuation: continuation) + + logger.info("Metadata stored for: \(fromURL.absoluteString)") } + + let downloadTask = session.downloadTask(with: fromURL) + downloadTask.resume() + + logger.info("Download resumed for: \(fromURL.absoluteString)") } } } @@ -70,6 +77,8 @@ extension BackgroundDownloadService: URLSessionDownloadDelegate { let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) try? FileManager.default.moveItem(at: location, to: tempLocation) + + logger.info("Moved file to temporary location: \(tempLocation) for: \(fromURL.absoluteString)") Task { defer { From 237014c273508dd03218c6bbf7670f44d1d9eadc Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Apr 2025 21:09:17 +0100 Subject: [PATCH 06/18] Added BackgroundDownloadDelegator to removed force unwrapping of URLSession property --- .../project.pbxproj | 2 + .../Application/AppDelegate.swift | 2 +- .../Network/BackgroundDownloadService.swift | 51 ++++++++++++------- .../AccentColor.colorset/Contents.json | 38 ++++++++++++++ 4 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index fb8e18c..e70dd21 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -314,6 +314,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -378,6 +379,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; diff --git a/BackgroundTransferRevised-Example/Application/AppDelegate.swift b/BackgroundTransferRevised-Example/Application/AppDelegate.swift index f712b0b..f3eca6b 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -13,6 +13,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { - BackgroundDownloadService().backgroundCompletionHandler = completionHandler + BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 456b5d1..67350cd 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -16,35 +16,37 @@ enum BackgroundDownloadError: Error { case serverError(_ underlyingResponse: URLResponse?) } -final class BackgroundDownloadService: NSObject { - var backgroundCompletionHandler: (() -> Void)? +final class BackgroundDownloadService { + var backgroundCompletionHandler: (() -> Void)? // TODO: Remove this as it has now been moved to delegator - static let identifier = "com.williamboles.background.download.session" - - private var session: URLSession! - private let store = BackgroundDownloadStore() - - private let logger = Logger(subsystem: "com.williamboles", category: "BackgroundDownloadService") + private static let identifier = "com.williamboles.background.download.session" + private let session: URLSession + private let store: BackgroundDownloadStore + private let logger: Logger // MARK: - Singleton + static let shared = BackgroundDownloadService() // MARK: - Init - override init() { - super.init() - configureSession() - } - - private func configureSession() { + + private init() { + self.store = BackgroundDownloadStore() + self.logger = Logger(subsystem: "com.williamboles", + category: "BackgroundDownload") + + let delegator = BackgroundDownloadDelegator(store: store, + logger: logger) let configuration = URLSessionConfiguration.background(withIdentifier: BackgroundDownloadService.identifier) configuration.isDiscretionary = false configuration.sessionSendsLaunchEvents = true self.session = URLSession(configuration: configuration, - delegate: self, + delegate: delegator, delegateQueue: nil) } // MARK: - Download + func download(from fromURL: URL, to toURL: URL) async throws -> URL { return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") @@ -65,8 +67,23 @@ final class BackgroundDownloadService: NSObject { } } -// MARK: - URLSessionDownloadDelegate -extension BackgroundDownloadService: URLSessionDownloadDelegate { +final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { + var backgroundCompletionHandler: (() -> Void)? // TODO: Should this be reversed so that the app delegate is called instead? + + private let store: BackgroundDownloadStore + + private let logger: Logger + + // MARK: - Init + + init(store: BackgroundDownloadStore, + logger: Logger) { + self.store = store + self.logger = logger + } + + // 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.") diff --git a/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AccentColor.colorset/Contents.json b/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..22c4bb0 --- /dev/null +++ b/BackgroundTransferRevised-Example/Resources/Images/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From e879aaa41bb4cc8f3fd1faedf40e25868ece6d4a Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Apr 2025 21:10:39 +0100 Subject: [PATCH 07/18] Updated project settings --- BackgroundTransferRevised-Example.xcodeproj/project.pbxproj | 4 +--- .../xcschemes/BackgroundTransferRevised-Example.xcscheme | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index e70dd21..0d0fc22 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -224,7 +224,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1520; - LastUpgradeCheck = 1520; + LastUpgradeCheck = 1600; TargetAttributes = { 43D1CF4E2D80860C00AC1ED9 = { CreatedOnToolsVersion = 15.2; @@ -499,7 +499,6 @@ 43D1CF772D80860E00AC1ED9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -522,7 +521,6 @@ 43D1CF782D80860E00AC1ED9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme b/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme index 34bbc39..2eee759 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme +++ b/BackgroundTransferRevised-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransferRevised-Example.xcscheme @@ -1,6 +1,6 @@ Date: Wed, 9 Apr 2025 21:44:59 +0100 Subject: [PATCH 08/18] Updated project to swift 6 --- .../project.pbxproj | 6 ++-- .../Application/AppDelegate.swift | 2 +- .../Network/BackgroundDownloadService.swift | 34 ++++++------------- .../Network/BackgroundDownloadStore.swift | 15 ++++++-- .../Network/ImageLoader.swift | 4 +-- .../Network/NetworkService.swift | 5 +-- 6 files changed, 34 insertions(+), 32 deletions(-) diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 0d0fc22..7eef126 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -370,7 +370,8 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -427,7 +428,8 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/BackgroundTransferRevised-Example/Application/AppDelegate.swift b/BackgroundTransferRevised-Example/Application/AppDelegate.swift index f3eca6b..73871ee 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -13,6 +13,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { - BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler +// BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 67350cd..4952cef 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -16,22 +16,16 @@ enum BackgroundDownloadError: Error { case serverError(_ underlyingResponse: URLResponse?) } -final class BackgroundDownloadService { - var backgroundCompletionHandler: (() -> Void)? // TODO: Remove this as it has now been moved to delegator - +actor BackgroundDownloadService { private static let identifier = "com.williamboles.background.download.session" private let session: URLSession private let store: BackgroundDownloadStore private let logger: Logger - // MARK: - Singleton - - static let shared = BackgroundDownloadService() - // MARK: - Init - private init() { - self.store = BackgroundDownloadStore() + init() { + self.store = BackgroundDownloadStore.shared self.logger = Logger(subsystem: "com.williamboles", category: "BackgroundDownload") @@ -47,31 +41,27 @@ final class BackgroundDownloadService { // MARK: - Download - func download(from fromURL: URL, to toURL: URL) async throws -> URL { + func download(from fromURL: URL, + to toURL: URL) async throws -> URL { return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") - Task { + Task { [store, fromURL, toURL, continuation] in await store.storeMetadata(from: fromURL, to: toURL, continuation: continuation) - - logger.info("Metadata stored for: \(fromURL.absoluteString)") } let downloadTask = session.downloadTask(with: fromURL) downloadTask.resume() - - logger.info("Download resumed for: \(fromURL.absoluteString)") } } } final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { - var backgroundCompletionHandler: (() -> Void)? // TODO: Should this be reversed so that the app delegate is called instead? +// var backgroundCompletionHandler: (() -> Void)? // TODO: Should this be reversed so that the app delegate is called instead? private let store: BackgroundDownloadStore - private let logger: Logger // MARK: - Init @@ -94,8 +84,6 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) try? FileManager.default.moveItem(at: location, to: tempLocation) - - logger.info("Moved file to temporary location: \(tempLocation) for: \(fromURL.absoluteString)") Task { defer { @@ -144,10 +132,10 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - Task { @MainActor in - self.backgroundCompletionHandler?() - self.backgroundCompletionHandler = nil - } +// Task { @MainActor in +// self.backgroundCompletionHandler?() +// self.backgroundCompletionHandler = nil +// } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift index 4c273cc..b9976ff 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift @@ -9,8 +9,19 @@ import Foundation actor BackgroundDownloadStore { - private var inMemoryStore = [String: CheckedContinuation]() - private let persistentStore = UserDefaults.standard + private var inMemoryStore: [String: CheckedContinuation] + private let persistentStore: UserDefaults + + // MARK: - Singleton + + static let shared = BackgroundDownloadStore() + + // MARK: - Init + + private init() { + self.inMemoryStore = [String: CheckedContinuation]() + self.persistentStore = UserDefaults.standard + } // MARK: - Store diff --git a/BackgroundTransferRevised-Example/Network/ImageLoader.swift b/BackgroundTransferRevised-Example/Network/ImageLoader.swift index 79632f2..71709cb 100644 --- a/BackgroundTransferRevised-Example/Network/ImageLoader.swift +++ b/BackgroundTransferRevised-Example/Network/ImageLoader.swift @@ -14,8 +14,8 @@ enum ImageLoaderError: Error { case invalidImageData } -class ImageLoader { - private let backgroundDownloader = BackgroundDownloadService.shared +actor ImageLoader { + private let backgroundDownloader = BackgroundDownloadService() // MARK: - Load diff --git a/BackgroundTransferRevised-Example/Network/NetworkService.swift b/BackgroundTransferRevised-Example/Network/NetworkService.swift index ce21b61..d16d418 100644 --- a/BackgroundTransferRevised-Example/Network/NetworkService.swift +++ b/BackgroundTransferRevised-Example/Network/NetworkService.swift @@ -19,8 +19,9 @@ enum NetworkServiceError: Error { case decodingErrror } -class NetworkService { - private let logger = Logger(subsystem: "com.williamboles", category: "NetworkService") +actor NetworkService { + private let logger = Logger(subsystem: "com.williamboles", + category: "NetworkService") // MARK: - Cats From 276a8973b565a4a155676a91e9c6219982f97cfe Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Apr 2025 22:16:59 +0100 Subject: [PATCH 09/18] Update app delegate when downloads are complete --- .../Application/AppDelegate.swift | 9 ++++++++- .../BackgroundTransferRevised_ExampleApp.swift | 3 ++- .../Features/Cats/CatsView.swift | 2 +- .../Features/Cats/CatsViewModel.swift | 6 +++--- .../Network/BackgroundDownloadService.swift | 12 ++++++------ .../Network/ImageLoader.swift | 8 +++++++- .../Network/NetworkService.swift | 11 ++++++++--- 7 files changed, 35 insertions(+), 16 deletions(-) diff --git a/BackgroundTransferRevised-Example/Application/AppDelegate.swift b/BackgroundTransferRevised-Example/Application/AppDelegate.swift index 73871ee..f96bd66 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -8,11 +8,18 @@ import UIKit class AppDelegate: NSObject, UIApplicationDelegate { + private var backgroundCompletionHandler: (() -> Void)? + // MARK: - Background func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { -// BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler + self.backgroundCompletionHandler = completionHandler + } + + func backgroundDownloadsComplete() { + self.backgroundCompletionHandler?() + self.backgroundCompletionHandler = nil } } diff --git a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift index 3d53461..3e0cb08 100644 --- a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift +++ b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift @@ -13,7 +13,8 @@ struct BackgroundTransferRevised_ExampleApp: App { @Environment(\.scenePhase) private var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - private let logger = Logger(subsystem: "com.williamboles", category: "app") + private let logger = Logger(subsystem: "com.williamboles", + category: "app") var body: some Scene { diff --git a/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift b/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift index 3c3ec08..e2ca05b 100644 --- a/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift +++ b/BackgroundTransferRevised-Example/Features/Cats/CatsView.swift @@ -30,7 +30,7 @@ struct CatsView: View { CatImageCell(viewModel: catViewModel) .frame(width: sideLength, height: sideLength) .task { - catViewModel.loadImage() + await catViewModel.loadImage() } } } diff --git a/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift b/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift index 4b55e88..c86f3bb 100644 --- a/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift +++ b/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift @@ -64,10 +64,10 @@ class CatViewModel: ObservableObject, Identifiable { // MARK: - Image - func loadImage() { + func loadImage() async { state = .retrieving - Task { +// Task { let uiImage = try? await imageLoader.loadImage(name: cat.id, url: cat.url) @@ -77,6 +77,6 @@ class CatViewModel: ObservableObject, Identifiable { } state = .retrieved(Image(uiImage: uiImage)) - } +// } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 4952cef..deb6cc4 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -8,6 +8,7 @@ import Foundation import OSLog +import UIKit enum BackgroundDownloadError: Error { case missingInstructionsError @@ -59,8 +60,6 @@ actor BackgroundDownloadService { } final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { -// var backgroundCompletionHandler: (() -> Void)? // TODO: Should this be reversed so that the app delegate is called instead? - private let store: BackgroundDownloadStore private let logger: Logger @@ -132,10 +131,11 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { -// Task { @MainActor in -// self.backgroundCompletionHandler?() -// self.backgroundCompletionHandler = nil -// } + Task { @MainActor in + if let appDelegate = UIApplication.shared.delegate as? AppDelegate { + appDelegate.backgroundDownloadsComplete() + } + } } } diff --git a/BackgroundTransferRevised-Example/Network/ImageLoader.swift b/BackgroundTransferRevised-Example/Network/ImageLoader.swift index 71709cb..48f8f07 100644 --- a/BackgroundTransferRevised-Example/Network/ImageLoader.swift +++ b/BackgroundTransferRevised-Example/Network/ImageLoader.swift @@ -15,7 +15,13 @@ enum ImageLoaderError: Error { } actor ImageLoader { - private let backgroundDownloader = BackgroundDownloadService() + private let backgroundDownloader: BackgroundDownloadService + + // MARK: - Init + + init() { + self.backgroundDownloader = BackgroundDownloadService() + } // MARK: - Load diff --git a/BackgroundTransferRevised-Example/Network/NetworkService.swift b/BackgroundTransferRevised-Example/Network/NetworkService.swift index d16d418..5bd8807 100644 --- a/BackgroundTransferRevised-Example/Network/NetworkService.swift +++ b/BackgroundTransferRevised-Example/Network/NetworkService.swift @@ -20,8 +20,14 @@ enum NetworkServiceError: Error { } actor NetworkService { - private let logger = Logger(subsystem: "com.williamboles", - category: "NetworkService") + private let logger: Logger + + // MARK: - Init + + init() { + self.logger = Logger(subsystem: "com.williamboles", + category: "NetworkService") + } // MARK: - Cats @@ -49,7 +55,6 @@ actor NetworkService { urlRequest.httpMethod = "GET" urlRequest.addValue(APIKey, forHTTPHeaderField: "x-api-key") - logger.info("Retrieving cats...") do { From 0aff0393c33b8129d861f4dddca9323ea596f8a7 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Apr 2025 22:28:18 +0100 Subject: [PATCH 10/18] Specify xcode version --- .github/workflows/swift.yml | 4 ++-- .../Network/BackgroundDownloadService.swift | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1e6f17e..85561fa 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -9,10 +9,10 @@ on: jobs: build: - runs-on: macos-latest + runs-on: macosx15.0 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install Bundle run: bundle install - name: Run unit tests diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index deb6cc4..8d2ab3b 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -18,7 +18,6 @@ enum BackgroundDownloadError: Error { } actor BackgroundDownloadService { - private static let identifier = "com.williamboles.background.download.session" private let session: URLSession private let store: BackgroundDownloadStore private let logger: Logger @@ -32,7 +31,7 @@ actor BackgroundDownloadService { let delegator = BackgroundDownloadDelegator(store: store, logger: logger) - let configuration = URLSessionConfiguration.background(withIdentifier: BackgroundDownloadService.identifier) + let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") configuration.isDiscretionary = false configuration.sessionSendsLaunchEvents = true self.session = URLSession(configuration: configuration, From 8743ce41ae3ffadcc2aa37831247dd73811885e0 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Apr 2025 22:42:39 +0100 Subject: [PATCH 11/18] Naming --- .github/workflows/swift.yml | 4 ++-- .../Network/BackgroundDownloadService.swift | 2 +- .../Network/BackgroundDownloadStore.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 85561fa..1e6f17e 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -9,10 +9,10 @@ on: jobs: build: - runs-on: macosx15.0 + runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 - name: Install Bundle run: bundle install - name: Run unit tests diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 8d2ab3b..445cc6b 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -27,7 +27,7 @@ actor BackgroundDownloadService { init() { self.store = BackgroundDownloadStore.shared self.logger = Logger(subsystem: "com.williamboles", - category: "BackgroundDownload") + category: "background.download") let delegator = BackgroundDownloadDelegator(store: store, logger: logger) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift index b9976ff..0025e1d 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift @@ -50,7 +50,7 @@ actor BackgroundDownloadStore { func removeMetadata(for forURL: URL) { let key = forURL.absoluteString - inMemoryStore[key] = nil + inMemoryStore.removeValue(forKey: key) persistentStore.removeObject(forKey: key) } } From 358c90ef1503c061df508aca9472f0357ad5e7ec Mon Sep 17 00:00:00 2001 From: William Boles Date: Mon, 14 Apr 2025 13:22:22 +0100 Subject: [PATCH 12/18] Added listing steps to build script --- .github/workflows/swift.yml | 6 +++++- BackgroundTransferRevised-Example.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1e6f17e..d6f4ce8 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -12,7 +12,11 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: List available Xcode versions + run: ls /Applications | grep Xcode + - name: Show current version of Xcode + run: xcodebuild -version - name: Install Bundle run: bundle install - name: Run unit tests diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 7eef126..5a35b2e 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -236,7 +236,7 @@ }; }; buildConfigurationList = 43D1CF4A2D80860C00AC1ED9 /* Build configuration list for PBXProject "BackgroundTransferRevised-Example" */; - compatibilityVersion = "Xcode 14.0"; + compatibilityVersion = "Xcode 15.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( From 1e24d56c211e1df69a5e0adfde2217ba83c4bf07 Mon Sep 17 00:00:00 2001 From: William Boles Date: Mon, 14 Apr 2025 13:27:35 +0100 Subject: [PATCH 13/18] Added step to build scrip to switch to running tests using xcode 16.1.0 --- .github/workflows/swift.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index d6f4ce8..bad4035 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,6 +17,8 @@ jobs: run: ls /Applications | grep Xcode - name: Show current version of Xcode run: xcodebuild -version + - name: Set up Xcode version + run: sudo xcode-select -s /Applications/Xcode_16.1.0.app/Contents/Developer - name: Install Bundle run: bundle install - name: Run unit tests From 98046f463b035cb7d9fe7eb071c830819a8f1aac Mon Sep 17 00:00:00 2001 From: William Boles Date: Mon, 14 Apr 2025 13:29:39 +0100 Subject: [PATCH 14/18] List selected xcode version after switching --- .github/workflows/swift.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index bad4035..e26e907 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -19,6 +19,8 @@ jobs: run: xcodebuild -version - name: Set up Xcode version run: sudo xcode-select -s /Applications/Xcode_16.1.0.app/Contents/Developer + - name: Show current version of Xcode + run: xcodebuild -version - name: Install Bundle run: bundle install - name: Run unit tests From b10e82a7c4f6922466ed988c2b598ba3a28c812a Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 15 Apr 2025 15:56:25 +0100 Subject: [PATCH 15/18] Added AppDelegate to handle background session call --- .../project.pbxproj | 2 ++ .../Application/AppDelegate.swift | 9 +++++--- ...BackgroundTransferRevised_ExampleApp.swift | 10 ++++++++- .../Features/Cats/CatsViewModel.swift | 22 ++++++++----------- .../Network/BackgroundDownloadService.swift | 21 ++++++++++++++---- 5 files changed, 43 insertions(+), 21 deletions(-) diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 5a35b2e..a90b712 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -442,6 +442,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"BackgroundTransferRevised-Example/Preview Content\""; + DEVELOPMENT_TEAM = A8RQWWHSHX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "BackgroundTransferRevised-Example/Application/Info.plist"; @@ -474,6 +475,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"BackgroundTransferRevised-Example/Preview Content\""; + DEVELOPMENT_TEAM = A8RQWWHSHX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "BackgroundTransferRevised-Example/Application/Info.plist"; diff --git a/BackgroundTransferRevised-Example/Application/AppDelegate.swift b/BackgroundTransferRevised-Example/Application/AppDelegate.swift index f96bd66..5e943e7 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -6,10 +6,13 @@ // import UIKit +import OSLog class AppDelegate: NSObject, UIApplicationDelegate { - private var backgroundCompletionHandler: (() -> Void)? + static var shared: AppDelegate? + private var backgroundCompletionHandler: (() -> Void)? + // MARK: - Background func application(_ application: UIApplication, @@ -19,7 +22,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func backgroundDownloadsComplete() { - self.backgroundCompletionHandler?() - self.backgroundCompletionHandler = nil + backgroundCompletionHandler?() + backgroundCompletionHandler = nil } } diff --git a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift index 3e0cb08..961e993 100644 --- a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift +++ b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift @@ -17,6 +17,14 @@ struct BackgroundTransferRevised_ExampleApp: App { category: "app") + // MARK: - Init + + init() { + AppDelegate.shared = appDelegate + } + + // MARK: - Scene + var body: some Scene { WindowGroup { let catsViewModel = CatsViewModel() @@ -27,7 +35,7 @@ struct BackgroundTransferRevised_ExampleApp: App { return } - logger.info("Scheduling download: \(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].absoluteString)") + 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 { diff --git a/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift b/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift index c86f3bb..c1bf88e 100644 --- a/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift +++ b/BackgroundTransferRevised-Example/Features/Cats/CatsViewModel.swift @@ -8,7 +8,6 @@ import Foundation import SwiftUI import UIKit -import OSLog @MainActor class CatsViewModel: ObservableObject { @@ -34,7 +33,6 @@ class CatsViewModel: ObservableObject { let viewModels = cats.map { CatViewModel(cat: $0) } state = .retrieved(viewModels) } catch { - os_log(.error, "Error when retrieving json: %{public}@", error.localizedDescription) state = .failed } } @@ -67,16 +65,14 @@ class CatViewModel: ObservableObject, Identifiable { func loadImage() async { state = .retrieving -// Task { - let uiImage = try? await imageLoader.loadImage(name: cat.id, - url: cat.url) - - guard let uiImage else { - state = .empty - return - } - - state = .retrieved(Image(uiImage: uiImage)) -// } + let uiImage = try? await imageLoader.loadImage(name: cat.id, + url: cat.url) + + guard let uiImage else { + state = .empty + return + } + + state = .retrieved(Image(uiImage: uiImage)) } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 445cc6b..4401c99 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -9,6 +9,7 @@ import Foundation import OSLog import UIKit +import SwiftUI enum BackgroundDownloadError: Error { case missingInstructionsError @@ -21,7 +22,13 @@ actor BackgroundDownloadService { private let session: URLSession private let store: BackgroundDownloadStore private let logger: Logger - + + var backgroundCompletionHandler: (() -> Void)? + + // MARK: - Singleton + + static let shared = BackgroundDownloadService() + // MARK: - Init init() { @@ -51,8 +58,9 @@ actor BackgroundDownloadService { to: toURL, continuation: continuation) } - + 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() } } @@ -130,10 +138,15 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + logger.info("Did finish events for background session") + Task { @MainActor in - if let appDelegate = UIApplication.shared.delegate as? AppDelegate { - appDelegate.backgroundDownloadsComplete() + guard let appDelegate = AppDelegate.shared else { + logger.error("App delegate is nil") + return } + + appDelegate.backgroundDownloadsComplete() } } } From 5c2afaeeac8ef02c42728f7ebb18bb1e6a5d5799 Mon Sep 17 00:00:00 2001 From: William Boles Date: Tue, 15 Apr 2025 17:57:03 +0100 Subject: [PATCH 16/18] Added task group to control when app delegate callback is triggered --- .../Network/BackgroundDownloadService.swift | 82 +++++++++++++++---- .../Network/BackgroundDownloadStore.swift | 2 +- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift index 4401c99..500bb7c 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift @@ -23,12 +23,6 @@ actor BackgroundDownloadService { private let store: BackgroundDownloadStore private let logger: Logger - var backgroundCompletionHandler: (() -> Void)? - - // MARK: - Singleton - - static let shared = BackgroundDownloadService() - // MARK: - Init init() { @@ -66,16 +60,46 @@ actor BackgroundDownloadService { } } +actor ProcessingDownloadsStore { + private var processingDownloads = [String: Task]() + + // MARK: - Add + + func store(from fromURL: URL, + task: Task) { + let key = fromURL.absoluteString + + processingDownloads[key] = task + } + + // MARK: - Retrieve + + func retrieveAll() -> [Task] { + Array(processingDownloads.values) + } + + // MARK: - Remove + + func remove(for forURL: URL) { + let key = forURL.absoluteString + + processingDownloads[key] = nil + } +} + final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { private let store: BackgroundDownloadStore private let logger: Logger + private let processsingStore: ProcessingDownloadsStore // MARK: - Init init(store: BackgroundDownloadStore, - logger: Logger) { + logger: Logger, + processsingStore: ProcessingDownloadsStore = ProcessingDownloadsStore()) { self.store = store self.logger = logger + self.processsingStore = processsingStore } // MARK: - URLSessionDownloadDelegate @@ -91,10 +115,11 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) try? FileManager.default.moveItem(at: location, to: tempLocation) - Task { + let processingTask = Task { defer { Task { await store.removeMetadata(for: fromURL) + await processsingStore.remove(for: fromURL) } } @@ -121,6 +146,12 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) } } + + // TODO: Update processing to use a serial queue + Task { + await processsingStore.store(from: fromURL, + task: processingTask) + } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { @@ -130,23 +161,44 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") - Task { + let processingTask = Task { let (_, continuation) = await store.retrieveMetadata(for: fromURL) continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) await store.removeMetadata(for: fromURL) + await store.removeMetadata(for: fromURL) + } + + // TODO: Update processing to use a serial queue + Task { + await processsingStore.store(from: fromURL, + task: processingTask) } } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { logger.info("Did finish events for background session") - Task { @MainActor in - guard let appDelegate = AppDelegate.shared else { - logger.error("App delegate is nil") - return + Task { + await withTaskGroup(of: Void.self) { group in + for task in await processsingStore.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() + } } - - appDelegate.backgroundDownloadsComplete() } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift index 0025e1d..1467257 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift @@ -14,7 +14,7 @@ actor BackgroundDownloadStore { // MARK: - Singleton - static let shared = BackgroundDownloadStore() + static let shared = BackgroundDownloadStore() // TODO: Should the service be the singleton instead? // MARK: - Init From 661c78e6d2f4511d2da450575fbb0529314cd629 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 16 Apr 2025 17:25:02 +0100 Subject: [PATCH 17/18] Split types out into their own files --- .../project.pbxproj | 32 ++++-- .../Application/AppDelegate.swift | 4 + ...BackgroundTransferRevised_ExampleApp.swift | 12 +- .../BackgroundDownloadDelegator.swift} | 108 +++--------------- .../BackgroundDownloadMetaStore.swift} | 8 +- .../BackgroundDownloadProcessingStore.swift | 35 ++++++ .../BackgroundDownloadService.swift | 65 +++++++++++ .../Network/ImageLoader.swift | 2 +- 8 files changed, 151 insertions(+), 115 deletions(-) rename BackgroundTransferRevised-Example/Network/{BackgroundDownloadService.swift => BackgroundDownload/BackgroundDownloadDelegator.swift} (53%) rename BackgroundTransferRevised-Example/Network/{BackgroundDownloadStore.swift => BackgroundDownload/BackgroundDownloadMetaStore.swift} (87%) create mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift create mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index a90b712..1823602 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -12,13 +12,15 @@ 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 */; }; + 4395E6632DB00E8300637803 /* BackgroundDownloadProcessingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6622DB00E8300637803 /* BackgroundDownloadProcessingStore.swift */; }; + 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.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 */; }; 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF802D808A5000AC1ED9 /* ImageLoader.swift */; }; 43D1CF852D808A5000AC1ED9 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D1CF812D808A5000AC1ED9 /* NetworkService.swift */; }; - 43E208BA2D942444006B7E12 /* BackgroundDownloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E208B82D942444006B7E12 /* BackgroundDownloadStore.swift */; }; - 43E208BB2D942444006B7E12 /* BackgroundDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E208B92D942444006B7E12 /* BackgroundDownloadService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,6 +41,10 @@ 437CD50E2D94C62800A909A6 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 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 = ""; }; + 4395E6622DB00E8300637803 /* BackgroundDownloadProcessingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadProcessingStore.swift; sourceTree = ""; }; + 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadDelegator.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; }; @@ -46,8 +52,6 @@ 43D1CF7E2D808A5000AC1ED9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43D1CF802D808A5000AC1ED9 /* ImageLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; 43D1CF812D808A5000AC1ED9 /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; - 43E208B82D942444006B7E12 /* BackgroundDownloadStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadStore.swift; sourceTree = ""; }; - 43E208B92D942444006B7E12 /* BackgroundDownloadService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,6 +107,17 @@ path = Application; sourceTree = ""; }; + 4395E65D2DB00E4100637803 /* BackgroundDownload */ = { + isa = PBXGroup; + children = ( + 4395E6622DB00E8300637803 /* BackgroundDownloadProcessingStore.swift */, + 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */, + 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */, + 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.swift */, + ); + path = BackgroundDownload; + sourceTree = ""; + }; 43D1CF462D80860C00AC1ED9 = { isa = PBXGroup; children = ( @@ -170,10 +185,9 @@ 43D1CF7F2D808A5000AC1ED9 /* Network */ = { isa = PBXGroup; children = ( + 4395E65D2DB00E4100637803 /* BackgroundDownload */, 43D1CF802D808A5000AC1ED9 /* ImageLoader.swift */, 43D1CF812D808A5000AC1ED9 /* NetworkService.swift */, - 43E208B92D942444006B7E12 /* BackgroundDownloadService.swift */, - 43E208B82D942444006B7E12 /* BackgroundDownloadStore.swift */, ); path = Network; sourceTree = ""; @@ -283,9 +297,11 @@ 43D1CF852D808A5000AC1ED9 /* NetworkService.swift in Sources */, 436CC0982D808CC500F9E4E2 /* CatsView.swift in Sources */, 436CC09B2D8091D300F9E4E2 /* GridItem+Layout.swift in Sources */, - 43E208BA2D942444006B7E12 /* BackgroundDownloadStore.swift in Sources */, - 43E208BB2D942444006B7E12 /* BackgroundDownloadService.swift in Sources */, 437CD5102D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, + 4395E65E2DB00E4100637803 /* BackgroundDownloadMetaStore.swift in Sources */, + 4395E65F2DB00E4100637803 /* BackgroundDownloadService.swift in Sources */, + 4395E6632DB00E8300637803 /* BackgroundDownloadProcessingStore.swift in Sources */, + 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegator.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 5e943e7..59958e6 100644 --- a/BackgroundTransferRevised-Example/Application/AppDelegate.swift +++ b/BackgroundTransferRevised-Example/Application/AppDelegate.swift @@ -12,6 +12,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { static var shared: AppDelegate? private var backgroundCompletionHandler: (() -> Void)? + private let logger = Logger(subsystem: "com.williamboles", + category: "appDelegate") // MARK: - Background @@ -22,6 +24,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func backgroundDownloadsComplete() { + logger.info("Triggering background session completion handler") + backgroundCompletionHandler?() backgroundCompletionHandler = nil } diff --git a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift index 961e993..61b5077 100644 --- a/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift +++ b/BackgroundTransferRevised-Example/Application/BackgroundTransferRevised_ExampleApp.swift @@ -37,12 +37,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. Comment out to test restoring app from a suspended state. +// Task { +// logger.info("Simulating app termination by exit(0)") +// +// exit(0) +// } } } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift similarity index 53% rename from BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift rename to BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift index 500bb7c..7b06a1e 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift @@ -1,105 +1,25 @@ // -// BackgroundDownloadService.swift -// BackgroundTransfer-Example +// BackgroundDownloadDelegator.swift +// BackgroundTransferRevised-Example // -// Created by William Boles on 26/03/2025. -// Copyright © 2025 William Boles. All rights reserved. +// Created by William Boles on 16/04/2025. // import Foundation import OSLog -import UIKit -import SwiftUI - -enum BackgroundDownloadError: Error { - case missingInstructionsError - case fileSystemError(_ underlyingError: Error) - case clientError(_ underlyingError: Error) - case serverError(_ underlyingResponse: URLResponse?) -} - -actor BackgroundDownloadService { - private let session: URLSession - private let store: BackgroundDownloadStore - private let logger: Logger - - // MARK: - Init - - init() { - self.store = BackgroundDownloadStore.shared - self.logger = Logger(subsystem: "com.williamboles", - category: "background.download") - - let delegator = BackgroundDownloadDelegator(store: store, - logger: logger) - let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") - configuration.isDiscretionary = false - configuration.sessionSendsLaunchEvents = true - self.session = URLSession(configuration: configuration, - delegate: delegator, - delegateQueue: nil) - } - - // MARK: - Download - - func download(from fromURL: URL, - to toURL: URL) async throws -> URL { - return try await withCheckedThrowingContinuation { continuation in - logger.info("Scheduling download: \(fromURL.absoluteString)") - - Task { [store, fromURL, toURL, continuation] in - await store.storeMetadata(from: fromURL, - to: toURL, - continuation: continuation) - } - - 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() - } - } -} - -actor ProcessingDownloadsStore { - private var processingDownloads = [String: Task]() - - // MARK: - Add - - func store(from fromURL: URL, - task: Task) { - let key = fromURL.absoluteString - - processingDownloads[key] = task - } - - // MARK: - Retrieve - - func retrieveAll() -> [Task] { - Array(processingDownloads.values) - } - - // MARK: - Remove - - func remove(for forURL: URL) { - let key = forURL.absoluteString - - processingDownloads[key] = nil - } -} final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { - private let store: BackgroundDownloadStore + private let metaStore: BackgroundDownloadMetaStore private let logger: Logger - private let processsingStore: ProcessingDownloadsStore + private let processsingStore: BackgroundDownloadProcessingStore // MARK: - Init - init(store: BackgroundDownloadStore, - logger: Logger, - processsingStore: ProcessingDownloadsStore = ProcessingDownloadsStore()) { - self.store = store + init(metaStore: BackgroundDownloadMetaStore, + logger: Logger) { + self.metaStore = metaStore self.logger = logger - self.processsingStore = processsingStore + self.processsingStore = BackgroundDownloadProcessingStore() } // MARK: - URLSessionDownloadDelegate @@ -118,12 +38,12 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { let processingTask = Task { defer { Task { - await store.removeMetadata(for: fromURL) + await metaStore.removeMetadata(for: fromURL) await processsingStore.remove(for: fromURL) } } - let (toURL, continuation) = await store.retrieveMetadata(for: fromURL) + let (toURL, continuation) = await metaStore.retrieveMetadata(for: fromURL) guard let toURL else { logger.error("Unable to find existing download item for: \(fromURL.absoluteString)") continuation?.resume(throwing: BackgroundDownloadError.missingInstructionsError) @@ -162,10 +82,10 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)") let processingTask = Task { - let (_, continuation) = await store.retrieveMetadata(for: fromURL) + let (_, continuation) = await metaStore.retrieveMetadata(for: fromURL) continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) - await store.removeMetadata(for: fromURL) - await store.removeMetadata(for: fromURL) + await metaStore.removeMetadata(for: fromURL) + await metaStore.removeMetadata(for: fromURL) } // TODO: Update processing to use a serial queue diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift similarity index 87% rename from BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift rename to BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift index 1467257..c09f81b 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownloadStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift @@ -8,17 +8,13 @@ import Foundation -actor BackgroundDownloadStore { +actor BackgroundDownloadMetaStore { private var inMemoryStore: [String: CheckedContinuation] private let persistentStore: UserDefaults - // MARK: - Singleton - - static let shared = BackgroundDownloadStore() // TODO: Should the service be the singleton instead? - // MARK: - Init - private init() { + init() { self.inMemoryStore = [String: CheckedContinuation]() self.persistentStore = UserDefaults.standard } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift new file mode 100644 index 0000000..d9ddabf --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift @@ -0,0 +1,35 @@ +// +// BackgroundDownloadProcessingStore.swift +// BackgroundTransferRevised-Example +// +// Created by William Boles on 16/04/2025. +// + +import Foundation + +actor BackgroundDownloadProcessingStore { + private var processingDownloads = [String: Task]() + + // MARK: - Add + + func store(from fromURL: URL, + task: Task) { + let key = fromURL.absoluteString + + processingDownloads[key] = task + } + + // MARK: - Retrieve + + func retrieveAll() -> [Task] { + Array(processingDownloads.values) + } + + // MARK: - Remove + + func remove(for forURL: URL) { + let key = forURL.absoluteString + + processingDownloads[key] = nil + } +} diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift new file mode 100644 index 0000000..e88d595 --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -0,0 +1,65 @@ +// +// BackgroundDownloadService.swift +// BackgroundTransfer-Example +// +// Created by William Boles on 26/03/2025. +// Copyright © 2025 William Boles. All rights reserved. +// + +import Foundation +import OSLog +import UIKit +import SwiftUI + +enum BackgroundDownloadError: Error { + case missingInstructionsError + 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 + + // MARK: - Singleton + + static let shared = BackgroundDownloadService() + + // MARK: - Init + + private init() { + self.metaStore = BackgroundDownloadMetaStore() + self.logger = Logger(subsystem: "com.williamboles", + category: "background.download") + + let delegator = BackgroundDownloadDelegator(metaStore: metaStore, + logger: logger) + let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") + configuration.isDiscretionary = false + configuration.sessionSendsLaunchEvents = true + self.session = URLSession(configuration: configuration, + delegate: delegator, + delegateQueue: nil) + } + + // MARK: - Download + + func download(from fromURL: URL, + to toURL: URL) async throws -> URL { + return try await withCheckedThrowingContinuation { continuation in + logger.info("Scheduling download: \(fromURL.absoluteString)") + + Task { [metaStore, fromURL, toURL, continuation] in + await metaStore.storeMetadata(from: fromURL, + to: toURL, + continuation: continuation) + } + + 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() + } + } +} diff --git a/BackgroundTransferRevised-Example/Network/ImageLoader.swift b/BackgroundTransferRevised-Example/Network/ImageLoader.swift index 48f8f07..880b130 100644 --- a/BackgroundTransferRevised-Example/Network/ImageLoader.swift +++ b/BackgroundTransferRevised-Example/Network/ImageLoader.swift @@ -20,7 +20,7 @@ actor ImageLoader { // MARK: - Init init() { - self.backgroundDownloader = BackgroundDownloadService() + self.backgroundDownloader = BackgroundDownloadService.shared } // MARK: - Load From f37a355d082680e54b6c11ef67b09c49b883b1a9 Mon Sep 17 00:00:00 2001 From: William Boles Date: Thu, 17 Apr 2025 21:24:31 +0100 Subject: [PATCH 18/18] Added task store --- .../project.pbxproj | 8 +-- .../BackgroundDownloadDelegator.swift | 63 +++++++++++-------- .../BackgroundDownloadMetaStore.swift | 32 +++++----- .../BackgroundDownloadProcessingStore.swift | 35 ----------- .../BackgroundDownloadService.swift | 23 ++++--- .../BackgroundDownloadTaskStore.swift | 31 +++++++++ 6 files changed, 107 insertions(+), 85 deletions(-) delete mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift create mode 100644 BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift diff --git a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj index 1823602..f05f77c 100644 --- a/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransferRevised-Example.xcodeproj/project.pbxproj @@ -14,7 +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 /* BackgroundDownloadProcessingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6622DB00E8300637803 /* BackgroundDownloadProcessingStore.swift */; }; + 4395E6632DB00E8300637803 /* BackgroundDownloadTaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6622DB00E8300637803 /* BackgroundDownloadTaskStore.swift */; }; 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.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 */; }; @@ -43,7 +43,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 /* BackgroundDownloadProcessingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadProcessingStore.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 = ""; }; 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 = ""; }; @@ -110,7 +110,7 @@ 4395E65D2DB00E4100637803 /* BackgroundDownload */ = { isa = PBXGroup; children = ( - 4395E6622DB00E8300637803 /* BackgroundDownloadProcessingStore.swift */, + 4395E6622DB00E8300637803 /* BackgroundDownloadTaskStore.swift */, 4395E65B2DB00E4100637803 /* BackgroundDownloadService.swift */, 4395E65C2DB00E4100637803 /* BackgroundDownloadMetaStore.swift */, 4395E6642DB00ECD00637803 /* BackgroundDownloadDelegator.swift */, @@ -300,7 +300,7 @@ 437CD5102D94C62800A909A6 /* BackgroundTransferRevised_ExampleApp.swift in Sources */, 4395E65E2DB00E4100637803 /* BackgroundDownloadMetaStore.swift in Sources */, 4395E65F2DB00E4100637803 /* BackgroundDownloadService.swift in Sources */, - 4395E6632DB00E8300637803 /* BackgroundDownloadProcessingStore.swift in Sources */, + 4395E6632DB00E8300637803 /* BackgroundDownloadTaskStore.swift in Sources */, 4395E6652DB00ECD00637803 /* BackgroundDownloadDelegator.swift in Sources */, 43D1CF842D808A5000AC1ED9 /* ImageLoader.swift in Sources */, ); diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift index 7b06a1e..45953a4 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadDelegator.swift @@ -10,8 +10,8 @@ import OSLog final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { private let metaStore: BackgroundDownloadMetaStore + private let taskStore: BackgroundDownloadTaskStore private let logger: Logger - private let processsingStore: BackgroundDownloadProcessingStore // MARK: - Init @@ -19,12 +19,14 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { logger: Logger) { self.metaStore = metaStore self.logger = logger - self.processsingStore = BackgroundDownloadProcessingStore() + self.taskStore = BackgroundDownloadTaskStore() } // MARK: - URLSessionDownloadDelegate - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + 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 @@ -37,61 +39,63 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { let processingTask = Task { defer { - Task { - await metaStore.removeMetadata(for: fromURL) - await processsingStore.remove(for: fromURL) - } + cleanUpDownload(forURL: fromURL) } - let (toURL, continuation) = await metaStore.retrieveMetadata(for: fromURL) - guard let toURL else { + let metaData = await metaStore.retrieveMetadata(key: fromURL.absoluteString) + guard let metaData else { logger.error("Unable to find existing download item for: \(fromURL.absoluteString)") - continuation?.resume(throwing: BackgroundDownloadError.missingInstructionsError) return } guard let response = downloadTask.response as? HTTPURLResponse, response.statusCode == 200 else { logger.error("Unexpected response for: \(fromURL.absoluteString)") - continuation?.resume(throwing: BackgroundDownloadError.serverError(downloadTask.response)) + metaData.continuation?.resume(throwing: BackgroundDownloadError.serverError(downloadTask.response)) return } logger.info("Download successful for: \(fromURL.absoluteString)") do { - try FileManager.default.moveItem(at: tempLocation, to: toURL) - continuation?.resume(returning: toURL) + 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)") - continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) + metaData.continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error)) } } // TODO: Update processing to use a serial queue Task { - await processsingStore.store(from: fromURL, - task: processingTask) + 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 { + 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 { - let (_, continuation) = await metaStore.retrieveMetadata(for: fromURL) - continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) - await metaStore.removeMetadata(for: fromURL) - await metaStore.removeMetadata(for: fromURL) + defer { + cleanUpDownload(forURL: fromURL) + } + + let metaData = await metaStore.retrieveMetadata(key: fromURL.absoluteString) + metaData?.continuation?.resume(throwing: BackgroundDownloadError.clientError(error)) } // TODO: Update processing to use a serial queue Task { - await processsingStore.store(from: fromURL, - task: processingTask) + await taskStore.storeTask(processingTask, + key: fromURL.absoluteString) } } @@ -100,7 +104,7 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { Task { await withTaskGroup(of: Void.self) { group in - for task in await processsingStore.retrieveAll() { + for task in await taskStore.retrieveAll() { group.addTask { await task.value } @@ -121,5 +125,14 @@ final class BackgroundDownloadDelegator: NSObject, URLSessionDownloadDelegate { } } } + + 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 c09f81b..2888e2a 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadMetaStore.swift @@ -8,6 +8,11 @@ import Foundation +struct BackgroundDownloadMetaData { + let toURL: URL + let continuation: CheckedContinuation? +} + actor BackgroundDownloadMetaStore { private var inMemoryStore: [String: CheckedContinuation] private let persistentStore: UserDefaults @@ -21,31 +26,30 @@ actor BackgroundDownloadMetaStore { // MARK: - Store - func storeMetadata(from fromURL: URL, - to toURL: URL, - continuation: CheckedContinuation) { - let key = fromURL.absoluteString - - inMemoryStore[key] = continuation - persistentStore.set(toURL, forKey: key) + func storeMetadata(_ metaData: BackgroundDownloadMetaData, + key: String) { + inMemoryStore[key] = metaData.continuation + persistentStore.set(metaData.toURL, forKey: key) } // MARK: - Retrieve - func retrieveMetadata(for forURL: URL) -> (URL?, CheckedContinuation?) { - let key = forURL.absoluteString + func retrieveMetadata(key: String) -> BackgroundDownloadMetaData? { + guard let toURL = persistentStore.url(forKey: key) else { + return nil + } - let toURL = persistentStore.url(forKey: key) let continuation = inMemoryStore[key] - return (toURL, continuation) + let metaData = BackgroundDownloadMetaData(toURL: toURL, + continuation: continuation) + + return metaData } // MARK: - Remove - func removeMetadata(for forURL: URL) { - let key = forURL.absoluteString - + func removeMetadata(key: String) { inMemoryStore.removeValue(forKey: key) persistentStore.removeObject(forKey: key) } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift deleted file mode 100644 index d9ddabf..0000000 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadProcessingStore.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// BackgroundDownloadProcessingStore.swift -// BackgroundTransferRevised-Example -// -// Created by William Boles on 16/04/2025. -// - -import Foundation - -actor BackgroundDownloadProcessingStore { - private var processingDownloads = [String: Task]() - - // MARK: - Add - - func store(from fromURL: URL, - task: Task) { - let key = fromURL.absoluteString - - processingDownloads[key] = task - } - - // MARK: - Retrieve - - func retrieveAll() -> [Task] { - Array(processingDownloads.values) - } - - // MARK: - Remove - - func remove(for forURL: URL) { - let key = forURL.absoluteString - - processingDownloads[key] = nil - } -} diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift index e88d595..6bbcf24 100644 --- a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadService.swift @@ -12,7 +12,6 @@ import UIKit import SwiftUI enum BackgroundDownloadError: Error { - case missingInstructionsError case fileSystemError(_ underlyingError: Error) case clientError(_ underlyingError: Error) case serverError(_ underlyingResponse: URLResponse?) @@ -36,6 +35,7 @@ actor BackgroundDownloadService { let delegator = BackgroundDownloadDelegator(metaStore: metaStore, logger: logger) + let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") configuration.isDiscretionary = false configuration.sessionSendsLaunchEvents = true @@ -51,15 +51,24 @@ actor BackgroundDownloadService { return try await withCheckedThrowingContinuation { continuation in logger.info("Scheduling download: \(fromURL.absoluteString)") - Task { [metaStore, fromURL, toURL, continuation] in - await metaStore.storeMetadata(from: fromURL, - to: toURL, - continuation: continuation) - } - + storeMetadata(from: fromURL, + to: toURL, + continuation: continuation) + 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() } } + + 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) + } + } } diff --git a/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift new file mode 100644 index 0000000..59400fe --- /dev/null +++ b/BackgroundTransferRevised-Example/Network/BackgroundDownload/BackgroundDownloadTaskStore.swift @@ -0,0 +1,31 @@ +// +// 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 + } +}