Post

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 the URLSessionDownloadTask 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.

This post is licensed under CC BY 4.0 by the author.