diff --git a/BackgroundTransfer-Example.xcodeproj/project.pbxproj b/BackgroundTransfer-Example.xcodeproj/project.pbxproj index e260560..518ba1e 100644 --- a/BackgroundTransfer-Example.xcodeproj/project.pbxproj +++ b/BackgroundTransfer-Example.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 438F85472D6E241D00AF956D /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438F85442D6E241D00AF956D /* ImageLoader.swift */; }; 438F85482D6E241D00AF956D /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438F85452D6E241D00AF956D /* NetworkService.swift */; }; 438F85492D6E241D00AF956D /* BackgroundDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */; }; + 43DAF0CE2D8C75D3005900E7 /* BackgroundDownloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,6 +49,7 @@ 438F85442D6E241D00AF956D /* ImageLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; 438F85452D6E241D00AF956D /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; 438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadService.swift; sourceTree = ""; }; + 43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -190,6 +192,7 @@ 438F85442D6E241D00AF956D /* ImageLoader.swift */, 438F85452D6E241D00AF956D /* NetworkService.swift */, 438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */, + 43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */, ); path = Network; sourceTree = ""; @@ -307,6 +310,7 @@ 43896B7A2D6906CE00FF34F8 /* CatCollectionViewCell.swift in Sources */, 3DEAF27720947086004FE44E /* AppDelegate.swift in Sources */, 438F85472D6E241D00AF956D /* ImageLoader.swift in Sources */, + 43DAF0CE2D8C75D3005900E7 /* BackgroundDownloadStore.swift in Sources */, 438F85492D6E241D00AF956D /* BackgroundDownloadService.swift in Sources */, 3DEAF2AA20948C8A004FE44E /* NSObject+Name.swift in Sources */, 43896B7B2D6906CE00FF34F8 /* CatsViewController.swift in Sources */, diff --git a/BackgroundTransfer-Example/Application/AppDelegate.swift b/BackgroundTransfer-Example/Application/AppDelegate.swift index c6d91e9..0c047df 100644 --- a/BackgroundTransfer-Example/Application/AppDelegate.swift +++ b/BackgroundTransfer-Example/Application/AppDelegate.swift @@ -7,6 +7,7 @@ // import UIKit +import Foundation import os @UIApplicationMain @@ -26,6 +27,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationDidEnterBackground(_ application: UIApplication) { + os_log(.info, "Downloaded content will be saved to: %{public}@", 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. DispatchQueue.main.asyncAfter(deadline: .now()) { os_log(.info, "Simulating app termination by exit(0)") @@ -48,7 +51,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Background - func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { - BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler + func application(_ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void) { + BackgroundDownloadService().backgroundCompletionHandler = completionHandler } } diff --git a/BackgroundTransfer-Example/Network/BackgroundDownloadService.swift b/BackgroundTransfer-Example/Network/BackgroundDownloadService.swift index ec38416..736bb8d 100644 --- a/BackgroundTransfer-Example/Network/BackgroundDownloadService.swift +++ b/BackgroundTransfer-Example/Network/BackgroundDownloadService.swift @@ -9,22 +9,18 @@ import Foundation import os -enum BackgroundDownloadServiceError: Error { +enum BackgroundDownloadError: Error { case missingInstructionsError case fileSystemError(_ underlyingError: Error) - case networkError(_ underlyingError: Error?) - case unexpectedResponseError - case unexpectedStatusCode + case clientError(_ underlyingError: Error) + case serverError(_ underlyingResponse: URLResponse?) } class BackgroundDownloadService: NSObject { var backgroundCompletionHandler: (() -> Void)? private var session: URLSession! - - private var foregroundCompletionHandlers = [String: ((result: Result) -> ())]() - - private let queue = DispatchQueue(label: "com.williamboles.background.download.service") + private let store = BackgroundDownloadStore() // MARK: - Singleton @@ -32,9 +28,13 @@ class BackgroundDownloadService: NSObject { // MARK: - Init - private override 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, @@ -47,71 +47,66 @@ class BackgroundDownloadService: NSObject { func download(from remoteURL: URL, saveDownloadTo localURL: URL, - completionHandler: @escaping ((_ result: Result) -> ())) { - queue.async { [weak self] in - os_log(.info, "Scheduling to download: %{public}@", remoteURL.absoluteString) - - self?.foregroundCompletionHandlers[remoteURL.absoluteString] = completionHandler - UserDefaults.standard.set(localURL, forKey: remoteURL.absoluteString) - - let task = self?.session.downloadTask(with: remoteURL) - task?.earliestBeginDate = Date().addingTimeInterval(2) // Remove this in production, the delay was added for demonstration purposes only - task?.resume() - } + completionHandler: @escaping BackgroundDownloadCompletion) { + os_log(.info, "Scheduling to download: %{public}@", remoteURL.absoluteString) + + store.storeMetadata(from: remoteURL, + to: localURL, + completionHandler: completionHandler) + + let task = session.downloadTask(with: remoteURL) + task.earliestBeginDate = Date().addingTimeInterval(2) // Remove this in production, the delay was added for demonstration purposes only + task.resume() } } // MARK: - URLSessionDownloadDelegate extension BackgroundDownloadService: URLSessionDownloadDelegate { - func urlSession(_ session: URLSession, + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - queue.sync { - guard let originalRequestURL = downloadTask.originalRequest?.url?.absoluteString else { - os_log(.error, "Unexpected nil URL") - // Unable to call the closure here as we use originalRequestURL as the key to retrieve the closure - - return - } - + 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) + + store.retrieveMetadata(for: fromURL) { [weak self] toURL, completionHandler in defer { - self.foregroundCompletionHandlers[originalRequestURL] = nil - UserDefaults.standard.removeObject(forKey: originalRequestURL) + self?.store.removeMetadata(for: fromURL) } - os_log(.info, "Download request completed for: %{public}@", originalRequestURL) - - let foregroundCompletionHandler = self.foregroundCompletionHandlers[originalRequestURL] - - guard let response = downloadTask.response as? HTTPURLResponse else { - os_log(.error, "Unexpected response for: %{public}@", originalRequestURL) - foregroundCompletionHandler?(.failure(.unexpectedResponseError)) + guard let toURL else { + os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString) + completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError)) return } - guard response.statusCode == 200 else { - os_log(.error, "Unexpected status code of: %{public}d, for: %{public}@", response.statusCode, originalRequestURL) - foregroundCompletionHandler?(.failure(.unexpectedStatusCode)) + 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}@", originalRequestURL) - - guard let saveDownloadToURL = UserDefaults.standard.url(forKey: originalRequestURL) else { - os_log(.error, "Unable to find existing download item for: %{public}@", originalRequestURL) - foregroundCompletionHandler?(.failure(.missingInstructionsError)) - - return - } + os_log(.info, "Download successful for: %{public}@", fromURLAsString) do { - try FileManager.default.moveItem(at: location, - to: saveDownloadToURL) + try FileManager.default.moveItem(at: tempLocation, + to: toURL) - foregroundCompletionHandler?(.success(saveDownloadToURL)) + completionHandler?(.success(toURL)) } catch { - foregroundCompletionHandler?(.failure(.fileSystemError(error))) + completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error))) } } } @@ -119,26 +114,23 @@ extension BackgroundDownloadService: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - queue.async { [weak self] in - guard let error = error else { - return - } - - guard let originalRequestURL = task.originalRequest?.url?.absoluteString else { - os_log(.error, "Unexpected nil URL") - - return - } - - defer { - self?.foregroundCompletionHandlers[originalRequestURL] = nil - UserDefaults.standard.removeObject(forKey: originalRequestURL) - } - - os_log(.info, "Download failed for: %{public}@", originalRequestURL) + 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) + + store.retrieveMetadata(for: fromURL) { [weak self] _, completionHandler in + completionHandler?(.failure(BackgroundDownloadError.clientError(error))) - let foregroundCompletionHandler = self?.foregroundCompletionHandlers[originalRequestURL] - foregroundCompletionHandler?(.failure(.networkError(error))) + self?.store.removeMetadata(for: fromURL) } } diff --git a/BackgroundTransfer-Example/Network/BackgroundDownloadStore.swift b/BackgroundTransfer-Example/Network/BackgroundDownloadStore.swift new file mode 100644 index 0000000..bc771e6 --- /dev/null +++ b/BackgroundTransfer-Example/Network/BackgroundDownloadStore.swift @@ -0,0 +1,56 @@ +// +// 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) -> () + +class BackgroundDownloadStore { + private var inMemoryStore = [String: BackgroundDownloadCompletion]() + private let persistentStore = UserDefaults.standard + + private let queue = DispatchQueue(label: "com.williamboles.background.download.service", + qos: .userInitiated, + attributes: .concurrent) + + // MARK: - Store + + func storeMetadata(from fromURL: URL, + to toURL: URL, + completionHandler: @escaping BackgroundDownloadCompletion) { + queue.async(flags: .barrier) { [weak self] in + self?.inMemoryStore[fromURL.absoluteString] = completionHandler + self?.persistentStore.set(toURL, forKey: fromURL.absoluteString) + } + } + + // MARK: - Retrieve + + func retrieveMetadata(for forURL: URL, + completionHandler: @escaping ((URL?, BackgroundDownloadCompletion?) -> ())) { + return queue.async { [weak self] in + let key = forURL.absoluteString + + let toURL = self?.persistentStore.url(forKey: key) + let metaDataCompletionHandler = self?.inMemoryStore[key] + + completionHandler(toURL, metaDataCompletionHandler) + } + } + + // MARK: - Remove + + func removeMetadata(for forURL: URL) { + queue.async(flags: .barrier) { [weak self] in + let key = forURL.absoluteString + + self?.inMemoryStore[key] = nil + self?.persistentStore.removeObject(forKey: key) + } + } +} diff --git a/BackgroundTransfer-Example/Network/ImageLoader.swift b/BackgroundTransfer-Example/Network/ImageLoader.swift index 4516ca1..ede8cc0 100644 --- a/BackgroundTransfer-Example/Network/ImageLoader.swift +++ b/BackgroundTransfer-Example/Network/ImageLoader.swift @@ -10,44 +10,52 @@ import Foundation import UIKit enum ImageLoaderError: Error { - case missingData case invalidImageData } class ImageLoader { - private let backgroundDownloader = BackgroundDownloadService.shared + private let backgroundDownloadService: BackgroundDownloadService + private let fileManager: FileManager + private let documentsDirectoryURL: URL + private let imageLoadingQueue = DispatchQueue(label: "com.williamboles.image.loader", + qos: .userInitiated) + + // MARK: - Init + + init(backgroundDownloadService: BackgroundDownloadService = .shared, + fileManager: FileManager = .default) { + self.backgroundDownloadService = backgroundDownloadService + self.fileManager = fileManager + self.documentsDirectoryURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + } // MARK: - Load func loadImage(name: String, url: URL, completionHandler: @escaping ((_ result: Result) -> ())) { - DispatchQueue.global(qos: .background).async { [weak self] in - let fileManager = FileManager.default - let paths = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectoryURL = paths[0] - let localImageURL = documentsDirectoryURL.appendingPathComponent(name) + imageLoadingQueue.async { [weak self] in + guard let self = self else { return } + + let localImageURL = self.documentsDirectoryURL.appendingPathComponent(name) - if fileManager.fileExists(atPath: localImageURL.path) { - self?.loadLocalImage(localImageURL: localImageURL, - completionHandler: completionHandler) + if let imageData = loadLocalImage(from: localImageURL) { + reportOutcome(imageData: imageData, + completionHandler: completionHandler) } else { - self?.loadRemoteImage(remoteImageURL: url, - localImageURL: localImageURL, - completionHandler: completionHandler) + loadRemoteImage(from: url, + to: localImageURL) { [weak self] imageData in + self?.reportOutcome(imageData: imageData, + completionHandler: completionHandler) + } } } } - private func loadLocalImage(localImageURL: URL, - completionHandler: @escaping ((_ result: Result) -> ())) { - guard let imageData = try? Data(contentsOf: localImageURL) else { - completionHandler(.failure(ImageLoaderError.missingData)) - return - } - + private func reportOutcome(imageData: Data?, + completionHandler: @escaping ((_ result: Result) -> ())) { DispatchQueue.main.async { - guard let image = UIImage(data: imageData) else { + guard let imageData, let image = UIImage(data: imageData) else { completionHandler(.failure(ImageLoaderError.invalidImageData)) return } @@ -55,18 +63,17 @@ class ImageLoader { } } - private func loadRemoteImage(remoteImageURL: URL, - localImageURL: URL, - completionHandler: @escaping ((_ result: Result) -> ())) { - backgroundDownloader.download(from: remoteImageURL, - saveDownloadTo: localImageURL) { [weak self] result in - switch result { - case let .success(url): - self?.loadLocalImage(localImageURL: url, - completionHandler: completionHandler) - case let .failure(error): - completionHandler(.failure(error)) - } + private func loadLocalImage(from fromURL: URL) -> Data? { + try? Data(contentsOf: fromURL) + } + + private func loadRemoteImage(from fromURL: URL, + to toURL: URL, + completionHandler: @escaping ((Data?) -> ())) { + backgroundDownloadService.download(from: fromURL, + saveDownloadTo: toURL) { _ in + let imageData = try? Data(contentsOf: toURL) + completionHandler(imageData) } } }