Docset Viewer: Resuming large downloads with NSURLConnection

As I’ve shown in my previous post announcing Docset Viewer, I want this series of posts to be more than me talking about my new app. In keeping with the instructional nature of my site, I’m going to show you a few things that I did in my new app Docset Viewer and how I put it together. This time around I’m going to show how I use NSURLConnection for downloading large files, and even resuming them.

In Docset Viewer I’ve added the ability to download docsets directly from Atom feeds, either from custom URLs or from a pre-configured list of Apple’s available docsets. Since you may not be consistently connected to the Internet, it’s important to be able to download documentation packages incrementally, especially since they can be anywhere from 300MB to 500MB.

Resuming large downloads with NSURLConnection

Apple’s NSURLConnection class is extremely easy to use, especially when you want to perform a simple network request.  Customizing a request involves a little bit more work, but it needn’t be complicated.  You begin by creating an NSMutableURLRequest object and checking to see if the request can be made.

NSMutableURLRequest *req;
req = [NSMutableURLRequest requestWithURL:docset.feedModel.downloadURL
                              cachePolicy:NSURLRequestUseProtocolCachePolicy
                          timeoutInterval:30.0];
if (![NSURLConnection canHandleRequest:req]) {
    // Handle the error
}

In order to resume a network download, you need to know where your download left off.  For that we need to check how many bytes we’ve downloaded so far, if any.

// Check to see if the download is in progress
NSUInteger downloadedBytes = 0;
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:docset.downloadPath]) {
    NSError *error = nil;
    NSDictionary *fileDictionary = [fm attributesOfItemAtPath:docset.downloadPath
                                                        error:&error];
    if (!error && fileDictionary)
        downloadedBytes = [fileDictionary fileSize];
} else {
    [fm createFileAtPath:docset.downloadPath contents:nil attributes:nil];
}

The code above checks if the download’s temporary file exists and checks its file length. If the file doesn’t exist, we create it so there’s a file for us to append to. From here now all we have to do is check to see if we need to resume a download, or start the download from scratch.

if (downloadedBytes > 0) {
    NSString *requestRange = [NSString stringWithFormat:@"bytes=%d-", downloadedBytes];
    [req setValue:requestRange forHTTPHeaderField:@"Range"];
}

The above code sets the HTTP-Range header, indicating which byte-range we want to download. By specifying a “XXX-” range, with a trailing dash, this tells the server that we want to download to the end of the file, starting from the indicated byte offset.

There are a few additional goals I had. In addition to downloading the content I wanted to make sure that very little memory was used, so I needed to save the file to disk as it’s downloaded. I also wanted to be able to cancel the download at any point. NSURLConnection has several different modes it can function in, so in this case I did the following:

NSURLConnection *conn = nil;
conn = [NSURLConnection connectionWithRequest:req delegate:self];
self.downloadingConnection = conn;
[conn start];

From this point forward the connection will begin downloading. Your class needs to implement the NSURLConnectionDataDelegate and NSURLConnectionDelegate protocols. The first protocol defines a set of methods that notifies you whenever a buffer of data is received from the connection. The second protocol defines methods that inform you when the network connection starts, when it fails, and when it completes. Lets go through each of these delegate methods one by one.

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    self.downloadingConnection = nil;
    // Show an alert for the error
}

That’s the easy one, but what we really care about is finding out from the server whether or not it was able to honour the HTTP-Range header so we know if we have to wipe our temporary file clean and start over again.

- (void)connection:(NSURLConnection *)connection
didReceiveResponse:(NSURLResponse *)response
{
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse*)response;
    if (![httpResponse isKindOfClass:[NSHTTPURLResponse class]]) {
        // I don't know what kind of request this is!
        return;
    }
  
    DocsetFeedModel *docset = self.downloadingDocset;
    NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:docset.downloadPath];
    self.fileHandle = fh;
    switch (httpResponse.statusCode) {
        case 206: {
            NSString *range = [httpResponse.allHeaderFields valueForKey:@"Content-Range"];
            NSError *error = nil;
            NSRegularExpression *regex = nil;

            // Check to see if the server returned a valid byte-range
            regex = [NSRegularExpression regularExpressionWithPattern:@"bytes (d+)-d+/d+"
                                                              options:NSRegularExpressionCaseInsensitive
                                                                error:&error];
            if (error) {
                [fh truncateFileAtOffset:0];
                break;
            }
  
            // If the regex didn't match the number of bytes, start the download from the beginning
            NSTextCheckingResult *match = [regex firstMatchInString:range
                                                            options:NSMatchingAnchored
                                                              range:NSMakeRange(0, range.length)];
            if (match.numberOfRanges < 2) {
                [fh truncateFileAtOffset:0];
                break;
            }
  
            // Extract the byte offset the server reported to us, and truncate our
            // file if it is starting us at "0".  Otherwise, seek our file to the
            // appropriate offset.
            NSString *byteStr = [range substringWithRange:[match rangeAtIndex:1]];
            NSInteger bytes = [byteStr integerValue];
            if (bytes <= 0) {
                [fh truncateFileAtOffset:0];
                break;
            } else {
                [fh seekToFileOffset:bytes];
            }
            break;
        }
  
        default:
            [fh truncateFileAtOffset:0];
            break;
    }
}

From what you can see above, we expect an HTTP 206 response code from the server when it tells us it’s able to fulfill the HTTP-Range request. But at this point I don’t want to blindly trust the server, so I inspect the HTTP headers and parse out the byte range it’s sending me to verify where the byte range begins. If all goes well we can seek our file handle to the appropriate byte offset, and we can resume the file from where we left off.

There are undoubtedly some bugs in here where, for example, the server returns the proper range response but perhaps the server malformed the header, meaning we’re storing corrupted content. But in that event simply deleting the file and starting over is probably an acceptable option.

Our next step is storing the data as it’s downloaded.

- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    [self.downloadingFileHandle writeData:data];
    [self.downloadingFileHandle synchronizeFile];
}

And finally closing the file when the download is complete.

- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    DocsetFeedModel *docset = self.downloadingDocset;
  
    [self.downloadingFileHandle closeFile];
    self.downloadingFileHandle = nil;
    self.downloadingConnection = nil;
    self.downloadingDocset = nil;
  
    NSFileManager *fm = [NSFileManager defaultManager];
    NSError *error = nil;
    if (![fm removeItemAtPath:docset.rootPath error:&error]) {
        // Show an error to the user
    }
    if (![fm moveItemAtPath:docset.downloadPath toPath:docset.rootPath error:&error]) {
        // Show an error to the user
    }
}

From this point the rest is yours. You can track your download progress like I do by sending NSNotificationCenter messages when convenient chunks of data are downloaded (I don’t post those messages on every connection:didReceiveData: message so I don’t overload the main thread with notifications), you can pause and resume downloads, and can do even more.

Once you begin working with NSURLConnection you can efficiently transfer as much data as you want, and can even play with multiple-range requests if you want to get really fancy.

Good luck, and if you want to check this out in action (and support my app at the same time), try out Docset Viewer on the iTunes App Store.

One thought on “Docset Viewer: Resuming large downloads with NSURLConnection

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s