Keeping downloads going with background transfers

July 15th, 2018

In the past, iOS focused solely on what the user was doing, ensuring a responsive user experience on performance-constrained devices. While beneficial for users, this focus meant that as soon as an app went into the background, iOS suspended it. However, with more powerful and energy-efficient devices, iOS has changed. iOS now allows apps to perform limited background work while maintaining foreground responsiveness.

Photo of a user leaving

It is now possible for download and upload requests to continue when an app has been suspended or even terminated. These network requests are collectively known as background-transfers. Support for background-transfers was introduced in iOS 7. While not a recent change, it remains a powerful tool.

In this post, we'll explore background-transfers and build a working solution to use this functionality.

This post will gradually build up to a working example, but I get it, this is exciting stuff 😎, so if you are unable to contain that excitement, then head on over to the completed example and take a look at BackgroundDownloadService and BackgroundDownloadStore to see how things end up.

Different types of sessions

When Apple introduced the URLSession suite of classes, they addressed a number of the shortfalls that were present in a NSURLConnection based networking stack. One particular pain point was that with an NSURLConnection networking stack, there was no built-in way to group related types of requests together - each request in a group had to be individually configured. URLSession changed this by moving that group configuration to a session. A session represents a configurable object that handles the configuration and coordination of network requests. An app can have multiple sessions. Any request scheduled on that session would inherit that session's configuration. While useful for app developers to group requests, this session-level configuration also allowed iOS to offer more functionality as standard, enabling support for background-transfers on special background sessions.

Each session can be described as one of the following three favours:

  1. Default - supports all URLSessionTask subtypes for making a network request. These network requests can only be performed with the app in the foreground.
  2. Ephemeral - similar to default but does not persist caches, cookies, or credentials to disk.
  3. Background - supports all URLSessionTask subtypes for making a network request. URLSessionDataTask can only be performed with the app in the foreground whereas URLSessionDownloadTask and URLSessionUploadTask can be performed with the app in the foreground, suspended or terminated.

Each session has its use case; let's explore how we can use a background session to enable our uploads and downloads to continue when the user leaves the app.

So how do background-transfers work? πŸ•΅οΈ

When scheduling a transfer on a background session, that transfer is passed to the nsurlsessiond daemon to be processed. As nsurlsessiond lives outside of the lifecycle of any given app, any transfer scheduled on nsurlsessiond will continue even if the app that the transfer belongs to is suspended/terminated. Once a background-transfer is complete, if the app has been suspended/terminated, iOS will wake the app in the background and allow the app to perform any post-transfer processing (within a limited time frame); if the app is in the foreground, control will be passed back to the app as if the transfer has been scheduled on a default or ephemeral session (without the limited time frame for post-transfer processing).

You might be thinking:

"That sounds pretty amazing! Why isn't this the default behaviour for transfers?"

Well, there are a few reasons why this isn't the default behaviour:

  • Resource Management - excessive background processing can drain battery life and consume unexpected bandwidth; Apple wants to ensure that as app developers, we use it responsibly and only where it's actually adding value.
  • Programming Complexity - implementing background-transfers requires forgoing the convenient closure-based methods of URLSession and instead requires conforming to URLSessionDownloadDelegate to receive updates about the transfers, adding complexity when compared to foreground-only sessions.

Setting up the project

The app must be granted additional permissions to enable background-transfers:

  1. Open the Signing & Capabilities tab in the target.
  2. Add the Background Modes capability.
  3. In the newly added Background Modes section, select the Background fetch and Background processing checkboxes.

After completing these steps, your settings should look like this:

Screenshot of background modes

With the project correctly configured, it is time to start coding.

This post will focus on downloading using a background session as it's much easier to find services that allow content to be downloaded than services that allow content to be uploaded. To see the complete working example, visit the repo and clone the project.

Let's get downloading

Our background-transfer layer has five primary responsibilities:

  1. Configuring a background session.
  2. Scheduling the download request.
  3. Responding to download updates.
  4. Processing any completed downloads by moving files to a permanent location on the file system or reporting an error.
  5. Recovering from a terminated/suspended state to process completed downloads.

And needs to be able to handle a download completing with the app in the following states:

  1. Foregrounded - when the app is open, the app needs to behave as if the session used the default configuration.
  2. Suspended - when the app has been suspended, the app needs to be woken up to finish the transfer.
  3. Terminated - when iOS has terminated the app, the app needs to be relaunched to finish the transfer.

Together, these responsibilities and states produce the following class structure and interactions:

Class diagram showing how BackgroundDownloadService, BackgroundDownloadStore, AppDelegate and URLSession interact

  • BackgroundDownloadService sets up the background session, schedules downloads, and responds to download updates.
  • BackgroundDownloadStore manages the metadata for each download, ensuring data persistence beyond app launches.
  • AppDelegate informs BackgroundDownloadService when the app has been woken/relaunched to complete background downloads.
  • URLSession informs BackgroundDownloadService of any updates to in-progress downloads via the URLSessionDownloadDelegate.

Don't worry if some parts of this diagram don't make sense; we will explore each part in turn as we build our solution.

Three pieces of metadata are needed to perform a download:

  1. Remote URL - the URL of the content to be downloaded.
  2. File path URL - the URL of where the downloaded content should be stored on the local file system.
  3. Completion handler - the closure to be called when the download is complete.

Let's take these three pieces of metadata and start implementing BackgroundDownloadStore, starting with adding the functionality to store that metadata:

typealias BackgroundDownloadCompletion = (_ result: Result<URL, Error>) -> ()

class BackgroundDownloadStore {
    //1
    private var inMemoryStore = [String: BackgroundDownloadCompletion]()

    // 2
    private let persistentStore = UserDefaults.standard

    // MARK: - Store

    // 3
    func storeMetadata(from fromURL: URL,
                       to toURL: URL,
                       completionHandler: @escaping BackgroundDownloadCompletion) {
        inMemoryStore[fromURL.absoluteString] = completionHandler
        persistentStore.set(toURL, forKey: fromURL.absoluteString)
    }
}

Here's what we did:

  1. Background session downloads don't support the completion-handler methods on URLSession; instead any download progress updates are provided via URLSessionDownloadDelegate however a closure based interface is perfect for downloading content where the caller is really only interested in either having the downloaded content or an error. So, to recreate that convenient closure-based interface, BackgroundDownloadService takes a closure when a download is requested and passes it onto BackgroundDownloadStore to store it in the inMemoryStore dictionary. As the outcome of a download can either succeed or fail, the Result type can represent both outcomes in one type. As the closure here is a communication path between the caller and callee, when the app is terminated, the memory holding the caller and callee will be freed, so the storage of these closures is only in-memory.
  2. As a download may complete while the app is terminated, details of where the downloaded content should end up on the local file system must be persisted outside of memory. UserDefaults is a simple and effective means of persisting that information.
  3. The method to store a download's metadata into inMemoryStore and persistentStore using fromURL as the key.

Storing metadata is all good, but that metadata needs to be retrieved:

class BackgroundDownloadStore {
    // Omitted methods and properties

    // MARK: - Retrieve

    // 1
    func retrieveMetadata(for forURL: URL) -> (URL?, BackgroundDownloadCompletion?) {
        let key = forURL.absoluteString

        let toURL = persistentStore.url(forKey: key)
        let metaDataCompletionHandler = inMemoryStore[key]

        return (toURL, metaDataCompletionHandler)
    }
}

Here's what we did:

  1. Any metadata associated with forURL is extracted from persistentStore and inMemoryStore. As there is no guarantee that the metadata associated with forURL was ever stored (or in the case of the closures stored in inMemoryStore, there is no guarantee the closure is still in memory), this method returns that metadata as a tuple with two optional values.

Finally, once a download has been completed, we need a way to remove that download's metadata from the store:

class BackgroundDownloadStore {
    // Omitted methods and properties

    // MARK: - Remove

    // 1
    func removeMetadata(for forURL: URL) {
        let key = forURL.absoluteString

        inMemoryStore[key] = nil
        persistentStore.removeObject(forKey: key)
    }
}

Here's what we did:

  1. Any metadata associated with forURL is deleted from persistentStore and inMemoryStore.

That's all we need to do to store the metadata associated with a download; let's move on to configuring a background session and making a background-transfer.

Before looking at BackgroundDownloadService, let's take a short detour and look at a supporting type that will used in BackgroundDownloadService:

enum BackgroundDownloadServiceError: Error {
    case missingInstructionsError
    case fileSystemError(_ underlyingError: Error)
    case networkError(_ underlyingError: Error?)
    case unexpectedResponseError
    case unexpectedStatusCode
}

BackgroundDownloadServiceError is an enum that conforms to the Error protocol and will be used to provide details about the unhappy download paths through BackgroundDownloadService.

Detour over πŸ—ΊοΈ.

Let's build the skeleton of BackgroundDownloadService:

class BackgroundDownloadService: NSObject { // 1
    // 2
    private var session: URLSession!

    // MARK: - Singleton

    // 3
    static let shared = BackgroundDownloadService()

    // 4
    override init() {
        super.init()

        configureSession()
    }

    private func configureSession() {
        // 5
        let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session")

        // 6
        configuration.sessionSendsLaunchEvents = true
        let session = URLSession(configuration: configuration,
                                 delegate: self,
                                 delegateQueue: nil)
        self.session = session
    }
}

Let's look at what we did above:

  1. As mentioned earlier, background session downloads don't support the completion-handler methods on URLSession; Instead BackgroundDownloadService will need to become the delegate to the background URLSession session by implementing the URLSessionDownloadDelegate. The need to conform to URLSessionDownloadDelegate is why BackgroundDownloadService is a subclass of NSObject.
  2. There exists a cyclic dependency between BackgroundDownloadService and URLSession where BackgroundDownloadService is responsible for not only creating the URLSession instance but also being its delegate. However, the delegate of URLSession can only be set at initialisation of the URLSession. Both these conditions cannot be satisfied. To break the cyclic dependency between BackgroundDownloadService and URLSession, the session property is set after BackgroundDownloadService is initialised, and to avoid having an optional property, session is implicitly unwrapped.
  3. BackgroundDownloadService is singleton to ensure that any background-transfer updates are funnelled into the same type that holds the metadata of that transfer.
  4. init() is private to ensure that another instance of BackgroundDownloadService cannot be created.
  5. A session's configuration is defined through a URLSessionConfiguration instance, which offers numerous configuration options. Every URLSessionConfiguration for a background session requires a unique identifier within that app's lifecycle. If an existing URLSession already has that identifier, iOS returns the previously created URLSession instance. The session identifier plays a vital role in ensuring that a download can survive app termination - for any download update, iOS will attempt to find a URLSession instance with the same identifier that the download was kicked off on to inform of the update; that found URLSession instance does not need to be the same instance that was used to kick off the download. To ensure the app always has a URLSession that can pick up any download updates, session is consistently configured with the same identifier.
  6. sessionSendsLaunchEvents tells iOS that any background-transfers scheduled on this session can launch the app if it is suspended/terminated.

So far, we have configured a session for performing background-transfers but can't yet download anythign; let's change that in:

class BackgroundDownloadService: NSObject {
    // Omitted other properties

    // 1
    private let store = BackgroundDownloadStore()

    // Omitted other methods

    // MARK: - Download

    func download(from remoteURL: URL,
                  saveDownloadTo localURL: URL,
                  completionHandler: @escaping ((_ result: Result<URL, BackgroundDownloadServiceError>) -> ())) {
        os_log(.info, "Scheduling to download: %{public}@", remoteURL.absoluteString)

        // 2
        store.storeMetadata(from: remoteURL,
                            to: localURL,
                            completionHandler: completionHandler)

        // 3
        let task = session.downloadTask(with: remoteURL)
        task.resume()
    }
}

Here's what we did:

  1. A BackgroundDownloadStore instance is stored as a property to ensure access to a download's metadata.
  2. The metadata associated with the current download request is added to the store.
  3. A URLSessionDownloadTask instance is created using the background session, and the download request is kicked off.

To better see the downloads happening in the background, you may wish to add a slight delay before the download starts, making it easier to put the app into the background:

task.earliestBeginDate = Date().addingTimeInterval(5)

Now that BackgroundDownloadService can kick off a download, we need to add in the code to handle the happy-path when a download completes by implementing urlSession(_:downloadTask:didFinishDownloadingTo) from URLSessionDownloadDelegate:

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        // 1
        guard let fromURL = downloadTask.originalRequest?.url else {
            os_log(.error, "Unexpected nil URL")
            // Unable to call the closure here as fromURL is the key to retrieving this download's closure
            return
        }

        // 2
        defer {
            store.removeMetadata(for: fromURL)
        }

        let fromURLAsString = fromURL.absoluteString

        os_log(.info, "Download request completed for: %{public}@", fromURLAsString)

        // 3
        let (toURL, completionHandler) = store.retrieveMetadata(for: fromURL)

        // 4
        guard let toURL else {
            os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString)
            completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError))
            return
        }

        os_log(.info, "Download successful for: %{public}@", fromURLAsString)

        // 5
        do {
            try FileManager.default.moveItem(at: location,
                                             to: toURL)
            // 6
            completionHandler?(.success(toURL))
        } catch {
            completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error)))
        }
    }
}

This delegate method can be called when the app is foregrounded, suspended, or terminated, so all three states need to be handled in the same method:

  1. In the download method, the remoteURL was used as the key for storing a download's metadata because that value is present when a download completes in the URLSessionDownloadTask instance. A URLSessionDownloadTask will automatically follow redirect instructions, so it is essential to ensure that the correct URL is used to connect the completed download with its metadata - specifically, the original URL stored in the originalRequest property. If originalRequest is nil, the method exits as no further action can be taken.
  2. As there are multiple paths through urlSession(_:downloadTask:didFinishDownloadingTo), a defer block is used to clean up the download by deleting its metadata on any exit.
  3. The downloads associated metadata is retrieved from the store.
  4. If toURL is nil, the download cannot be processed because there is no known location to move the downloaded content to. As a result, the method exits with no further action taken.
  5. When URLSessionDownloadTask completes a download, it will store that downloaded content in a temporary location and allow us to move that downloaded file to a permanent location before iOS deletes it. Using FileManager, the downloaded content is moved from its temporary location to its permanent location. If the move is successful, the completion handler is called with the file's new location; if the move fails, the completion handler is called with an error.
  6. If the app has been relaunched from a terminated state, any closure associated with this download is no longer available, so there is no enforcement that completionHandler must be non-nil.

That's the happy path completed; let's add in the unhappy-path.

The unhappy-path comes in two forms: server-side and client-side errors. Let's start with the server-side errors by updating urlSession(_:downloadTask:didFinishDownloadingTo:):

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 fromURL is the key to retrieving this download's closure
            return
        }

        defer {
            store.removeMetadata(for: fromURL)
        }

        let fromURLAsString = fromURL.absoluteString

        os_log(.info, "Download request completed for: %{public}@", fromURLAsString)

        let (toURL, completionHandler) = 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
        }

        // 1
        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: location,
                                             to: toURL)

            completionHandler?(.success(toURL))
        } catch {
            completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error)))
        }
    }
}

Here's what we changed:

  1. To determine if a download was successful, a 200 HTTP status code is expected. Here, the response is checked. If the status code is anything other than a 200, then completionHandler is triggered with serverError.

If your server returns other status codes that should be treated as success, then it is simple enough to add those by refactoring the guard to accommodate those additional status codes.

Now that server-side errors are handled, let's take care of any client-side errors:

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    // Omitted other methods

    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        // 1
        guard let error = error else {
            return
        }

        // 2
        guard let fromURL = task.originalRequest?.url else {
            os_log(.error, "Unexpected nil URL")
            return
        }

        let fromURLAaString = fromURL.absoluteString

        os_log(.info, "Download failed for: %{public}@", fromURLAaString)

        // 3
        let (_, completionHandler) = store.retrieveMetadata(for: fromURL)

        completionHandler?(.failure(BackgroundDownloadError.clientError(error)))

        // 4
        store.removeMetadata(for: fromURL)
    }
}

Here's what we did:

  1. This delegate method is called when any network request completes, meaning it will be triggered for both successful and unsuccessful network requests. If a network request fails, error will contain a value. To exclude successful downloads from the unhappy-path, error is checked for nil. If error is nil, the method exits.
  2. Like in urlSession(_:downloadTask:didFinishDownloadingTo:), the remote URL is extracted from the completed URLSessionTask instance as fromURL. If originalRequest is nil, the method exits as no further action can be taken.
  3. The closure associated with this download is retrieved from store and triggered with networkError.
  4. The now complete downloads metadata is deleted.

If you've been putting together the code snippets, you should be able to download files in the foreground; however, as soon as the app is put into the background, a stream of errors will begin to appear in the Xcode console, similar to:

Screenshot showing error produced by completion-closure not being called

So far, BackgroundDownloadService only works when the app is in the foreground. Let's extend it to support downloads when the app enters a suspended/terminated state.

Keeping downloads going

When an app is in a suspended/terminated state and a download completes, iOS will wake the app up (in the background) by calling application(_:handleEventsForBackgroundURLSession:completionHandler:) on the AppDelegate. Regardless of how many downloads are in progress, application(_:handleEventsForBackgroundURLSession:completionHandler:) is only called once - when all downloads have been completed. If the user foregrounds the app before the above method is called, urlSession(_:downloadTask:didFinishDownloadingTo:) is called for each completed download.

class AppDelegate: UIResponder, UIApplicationDelegate {

    // Omitted properties and methods

    // 1
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        // 2
        BackgroundDownloadService.shared.backgroundCompletionHandler = completionHandler
    }
}

Here's what we did:

  1. iOS provides the app with the identifier of the session that the download was scheduled on and a completion handler to be called once all post-download work is complete. As the background session always uses the same identifier, identifier can ignored.
  2. completionHandler is passed to BackgroundDownloadService so that it can be called once all post-download work is complete.

As the completionHandler closure is passed to the shared BackgroundDownloadService instance, a new property needs to be added to that class:

class BackgroundDownloadService: NSObject {
    var backgroundCompletionHandler: (() -> Void)?

    // Omitted properties and methods
}

When all the downloads are complete, urlSession(_:downloadTask:didFinishDownloadingTo:) will be called for each download. After all the calls to urlSession(_:downloadTask:didFinishDownloadingTo:) have been made, a special background-transfer only delegate call is made:

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    // Omitted methods

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        // 1
        DispatchQueue.main.async {
            self.backgroundCompletionHandler?()
            self.backgroundCompletionHandler = nil
        }
    }
}

Here's what we did:

  1. Triggering backgroundCompletionHandler instructs iOS to take a new snapshot of the app's UI for the app switcher preview. As such, backgroundCompletionHandler must be called from the main queue. Failure to call backgroundCompletionHandler will result in iOS treating the app as a bad citizen, reducing future background processing opportunities.

While it's possible to make additional network requests with the app running in the background, they are subject to a rate limiter to prevent abuse of background sessions - this rate limiter delays the start of those network requests. With each separate network request, the delay increases - this delay is reset when the user brings the app to the foreground or if your app does not make any additional network requests during the delay.

And that's all the changes required to continue network requests when the app is suspended/terminated.

Living in a multi-threaded world

Using the code we've written so far on a single thread works fine, but using it simultaneously from multiple threads will eventually cause the app to crash. When accessing BackgroundDownloadStore from multiple threads, one thread may be modifying the internal mutable state of BackgroundDownloadStore while another thread is also modifying it. As BackgroundDownloadStore doesn't lock its state during a modification operation, simultaneous modification operations will eventually result in data corruption and the app crashing.

Thoughtful use of a DispatchQueue can prevent these issues. Our DispatchQueue instance will allow concurrent read operations but serial write operations. If a write operation is being processed, any new operation (write or read) added to the queue will be blocked until the in-progress write operation completes. Using a DispatchQueue instance will ensure that only one write operation runs at a time, preventing data corruption and stopping the app crashes we are currently experiencing.

Let's add state access controls into BackgroundDownloadStore to protect its shared mutable state:

class BackgroundDownloadStore {
    // 1
    private let queue = DispatchQueue(label: "com.williamboles.background.download.service",
                                      qos: .userInitiated,
                                      attributes: .concurrent)

    // Omitted properties and methods
}

Here's what we did:

  1. As the downloads are triggered via user action, the queue priority is set to the higher end of the scale by setting its qos to userInitiated; this will ensure that operations on this queue are given greater resources when compared to lower priority operations on other queues. By default, an instance of DispatchQueue is a serial queue; this default is overridden by creating a concurrent queue using the concurrent attribute.

With the above queue property, let's update the rest of BackgroundDownloadStore, starting with the storeMetadata(from:to:completionHandler:) method:

class BackgroundDownloadStore {
    // Omitted properties and methods

    func storeMetadata(from fromURL: URL,
                         to toURL: URL,
                         completionHandler: @escaping BackgroundDownloadCompletion) {
          // 1
          queue.async(flags: .barrier) { [weak self] in
              self?.inMemoryStore[fromURL.absoluteString] = completionHandler
              self?.persistentStore.set(toURL, forKey: fromURL.absoluteString)
          }
      }
}

Here's what we did:

  1. Using our DispatchQueue instance, we asynchronously schedule the metadata storage to happen on that queue. Adding the operation asynchronously ensures that the calling thread won't be blocked. As this is a write operation, the barrier flag is set when adding the operation to queue; this flag will switch the dispatch queue from concurrent processing to serial processing for this operation.

The changes needed to protect removeMetadata(for:) are almost the same as those made for storeMetadata(from:to:completionHandler:):

class BackgroundDownloadStore {
      // Omitted properties and methods

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

retrieveMetadata(for:) requires slightly more change as it needs to return a download's metadata without blocking the calling thread. The best way to achieve this is to return its result via a closure:

class BackgroundDownloadStore {
      // Omitted properties and methods

      func retrieveMetadata(for forURL: URL,
                            completionHandler: @escaping ((URL?, BackgroundDownloadCompletion?) -> ())) {
          // 1
          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)
          }
      }
}

Here's what we did:

  1. The retrieval of the metadata associated with forURL is asynchronously scheduled on queue. Adding the retrieval operation asynchronously ensures that the calling thread won't be blocked. As this is a read operation, the operation can run concurrently with any other read operations, so unlike in storeMetadata(from:to:completionHandler:) and removeMetadata(for:) the operation is not flagged as a barrier operation.

We could have added the read operation as a synchronous operation to the dispatch queue using queue.sync as we know that BackgroundDownloadService will call this method from a background thread and so avoid having to add a closure to return the result of the retrieval. However, I felt that adding a blocking thread call here would lead to a nasty surprise if retrieveMetadata(for:) was ever used on the main thread.

With the changes to the interface of BackgroundDownloadStore to ensure thread safety, BackgroundDownloadService needs to be updated:

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 fromURL is the key to retrieving this download's closure
              return
          }

          let fromURLAsString = fromURL.absoluteString

          os_log(.info, "Download request completed for: %{public}@", fromURLAsString)

          // 1
          let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent)
          try? FileManager.default.moveItem(at: location,
                                            to: tempLocation)

          // 2
          store.retrieveMetadata(for: fromURL) { [weak self] toURL, completionHandler in
              // 3
              defer {
                  self?.store.removeMetadata(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 {
                  // 4
                  try FileManager.default.moveItem(at: tempLocation,
                                                   to: toURL)

                  completionHandler?(.success(toURL))
              } catch {
                  completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error)))
              }
          }
      }

     // Omitted other methods
}

Here's what we changed:

  1. As mentioned before, iOS only guarantees until the end of this method that the downloaded content will be found at location. As retrieveMetadata(for:completionHandler:) is an asynchronous method, waiting on the completion handler being called with the download's metadata will mean that urlSession(_:downloadTask:didFinishDownloadingTo) will have exited and the downloaded content will have been deleted. So before attempting to fetch the download's metadata, the downloaded content is moved from its current temporary location to a different temporary location that BackgroundDownloadService controls. Moving to a temporary location ensures that the downloaded content will still be accessible when the completion handler calls back with the metadata.
  2. Using the closure-based interface for retrieving the completed download's metadata.
  3. Moved the defer block into the closure so that it is associated with the closure block rather than the method itself.
  4. Using the tempLocation URL as the source URL for the move operation.

And finally, let's use the same pattern with urlSession(_:task:didCompleteWithError)

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    // Omitted other methods

    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 fromURLAaString = fromURL.absoluteString

           os_log(.info, "Download failed for: %{public}@", fromURLAaString)

           // 1
           store.retrieveMetadata(for: fromURL) { [weak self] _, completionHandler in
               completionHandler?(.failure(BackgroundDownloadError.clientError(error)))

               self?.store.removeMetadata(for: fromURL)
           }
       }
}

Here's what we did:

  1. Using the closure-based interface for retrieving the completed download's metadata.

If you run the project now in a multi-threaded app, downloads should complete without app crashes due to data corruption.

And that's all the code needed to support background-transfers πŸ’ƒ.

However, there is one gotcha 🧌 when testing the above code. If an app is manually force-quit, iOS interprets that action as the user explicitly saying that they want to stop all activity associated with that app. As a result, all scheduled background-transfers are cancelled, preventing testing of the restoration functionality. To overcome this gotcha, app termination needs to happen programmatically using exit(0), which shuts down the app without user intervention:

class AppDelegate: UIResponder, UIApplicationDelegate {

    // Omitted properties and methods

    func applicationDidEnterBackground(_ application: UIApplication) {
        //Exit the app to test restoring the app from a terminated state. Comment out to test restoring the app from a suspended state.
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            os_log(.info, "App is about to quit")

            exit(0)
        }
    }
}

Remember to remove this in production 😜.

This time we are really at the end πŸŽ‰ - congratulations on making it here.

Downloads keep going 🀯

Background-transfers provide a powerful tool to meet your users' ever-increasing expectations about what your app can do for them even when your app isn't in the foreground. I believe that the enhanced user experience we gain from supporting background-transfers outweighs any additional complexity supporting background-transfers adds. As we seen with careful planning, that complexity can be isolated from the rest of the app so we end up providing functionality without getting in the way of features.

To see the complete working example, visit the repo and clone the project.


Running the example project

I've used TheCatAPI in the example project to populate the app with images. TheCatAPI has a great JSON-based API and an extensive library of lovely, freely available cat photos. While free to use, TheCatAPI requires you to register to get an x-api-key token to access those cats. You must add this token as the APIKey property in NetworkService for any network requests to work.