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
Archived Comments
I've had the same issues with images in JQM popups before. JQM treats the image height and width as 0 until it is loaded. So it is placing the popup on open, then loading the image, then resizing the popup. You can call the reposition method, but it is kind of jumpy. I prefer to work around it with something like this:
https://gist.github.com/max...
Off topic for your blog post, but just thought you'd be curious.
Thank you for the demo Ray !!! I've just chosen Jqm to start my journey into mobile app development after trying onsen and ionic and realising learning angular from scratch may postpone my app development for too long and it's pretty tough finding up to date cordova and jqm examples
@Max: Thanks for sharing that!
Nice, I didn't know about groupBy (I'm still learning Angular).
Very nice tutorial.
I hope you publish an article like this with use of ngCordova and Ionic. I can't solve the issues many people have with ngCordivaFile functions. ngCordova returns on all functions except up- and download an error code 5. I see this a lot on some forums.
If you skip ngCordova and just use the file system as is, does it work? If so, it is a bug w/ the ngCordova library and should be reported as such - right?
Hi, thanks for your quick reply.
In the meantime a investigated a bit more on this item. It seems ngCordova is working. Also, to my surprise, it works in the browser as well.
There is one thing: it works if I start path with ‘/‘. The URL you can get with getURL from a dir- or a fileEntry points to directory: filesystem:http://localhost/persistent/
What I did not find out is how do I read files from other paths.
I downloaded some file with ngCordova and as a local path I used: cordova.file.externalDataDirectory. This is pointing to directory: file://storage/emulated/0/Android/data/com.ionicframework.trinl834518/files
How do I read files from directory where my downloaded files are stores??
thank you in advance
Kind regards,
Piet from the Netherlands
I'm not quite sure I understand. Reading is the same no matter where it is. You would use FileEntry objects to do reads. Did you see my post on this?
http://www.raymondcamden.co...
Yes. I did read that post. I tried it this way in my app. Result is error 5.
Error code 5 means encoding_err. I don't have clue what it does mean. Where to look or how to debug this. I can create a file in the root directory. Check it. I can get a path with toURL(). But trying to write or te read (empty) this file return errorcode 5.
What I actually want is reading files a download to cordiva.file.externalDataDirectory + 'fileName'.
Using this as a filePath fir read returns error code 5.
Where do I have to,look for the reason of errorcode 5?
Did you check the docs? I recently did some updates there to include the error codes.
Error code 5: encoding_error.
But what does this mean.
Come on, isn't it obvious? ;)
Sorry - no - I don't know what it could be for you. I could look at your code, but it would need to be a paid engagement. Or you can post to Stackoverflow. Not saying "go away" per se, but this seems to be OT now for the blog post.
Hi,
For Windows platforms (Windows Phone 8.1 or Windows 8.1) there is no alias like "cordova.file.dataDirectory". On Windows Phone I set "cordova.file.dataDirectory" to "///" but for Windows 8.1 desktop I don't find the path like an "%appdata%/com.company.helloworld/".
Any ideas or tips are welcome :)
So it doesn't help you, but it is documented that it isn't supported. :\ These aliases are simply helpers - the lack of them doesn't mean you can't use the File System on Windows Phone, you just have to figure out the location yourself. WinPhone should have a similar concept, right? A place for the app to store stuff. You would just need to google to see what that standard location is and use that.
The box containing the above source code is not wide enough to contain the lines of the source code.
There should be a scroll bar. You can select it all and paste it into your editor as well.
Hi Ray, this is amazingly helpful - thanks again for the JSON tip - it worked a treat. I am attempting to adapt this for some video files and merge with jplayer for a small gallery of videos offline. I can get the video files downloaded - I just need the url to them for phonegap and cannot get any 'find filesystem filepath' coding examples that actually work for some reason? Any advice would be great. I am looking into using the variable url you set in the original script but am hitting a brickwall syntax/formatting wise. I am plowing into documentation though! Thanks!
If you get a FileEntry object for the resources on your device, I believe you can do .toURL() on them to get a URL you can pass to your player.
Ray, can you post the contents of your json file that lists the image urls?
I've had a few folks ask about the assets.json file. If you don't know what an array looks like in JSON, it looks like so: ["foo","goo","zoo"]. Brackets with each item separated by a comma. In my example, each item was a URL pointing to an image.
I don't have it available but I just posted a comment with an example.