Adventures with HTML5 and offline content on an iPad

I’ve been thrown on a project at work where we need to make an app work completely offline.  The catch, the app was written in sencha touch (HTML5) and talkes to a web service for a whole range of content.  Some of those are interactive HTML elements and while others are videos.  Due to a deadline we’re not able to re-architect the project like myself and many on my team would like.

Our first idea was to simply use HTML5 cache to handling the offline bits.  However the data coming from the web services that would need to be cached is on the order of a couple of gigabytes of data.  We wrote up some test code that overrides the handling of Cache (NSURLCache).  This kind of worked but required us downloading content twice and not all AJAX calls were being intercepted by the cache.  We settled on implementing the NSURLProtocol to capture the responses when online and saving them to a special cache folder.

This works by intercepting the users requests when online and saving the response to the file system with the exact path of the request.  Not the best strategy but considering the time constraint the best one available for this application.  This works well for items you’ve already viewed but doesn’t handle the case for locations the user hasn’t browsed yet.  We ended up creating a download gesture that downloads all content needed for offline.  Unfortunately the web services were never designed with this in mind so it takes approximately 1 hour to download all the content.  Obviously it isn’t designed for a user to do, just an admin.

Two major issues with this solution are loading of CSS and HTML5 video.  For some strange reason if you turn the wifi off on the ipad, it no longer returns the correct MIME type for CSS files. The solution was to add an additional check and return the correct mime.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (NSString *)mimeTypeForPath:(NSString *)originalPath
{
    if (![[NSFileManager defaultManager] fileExistsAtPath:originalPath]) {
        return @"";
    }
    // Borrowed from http://stackoverflow.com/questions/5996797/determine-mime-type-of-nsdata-loaded-from-a-file
    // itself, derived from http://stackoverflow.com/questions/2439020/wheres-the-iphone-mime-type-database
    CFStringRef UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (CFStringRef)[originalPath pathExtension], NULL);
    CFStringRef mimeType = UTTypeCopyPreferredTagWithClass(UTI, kUTTagClassMIMEType);
    CFRelease(UTI);
    if (!mimeType) {
        if ([[originalPath pathExtension] isEqualToString:@"css"]) {
            return @"text/css";
        } else {
            return @"application/octet-stream";
        }
    }
    return [NSMakeCollectable((NSString *)mimeType) autorelease];
}

The harder issue proved to be the HTML5 video when offline. For it to play on the device you have to set the appropriate content range and accept headers otherwise it just won’t work. Here is a quick and dirty example that I used to get it working.

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
    ...
    NSInteger status = 200;
    NSUInteger contentLength = [content length];
    NSDictionary* headers = [[self request] allHTTPHeaderFields];
    NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionary];
    if ([[headers allKeys] containsObject:@"Range"]) {
        NSArray *parts = [[[headers objectForKey:@"Range"] substringFromIndex:6] componentsSeparatedByString:@"-"];
        NSRange range;
        range.location = [[parts objectAtIndex:0] integerValue];
        range.length = [[parts objectAtIndex:1] integerValue] - [[parts objectAtIndex:0] integerValue] + 1;
        [responseHeaders setObject:[NSString stringWithFormat:@"%d", range.length] forKey:@"Content-Length"];
        [responseHeaders setObject:[NSString stringWithFormat:@"bytes %@-%@/%i",
                                       [parts objectAtIndex:0],
                                       [parts objectAtIndex:1],
                                       contentLength]
                            forKey:@"Content-Range"];
        content = [content subdataWithRange:range];
        status = 206;
    } else {
        [responseHeaders setObject:[NSString stringWithFormat:@"%d", [content length]] forKey:@"Content-Length"];
    }
    [responseHeaders setObject:mime forKey:@"Content-Type"];
    [responseHeaders setObject:@"bytes" forKey:@"Accept-Ranges"];
    [responseHeaders setObject:@"Keep-Alive" forKey:@"Connection"];
    response = [[[NSHTTPURLResponse alloc] initWithURL:[[self request] URL]
                                            statusCode:status
                                           HTTPVersion:@"1.1"
                                          headerFields:responseHeaders] autorelease];
 
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    [[self client] URLProtocol:self didLoadData:content];
    [[self client] URLProtocolDidFinishLoading:self];
	...

So if you’re in a similar boat and needing to get an HTML5 movie to play in a UIWebview this might help.

Update!

It does appear that this only works in the simulator. As of right now the iPad or iPhone does not work with the above code. The NSURLProtocol never event gets called when offline for a video.


Comments are closed.