Difference between revisions of "A Downloader Controller"

From AwkwardTV
Jump to: navigation, search
(New page: {{template:banner}} Back in the first article in this series we created a BRControl wrapper around the ProgressBar widget. I said then that we would put...)
 
(The 'emacs save twitch' strikes again, half-way through...)
Line 55: Line 55:
  
 
The file at <tt>&lt;Foundation/NSPathUtilities.h&gt;</tt> contains the values you'll need for this, and a handy Objective-C wrapper for the NSSystemDirectories API (in <tt>&lt;NSSystemDirectories.h&gt;</tt>). 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:
 
The file at <tt>&lt;Foundation/NSPathUtilities.h&gt;</tt> contains the values you'll need for this, and a handy Objective-C wrapper for the NSSystemDirectories API (in <tt>&lt;NSSystemDirectories.h&gt;</tt>). 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);
+
  NSArray * searchPath = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask | NSLocalDomainMask, YES);
  
 
This call would return the following list of paths:
 
This call would return the following list of paths:
Line 77: Line 77:
 
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.
 
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.
  
===The Interface===
+
===Member Variables===
Our class will need a few member variables to maintain state nicely. Firstly we'll have some UI controls:
+
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;
 
  BRHeaderControl * _header;
 
  BRTextControl * _sourceText;
 
  BRTextControl * _sourceText;
Line 84: Line 84:
  
 
We will also need some items to manage the download itself:
 
We will also need some items to manage the download itself:
  NSURLDownload * _download;
+
  NSURLDownload * _downloader;
 
  NSString * _outputPath;
 
  NSString * _outputPath;
 +
long long _totalLength;
 +
long long _gotLength;
 +
 +
===Implementation===
 +
Firstly, let's define a useful method for determining the name of our downloaded file. Here we'll use <tt>NSSearchPathForDirectoriesInDomains()</tt> 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.
 +
<pre>
 +
+ (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]] );
 +
}
 +
</pre>
 +
 +
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).
 +
<pre>
 +
- (void) wasPushed
 +
{
 +
    if ( [self beginDownload] == NO )
 +
    {
 +
        [_header setTitle: @"Download Failed"];
 +
        [_progressBar setPercentage: 0.0f];
 +
        [[self scene] renderScene];
 +
    }
 +
 +
    [super wasPushed];
 +
}
 +
 +
- (void) willBePopped
 +
{
 +
    [self cancelDownload];
 +
    [super willBePopped];
 +
}
 +
</pre>
 +
 +
We'll also need some methods for handling the download itself. Firstly, we'll have the plain start-downloading function:
 +
<pre>
 +
- (BOOL) beginDownload
 +
{
 +
    // see if we can resume a partial download
 +
    if ( [self resumeDownload] == YES )
 +
        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://homepage.mac.com/jimdovey/nukes/nukes/files/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 );
 +
}
 +
</pre>
 +
 +
Resumption of a download looks similar to the sequence above:
 +
<pre>
 +
- (BOOL) resumeDownload
 +
{
 +
    NSString * resumeDataPath = [[_outputPath stringByDeletingLastPathComponent]
 +
        stringByAppendingPathComponent: @"ResumeData"];
 +
 +
    if ( [[NSFIleManager defaultManager] fileExistsAtPath: resumeDataPath] == NO )
 +
        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 );
 +
}
 +
</pre>
 +
 +
 +
- (void) cancelDownload;
 +
 +
Since we're using the NSURLDownload class, we will need to implement some delegate methods:

Revision as of 07:57, 20 April 2007

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



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 of type: 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: (NSSrting *) 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 poject 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 Data

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

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]] );
}

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] == NO )
    {
        [_header setTitle: @"Download Failed"];
        [_progressBar setPercentage: 0.0f];
        [[self scene] renderScene];
    }

    [super wasPushed];
}

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

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] == YES )
        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://homepage.mac.com/jimdovey/nukes/nukes/files/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] == NO )
        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 );
}


- (void) cancelDownload;

Since we're using the NSURLDownload class, we will need to implement some delegate methods: