Managing PDF documents in a hybrid app for offline availability

It is a common requirement for a mobile app to give access to the end user to a set of documentation like PDF files, so that he can read them even when he is offline. This blog post explains how to manage documents metadata with the JSONStore, download documents from a remote location and read them locally.

Managing documents metadata

Depending on the number of documents to be downloaded and their size, it may be useful to manage which ones are already available, which ones have been downloaded, and thus only update those that are new or that changed since the last synchronization. Of course this is only possible if you have a service that can give you metadata about the files (but this is something usually available or at least easy to implement by reading a directory content in a file system).

For the purpose of this demonstration, we have implemented an adapter service that simulates a list of documents on an initial call, and updates on a second call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//TODO: ensure that these documents exist or replace the names
var docs = [
            { name: "doc1.pdf", title: "Procedure 1", size: "10kb", timestamp: 1234},
            { name: "doc2.pdf", title: "Procedure 2", size: "20kb", timestamp: 1234},
            { name: "doc3.pdf", title: "Procedure 3", size: "30kb", timestamp: 1234},
            { name: "doc4.pdf", title: "Procedure 4", size: "40kb", timestamp: 1234},
            { name: "doc5.pdf", title: "Procedure 5", size: "50kb", timestamp: 1234}
            ];
var count = 0;
function getDocumentList() {
	if(count>0) { // modify table so that second time we call the function it returns different content
		docs[0] = { name: "doc1.pdf", title: "Procedure 1", size: "15kb", timestamp: 4321}; // updated item
		docs[5] = { name: "doc6.pdf", title: "Procedure 6", size: "1Mb", timestamp: 1234}; // new item
	}
	var res = {
		documents: docs,
		statusCode: 200
	};
	count++;
	return res;
}

On the client side, we need to initialize a JSONStore collection to hold these metadata:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var collections = {};
collections[collectionName] = {
		searchFields : { name:"string", timestamp:"integer"},
};
//Initialize the document collection
WL.JSONStore.init(collections)
.then(function() {
	documentsCollection = WL.JSONStore.get(collectionName);
	documentsCollection.findAll({}).then(function (allDocs) { // If any document already available, display in the list
		printList(allDocs);
	});
})
.fail(function(errorObject) {
	console.log("Failed to initialize collection");
});

And then create a function that calls the adapter service and fills the local collection with the result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
var nbDocsFound;
var docsToUpdate;
var docsToAdd;
function getDocumentList() {
	var invocationData = {
			adapter : 'DocumentAdapter',
			procedure : 'getDocumentList',
			parameters : [],
			compressResponse: true
	};
WL.Client.invokeProcedure(invocationData)
	.then(function (responseFromAdapter) {
		// Handle invokeProcedure success.
		var data = responseFromAdapter.invocationResult.documents;
		// First check if some documents have their timestamp updated
		nbDocsFound = data.length;
		docsToUpdate = [];
		docsToAdd = [];
	data.forEach( function(doc) {
			console.log("current doc "+doc.name);
			doc.pdfLoaded = false;
			documentsCollection.find({'name': doc.name}, {limit:1}).then(function (existingDocs) {
				// This code is executed asynchronously (after the loop exits)
				if(existingDocs.length==1) { // document already exists locally
					if(existingDocs[0].json.timestamp!=doc.timestamp) { // document needs to be updated
						console.log(doc.name + " is updated!");
						docsToUpdate.push({_id: existingDocs[0]._id, json: doc});
					}
				} else if(existingDocs.length==0) { // document doesn't exist locally
					console.log("adding document "+doc.name);
					docsToAdd.push(doc);
				}
				displayUpdatedDocumentList();
			});
		});
	})
	.fail(function (errorObject) {
		// Handle invokeProcedure failure.
	});
}
function displayUpdatedDocumentList() {
	if(--nbDocsFound) return; // Wait until all promises have been executed
	console.log("after promises "+docsToAdd.length+":"+docsToUpdate.length);
	updateDocs()
	.then(function(numberOfDocumentsReplaced) {
		console.log("Successfully updated "+numberOfDocumentsReplaced+" documents");
		addDocs()
		.then(function (numberOfDocumentsAdded) {
			console.log("Successfully added "+numberOfDocumentsAdded+" documents");
			documentsCollection.findAll({})
			.then(function (allDocs) {
				console.log("printing list");
				printList(allDocs);
			});
		});
	});
}
function updateDocs() {
	if(docsToAdd.length>0) { // Add new items into collection
		return documentsCollection.add(docsToAdd, {markDirty: false});
	}
	var dfd = new $.Deferred();
	dfd.resolve(0);
	return dfd.promise();
}
function addDocs() {
	if(docsToUpdate.length>0) { // Update collection
		return documentsCollection.replace(docsToUpdate, {markDirty: false});
	}
	var dfd = new $.Deferred();
	dfd.resolve(0);
	return dfd.promise();
}
function printList(allDocs) {
	var ul = $('#docList'), doc, li;
	ul.empty();
	for (var i = 0; i < allDocs.length; i += 1) {
		doc = allDocs[i].json;
		// Create new <li> element
		li = $('<li></li>');
		var text = $('<span></span>').text(doc.title);
		li.append(text);
		var loadedText = doc.pdfLoaded ? "" : " (not downloaded)";
		li.append('<div> ' + doc.timestamp + ", size: "+ doc.size + loadedText + </div>');
		ul.append(li);
	}
}

Notice that because of the asynchronous execution of some APIs, the code has been spread into several functions in order to ensure the consistency of the results stored and displayed. An initial call to the getDocumentList() function (button "Refresh List") gives the following result:

missing_alt

After having downloaded the initial set of documents (button “Download files”) as we will see later in the post, a second call to the getDocumentList() function gives the following new result:

missing_alt

Downloading documents into the mobile app

Once we know which documents are available, the next step is to be able to download these locally. We will explore two options for downloading the documents.

Option 1: download the documents from a remote web server

This option is the easiest and preferred way to download the documents into the app. The following function is responsible for downloading an individual document:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//TODO: replace with URL of the web server where documents are located
var pdfRemoteUrl = "http://192.168.1.26:10080/MockService/";
function downloadDocument(docName) {
	var localPath = getFilePath(docName);
	var fileTransfer = new FileTransfer();
	fileTransfer.download(
			pdfRemoteUrl + docName, // remote file location
			localPath, // where to store file locally
			function (entry) {
				console.log("download complete: " + entry.fullPath);
			},
			function (error) {
				//Download abort errors or download failed errors
				console.log("download error source " + error.source);
			}
	);
}

Notice here the hardcoded base URL of the remote server that should be calculated or dynamically retrieved in a real scenario. Notice also the getFilePath() function that calculates where the file should be stored. We’ll see later why this is platform dependent.

In this sample, a downloadDocuments() function loops over the local metadata from the JSONStore and updates the pdfLoaded flag before redrawing the list with a link per line that gives access to each file downloaded:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function downloadDocuments() {
	documentsCollection.findAll({})
	.then(function (allDocs) {
		allDocs.forEach( function(jdoc) {
			doc = jdoc.json;
			if(!doc.pdfLoaded) {
				downloadDocument(doc.name);
				doc.pdfLoaded = true;
			}
		});
		// update the collection
		documentsCollection.replace(allDocs, {markDirty : false});
		// redraw list
		printList(allDocs);
	})
	.fail(function (errorObject) {
	  // Handle failure.
	});
}

If you implement such a way to download documents, take advantage of the MobileFirst Platform security framework in order to secure the two servers but still provide a SSO between them.

Option 2: download the documents through an adapter service

In case option 1 is not possible, this option gives you the opportunity to get access to the files from the MFP server directly. I’d personally not recommend this way of doing since, as you’ll see, it requires to encode and decode the documents which will lead to lower performance as their size increases.

The adapter service is quite simple:

1
2
3
4
5
6
7
8
9
10
11
12
/**
 * Used only when individual document content is served by MFP server
 * Replace localFilePath with root directory where documents can be found
 * @param docName
 * @returns document content encoded in base 64
 */
function getDocument(docName) {
	var localFilePath = "/Users/enoiret/Documents/workspaces/v62/MockService/WebContent/";
	return {
		pdf : com.acme.document.DocumentReader().getEncodedContent(localFilePath + docName)
	};
}

It requires a piece of Java code in order to read the content of the file and return it encoded in base64 format:

missing_alt

The Java code itself is quite simple:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static String getEncodedContent(String url)
        throws IOException {
    byte[] buf = new byte[8192];
    InputStream is = new FileInputStream(url);
    ByteArrayOutputStream bos = new  ByteArrayOutputStream();
    int read = 0;
    while ((read = is.read(buf, 0, buf.length)) > 0) {
    	bos.write(buf, 0, read);
    }
    bos.close();
    is.close();
    return Base64.encodeBase64String(bos.toByteArray());
}

Since the documents are downloaded differently, the downloadDocument() function on the client side needs of course to be updated accordingly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function downloadDocument(docName) {
	var invocationData = {
			adapter : 'DocumentAdapter',
			procedure : 'getDocument',
			parameters : [docName],
			compressResponse: true
	};
	WL.Client.invokeProcedure(invocationData)
	.then(function (response) {
		// Handle invokeProcedure success: remove backslash character and decode binary data
		var content = Base64Binary.decodeArrayBuffer(response.invocationResult.pdf.replace(/\\/g,"" ));
		var localPath = getFilePath(docName);
		function writeDocument(fileEntry) {
		    fileEntry.createWriter(
		    		function (writer) {
		    		    writer.onwriteend = function(evt) {
		    		        console.log("done written pdf "+docName);
		    		    };
		    		    writer.write(content);
		    		},
		    		fail);
		};
		// Write file on local system
		window.resolveLocalFileSystemURL(
				localPath.substring(0, localPath.lastIndexOf('/')), // retrieve directory
				function(dirEntry) {
					dirEntry.getFile( // open new file in write mode
							docName,
							{create: true, exclusive: false},
							writeDocument,
							fail);
				},
				fail);
	})
	.fail(function (errorObject) {
		// Handle invokeProcedure failure.
		console.log("Failed to load pdf from adapter", errorObject);
	});
}

Display a document from the mobile app

Once the documents are in the app, they can be displayed at any time, even when there is no connectivity.

The tricky thing here is that depending on the platform, you won’t store the files at the same location. Indeed, iOS has a built-in PDF reader available for Safari, whilst for Android an external app is required to render the PDF. Therefore on Android it is important to store the files in a location that will be accessible from this app. In order to have a platform dependent implementation, the same function can be written specifically under its own platform, as shown in figure 4:

missing_alt

In the main.js file under the Android folder, the geFilePath() function is implemented as follow:

1
2
3
4
5
6
7
8
9
10
function getFilePath(fileName) {
	console.log("external dir:"+cordova.file.externalDataDirectory);
	return cordova.file.externalDataDirectory + fileName; // Works starting with MFP 6.3
	// For Worklight 6.2, use one of the following lines:
	//return "file:///mnt/sdcard/Android/data/" + "com.DocViewer" + "/" + fileName; //TODO: replace "com.DocViewer" with package name of the app
	//return "file:///storage/emulated/0/Android/data/" + "com.DocViewer/files/" + fileName; //TODO: replace "com.DocViewer" with package name of the app
}
<p>function getTarget() {
	return "_system";
}

(Notice here that prior to MFP 6.3 the API cordova.file was not available, so an alternative hardcoded way is given here as an example).

Under the iPhone folder, the geFilePath() function has a different implementation:

1
2
3
4
5
6
function getFilePath(fileName) {
	return ctx.fileSystem.root.toURL() + fileName;
}
<p>function getTarget() {
	return "_blank";
}

Finally, it is also needed to create the URL links properly on each platform in order to be able to launch the right PDF viewer:

1
2
3
4
5
6
7
8
9
$('#docList').on('click', 'li', function() {
	var docLoaded = $(this).attr("doc_loaded")==="true";
	if(docLoaded) {
		var docName = $(this).attr("doc_name");
		console.log("before trying to launch "+docName);
		var localPath = getFilePath(docName);
		window.open(localPath, getTarget(), "location=yes,hidden=no,closebuttoncaption=Close");
	}
});

Figure 5 shows how it renders on iOS:

missing_alt

On Android it is necessary to hit the system back button to go from the PDF viewer app back to the hybrid app.
Use git clone https://hub.jazz.net/git/enoiret/MFPDocViewer if you want to download the sample project to make tests.

Last modified on May 01, 2016
Share this post: