2

I have an app that needs to download a file which may be rather large (perhaps as large as 20 MB). I've been reading up on URLSession downloadTasks and how they work when the app goes to the background or is terminated by iOS. I'd like for the download to continue and from what I've read, that's possible. I found a blog post here that discusses this topic in some detail.

Based on what I've read, I first created a download manager class that looks like this:

class DownloadManager : NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate {

    static var shared = DownloadManager()

    var backgroundSessionCompletionHandler: (() -> Void)?

    var session : URLSession {
        get {
            let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")
            return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
        }
    }

    private override init() {
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            if let completionHandler = self.backgroundSessionCompletionHandler {
                self.backgroundSessionCompletionHandler = nil
                completionHandler()
            }
        }
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        if let sessionId = session.configuration.identifier {
            log.info("Download task finished for session ID: \(sessionId), task ID: \(downloadTask.taskIdentifier); file was downloaded to \(location)")

            do {
                // just for testing purposes
                try FileManager.default.removeItem(at: location)
                print("Deleted downloaded file from \(location)")
            } catch {
                print(error)
            }         
        }
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        if totalBytesExpectedToWrite > 0 {
            let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
            let progressPercentage = progress * 100
            print("Download with task identifier: \(downloadTask.taskIdentifier) is \(progressPercentage)% complete...")
        }
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            print("Task failed with error: \(error)")
        } else {
            print("Task completed successfully.")
        }
    }
}

I also add this method in my AppDelegate:

    func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {

        DownloadManager.shared.backgroundSessionCompletionHandler = completionHandler

        // if the app gets terminated, I need to reconstruct the URLSessionConfiguration and the URLSession in order to "re-connect" to the previous URLSession instance and process the completed download tasks
        // for now, I'm just putting the app in the background (not terminating it) so I've commented out the lines below
        //let config = URLSessionConfiguration.background(withIdentifier: identifier)
        //let session = URLSession(configuration: config, delegate: DownloadManager.shared, delegateQueue: OperationQueue.main)

        // since my app hasn't been terminated, my existing URLSession should still be around and doesn't need to be re-created
        let session = DownloadManager.shared.session

        session.getTasksWithCompletionHandler { (dataTasks, uploadTasks, downloadTasks) -> Void in

            // downloadTasks = [URLSessionDownloadTask]
            print("There are \(downloadTasks.count) download tasks associated with this session.")
            for downloadTask in downloadTasks {
                print("downloadTask.taskIdentifier = \(downloadTask.taskIdentifier)")
            }
        }
    }

Finally, I start my test download like this:

    let session = DownloadManager.shared.session

    // this is a 100MB PDF file that I'm using for testing
    let testUrl = URL(string: "https://scholar.princeton.edu/sites/default/files/oversize_pdf_test_0.pdf")!            
    let task = session.downloadTask(with: testUrl)

    // I think I'll ultimately need to persist the session ID, task ID and a file path for use in the delegate methods once the download has completed

    task.resume()

When I run this code and start my download, I see the delegate methods being called but I also see a message that says:

A background URLSession with identifier com.example.testapp.background already exists!

I think this is happening because of the following call in application:handleEventsForBackgroundURLSession:completionHandler:

let session = DownloadManager.shared.session

The getter for the session property in my DownloadManager class (which I took directly from the blog post cited previously) is always trying to create a new URLSession using the background configuration. As I understand it, if my app had been terminated, then this would be the appropriate behavior to "reconnect" to the original URLSession. But since may app is not being terminated but rather just going to the background, when the call to application:handleEventsForBackgroundURLSession:completionHandler: happens, I should be referencing the existing instance of URLSession. At least I think that's what the problem is. Can anyone clarify this behavior for me? Thanks!

bmt22033
  • 6,880
  • 14
  • 69
  • 98
  • Does constructing the URLSession object alone is enough to reconnect to the previous instance or we have to resume any task on that session like discussed in this forum https://forums.developer.apple.com/thread/77666 – Gowtham Ravi Feb 12 '20 at 05:51

1 Answers1

3

Your problem is that you are creating a new session every time you reference the session variable:

var session : URLSession {
        get {
            let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")
            return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
        }
    }

Instead, keep the session as an instance variable, and just get it:

class DownloadManager:NSObject {

    static var shared = DownloadManager()
    var delegate = DownloadManagerSessionDelegate()
    var session:URLSession

    let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")

    override init() {
        session = URLSession(configuration: config, delegate: delegate, delegateQueue: OperationQueue())
        super.init()
    }
}

class DownloadManagerSessionDelegate: NSObject, URLSessionDelegate {
    // implement here
}

When I do this in a playground, it shows that repeated calls give the same session, and no error:

playground

The session doesn't live in-process, it's part of the OS. You're incrementing reference count every time you access your session variable as written, which causes the error.

David S.
  • 6,567
  • 1
  • 25
  • 45