A Downloader Controller

From AwkwardTV

Jump to: navigation, search

<Google>WIKI</Google>

Back in the first article in this series we created a BRControl wrapper around the ProgressBar widget. I said then that we would put this to use shortly, and so we will. In today's tutorial we will look at a more substantive example of plugin development, including localizations and resources, some preferences, and asynchronous download from the internet.

Our end result will be a controller which downloads from a URL stored in our preferences, displaying the progress of the download as it does so. It will also show a method for supporting resumption of an interrupted download.



Contents

Plugin Developers' Toolkit

Since we're going to look at a real-world example today, it seems right that we introduce some important items for your toolkit: resources, localizations, and the preferences system.

Resource Access

This is the simplest of the three; the interface to access your own resources and those of the BackRow framework is simply the NSBundle class. For you own resources, you fetch the bundle containing your own class, like so:

[[NSBundle bundleForClass: [self class]] pathForResource: name ofType: type];

For an object within the BackRow framework itself, such as a standard image, the intro movie or the strings file containing error descriptions, use the backRowFramework( ) function, like so:

[backRowFramework( ) pathForResource: name ofType: type];

Having retrieved a path to the item in question, you can then access the resource directly, or create a URL using [NSURL fileURLWithPath:] as appropriate.

Localization

Localization is handled mostly by the BRLocalizedStringManager class. It offers four different functions for locating localized strings:

+ (NSString *) backRowLocalizedStringForKey: (NSString *) key inFile: (NSString *) stringsFile;
+ (NSString *) applicationLocalizedStringForKey: (NSString *) key inFile: (NSString *) stringsFile;
+ (NSString *) appliance: (id) anyObjInAppliance localizedStringForKey: (NSString *) key inFile: (NSString *) stringsFile;
+ (NSString *) localizedStringForKey: (NSString *) key inFile: (NSString *) stringsFile fromBundle: (NSBundle *) bundle;

The base routine used to fetch the string is +localizedStringForKey:inFile:fromBundle:, and the other three simply pull together the parameters for that call. The +applicationLocalizedStringForKey:inFile: call is used to locate a string within the bundle of the actual current application (i.e. the Finder). The other two are used to fetch data from the BackRow framework or from the bundle of the calling appliance. For strings within your appliance's default strings file 'Localizable.strings', you would use:

NSString * localized = [BRLocalizedStringManager appliance: self localizedStringForKey: @"SomeString" inFile: nil]

If you have other strings files, you can specify the name of the one you want (minus the '.strings' extension) in the last parameter of that call.

So, we have an easy way of reading our own localized strings, but that's only good for reading. When we use the NSLocalizedString() macro normally, we are able to use the genstrings command-line utility to generate the strings files themselves. Fortunately, genstrings can be told to look for a different form of macro from the default 'NSLocalizedString', meaning we can define our own BackRow-based versions. To do this, create a file in your project called something like 'BRLocalizations.h', and put this inside it:

#import <BackRow/BRLocalizedStringManager.h>

#define BRLocalizedString(key, comment) \
    [BRLocalizedStringManager appliance:self localizedStringForKey:(key) inFile:nil]
#define BRLocalizedStringFromTable(key, table, comment) \
    [BRLocalizedStringManager appliance:self localizedStringForKey:(key) inFile:(table)]

You can then run the following command from your project directory to generate your strings files:

genstrings -s BRLocalizedString -o English.lproj/

Preferences

You can, as always, use NSUserDefaults to manage your own preferences. However, BackRow provides an interface for both the FrontRow preferences and those for specific named domains. To retrieve a value from the FrontRow preferences you can use the RUIPreferences object:

int displayID = [[RUIPreferences sharedFrontRowPreferences] integerForKey: @"FrontRowUsePreferredDisplayID"];

To use a specific preference domain, you can use the 'RUIPreferenceManager' object:

int displayID = [[RUIPreferenceManager sharedPreferences] integerForKey: @"FrontRowUsePreferredDisplayID" domain: @"com.apple.frontrow"];

Both classes provide synchronization facilities. RUIPreferences provides -setSynchronizeOnWrite: (BOOL) sync and -syncNow while RUIPreferenceManager provides the -syncDomain: (NSString *) domain function.

Directory Locations

This section isn't much to do with BackRow, admittedly, but it is useful in the context of the tutorial in general, since we'll be using this method to locate the folder used to hold our downloads.

The file at <Foundation/NSPathUtilities.h> contains the values you'll need for this, and a handy Objective-C wrapper for the NSSystemDirectories API (in <NSSystemDirectories.h>). You can use this to get lists of directories matching certain criteria. Within that header file you'll find two enumerations; the top one identifies a specific folder (Application Support, Documents, Library, etc.), and the lower one specifies masks for the different domains in which they can exist (System, Local, User, Network, etc.). So, to get a list of all paths for the Application Support folders in the Local and User domains, you would use:

NSArray * searchPath = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask | NSLocalDomainMask, YES);

This call would return the following list of paths:

/Users/[username]/Library/Application Support
/Library/Application Support

In the main example, we'll use this to place our downloaded data into the user's Caches folder.

Downloading to a File

To download data to a file (as opposed to simply retrieving an NSData object) we'll use the NSURLDownload class. This supports resumption of data and also in-transit decoding of certain MIME types: MacBinary, BinHex, and GZip. Note that partially-downloaded files decoded from Gzip format cannot be resumed, so if resumption is more important you might choose to disable decoding of that MIME type by implementing -download:shouldDecodeSourceDataOfMIMEType: and returning NO for the Gzip MIME type.

To begin a new download, you need to create an NSURLRequest and hand that to the NSURLDownload constructor. Since we would like to resume any partial downloads, we'll tell the downloader not to delete the file when it fails (or is cancelled).

NSURLRequest * request = [NSURLRequest requestWithURL: url cachePolicy: NSURLRequestUseProtocolCachePolicy timeoutInterval: 20.0];
NSURLDownload * download = [[NSURLDownload alloc] initWithRequest: request delegate: self];
[download setDeletesFileUponFailure: NO];

To resume an existing download, we can pass the resume data (from a prior call to NSURLDownload's -resumeData method) into a new NSURLDownload instance. If the initializer returns nil in this case, resumption wasn't possible and the download must be restarted from the beginning.

[[NSURLDownload alloc] initWIthResumeData: data delegate: self path: downloadedFilePath];

The Download Controller

Unlike the last couple of examples, there is a fair amount of code involved in this class, so I won't include all of it here. However, you can download the sample project provided at the end to see the whole thing. Here I will include the main parts needed for starting the download and maintaining progress, but I'll leave out some of the NSURLDownload delegate methods and such.

Member Variables

Our class will need a few member variables to maintain state nicely. Firstly we'll have some UI controls for the title, the URL, and the download progress:

BRHeaderControl * _header;
BRTextControl * _sourceText;
QuProgressBarControl * _progressBar;

We will also need some items to manage the download itself:

NSURLDownload * _downloader;
NSString * _outputPath;
long long _totalLength;
long long _gotLength;

Implementation

Output Path

Firstly, let's define a useful method for determining the name of our downloaded file. Here we'll use NSSearchPathForDirectoriesInDomains() to get the path for the current user's Caches folder, then append some items to it. We'll use a .download folder similar to that used by Safari, so that we can store resume data within there if the user presses the menu button before we've finished downloading. This function will return the path to the actual file within the .download folder, however.

+ (NSString *) outputPathForURLString: (NSString *) urlstr
{
    NSString * cachePath = nil;
    
    NSArray * list = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES );
    if ( (list != nil) && ([list count] != 0) )
        cachePath = [list objectAtIndex: 0];
    else
        cachePath = NSTemporaryDirectory( );

    cachePath = [cachePath stringByAppendingPathComponent: @"QuDownloads"];
    // ensure this exists
    [[NSFileManager defaultManager] createDirectoryAtPath: cachePath attributes: nil];

    NSString * name = [urlstr lastPathComponent];

    // trim any parameters from the URL
    NSRange range = [name rangeOfString: @"?"];
    if ( range.location != NSNotFound )
        name = [name substringToIndex: range.location];

    NSString * folder = [[name stringByDeletingPathExtension] stringByAppendingPathExtension: @"download"];

    return [NSString pathWithComponents: [NSArray arrayWIthObjects: cachePath, folder, name, nil]];
}

Stack Callbacks

Now we'll need some stack callbacks to make us do things at opportune times. When we're pushed we will begin the download, and when popped we will cancel (saving resume data if applicable).

- (void) wasPushed
{
    if ( ! [self beginDownload] )
    {
        [_header setTitle: @"Download Failed"];
        [_progressBar setPercentage: 0.0f];
        [[self scene] renderScene];
    }

    [super wasPushed];
}

- (void) willBePopped
{
    [self cancelDownload];
    [super willBePopped];
}

Download Handling

We'll also need some methods for handling the download itself. Firstly, we'll have the plain start-downloading function:

- (BOOL) beginDownload
{
    // see if we can resume a partial download
    if ( [self resumeDownload] )
        return YES;

    // didn't work, so delete & try again
    [self deleteDownload];

    // fetch from prefs, or provide a default value
    NSString * urlstr = [[RUIPreferenceManager sharedPreferences] stringForKey: @"QuDownloadURL"
        forDomain: @"org.quatermain.downloader"];

    // my mate's band, woo!
    if ( urlstr == nil )
        urlstr = @"http://alanquatermain.net/bits/BiggerThanYouEP.mp3";

    NSURL * url = [NSURL URLWithString: urlstr];
    if ( url == nil )
        return NO;

    NSURLRequest * req = [NSURLRequest requestWithURL: url
        cachePolicy: NSURLRequestUseProtocolCachePolicy timeoutInterval: 20.0];

    // create the downloader
    _downloader = [[NSURLDownload alloc] initWithRequest: req delegate: self];
    if ( _downloader == nil )
        return NO;

    // we'll make sure anything downloaded stays around if we cancel or it fails haflway through
    [_downloader setDeletesFileUponFailure: NO];

    return YES;
}

Resumption of a download looks similar to the sequence above:

- (BOOL) resumeDownload
{
    NSString * resumeDataPath = [[_outputPath stringByDeletingLastPathComponent]
        stringByAppendingPathComponent: @"ResumeData"];

    if ( ! [[NSFIleManager defaultManager] fileExistsAtPath: resumeDataPath] )
        return NO;

    NSData * resumeData = [NSData dataWithContentsOfFile: resumeDataPath];
    if ( (resumeData == nil) || ([resumeData length] == 0) )
        return NO;

    // try to initialize using the saved data
    _downloader = [[NSURLDownload alloc] initWithResumeData: resumeData
        delegate: self path: _outputPath];
    if ( _downloader == nil )
        return NO;

    [_downloader setDeletesFileUponFailure: NO];

    return YES;
}

When we cancel the download, or if it fails partway through, we want to store the resume data on disk:

- (void) storeResumeData
{
    NSData * data = [_downloader resumeData];
    if ( data != nil )
    {
        // store this in the .download folder
        NSString * path = [[_outputPath stringByDeletingLastPathComponent]
            stringByAppendingPathComponent: @"ResumeData"];
        [data writeToFile: path atomically: YES];
    }
}

Our method to cancel the download is a simple wrapper routine:

- (void) cancelDownload
{
    [_downloader cancel];
    [self storeResumeData];
}

We also could use a method to delete the .download folder and its contents:

- (void) deleteDownload
{
    [[NSFileManager defaultManager] removeFileAtPath: [_outputPath stringByDeletingLastPathComponent]
        handler: nil];
}

NSURLDownload Delegate

Since we're using the NSURLDownload class, we will need to implement some delegate methods. I'll leave error-handling out of this article for now (an example can be found in the sample project at the end of this article), but the remainder are important enough to cover here.

Firstly, we want to be able to set the destination for the download:

- (void) download: (NSURLDownload *) download decideDestinationWithSuggestedFilename: (NSString *) filename
{
    // we'll ignore the filename and provide our own

    // ensure that the .download folder exists
    [[NSFileManager defaultManager] createDirectoryAtPath: [_outputPath stringByDeletingLastPathComponent]
        attributes: nil];

    [download setDestination: _outputPath allowOverwrite: YES];
}

Next, we want to know how much data is being downloaded, and if resuming we'll want to know how much we already have. This is achieved using the following two methods:

- (void) download: (NSURLDownload *) download didReceiveResponse: (NSURLResponse *) response
{
    // we might get more than one of these (URL redirects) so we'll reset state each time
    _gotLength = 0;
    _totalLength = [response expectedContentLength];
    [progressBar setMaxValue: (float) _totalLength];
    [progressBar setCurrentValue: 0.0f];
}

- (void) download: (NSURLDownload *) download willResumeWIthResponse: (NSURLResponse *) response fromByte: (long long) startingByte
{
    // as above, reset state whenever this is called, in case of redirects
    _gotLength = startingByte;

    // the expected length here is the amount remaining, not the total
    _totalLength = _gotLength + [response expectedContentLength];
    [_progressBar setMaxValue: (float) _totalLength];
    [_progressBar setCurrentValue: (float) _gotLength];
}

Next we'll need to update the progress bar as the download progresses:

- (void) download: (NSURLDownload *) download didReceiveDataOfLength: (unsigned) length
{
    _gotLength += (long long) length;
    [_progressBar setCurrentValue: (float) _gotLength];
}

Lastly, we'll want to do something when the download completes:

- (void) downloadDidFinish: (NSURLDownload *) download
{
    // fill the progress bar (it might finish early, for all we know)
    [_progressBar setPercentage: 100.0f];

    // release our download controller, in case calling -cancel causes a problem later
    // use autorelease, because the downloader has code running on the stack
    [_downloader autorelease];
    _downloader = nil;

    ////////////////////////////
    // do your stuff here
    ////////////////////////////
}

User Interface

Now that we've gone through the download process, we just need to finalize the UI code to wrap things up. We'll have three routines here: the initializer, deallocator, and a method to set the source text. The last item has its own method because it is the one item whose frame is likely to change.

Firstly, the initializer. This sets up the default content based on the URL loaded from our preferences.

- (id) initWithScene: (BRRenderScene *) scene
{
    if ( ( self = [super initWithScene: scene] ) == nil )
        return nil;

    NSString * urlstr = [[BRPreferenceManager sharedPreferences] stringForKey: @"QuDownloadURL"
        forDomain: @"org.quatermain.downloader"];

    // if no URL, use some default value
    if ( urlstr == nil )
        urlstr = @"http://alanquatermain.net/bits/BiggerThanYouEP.mp3";

    _header = [[BRHeaderControl alloc] initWithScene: scene];
    _sourceText = [[BRTextControl alloc] initWithScene: scene];
    _progressBar = [[QuProgressBarControl alloc] initWithScene: scene];

    // work out the output path
    _outputPath = [[QuDownloadController outputPathForURLString: urlstr] retain];

    // lay out the UI
    NSRect masterFrame = [[self masterLayer] frame];
    NSRect frame = masterFrame;

    // header goes in a specific location
    frame.origin.y = frame.size.height * 0.82f;
    frame.size.height = [[BRThemeInfo sharedTheme] listIconHeight];
    [_header setFrame: frame];

    // the progress bar also goes in a specific place: 1/8th the way from the bottom of the screen
    frame.size.width = masterFrame.size.width * 0.45f;
    frame.size.height = ceilf( frame.size.width * 0.068f );
    frame.origin.x = (masterFrame.size.width - frame.size.width) * 0.5f;
    frame.origin.y = masterFrame.origin.y + (masterFrame.size.height * (1.0f / 8.0f));
    [_progressBar setFrame: frame];

    [_header setTitle: BRLocalizedString(@"QuDownloader Example", @"Title text")];
    [self setSourceText: urlstr];
    [_progressBar setCurrentValue: [_progressBar minValue]];

    // add the controls to the master layer
    [self addControl: _header];
    [self addControl: _sourceText];
    [self addControl: _progressBar];

    return self;
}

In the deallocator, we will cancel the download:

- (void) dealloc
{
    [self cancelDownload];

    [_header release];
    [_sourceText release];
    [_progressBar release];
    [_downloader release];
    [_outputPath release];

    [super dealloc];
}

The last item on the bill is the sourceText control. Since this may be given text that will need to wrap, it has its own setter method which will adjust its frame rectangle appropriately.

- (void) setSourceText: (NSString *) text
{
    // always set the attributes first, because the text seems to be re-rendered only upon receipt of the -setText: method
    [_sourceText setTextAttributes: [[BRThemeInfo sharedTheme] paragraphTextAttributes]];
    [_sourceText setText: text];

    NSRect masterFrame = [[self masterLayer] frame];

    [_sourceText setMaximumSize: NSMakeSize(masterFrame.size.width * (2.0f / 3.0f),
        masterFrame.size.height)];

    NSSize txtSize = [_sourceText renderedSize];

    NSRect frame;
    frame.origin.x = (masterFrame.size.width - txtSize.width) * 0.5f;
    frame.origin.y = (masterFrame.size.height * 0.75f) - txtSize.height;
    frame.size = txtSize;
    [_sourceText setFrame: frame];
}

Conclusion

We now have a controller class which performs a useful function -- downloading content from the internet -- and is prepared to make use of it. If such use requires that another controller be put onto the stack, it is advisable to use [[self stack] swapController: newController] to perform that task, so that the download controller does not appear again after pressing the menu button.

Beyond that, the sky is the limit. The example project available here contains a few more functions, more error checking, and will play the downloaded file through a BRQTKitVideoPlayer, for example.

Contact Details

I can be reached at #awkwardtv on irc.moofspeak.net, username alan_quatermain, or at http://forum.awkwardtv.org/, username Quatermain.

Take 2 Update

I decided to add a little archive of a take 2 compatible updated version of this useful QuDownloader tutorial, i didn't update the information in the tutorial itself here, didn't really have the time. The updated zip is available here --Nito 23:08, 23 August 2008 (CEST)

Personal tools