Oct 18

You should always avoid downloading data over a wireless network whenever possible. If there is a file on a server that your app needs and it’s infrequently updated, then a good approach would be to cache that file in your app and only download it when an update is detected. (You could also package a version of the file inside the app bundle to provide a quick startup time the first time the app is launched.)

There are several ways to solve this problem. The code below does a HTTP HEAD request, checks the returned "Last-Modified" header, and compares it with updated timestamp of the local file. (A slightly more efficient approach would be to send a "If-Modified-Since" header with your request. But there seems to be some issues with that, so proceed carefully with that approach.)

High-level steps:

  1. Create a HTTP HEAD request.
  2. Read the "Last-Modified" header and convert the string to a NSDate.
  3. Read the last modification timestamp of the local file.
  4. Compare the two timstamps.
  5. Download the file from the server if it has been updated.
  6. Save the downloaded file.
  7. Set the last modification timestamp of the file to match the "Last-Modified" header on the server.

Please note that the code uses synchronous requests to make the code shorter and better illustrate the point. Obviously you should not call this method from the main thread. Depending on your requirements, you could rewrite the code to use asynchronous requests instead.

- (void)downloadFileIfUpdated {
	NSString *urlString = ... your URL ...
	DLog(@"Downloading HTTP header from: %@", urlString);
	NSURL *url = [NSURL URLWithString:urlString];
	
	NSString *cachedPath = ... path to your cached file ...
	NSFileManager *fileManager = [NSFileManager defaultManager];

	BOOL downloadFromServer = NO;
	NSString *lastModifiedString = nil;
	NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
	[request setHTTPMethod:@"HEAD"];
	NSHTTPURLResponse *response;
	[NSURLConnection sendSynchronousRequest:request returningResponse:&response error: NULL];
	if ([response respondsToSelector:@selector(allHeaderFields)]) {
		lastModifiedString = [[response allHeaderFields] objectForKey:@"Last-Modified"];
	}
	
	NSDate *lastModifiedServer = nil;
	@try {
		NSDateFormatter *df = [[NSDateFormatter alloc] init];
		df.dateFormat = @"EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'";
		df.locale = [[[NSLocale alloc] initWithLocaleIdentifier:@"en_US"] autorelease];
		df.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"GMT"];
		lastModifiedServer = [df dateFromString:lastModifiedString];
		[df release];
	}
	@catch (NSException * e) {
		NSLog(@"Error parsing last modified date: %@ - %@", lastModifiedString, [e description]);
	}
	DLog(@"lastModifiedServer: %@", lastModifiedServer);

	NSDate *lastModifiedLocal = nil;
	if ([fileManager fileExistsAtPath:cachedPath]) {
		NSError *error = nil;
		NSDictionary *fileAttributes = [fileManager attributesOfItemAtPath:cachedPath error:&error];
		if (error) {
			NSLog(@"Error reading file attributes for: %@ - %@", cachedPath, [error localizedDescription]);
		}
		lastModifiedLocal = [fileAttributes fileModificationDate];
		DLog(@"lastModifiedLocal : %@", lastModifiedLocal);
	}
	
	// Download file from server if we don't have a local file
	if (!lastModifiedLocal) {
		downloadFromServer = YES;
	}
	// Download file from server if the server modified timestamp is later than the local modified timestamp
	if ([lastModifiedLocal laterDate:lastModifiedServer] == lastModifiedServer) {
		downloadFromServer = YES;
	}
	
	if (downloadFromServer) {
		DLog(@"Downloading new file from server");
		NSData *data = [NSData dataWithContentsOfURL:url];
		if (data) {
			// Save the data
			if ([data writeToFile:cachedPath atomically:YES]) {
				DLog(@"Downloaded file saved to: %@", cachedPath);
			}
			
			// Set the file modification date to the timestamp from the server
			if (lastModifiedServer) {
				NSDictionary *fileAttributes = [NSDictionary dictionaryWithObject:lastModifiedServer forKey:NSFileModificationDate];
				NSError *error = nil;
				if ([fileManager setAttributes:fileAttributes ofItemAtPath:cachedPath error:&error]) {
					DLog(@"File modification date updated");
				}
				if (error) {
					NSLog(@"Error setting file attributes for: %@ - %@", cachedPath, [error localizedDescription]);
				}
			}
		}
	}
}

Line item notes:

03. See this post for the source to the DLog macro.

14. It’s always good practice to check if the returned object responds to a selector before you send a message to it. See this post for more details.

22. The "Last-Modified" timestamp on the server includes the name of the month as text. Set the locale of the date formatter to English (assuming that your server responds in English) otherwise the code will fail when run on devices in non-English countries. See this post for more details.

Keep in mind that wireless networks have very high latency. That means it’s very expensive to create a network connection to a server. If the size of the file you want to download is very small, then you will not gain much with the approach above since it will take almost as long time to get the header information as downloading the entire file.

written by Nick \\ tags: , , , , , , ,

Oct 08

A quick Internet search to find out how to get the disk space on a device showed a number of blog posts that proposed solutions similar to this:

#include <sys/param.h>
#include <sys/mount.h>

+(float)getTotalDiskSpaceInBytes {
	NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);	
	struct statfs tStats;
	statfs([[paths lastObject] cString], &tStats);
	float totalSpace = (float)(tStats.f_blocks * tStats.f_bsize);
	
    return totalSpace;
}

This approach relies on the handy Unix statfs() function that provides the required file system information. I coded it up and it worked on my 3.x devices. Fine I thought, I’m off and running. Then when testing on a 2.x device, it crashed. The problem looks to be differences in the compiler settings between the two OS versions. Rather than figure that out (I leave that as an exercise to the reader 🙂 ), I continued my search and came across what I consider the “correct” solution since it uses the iPhone SDK itself, as given below.

+(float)getTotalDiskSpaceInBytes {
	float totalSpace = 0.0f;
	NSError *error = nil;
	NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);	
	NSDictionary *dictionary = [[NSFileManager defaultManager] attributesOfFileSystemForPath:[paths lastObject] error: &error];
	
	if (dictionary) {
		NSNumber *fileSystemSizeInBytes = [dictionary objectForKey: NSFileSystemSize];
		totalSpace = [fileSystemSizeInBytes floatValue];
	} else {
		DLog(@"Error Obtaining File System Info: Domain = %@, Code = %@", [error domain], [error code]);
	}
	
    return totalSpace;
}

As stated in the documentation, these interfaces wrapper the statfs() function, so they ultimately do the same thing, but they have been a part of the SDK since version 2.0! Testing verified my assumption and this approach works for me on my 2.x and 3.x devices and the numbers match what iTunes shows as well.

written by Jess \\ tags: , ,