Leave a rating/review
Notes: 17. Pause, Resume & Cancel Downloads
URLSessionConfiguration - Apple Developer URLSession - Apple Developer
When users start to download a file, they may later change their mind about the download. The file may be too large or their connection is too slow. Or maybe, they need to pause the download so they can download another file first.
Thankfully, this is all really easy to do. The thing is that this will use URLSession tasks and not the asynchronous transfer methods. No worries, I’ll show you how to do that in a bit.
To cancel a download, you need to call the cancel method on the task. That’s it. The download stops.
Pausing and resuming is a little bit different. To pause, you call cancel-by-producing-resume-data. This cancels the task, but you’ll receive a data object. This data object contains the resume data for your file. The file itself still exists in a temporary location on-disk.
You can do whatever you want with the resume data. For instance, you may want to save it to disk until the next time the user wants to restart the download.
Later, when you want to resume the download, you need to create a new task passing in the data. You call the URLSession method, downloadTask withResumeData. You pass in that data and the download will continue.
Just keep in mind that there are a few stipulations when it comes to resuming downloads. First, you won’t be able to resume downloading if the resource has changed. Meaning, if the original file receives an update or has since been removed.
Next, the download must have originated from an HTTP or HTTPS GET request. The server must also support either an Etag or Last Modified header in its response and must support byte range requests.
Finally, the temporary downloaded file must not have been deleted from the disk. As you can see, you need to do a bit of server configuration to get it to work. When it does work, however, your users will greatly appreciate it. Let’s see all of this in action.
Open this episodes Starter project and start by creating a new model file called MutableSongDownloader.swift. Replace its import with one for SwiftUI:
import SwiftUI
Next, add the following code for the class itself:
class MutableSongDownloader: NSObject, ObservableObject {
@Published var downloadLocation: URL?
}
This class is going to be similar in functionality to SongDownloader, but you’re putting this version in a different class in order for you to have a separation of how a URLSession task works versus the URLSession asynchronous transfer methods.
In your apps and projects, you can keep things separate or put them all in a single networking class or layer.
This code declares a Published property that’ll keep track of the downloaded song’s location on disk. Add this code next:
private lazy var session: URLSession = {
let configuration = URLSessionConfiguration.default
return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}()
Whereas SongDownloader creates a default URLSessionConfiguration and a URLSession object with that configuration, this class creates a configuration that indicates MutableSongDownloader is the session delegate.
You don’t do this in an init method in order to avoid having to force unwrap properties due to having to intialize them prior to the usage of self in `init.
An error will come up because MutableSongDownloader doesn’t conform to URLSessionDelegate. So add the following code to address that:
extension MutableSongDownloader: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
}
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
}
}
The only required method is didFinishDownloadingTo, but the other methods will help you get updates on the download as well as when an error occurs. Moving on, add two properties to your class:
private var downloadURL: URL?
private var downloadTask: URLSessionDownloadTask?
You’ll use one to keep track of the download URL, in order to resume the download, and another one for the task that’ll be used for background downloads.
With your class set up for now, it’s time to start working on the download logic. Add the following method:
func downloadSong(at url: URL) {
downloadURL = url
downloadTask = session.downloadTask(with: url)
downloadTask?.resume()
}
Similar to the methods you’ve written before, this method takes a URL for the location of the song to download.
You store the URL from the parameter in your downloadURL property and create a new URLSessionDownloadTask from your URLSession with the URL.
Finally, and as you covered early in the course, you call resume on your task in order to actually begin the download.
That’s pretty much it for this method as far as downloading is concerned. Because you’re implementing URLSessionDelegate, you’ll do more work there in a bit. To keep track of the download progress, add the following property:
@Published var downloadProgress: Float = 0
And then use it in the didWriteData URLSesisonDelegate method:
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
Task {
await MainActor.run {
downloadProgress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
}
}
}
This method is similar in concept to the one from URLSession that returned an async sequence of bytes, except you get the download progress from the delegate method.
You check the total versus downloaded bytes and update your downloadProgress property. Note how you perform all of this in the Main Actor, so this code runs on the main thread. Let’s to implement the delegate method for when a download error occurs:
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
Task {
await MainActor.run {
if let httpResponse = task.response as? HTTPURLResponse,
httpResponse.statusCode != 200 {
print("Request failed.")
}
}
}
}
Here, all within the main actor, you check the response code and print an error if it’s not 200. Once again you’re not focusing on handling all errors for now as you’ll take care of that in a bit.
Next, add the following code to implement the only required delegate method:
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
let fileManager = FileManager.default
guard let documentsPath = fileManager.urls(for: .documentDirectory,
in: .userDomainMask).first,
let lastPathComponent = downloadURL?.lastPathComponent
else {
print("Document directory error.")
return
}
let destinationURL = documentsPath.appendingPathComponent(lastPathComponent)
do {
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
try fileManager.copyItem(at: location, to: destinationURL)
Task {
await MainActor.run {
downloadLocation = destinationURL
}
}
} catch {
Task {
await MainActor.run {
print("Error copying song.")
}
}
}
}
Phew, that was a fair bit of code, but this is pretty much the same code from SongDownloader to handle what happens when your song is successfully downloaded.
Once again, you print an error for now in order to focus on the important URLSession concepts. Build your code to ensure everything is working well for now.
Time to Support Cancel, Pause, And Resume
Because MutableSongDownloader can pause, resume, or cancel, you want a way to keep track of the state of your download. To do so, add the following enum inside of your class:
enum State {
case paused
case downloading
case failed
case finished
case waiting
}
You declare states for paused, downloading, failed, finished and waiting. Add a property for your state:
var state: State = .waiting
It’s initial value will be waiting as MutableSongDownloader is waiting for any downloads to be performed. Let’s keep track of your download state. Add this line at the bottom of the downloadSong method:
state = .downloading
This code sets the state to downloading when the download task starts.
At this point you’ve set up your class to download songs but using URLSessionTask. The focus of this episode, however, is canceling, pausing, and resuming.
Let’s start with canceling. Add this method:
func cancel() {
state = .waiting
downloadTask?.cancel()
Task {
await MainActor.run {
downloadProgress = 0
}
}
}
Not a lot of code, so let’s walk through it. You first set the state to waiting since the download is now canceled and never completed. You then call cancel on downloadTask to actually cancel the download.
Finally, and ensuring it’s done on the main actor, you set your download progress to 0 so any UI can be updated accordinly.
For pausing a download, remember that you want to get a resume data object that you can use should you want to support resuming the download. So add a property for your resume data:
private var resumeData: Data?
Then write the pause method:
func pause() {
downloadTask?.cancel(byProducingResumeData: { data in
Task {
await MainActor.run {
self.resumeData = data
self.state = .paused
}
}
})
}
You call cancel(byProducingResumeData:) on your task. It requires you implement a closure for when the data is ready. You store the returned resume data in your resumeData property and set the state of the download to paused.
All of this is done on the main thread since the state is a property that might be tracked from the UI.
The last method you need to write is the one for resuming. Add the following code:
func resume() {
guard let resumeData = resumeData else {
return
}
downloadTask = session.downloadTask(withResumeData: resumeData)
downloadTask?.resume()
state = .downloading
}
The first thing it does is check whether you actually have any resumeData before continuing. If you do, then you call downloadTask(withResumeData:) on your URLSession and store the task in your downloadTask property.
The task is started by calling resume and the state is set to downloading. Now to handle things the download state when the download finishes. Replace the call to print in the first guard statement with the following:
Task {
await MainActor.run {
state = .failed
}
}
This sets the state to failed if you are unable to acquire the path to the app’s Documents directory. On the line where you set downloadLocation, add this afterwards:
state = .finished
This will indicate the download finished successfully. Finally, replace the last print statement with the following:
state = .failed
Should something go wrong when saving the song to disk then you update the state to failed.
Note how all updates to the state property were done on the main actor. This is to ensure there are no bugs or problems from the UI side since it’s likely one to be tracked to match the UI to the state.
The final thing to do is replace the print statement in the delegate method for when a download error occurs:
state = .failed
Excellent! You’ve wrapped up all work on MutableSongDownloader. There is more code, and it’s less cohesive given the delegate that is used, but it’s still clean, easy-to-read code that allows for pausing, resuming, and canceling your song downloads.
With the work on the downloader wrapped up, switch over to SongDetailView.swift. First, create a new property:
@ObservedObject private var mutableDownloader: MutableSongDownloader = MutableSongDownloader()
Next, write a new method that uses the mutable downloader:
private func mutableDownloadTapped() {
switch mutableDownloader.state {
case .downloading:
mutableDownloader.pause()
case .failed, .waiting:
guard let previewURL = musicItem.previewURL else {
return
}
mutableDownloader.downloadSong(at: previewURL)
case .finished:
playMusic = true
case .paused:
mutableDownloader.resume()
}
}
This method leverages the downloader’s state property to know what to do when the button is tapped. If the song is downloading then tapping the button pauses the download. If the download failed, or the downloader is waiting, you ensure you have a valid URL and proceed to download the song.
If the download finished then you show the player sheet. And if the download is paused you resume the download.
This is the cool thing about using an enum for the state, so you don’t end up with a lot of if statements.
Update the sheet modifier to use the mutable downloader’s download URL:
AudioPlayer(songUrl: mutableDownloader.downloadLocation!)
Now update the if statement that determines whether to show the progress view or not:
if mutableDownloader.state == .paused || mutableDownloader.state == .downloading {
ProgressView(value: mutableDownloader.downloadProgress)
}
This checks the state of the downloader to determine the progress view’s visibility. * Finally, and in order to give everything a test, update the code for your button:
Button<Text>(action: mutableDownloadTapped) {
switch mutableDownloader.state {
case .downloading:
return Text("Pause")
case .failed:
return Text("Retry")
case .finished:
return Text("Listen")
case .paused:
return Text("Resume")
case .waiting:
return Text("Download")
}
}
The title of the button is now determined by the state of the mutable song downloader. The action is always to call mutableDownloadTapped as it’ll know what to do based on the mutable song downloader’s state. Build and run the app to test everything out.
If the download is too fast remember to use the Network Link Conditioner so your downloads speeds are slower.
Everything works as expected. Well done!
That was a fair bit of work you had to do, but kudos for making it all the way to the end.
You now have two versions of your song downloader, one that supports pausing, canceling, and resuming the download and uses URLSessionDownloadTask, and one that doesn’t support these features but leverages the asynchronous transfer methods of URLSession.
And with that, time for our course conclusion. I’ll see ya there! :)