MK Downloader progress log - Part 2
Most of this week was spent studying the innards of URLSession downloadTask
API and some of its pitfalls.
To recap, I had planned to migrate my download code from using the new async/await bytes
API to downloadTask
API. While the former is quite a flexible bare-bones API, it does have the following downsides:
- It requires a tight
for await AsyncSequence
loop. Refer to this post by Soroush Khanlou for details. - No background downloading support.
This week, I present to you the one deal breaker downside of using the downloadTask
API: The ability to change the download URL mid-way out-of-the-box: Lack of this ability in any conventional file downloading software/browser is why I prefer command-line tools like curl
and wget
, and why I set out to build this project in the first place. Incidentally, Apple’s URLSession
API uses libcurl
behind the scenes as well.
But worry not, for changing the URL of a download is indeed possible, but it involves a bit of extra work
- Decoding
resumeData
whenever theURLSessionDownloadTask
is cancelled. - Forgoing built-in support for download resume, i.e. managing the
URLRequest
.
Into the resumeData
Whenever a URLSessionDownloadTask
is launched by calling resume()
, it creates a file in the temporary directory of the app and begins downloading content as per URLRequest
to this temporary file. Once download completes fully, a URL of this file is returned to the caller. If there’s an error, or the download is cancelled (such as when a user pauses a download or deletes it all together), no URL to this temporary file is returned and we resumeData
object of type Data
, which is actually a binary plist that contains the name of this temporary file. Here’s an example (binary plist converted to XML):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>$archiver</key>
<string>NSKeyedArchiver</string>
<key>$objects</key>
<array>
<string>$null</string>
<dict>
<key>$class</key>
<dict>
<key>CF$UID</key>
<integer>19</integer>
</dict>
<key>NS.keys</key>
<array>
<dict>
<key>CF$UID</key>
<integer>2</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>3</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>4</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>5</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>6</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>7</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>8</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>9</integer>
</dict>
</array>
<key>NS.objects</key>
<array>
<dict>
<key>CF$UID</key>
<integer>10</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>12</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>13</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>14</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>15</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>16</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>17</integer>
</dict>
<dict>
<key>CF$UID</key>
<integer>18</integer>
</dict>
</array>
</dict>
<string>NSURLSessionResumeCurrentRequest</string>
<string>NSURLSessionResumeOriginalRequest</string>
<string>NSURLSessionDownloadURL</string>
<string>NSURLSessionResumeInfoTempFileName</string>
<string>NSURLSessionResumeBytesReceived</string>
<string>NSURLSessionResumeEntityTag</string>
<string>NSURLSessionResumeInfoVersion</string>
<string>NSURLSessionResumeServerDownloadDate</string>
<dict>
<key>$class</key>
<dict>
<key>CF$UID</key>
<integer>11</integer>
</dict>
<key>NS.data</key>
<data>
YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0
b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICV8Q
G05TS2V5ZWRBcmNoaXZlUm9vdE9iamVjdEtleYABrxAZCww+YWdo
.....trimmed
</data>
</dict>
<dict>
<key>$classes</key>
<array>
<string>NSMutableData</string>
<string>NSData</string>
<string>NSObject</string>
</array>
<key>$classname</key>
<string>NSMutableData</string>
</dict>
<dict>
<key>$class</key>
<dict>
<key>CF$UID</key>
<integer>11</integer>
</dict>
<key>NS.data</key>
<data>
YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0
b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICV8Q
G05TS2V5ZWRBcmNoaXZlUm9vdE9iamVjdEtleYABrQsMVFVbXGIz
.....trimmed
</data>
</dict>
<string>https://esamultimedia.esa.int/docs/EarthObservation/Induction_Study_150110.pdf</string>
<string>CFNetworkDownload_TySS5B.tmp</string>
<integer>6230930</integer>
<string>"4bc89d01-164a3cc"</string>
<integer>5</integer>
<string>Fri, 16 Apr 2010 17:23:13 GMT</string>
<dict>
<key>$classes</key>
<array>
<string>NSMutableDictionary</string>
<string>NSDictionary</string>
<string>NSObject</string>
</array>
<key>$classname</key>
<string>NSMutableDictionary</string>
</dict>
</array>
<key>$top</key>
<dict>
<key>NSKeyedArchiveRootObjectKey</key>
<dict>
<key>CF$UID</key>
<integer>1</integer>
</dict>
</dict>
<key>$version</key>
<integer>100000</integer>
</dict>
</plist>
The above-mentioned sample XML points to a temporary file CFNetworkDownload_TySS5B.tmp
(mentioned on line no. 134 in above XML snippet), which resides in the temporary directory. The strategy is to read the contents of this file and persist them to the main download at various stages of a typical download lifecycle.
How to resume a download using downloadTask
API?
Apple recommends storing the resumeData
object and using it to resume downloads. But as pointed out earlier, this is a limited approach, because we might need to change download parameters sometimes, which is currently not supported by the downloadTask
API.
Since we can get a URL to the temporary file as shown above, we can read its contents and persist them to our destination file (either when resuming the downloadTask, or when cancelling it). Once destination file is updated with the bytes downloaded so far, we can read the size of the destination file using the following approach:
1
2
3
4
5
6
7
8
func getSizeofFile(atPath url: URL) -> Int64 {
if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: url.path) {
if let bytes = fileAttributes[.size] as? Int64 {
return bytes
}
}
return -1
}
AND cookup our own URLRequest
using the following code:
1
2
3
4
5
var request = URLRequest(url: url)
let fileSizeOnDisk = getSizeofFile(atPath: destination)
if fileSizeOnDisk > 0 {
request.setValue("bytes=\(fileSizeOnDisk)-", forHTTPHeaderField: "Range")
}
We can then create a URLSessionDownloadTask
using the request above:
1
URLSession.shared.downloadTask(with: request)
Way forward
If Apple provided a way to directly download to a destination URL using the downloadTask
API, it would’ve saved the users a lot of state management code and recording temporary files per download. The method of extracting temporary file from resumeData
is also tedious, and is not guaranteed to work across different iOS versions, since Apple might just change the format in the future. For now, this is the most feasible solution.
WARNING: I’ve omitted a lot of delegate and error handling code like checking the HTTP status code of the
URLResponse
. Make sure to handle error cases properly.