Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions BackgroundTransfer-Example.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -48,6 +49,7 @@
438F85442D6E241D00AF956D /* ImageLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = "<group>"; };
438F85452D6E241D00AF956D /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = "<group>"; };
438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadService.swift; sourceTree = "<group>"; };
43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadStore.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -190,6 +192,7 @@
438F85442D6E241D00AF956D /* ImageLoader.swift */,
438F85452D6E241D00AF956D /* NetworkService.swift */,
438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */,
43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */,
);
path = Network;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
9 changes: 7 additions & 2 deletions BackgroundTransfer-Example/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import UIKit
import Foundation
import os

@UIApplicationMain
Expand All @@ -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)")
Expand All @@ -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
}
}
136 changes: 64 additions & 72 deletions BackgroundTransfer-Example/Network/BackgroundDownloadService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,32 @@
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<URL, BackgroundDownloadServiceError>) -> ())]()

private let queue = DispatchQueue(label: "com.williamboles.background.download.service")
private let store = BackgroundDownloadStore()

// MARK: - Singleton

static let shared = BackgroundDownloadService()

// 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,
Expand All @@ -47,98 +47,90 @@ class BackgroundDownloadService: NSObject {

func download(from remoteURL: URL,
saveDownloadTo localURL: URL,
completionHandler: @escaping ((_ result: Result<URL, BackgroundDownloadServiceError>) -> ())) {
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)))
}
}
}

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)
}
}

Expand Down
56 changes: 56 additions & 0 deletions BackgroundTransfer-Example/Network/BackgroundDownloadStore.swift
Original file line number Diff line number Diff line change
@@ -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<URL, Error>) -> ()

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)
}
}
}
Loading