SwiftUI Background Downloads

SwiftUI Background Downloads

Introduction

I needed to implement background downloads in a SwiftUI application. Checking in Internet, I have found only old documentation (a part from the official Apple docs). Because of this, I have developed a little POC application in SwiftUI that implements background downloads.
In this blog post, I will describe the rationale that has brought me to the final solution.

Downloads in memory

Downloads in memory are not suited for downloads, with the only exception when you already know that the files to download are small in size (in fact the Apple documentation Fetching Website Data into Memory starts describing For small interactions with remote servers....
What is nice is that with Swift 5.5 you can use aysnc/await with downloads in memory, as shown here:

@MainActor
class DownloadForegroundViewModel: NSObject, ObservableObject {
	let urlToDownloadFormat = "https://speed.hetzner.de/%1$@.bin"
	let availableDownloadSizes = ["100MB", "1GB", "10GB", "ERR"]
	var selectedDownloadSize: String = "100MB"
	var fileToDownload: String {
		get {
			String(format: urlToDownloadFormat, selectedDownloadSize)
		}
	}
	
	@Published private(set) var isBusy = false
	@Published private(set) var error: String? = nil
	@Published private(set) var percentage: Int? = nil
	@Published private(set) var fileName: String? = nil
	@Published private(set) var downloadedSize: UInt64? = nil

	// https://developer.apple.com/documentation/foundation/url_loading_system/fetching_website_data_into_memory
	func downloadInMemory() async {
		self.isBusy = true
		self.error = nil
		self.percentage = 0
		self.fileName = nil
		self.downloadedSize = nil

		defer {
			self.isBusy = false
		}
		
		do {
			let request = URLRequest(url: URL(string: fileToDownload)!)
			let (data, response) = try await URLSession.shared.compatibilityData(for: request)
			guard let httpResponse = response as? HTTPURLResponse else {
				self.error = "No HTTP Result"
				return
			}
			guard (200...299).contains(httpResponse.statusCode) else {
				self.error =  "Http Result: \(httpResponse.statusCode)"
				return
			}
			
			self.error = nil
			self.percentage = 100
			self.fileName = nil
			self.downloadedSize = UInt64(data.count)
		} catch {
			self.error = error.localizedDescription
		}
	}
}

In summary with downloads in memory:

  • PRO: you can develop in a very straightforward way leveraging async/await;
  • CONS: it's not suited for even medium sized files.

Downloads to files

Download to files is the next step to specific APIs that allow later more interesting platform features. These APIs is described in the Apple article Downloading Files from Websites.

Note this important information copied from the official documentation: If the download is successful, your completion handler receives a URL indicating the location of the downloaded file on the local filesystem. This storage is temporary. If you want to preserve the file, you must copy or move it from this location before returning from the completion handler..

In the simplest case it uses the shared URLSession instance, and still we can leverage async/await, as shown here:

	// https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_from_websites
	func downloadToFile() async {
		self.isBusy = true
		self.error = nil
		self.percentage = 0
		self.fileName = nil
		self.downloadedSize = nil
		
		defer {
			self.isBusy = false
		}
		
		do {
			let (localURL, response) = try await URLSession.shared.compatibilityDownload(from: URL(string: fileToDownload)!)
			guard let httpResponse = response as? HTTPURLResponse else {
				self.error = "No HTTP Result"
				return
			}
			guard (200...299).contains(httpResponse.statusCode) else {
				self.error = "Http Result: \(httpResponse.statusCode)"
				return
			}
			
			let attributes = try FileManager.default.attributesOfItem(atPath: localURL.path)
			let fileSize = attributes[.size] as? UInt64
		
			self.error = nil
			self.percentage = 100
			self.fileName = localURL.path
			self.downloadedSize = fileSize
		} catch {
			self.error = error.localizedDescription
		}
	}

In summary with downloads in memory:

  • PRO: you can develop in a very straightforward way leveraging async/await and it's suited to download medium sized files;
  • CONS: it's not suited for more advanced scenarios.

Downloads with progress status and pause/resume

The next level is to allow the user to have feedback about the download progress and eventually be able to pause and resume the download.
This is described in the same Apple article as before, Downloading Files from Websites.

When receiving updates, it is not possible anymore to use a completion handler or async/await.
Instead, you need to implement a class that comforms to URLSessionDownloadDelegate (in my case it is the same view model class).
This class will be passed as parameter during the initialization of a custom URLSession.

So the code becomes this:

	// https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_from_websites
	private lazy var urlSession = URLSession(configuration: .default,
											 delegate: self,
											 delegateQueue: nil)
	@Published private var downloadTask: URLSessionDownloadTask? = nil
	func downloadToFileWithProgress() async {
		self.isBusy = true
		self.error = nil
		self.percentage = 0
		self.fileName = nil
		self.downloadedSize = nil
		
		let downloadTask = urlSession.downloadTask(with: URL(string: fileToDownload)!)
		downloadTask.resume()
		self.downloadTask = downloadTask
	}
	
	// https://developer.apple.com/documentation/foundation/url_loading_system/pausing_and_resuming_downloads
	@Published private var resumeData: Data? = nil
	var canPauseDownload: Bool {
		get { return self.downloadTask != nil && self.resumeData == nil }
	}
	func pauseDownload() {
		guard let downloadTask = self.downloadTask else {
			return
		}
		downloadTask.cancel { resumeDataOrNil in
			guard let resumeData = resumeDataOrNil else {
				// download can't be resumed; remove from UI if necessary
				return
			}
			Task { @MainActor in self.resumeData = resumeData }
		}
	}
	
	// https://developer.apple.com/documentation/foundation/url_loading_system/pausing_and_resuming_downloads
	var canResumeDownload: Bool {
		get { return self.resumeData != nil}
	}
	func resumeDownload() {
		guard let resumeData = self.resumeData else {
			return
		}
		let downloadTask = urlSession.downloadTask(withResumeData: resumeData)
		downloadTask.resume()
		self.error = nil
		self.downloadTask = downloadTask
		self.resumeData = nil
	}

Note that in the code I have made downloadTask and resumeData @Published but private. This was necessary so that the calculated properties canPauseDownload and canResumeDownload publish their changes of values.

And this is the final implementation of the protocol:

extension DownloadForegroundViewModel: URLSessionDownloadDelegate
{
	// https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_from_websites
	func urlSession(_ session: URLSession,
					downloadTask: URLSessionDownloadTask,
					didWriteData bytesWritten: Int64,
					totalBytesWritten: Int64,
					totalBytesExpectedToWrite: Int64) {
		if downloadTask != self.downloadTask {
			return
		}
			
		let percentage = Int(totalBytesWritten * 100 / totalBytesExpectedToWrite)

		Task { @MainActor in self.percentage = percentage }
	}
	
	// https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_from_websites
	func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
		if downloadTask != self.downloadTask {
			return
		}
		
		defer {
			Task { @MainActor in self.isBusy = false }
		}
		
		guard let httpResponse = downloadTask.response as? HTTPURLResponse else {
			Task { @MainActor in self.error = "No HTTP Result" }
			return
		}
		guard (200...299).contains(httpResponse.statusCode) else {
			Task { @MainActor in self.error = "Http Result: \(httpResponse.statusCode)" }
			return
		}
		
		let fileName = location.path
		let attributes = try? FileManager.default.attributesOfItem(atPath: fileName)
		let fileSize = attributes?[.size] as? UInt64
		
		Task { @MainActor in
			self.error = nil
			self.percentage = 100
			self.fileName = fileName
			self.downloadedSize = fileSize
			self.downloadTask = nil
		}
	}
	
	// https://developer.apple.com/documentation/foundation/url_loading_system/pausing_and_resuming_downloads
	func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
		guard let error = error else {
			return
		}
		Task { @MainActor in self.error = error.localizedDescription }
		
		let userInfo = (error as NSError).userInfo
		if let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
			Task { @MainActor in self.resumeData = resumeData }
		} else {
			Task { @MainActor in
				self.isBusy = false
				self.downloadTask = nil
			}
		}
	}
}

Final comments about this solution:

  • PRO: now we start to have more platform features, like progress updates and the capability to pause and resume a download;
  • CONS: the code is a bit more complex.

Downloads in background

The solution seen so far works well, but only until the app is in foreground on our iPhone / iPad. If we switch to another app, or we lock the device, the download is suspended (and the code above handles this scenario correctly). Above all for big downloads, this can be not very friendly to the user.

Since iOS 7, there is the possibility to implement background downloads in our applications. This is described in the Apple article Downloading Files in the Background.
Luckily the changes to apply are not very big.

First of all, it is necessary to create the URLSession instance in a different way. But then, the download is very similar to the previous case:

	// https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background
	private lazy var urlSession: URLSession = {
		let config = URLSessionConfiguration.background(withIdentifier: "me.curia.MySessionBackground")
		config.isDiscretionary = true
		config.sessionSendsLaunchEvents = true
		return URLSession(configuration: config, delegate: self, delegateQueue: nil)
	}()
	@Published private var downloadTask: URLSessionDownloadTask? = nil
	func downloadInBackground() {
		self.isBusy = true
		self.error = nil
		self.percentage = 0
		self.fileName = nil
		self.downloadedSize = nil
		
		let downloadTask = urlSession.downloadTask(with: URL(string: fileToDownload)!)
		//downloadTask.earliestBeginDate = Date().addingTimeInterval(60 * 60)
		//downloadTask.countOfBytesClientExpectsToSend = 200
		//downloadTask.countOfBytesClientExpectsToReceive = 500 * 1024
		downloadTask.resume()
		self.downloadTask = downloadTask
	}

The biggest difference is handling app suspension. This must be done with an app delegate. In SwiftUI this can be done making our SwiftUI application to comform to UIApplicationDelegate:

// From: https://www.hackingwithswift.com/quick-start/swiftui/how-to-add-an-appdelegate-to-a-swiftui-app
class AppDelegate: NSObject, UIApplicationDelegate {
	private var backgroundCompletionHandler: (() -> Void)? = nil
	
	func application(_ application: UIApplication,
					 handleEventsForBackgroundURLSession identifier: String,
					 completionHandler: @escaping () -> Void) {
		backgroundCompletionHandler = completionHandler
	}
	
	func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
		Task { @MainActor in
			guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
				  let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else {
				return
			}
			
			backgroundCompletionHandler()
		}
	}
}

After this, there are no big changes to the previous case.

Concluding:

  • PRO: we leverage all the features offered by iOS to implement downloads with progress updates, pause and resume, and now downloads in background;
  • CONS: the code is again just a bit more complex.

Multiple downloads in parallel

It's possible to handle multiple downloads in parallel, as this feature is offered by iOS.
Implementing it is only a matter of reorganizing code and statuses, taking care now of this aspect.

Here is the data struct that handles a single download:

@MainActor
class DownloadModel: Identifiable, ObservableObject {
	let id = UUID().uuidString
	let fileToDownload: String
	@Published var isBusy: Bool = false
	@Published var error: String? = nil
	@Published var percentage: Int? = nil
	@Published var fileName: String? = nil
	@Published var downloadedSize: UInt64? = nil
	@Published var downloadTask: URLSessionDownloadTask? = nil
	@Published var resumeData: Data? = nil
	
	init(fileToDownload: String) {
		self.fileToDownload = fileToDownload
	}
}

Clearly now the main view model will be updated to reflect that there might be multiple downloads in parallel:

@MainActor
class MultipleDownloadsViewModel: NSObject, ObservableObject {
	@Published var downloads: [DownloadModel]

	// Init of properties in actor: see https://stackoverflow.com/questions/71396296/how-do-i-fix-expression-requiring-global-actor-mainactor-cannot-appear-in-def/71412877#71412877
	@MainActor override init() {
		downloads = [
			DownloadModel(fileToDownload: "https://speed.hetzner.de/100MB.bin"),
			DownloadModel(fileToDownload: "https://speed.hetzner.de/1GB.bin"),
			DownloadModel(fileToDownload: "https://speed.hetzner.de/10GB.bin")
		]
	}

    ...
}

Additional settings

One user has opened a GitHub issue because the app was not working. He had Low Data Mode enabled on his phone and by default downloads are not allowed.
Still there are some properties that it is possible to set, and I have updated the code adding them, currently commented (read the final comment at the issue if you are interested in the reasons on this).

The properties that it is possible to set are:

  • waitsForConnectivity: this property is by default set to false. In case of missed connectivity, it returns immediately an error. If set to true, in case of missed connectivity the method URLSession:taskIsWaitingForConnectivity: is called, where the app can offer for example the possibility to allow an offline-mode or a cellular-mode.
  • allowCellularAccess: this property is by default set to true. If set to false, and the use is connected to interview via cellular network, networks requests are forbidden.
  • allowsConstrainedNetworkAccess: this property is set by default to true. If the property is set to false and the user is on Low Data Mode, network requests are forbidden.
  • allowsExpensiveNetworkAccess: this property is set by default to true. The effect of setting it to false are not clearly documented by Apple. Still, the official documentation says to use preferably the previous property allowsConstrainedNetworkAccess, so I have not included this property in my sample (even if it would be very easy to do).

Conclusion

You can find a sample POC with all possible types of download in my GitHub example SwiftUIDownloader.

A part from the official documentation links here above, another well done blog post on this topic is: Downloading files in background with URLSessionDownloadTask