A few weeks ago (before I thought it would be a good idea to fly to China for a few weeks and dramatically increase the size of my family), I blogged about how a Cordova application could handle downloading binary assets after release. (You can find the discussion here: Cordova and Large Asset Downloads - An Abstract.) I finally got around to completing the demo.

Before I go any further, keep in mind that this demo was built to illustrate an example of the concept. It isn't necessarily meant to be something you can download and use as is. Consider it a sample to get you inspired. Here is how the application works.

First off, we don't do anything "special" until we need to, so the home page is just regular HTML. I'm using jQuery Mobile for this example (don't tell Ionic I strayed).

Clicking the Assets button is what begins the thing we want to demonstrate. When this page loads, we need to do a few things. First, we check the file system to see if we have any downloaded assets. If we do, we display them in a list. If not, we tell the user.

At the same time, we do a "once-per-app" hit to a server where new assets may exist. In my case I just added a JSON file to my local Apache server. This JSON file returned an array of URLs that represent new assets. For each we compare against the list of files we have (and on our first run we will have none), and if we don't have it, we use the FileTransfer plugin to download it.

The next time the user views that particular page, they will see a list of assets. In my demo they are listed by file name which may not be the best UX. You could return metadata about your assets from the server to include things like a nicer name.

To wrap up my demo, I used jQuery Mobile's popup widget to provide a way to view the assets. (Note: I've got an odd bug where the first click returns an oddly placed popup. The next clicks work just fine. Not sure if that is a jQuery Mobile bug or something else. Since it isn't really relevant to the topic at hand, I'm going to drink a beer and just not give a you know what.)

Ok, so let's take a look at the code.

var globals = {};
globals.checkedServer = false;
globals.assetServer = "http://192.168.1.67/assets.json";
globals.assetSubDir = "assets";

document.addEventListener("deviceready", init, false);
function init() {
	
	$(document).on("pageshow", "#downloadPage", function(e) {
	
		console.log("page show for downloads");
		
		//get the current list of assets
		var assetReader = getAssets();
		assetReader.done(function(results) {
			console.log("promise done", results);
			if(results.length === 0) {
				$("#assetDiv").html("<p>Sorry, but no assets are currently available.</p>");	
			} else {
				var list = "<ul data-role='listview' data-inset='true' id='assetList'>";
				for(var i=0, len=results.length; i<len; i++) {
					list += "<li data-url='"+results[i].toURL()+"'>"+results[i].name+"</li>";	
				}
				list += "</ul>";
				console.log(list);
				$("#assetDiv").html(list);
				$("#assetList").listview();
				
			}
			
			if(!globals.checkedServer) {
				$.get(globals.assetServer).done(function(res) {
					/*
					Each asset is a URL for an asset. We check the filename
					of each to see if it exists in our current list of assets					
					*/
					console.log("server assets", res);
					for(var i=0, len=res.length; i<len; i++) {
						var file = res[i].split("/").pop();
						var haveIt = false;

						for(var k=0; k<globals.assets.length; k++) {
							if(globals.assets[k].name === file) {
								console.log("we already have file "+file);
								haveIt = true;
								break;
							}
						}
						
						if(!haveIt) fetch(res[i]);
						
					}
				});
			}
		});
		
		//click handler for list items
		$(document).on("touchend", "#assetList li", function() {
			var loc = $(this).data("url");
			console.dir(loc);
			$("#assetImage").attr("src", loc);
			$("#popupImage").popup("open");
		});
		
	});
	
}

function fsError(e) {
	//Something went wrong with the file system. Keep it simple for the end user.
	console.log("FS Error", e);
	navigator.notification.alert("Sorry, an error was thrown.", null,"Error");
}

/*
I will access the device file system to see what assets we have already. I also take care of, 
once per operation, hitting the server to see if we have new assets.
*/
function getAssets() {
	var def = $.Deferred();

	if(globals.assets) {
		console.log("returning cached assets");
		def.resolve(globals.assets);
		return def.promise();
	}
	
	var dirEntry = window.resolveLocalFileSystemURL(cordova.file.dataDirectory, function(dir) {
		//now we have the data dir, get our asset dir
		console.log("got main dir",dir);
		dir.getDirectory(globals.assetSubDir+"/", {create:true}, function(aDir) {
			console.log("ok, got assets", aDir);	
			
			var reader = aDir.createReader();
			reader.readEntries(function(results) {
				console.log("in read entries result", results);
				globals.assets = results;
				def.resolve(results);
			});
			
			//we need access to this directory later, so copy it to globals
			globals.assetDirectory = aDir;
			
		}, fsError);
		
	}, fsError);
	
	return def.promise();
}

function fetch(url) {
	console.log("fetch url",url);
	var localFileName = url.split("/").pop();
	var localFileURL = globals.assetDirectory.toURL() + localFileName;
	console.log("fetch to "+localFileURL);
	
	var ft = new FileTransfer();
	ft.download(url, localFileURL, 
		function(entry) {
			console.log("I finished it");
			globals.assets.push(entry);
		},
		fsError); 
				
}

There is a lot going on here so I'll try to break it into somewhat manageable chunks.

The core code is in the pageshow event for the download page. (The page you see when you click the assets button. I had called it downloads at first.) This event is run every time the page is shown. I used this instead of pagebeforecreate since we can possibly get new assets after the page is first shown.

As mentioned above we do two things - check the file system and once per app run, hit the server. The file reader code is abstracted into a method called getAssets. You can see I use a promise there to handle the async nature of the file system. This also handles returning a cached version of the listing so we can skip doing file i/o on every display.

The portion that handles hitting the server begins inside the condition that checks globals.checkedServer. (And yeah, using a variable like globals made me feel dirty. I'm not a JavaScript ninja and Google will never hire me. Doh!) For the most part this is simple - get the array and compare it to the list we got from the file system. When one is not found, we call a function, fetch, to handle downloading it.

The fetch method simply uses the FileTransfer plugin. It grabs the resource, stores it, and appends it to the list of assets. This is what is used for the page display. One issue with this setup is that we will not update the view automatically. You have to leave the page and come back. We could update the list, just remember that it is possible that the user left the page and went do other things in your app. I figured this was an implementation detail not terribly relevant so I kept it simple.

So that's it. Thoughts? You can find the complete source code for this here: https://github.com/cfjedimaster/Cordova-Examples/tree/master/asyncdownload