How to create an awesome hybrid mobile app using Cordova - Part III
Table of Contents
- 1. Introduction
- 2. Part III - Build the add/edit photo page
- 2.1 Create adapter to interact with camera
- 2.2 Create adapter to encrypt/decrypt content
- 2.3 Create adapter to resize image
- 2.4 Create adapter to interact with file system
- 2.5 Override the Backbone's default persistent strategy
- 2.6 Override the File model's persistent strategy
- 2.7 Create the View
- 2.8 Update the router
- 3. Summary
Introduction
Welcome to the third part of creating an awesome hybrid mobile app series. In the last two parts we've set-up the development environment, created grunt tasks to support development and deployment and also we've developed two important pages of the app: list page and the single photo view page. If you remember since we didn't complete the add functionality we've to hard-coded some data in the router to complete these two pages. In this part we are going to complete the complex page of the app - the add/edit page.
Once you completed this part you can successfully upload your photos from the photo library and keep them safe. In the next and the final part we are going to work on the login functionality. Please share your questions and thoughts through comments.
Part III - Build the add/edit photo page
The add/edit photo page is used to add a new photo or edit an already saved photo. Below is the mockup of the page.
Following are the important functionalities of the add/edit page.
- Allow user to select a photo from the photo library
- Encrypt the selected photo
- Create a thumbnail by resizing the image
- Save the metadata information to the localstorage
- Save the encrypted base64 string to file system
The add/edit page is little complicated than others. This is this page where we've to interact more with the hardware. We've to interact with the photo gallery to upload the photo. We've to interact with the built-in encryption engine to encrypt the photo. We've to interact with the hardware to resize the image and we've to access the file system to persist the encrypted photo. Cordova provides built-in APIs to interact with camera/photo gallery or file system. For encryption and resizing images we've to use separate plugins. We've already downloaded and configured all these plugins in the previous part. We've to create adapters to interact with the hardware APIs. If you remember we've already seen how to create an adapter in the previous part when we developed one to interact with the notification API for displaying alerts.
The next thing is about persistence. As I already said in the previous parts, we are going to store the metadata information of the photo like description, thumbnail in localstorage and the encrypted base64 string of the photo in the file system. For storing information to localstorage, we've to override the Backbone's default persistent strategy. To store the encrypted base64 string to file system we've to specifically override the File model. Keeping all these things in mind, following are the tasks we've to do to complete this page.
- Create adapter to interact with camera
- Create adapter to encrypt/decrypt content
- Create adapter to resize image
- Create adapter to interact with file system
- Override the backbone's default persistent strategy
- Override the File model's persistent strategy
- Create the view
- Update the router
That's quite a lot of tasks isn't it? Let's get to work then!
Create adapter to interact with camera
Cordova provides a common API to access the camera and the library. This API is common for all devices. To know about Cordova's camera API please read this. We've to create an adapter for the camera API first and then a fake object, both should follow the same interface.
Create the real object
Let's create a new file with name "camera.js" under the "adapters" folder and paste the below code. Cordova provides options to directly capture image from camera or you can also read from the album or library. In our app, to keep things simple we are going to read the photo only from the library. For the sake of completeness (?) I've created all the methods in the adapter.
define(['jquery', 'underscore'], function($, _) { 'use strict'; return { getPicture: function(options) { var d = $.Deferred(), cameraOptions = _.extend({ encodingType: navigator.camera.EncodingType.JPEG, destinationType: navigator.camera.DestinationType.DATA_URL, targetWidth: 320, targetHeight: 480 }, options); navigator.camera.getPicture(d.resolve, d.reject, cameraOptions); return d.promise(); }, capturePicture: function() { return this.getPicture({ sourceType: navigator.camera.PictureSourceType.CAMERA }); }, getPictureFromAlbum: function() { return this.getPicture({ sourceType: navigator.camera.PictureSourceType.SAVEDPHOTOALBUM }); }, getPictureFromLibrary: function() { return this.getPicture({ sourceType: navigator.camera.PictureSourceType.PHOTOLIBRARY }); } }; });
Listing 1. camera.js
To make our life simple we've used promises over callbacks. The camera adapter provides four methods. The getPicture is the generic method that can be used to retrieve image from any source. Other three methods are used to retrieve image from a specific source. We'll be using only the getPictureFromLibrary method in our app.
If you see the getPicture method we've passed hard-coded values for targetWidth (320) and targetHeight (480). The targetWidth and targetHeight properties are used to scale the image once it is read from camera or album. Though we've to pass both these values the aspect ratio remains same. I've passed values lower in the resolution scale to save encryption/decryption time. You can change the values based on your need. It is better to keep these values in a separate file. Let's create a new file called "settings.js" at the root of "js" folder. The settings file contains the different parameters used in the app that can be changed based on user needs.
define(function() { 'use strict'; return { targetWidth: 320, targetHeight: 480 }; });
Listing 2. settings.js
We'll be adding couple more parameters to the settings file in future. Let's update the "camera.js" file to read the width and height values from the settings.
define(['jquery', 'underscore', 'settings'], function($, _, settings) { 'use strict'; return { getPicture: function(options) { var d = $.Deferred(), cameraOptions = _.extend({ encodingType: navigator.camera.EncodingType.JPEG, destinationType: navigator.camera.DestinationType.DATA_URL, targetWidth: settings.targetWidth, targetHeight: settings.targetHeight }, options); ... }; } });
Listing 3. camera.js
We are done with the real adapter. Let's create the fake one.
Create the fake object
In the fake object we are just going to return a hardcoded base64 string for all the methods. Create a new file with the same "camera.js" under fake folder and paste the below content.
define(['jquery'], function($) { 'use strict'; var samplebase64Image = 'iVBORw0KGgoAAAANSUhEUgAAACoAAAAUCAIAAAB00TjRAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAASAAAAEgARslrPgAABntJREFUSMfFlltsXEcZx+d2rrvnnF2vvU7WGzv1JTZJHSeo4SJVpEoRVZMmQpF4KFRCgFCAqoISURGpAsFbHyCRKpSKh0IkLi+lKFBUEWhEColwkiq4abK4tvc4Xttr733PZc/ZOWdmeFg1LU1TCC/M42i++X3/b77LQCEE+P8t8r+ZCSHAnX7/+w6EECD04ffAe1LfOwwhvIfznEOEwF1MyD2xe2AeRZ1G06vVOrV65DggCLgAiDMt8ADtcs6lZNLM55Nj4zC/DWAMhACcf2AkyH9J7klubWwslUoLhcLy4mJlY9N33U6r6QRBrl55OPJvJtOerJgDA7qiMqdtMbZ7W/7+T38m+dmjQNc/0IP/FHwhBAAQQk7p4lvXl1bXYoRkWaaUMs6zAwMXLl364+/OfrVT//vknhUzPWwae2Z2p1IpWVF++9JLWjccsouPWsbe7/9Q7NkL7/CA3FUuAADCnuig3b755tx62+nLZjVNgwgRQiCEYRheX1g6Urml7ZrelRvMr6x0+9O35uereuLgY4cmxyfcIFAeOvDqfGHzW08d+N4PpAMPA8YgxndR38vndxyMOWdxHDjtzfXyar1BXS/2/dB1Q8+J43ijXLZXV8ySPbq5erLe2acTmjDHHzloZfpBGN6X29pcX/eazUEjqfRlSn+98HkEBp87iWdm3vsKd+AhZJx3NiqdcjlsNGLXizwfRRQ4rWqpVKlstquVsF7lXsugXpbgoUTykuNfazaHjdSAqm7T1SRCMoSIc0yIJMmKJGNMVCu13KjLYx8Z/+4Ja2K8B3o/vhOG3StXa394pWnbvtPqek6347HAbfj+ApJxKt3vNtOAYwiSqi6ribjjD5umme7XJSyrmsA4hhBgLDBUNH3JXuBcjE3txKpaLC6W/vHG+GAumx7QvnTMPHq0F4N38QFj1TNn0C/PVIeH/XoV/XOOSiigoS3Qr5GV3r33/k89NHT2Fx9dLvRv30ETZpPDQQm5NCYSlOIuoAGPujyiIo4EiwBjgjHOAYEiZrwd49EtW+eY2Gi1jhx4ZPDHpyHBQAgCABCcQ4RaNwvOn89BQ0fZrN6sglQmiGjoOkF+NDG8y4R8X3lpa702PLX396538upb9bA7LuipB6bjtuutLRJZ4QIACASAEEIAIYKQQ8Qg6AKyLbPlN+XNH4VSPQjP/ezFFyYmMk99BwoBBecAwrBaaT9/anWp2KJd5ro0jiljgdvUOCWE0FTGYlGeiWQq/Ze1+adtN5PLKUKUvM5Mc/25HTm/VVcJYlyIdzojAAABwCGCMU3kpgRCx0qbgZmxdG3pzbmf7P/Y5145LzgngnOIcbO08refP58fHOgjWJIkggkiUqSDLkMAITlyoSSjpFENN18sN7ZunxpKG2YqndqsFDvujdXVCUOLaSTebfYQQAgggBAyhBUVzTUa1Mw8/fVjjuefKhaXlQQAgAtBejUgjdz3TWa0rt3SCUacYSAQEH39WYax4/m58bFcc/VZ5DQi5siDZkJ9/AtPjI6NvXD6tL2yUqH+MJI6qskhYgiHMQtiFgkQcUEj3uHy0M3CLOVkYsuyveyFoSxJ2U882Cs00qsBFaHtmCwwrkiSpKkxYwJAKABotrIQKEvFB/oMJ8bX235Nj0cxee38+dnLV1rtNsbo7UZH1awShQCCDg2sTMbKZpCsyImEput9lnXj7YVfnfvTx2e0G4VCJwi+/cwzX3zySQAAJoRAhAAAyXT6p2fPijh69sSJV187P6Br3TAgRuIbX/nycqHgNpt2HFfNkZJWW51ffHD//lqtbtv2jsnJ4Xx+ciinyvLcyy9fuHLV0PWvPf6EOTzChJAURVYULZHYue+TyhvXLr7++mOHDyuyPLVzp9/pCCEURYGu61JKwzBknMeMz16ePX78+HqpBAB49MiRQ4cPl0qlWr3utNoIQtsuXr54MZvNHjx0iBByeXa2aNuyqvq+H8cxiyJJlgVjQghFlhVF0TRN17RUOtU/kAUQpixrbGxsenp6ZGRkaGjINE24sbHhOE65XLZtu1gs1uv1dqu1trYWBIHneZVKRQjRS2bBuaIoCcNgjFmWlc1mNU3r6+szDSOVSpmWaVmpZCJhmKZhGIlEQtd1VVVVVZVlGWOsKIokSb2RffvH8P6JJ4SIoogxRimllHLO4zjmnAMAEEIYY4SQJEkIY4kQRVHwe+bHh39SboMghLfx/wLg1oT6h+nc9AAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNS0wMS0xN1QwODoxMjo1Mi0wNTowMD3Ny5MAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTUtMDEtMTdUMDg6MTI6NTItMDU6MDBMkHMvAAAAAElFTkSuQmCC'; return { getPicture: function() { var d = $.Deferred(); d.resolve(samplebase64Image); return d.promise(); }, capturePicture: function(success) { return this.getPicture(success); }, getPictureFromAlbum: function(success) { return this.getPicture(success); }, getPictureFromLibrary: function(success) { return this.getPicture(success); } }; });
Listing 4. Fake camera.js file
Create adapter to encrypt/decrypt content
Before writing the base64 string of the image to the file system we've to encrypt it and after reading the base64 string we've to decrypt it. Cordova doesn't provides a built-in API for encryption/decryption. Luckily a kind hearted person has shared a plugin (simple-crypto) that does what we want and with his permission I've dropped it to my Github repository. Without that plugin this app would have been impossible!
Create the real object
Create a new file with name "crypto.js" under "adapaters". Paste the below code.
define(['jquery'], function($) { 'use strict'; var getSimpleCrypto = function() { return cordova.require('com.disusered.simplecrypto.SimpleCrypto'); }; return { encrypt: function(key, data) { var d = $.Deferred(); getSimpleCrypto().encrypt(key, data, d.resolve, d.reject); return d.promise(); }, decrypt: function(key, data) { var d = $.Deferred(); getSimpleCrypto().decrypt(key, data, d.resolve, d.reject); return d.promise(); } }; });
Listing 5. crypto.js
The code is pretty self explanatory. The adapter provides two methods: encrypt and decrypt. Both of them takes two parameters: key and data.
Create the fake object for encryption/decryption
In the fake object we are are not going to encrypt or decrypt but we are going to return the passed string as it is. Create a new file with the same name "crypto.js" under "fake" folder. Drop the below snippet.
define(['jquery', 'underscore'], function($, _) { 'use strict'; return { encrypt: function(key, data) { return _.resolve(data); }, decrypt: function(key, data) { return _.resolve(data); } }; });
Listing 6. Fake crypto.js
Create adapter to resize image
To create thumbnails we need to resize the original image. Cordova doesn't provides any built-in API for image resizing. Luckily it was not hard for me to find a plugin. We are going to use the cordova-imageResizer plugin (originally written by Raanan) for resizing. Please go through their documentation and their API is quite simple, all it provides is just three methods!
Create the real object
Create a new file with name "imageresizer.js" under "adapters" folder. Copy and paste the below code.
define(['jquery'], function($) { 'use strict'; return { resize: function(imageBase64, width, height) { var d = $.Deferred(); window.imageResizer.resizeImage(function(data) { d.resolve(data); }, function(error) { d.reject(error); }, imageBase64, width, height, { imageType: ImageResizer.IMAGE_DATA_TYPE_BASE64, resizeType: ImageResizer.RESIZE_TYPE_PIXEL, storeImage: false, pixelDensity: true, photoAlbum: false }); return d.promise(); } }; });
Listing 7. imageresizer.js
The adapter contains a single method called resize that takes three parameters: imageBase64 (the base64 string of the image), width (the resize width), height (the resize height). We can resize the image using the plugin either by width or height not by both. If you want to resize by width then you should pass the height as 0. Alright, let's create the fake object.
Create the fake object for image resizer
Create a new file "imageresizer.js" under "fake" folder. In the fake object we are just going to return a hardcoded base64 string that represents the smaller car image.
define(['jquery'], function($) { 'use strict'; return { /* jslint unused: false */ resize: function(imageBase64, width, height) { var deferred = $.Deferred(); var resizedImageBase64 = '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'; deferred.resolve({ imageData: resizedImageBase64 }); return deferred.promise(); } }; });
Listing 8. Fake imageresizer.js
Let's create the final adapter - the one that helps to interact with the file system.
Create adapter to interact with file system
We are going to store the encrypted photos in File System. To read and write into File System Cordova provides a generic API (based on W3C File API) for all devices. The File API provides quite bunch of methods for managing directories and files. I'm not going to talk about the File API here. It is described in detail here.
Create the real object
Create a new file with name "persistentfilestorage.js" under "adapters" folder. Drop the below content. I can't explain in detail what's going inside the adapter. But at a higher level I can say it provides methods to create and remove directories and files. I referred tonyhursh's gapfile (The github repo is no more available) as an example to create the adapter. The important difference from his version is instead of using callbacks I've used promises.
// FileStorage module. // A wrapper to the cordova file plugin. // Contains methods to read/write folders and files into the persistent file storage. // Ref: https://github.com/tonyhursh/gapfile/blob/master/www/gapfile.js define(['jquery'], function($) { 'use strict'; var root = '/'; var extractDirectory = function(path) { var dirPath, lastSlash = path.lastIndexOf('/'); /*jslint eqeqeq:true*/ if (lastSlash == -1) { dirPath = root; } else { dirPath = path.substring(0, lastSlash); if (dirPath === '') { dirPath = root; } } return dirPath; }; var extractFile = function(path) { var lastSlash = path.lastIndexOf('/'); /*jslint eqeqeq:true*/ if (lastSlash == -1) { return path; } var filename = path.substring(lastSlash + 1); return filename; }; return { getFileSystem: function() { var d = $.Deferred(); window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, d.resolve, d.reject); return d.promise(); }, getDirectory: function(name, options) { var d = $.Deferred(); this.getFileSystem().then(function(fS) { fS.root.getDirectory(name, options, d.resolve, d.reject); }, d.reject); return d.promise(); }, createDirectory: function(name) { return this.getDirectory(name, { create: true, exclusive: false }); }, removeDirectory: function(name) { var d = $.Deferred(); this.getDirectory(name, { create: false, exclusive: false }).then(function(dirEntry) { dirEntry.removeRecursively(d.resolve, d.reject); }, d.reject); return d.promise(); }, getFile: function(path, dirOptions, fileOptions) { var d = $.Deferred(); this.getDirectory(extractDirectory(path), dirOptions).then(function(dirEntry) { dirEntry.getFile(extractFile(path), fileOptions, d.resolve, d.reject); }, d.reject); return d.promise(); }, writeToFile: function(path, data, append) { var d = $.Deferred(); this.getFile(path, { create: true, exclusive: false }, { create: true }).then(function(fileEntry) { var fileURL = fileEntry.toURL(); fileEntry.createWriter( function(writer) { writer.onwrite = function() { d.resolve(fileURL); }; writer.onerror = d.reject; if (append === true) { writer.seek(writer.length); } writer.write(data); }, d.reject); }, d.reject); return d.promise(); }, readFromFile: function(path, asText) { var d = $.Deferred(); this.getFile(path, { create: false, exclusive: false }, { create: false }).then(function(fileEntry) { fileEntry.file(function(file) { var reader = new FileReader(); reader.onloadend = function(evt) { d.resolve(evt.target.result); }; reader.onerror = d.reject; if (asText === true) { reader.readAsText(file); } else { reader.readAsDataURL(file); } }, d.reject); }); return d.promise(); }, deleteFile: function(path) { var d = $.Deferred(); this.getFile(path, { create: false, exclusive: false }, { create: false }).then(function(fileEntry) { fileEntry.remove(d.resolve, d.reject); }, d.reject); return d.promise(); } }; });
Listing 9. persistentfilestorage.js
Create the fake object
The fake object implements the same methods as the adapter but does nothing in most of the methods. Only in the method readFromFile it returns a hard-coded string.
define(['jquery', 'underscore'], function($, _) { 'use strict'; var fileContent = '{"id": "1", "data": "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"}'; var deferNothing = function() { return _.resolve(); }; return { createDirectory: function() { return deferNothing(); }, removeDirectory: function() { return deferNothing(); }, writeToFile: function() { return deferNothing(); }, readFromFile: function() { return _.resolve(fileContent); }, deleteFile: function() { return deferNothing(); } }; });
Listing 10. Fake persistentfilestorage.js
Alright, we've created all the adapters to interact with Cordova's APIs and their respective fake objects. Next we are going to see how we can customize the Backbone's persistent strategy to store the data to localStorage.
Override the Backbone's default persistent strategy
As default Backbone persist the model state in server through XHR. In our app we are not going to persist data in server. We are going to store some of the data in localStorage and some in FileSystem. Backbone is a good kid for customization. We can override the persistent strategy either at global level or at model level. Initially I thought to override the global persistent strategy and store the data either to localStorage or FileSystem. But I felt later it'll complicate things. Not only that, other than photos we are going to store the remaining data in localStorage. So I decided to use localStorage as the default persistent strategy by overriding Backbone at global level. Only for the File model we are going to override the persistent strategy at the model level.
We can override the Backbone's default persistent strategy at global level by overriding Backbone.sync function. Whenever you call any CRUD method in any model they'll be routed through the Backbone.sync function. The default implementation of Backbone.sync function is such that it makes RESTful AJAX calls to persist or retrieve the data from server. We can override the Backbone.sync function to change the persistent store to LocalStorage, SessionStorage, FileSystem etc. There is already a Backbone plugin written by Jerome available for LocalStorage. Unfortunately we can't use it in our app because of one main reason. The API methods are synchronous in nature and it makes because the W3C's LocalStorage API itself synchronous in nature. But in our case we've to encrypt the data before writing into LocalStorage and also after reading the data from LocalStorage we've to decrypt it. The encryption and decryption process takes time and therefor they are asynchronous in nature. Unfortunately we can't override the Backbone plugin methods to hook the encryption/decryption login and make it asynchronous. Because of that I'm forced to write myself a custom plugin based on Jerome's one.
Explaining about my custom plugin is out of scope and it may deviate your focus. I would request you to create a file with name "backbonelocalstorage.js" under the "js" folder and copy-paste the code. Go through the code and you could understand what's going on. If you've any specific questions please free to post a comment.
// Customized localstorage based persistent store for backbone. // Ref: https://github.com/jeromegn/Backbone.localStorage/blob/master/backbone.localStorage.js define([ 'jquery', 'backbone', 'underscore', 'serializer' ], function( $, Backbone, _, serializer ) { 'use strict'; Backbone.LocalStorage = function(name) { this.name = name; var store = this.localStorage().getItem(this.name); this.records = (store && store.split(',')) || []; }; _.extend(Backbone.LocalStorage.prototype, { // Save the data to localstorage. save: function() { this.localStorage().setItem(this.name, this.records.join(',')); }, // Serialize the newly created model and persist it. // Invoked by 'collection.create'. create: function(model) { var d = $.Deferred(); if (!model.id && model.id !== 0) { model.id = _.guid(); model.set(model.idAttribute, model.id); } serializer.serialize(model) .done(_.bind(function(data) { this.localStorage().setItem(this.itemName(model.id), data); this.records.push(model.id.toString()); this.save(); d.resolve(this.find(model)); }, this)) .fail(d.reject); return d.promise(); }, // Update the persisted model. update: function(model) { var d = $.Deferred(); serializer.serialize(model) .done(_.bind(function(data) { this.localStorage().setItem(this.itemName(model.id), data); var modelId = model.id.toString(); if (!_.contains(this.records, modelId)) { this.records.push(modelId); this.save(); } d.resolve(this.find(model)); }, this)) .fail(d.reject); return d.promise(); }, find: function(model) { return serializer.deserialize(this.localStorage().getItem(this.itemName(model.id))); }, // http://stackoverflow.com/questions/27100664/exit-from-for-loop-when-any-jquery-deferred-fails/27118636#27118636 findAll: function() { var d = $.Deferred(); var promises = this.records.map(_.bind(function(record) { return serializer.deserialize(this.localStorage().getItem(this.itemName(record))); }, this)); $.when.apply($, promises).done(function() { d.resolve(Array.prototype.slice.apply(arguments)); }).fail(d.reject); return d.promise(); }, destroy: function(model) { var d = $.Deferred(); this.localStorage().removeItem(this.itemName(model.id)); var modelId = model.id.toString(); for (var i = 0; i < this.records.length; i++) { if (this.records[i] === modelId) { this.records.splice(i, 1); } } this.save(); d.resolve(model); return d.promise(); }, clear: function() { var local = this.localStorage(), itemRe = new RegExp('^' + this.name + '-'); local.removeItem(this.name); for (var k in local) { if (itemRe.test(k)) { local.removeItem(k); } } this.records.length = 0; }, storageSize: function() { return this.localStorage().length; }, itemName: function(id) { return this.name + '-' + id; }, localStorage: function() { return window.localStorage; } }); // TODO: need to eliminate this. function result(object, property) { if (object === null) { return void 0; } var value = object[property]; return (typeof value === 'function') ? object[property]() : value; } // Localstorage sync. Backbone.LocalStorage.sync = function(method, model, options) { var d = $.Deferred(), resp, store = result(model, 'localStorage') || result(model.collection, 'localStorage'); switch (method) { case 'read': resp = (model.id !== undefined ? store.find(model) : store.findAll()); break; case 'create': resp = store.create(model); break; case 'update': resp = store.update(model); break; case 'delete': resp = store.destroy(model); break; } resp.done(function(result) { if (options && options.success) { options.success(result); } d.resolve(result); }).fail(function(error) { if (options && options.error) { options.error(error); } d.reject(error); }); if (options && options.complete) { options.complete(resp); } return d.promise(); }; // Override the backbone sync with localstorage sync. Backbone.sync = function(method, model, options) { return Backbone.LocalStorage.sync.apply(this, [method, model, options]); }; return Backbone.LocalStorage; });
Listing 11. backbonelocalstorage.js
If you see the dependency we are using a module called serializer. This module provides couple of methods: serialize and deserialize. The serialize method serializes the JavaScript object into JSON string, encrypt it and returns back in a promise object. The deserialize method does the reverse. Let's create the serializer module.
Create a new file with name "serializer.js" under "js" folder and paste the below code.
// Serializes javascript object to string and vice-versa. define([ 'jquery', 'underscore', 'adapters/crypto' ], function( $, _, crypto ) { 'use strict'; var key = 'SOME_KEY_THAT_WILL_BE_READ_FROM_SESSION_IN_FUTURE'; return { serialize: function(item) { var result = JSON.stringify(item); return crypto.encrypt(key, result); }, deserialize: function(data) { var d = $.Deferred(); crypto.decrypt(key, data) .done(function(result) { d.resolve(JSON.parse(result)); }) .fail(d.reject); return d.promise(); } }; });
Listing 12. serializer.js
We've hardcoded the key in the module. Actually what we will be doing is for each user we are going to create a unique key when he register into the app and store it in the localstorage along with the credential. Everytime he logs-in we are going to read the key and keep in session. We'll see how to accomplish this in the next part until that time let's use the hardcoded one.
Coming back to our custom backbone plugin, we've to do couple of things to put in into use. First, we've to include our plugin as a dependency in "extensions.js". Second update the photos collection add a new key (this is different from the encryption key I talked just before) that is required by our custom backbone plugin for persistence.
Open the "extensions.js" file and include the dependency backbonelocalstorage to the define statement.
define([ 'jquery', 'underscore', 'backbone', 'router', 'validation', 'stickit', 'touch', 'backbonelocalstorage' ], function( $, _, Backbone, router ) { // More Code Will Follow Here });
Listing 13. extensions.js
Open the "photos.js" file under the "collections" folder, add the dependency to backbonelocalstorage and add a new property with name localStorage to the collection and assign it a new instance of our custom plugin Backbone.LocalStorage passing a random key (the collection is stored in localStorage under this key).
define(['backbone', 'models/photo', 'backbonelocalstorage'], function(Backbone, Photo) { 'use strict'; // Represent collection of photos. var Photos = Backbone.Collection.extend({ // Use localstorage for persistence. localStorage: new Backbone.LocalStorage('secret-photos'), ... }); return Photos; });
Listing 14. photos.js under collections
Alright kudos! finally we customized the Backbone's default persistent strategy. Now we can store any collection or model to LocalStorage by just adding a property called localStorage and assigning a new instance of Backbone.LocalStorage passing the storage key as we did above. But remember in our app we don't the want File model to the persisted in LocalStorage and it should use the mobile's FileSystem as the persistent store. To achieve that let's override the sync method of that model.
Override the File model's persistent strategy
This is how our File model looks now.
define(['backbone', 'jquery'], function(Backbone, $) { 'use strict'; // Model used to represent the actual image file. var File = Backbone.Model.extend({ defaults: { data: null // file content }, // Validation rules. validation: { data: { required: true } }, fetch: function() { var d = $.Deferred(); var image = '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'; this.set({ data: image }); d.resolve(); return d.promise(); } }); return File; });
Listing 15. File model
We are returning a hard-coded encrypted base64 string of some photo from the fetch method. Actually what we should be doing is read the encrypted base64 string from the file system and set to the model properties. We don't have even methods to save or delete. Let's throw the fetch method out and override the sync method as we did in our custom backbone plugin. Explanation follows the code.
define([ 'jquery', 'underscore', 'backbone', 'adapters/persistentfilestorage', 'serializer' ], function( $, _, Backbone, persistentfilestorage, serializer ) { 'use strict'; // Model used to represent the actual image file. var File = Backbone.Model.extend({ defaults: { data: null // file content }, // Validation rules. validation: { data: { required: true } }, // Override the sync method to persist the photo in filesystem. sync: function(method, model, options) { var d = $.Deferred(), resp; switch (method) { case 'read': resp = this.findFile(); break; case 'update': resp = this.writeFile(model); break; case 'delete': resp = this.deleteFile(); break; } resp.done(function(result) { if (options && options.success) { options.success(result); } d.resolve(result); }).fail(function(error) { if (options && options.error) { options.error(error); } d.reject(error); }); if (options && options.complete) { options.complete(resp); } return d.promise(); }, findFile: function() { var d = $.Deferred(); persistentfilestorage.readFromFile(this.getPhotoPath(), true) .then(_.bind(function(persistedImage) { return serializer.deserialize(persistedImage); }, this), d.reject) .then(_.bind(function(image) { d.resolve(image); }, this), d.reject); return d.promise(); }, writeFile: function(model) { var d = $.Deferred(); serializer.serialize(model) .done(_.bind(function(encImage) { persistentfilestorage.writeToFile(this.getPhotoPath(), encImage); d.resolve(); }, this)) .fail(d.reject); return d.promise(); }, deleteFile: function() { return persistentfilestorage.deleteFile(this.getPhotoPath()); }, getPhotoPath: function() { return 'Photos/' + this.id + '.dat'; } }); return File; });
Listing 16. File model
In the sync method, based on the method parameter we are performing the required CRUD operation. We've created separate methods for each operation. The implementation very much follows our custom backbone plugin. Note that we are going to store all the encrypted files under a folder called "Photos" and the name of the file is going to be the model's id. The getPhotoPath returns the relative path of the file that is used by different methods.
We've completed coding the model layer for add/edit operation. The next thing we should do is create the view.
Create the View
Create the template
Before creating the view let's create the template. If you take a relook at the mockup (Fig. 2) you should notice the header contains cancel and save buttons. In the case of edit cancel button takes you to the single photo view page else the home page.
Then we've a form to edit the title and a placeholder to upload the photo. We also have a delete button at the bottom. Keeping all these things in the template makes our "addedit.handlebars" looks like below.
<header class="bar bar-nav"> {{#if id}} <a href="#photos/{{id}}" class="btn btn-link btn-nav pull-left"> {{else}} <a href="#photos" class="btn btn-link btn-nav pull-left"> {{/if}} <span class="icon icon-close"></span> </a> <button id="save" disabled class="btn btn-link btn-nav pull-right"> <span class="icon icon-check"></span> </button> <h1 class="title"> {{#if id}} Edit Photo {{else}} Add Photo {{/if}} </h1> </header> <nav class="bar bar-tab"> <a class="tab-item" href="#photos"> <span class="icon icon-home"></span> <span class="tab-label">Home</span> </a> <a class="tab-item" href="#settings"> <span class="icon icon-gear"></span> <span class="tab-label">Settings</span> </a> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav> <div class="content"> <form id="addedit-form" class="content-padded"> <input id="description" name="description" type="text" placeholder="Enter description" maxlength="25"> <a href="javascript:void(0);" id="addphoto"> {{#if thumbnail}} <img id="photo" class="img-responsive" src="data:image/png;base64,{{thumbnail}}" /> {{else}} <img id="photo" class="img-responsive" src="images/placeholder.png" /> {{/if}} </a> {{#if id}} <input id="delete" type="button" value="Delete" class="btn btn-negative btn-block"> {{/if}} </form> </div>
Listing 17. addedit.handlebars
There is one thing we should do before moving to create the view. Create a new folder called "images" under "src" and copy the placeholder image to it. The placeholder image is used by the <img> element when no image is selected.
Create the backbone view
Create a new file with name "addedit.js" under "views" folder. We've to do lot of things in the view. Unlike I did in other places I don't want to throw the complete code at your face and infact I want to show you how to build this one progressively. Let's start by breaking the whole work into smaller tasks.
- Render the view
- Allow user to select photo from library
- Complete the save functionality
- Complete the delete functionality
Before picking the first task, let's create a define statement with dependencies of all adapters and return an empty view from it.
define([ 'underscore', 'backbone', 'adapters/camera', 'adapters/imageresizer', 'adapters/crypto', 'adapters/persistentfilestorage', 'adapters/notification', 'templates' ], function( _, Backbone, camera, imageresizer, crypto, persistentfilestorage, notification, templates ) { 'use strict'; var AddEdit = Backbone.View.extend({ // lots of code here }); return AddEdit; });
Listing 18. addedit.js
Let's start working on the first task.
Render the view
To complete this task we've to complete two methods: initialize and render. We all know we are using a single view for both add and edit functionalities. Both the cases we've to pass the Photo and File model instances from router to the view. In the case of add we just have to pass empty objects and while in the case of edit we've to pass the persisted model instances. Also, in the case of add we've to pass the photos collection as well (which is available in the router).
Let's create the initialize method.
var AddEdit = Backbone.View.extend({ initialize: function(options) { // If model is not passed throw error. if (!this.model) { throw new Error('model is required'); } // If file model is not passed throw error. if (!(options && options.file)) { throw new Error('file is required'); } this.file = options.file; // A flag to store whether the view is rendered to add or edit photo. this.isEdit = this.model.id ? true : false; // If collection is not passed for add throw error. if (!this.isEdit && !this.collection) { throw new Error('collection is required'); } } // More Code To Follow });
Listing 19. initialize method
The initialize method contains mostly the validations other than the file and isEdit properties (you'll see their use soon). Let's create the render method. First let's start with rendering the template passing the model to it. Don't forget to set the template and the bindings.
var AddEdit = Backbone.View.extend({ template: templates.addedit, bindings: { '#description': 'description' }, ... render: function() { this.$el.html(this.template(this.model.toJSON())); this.stickit(); // Cache the DOM elements for easy access/manipulation. this.$save = this.$('#save'); this.$photo = this.$('#photo'); this.$delete = this.$('#delete'); this.$form = this.$('#addedit-form'); return this; } });
Listing 20. render method
Whatever we've so far in the render method will display only the description but not the image. Like we did in the single photo view page we are going to load the actual image lazily here.
render: function() { ... // If edit, load the image! if (this.isEdit) { this.file.fetch() .fail(function() { notification.alert('Failed to load image. Please try again.', 'Error', 'Ok'); }); } return this; }
Listing 21. Lazy loading image in render method
We've completed our first task. Let's move to next.
Allow user to select photo from library
When user touches the placeholder image we are going to open the photo library and on selecting any photo we are going to do three things.
- We are going to set the passed File model instance's data property
- Replace the placeholder image with the selected image's base64 string
- Create thumbnail
Before doing these things, first we've to wire-up the events. Let's create the event mappings for all the actions.
events: { 'click #addphoto': 'addPhoto', 'click #save': 'submit', 'submit #addedit-form': 'save', 'click #delete': 'delete' }
Listing 22. events mapping object
Though at the moment we worried about addPhoto handler (which is fired when we click the placeholder image), let's create empty handlers for other events. Below is the definition for the addPhoto method.
addPhoto: function(e) { e.preventDefault(); camera.getPictureFromLibrary() .done(_.bind(function(base64image) { this.file.set('data', base64image); this.createThumbnail(); }, this)); }
Listing 23. addPhoto handler
As I said earlier, we are opening the photo library by calling the getPictureFromLibrary of camera object and when the user selects any image we are updating the file object's data property with the base64 string of the image and finally we are calling a method to create the thumbnail. Below is the definition of createThumbnail.
createThumbnail: function() { var imageData = this.file.get('data'), resizeWidth = 42; imageresizer.resize(imageData, resizeWidth, 0) .done(_.bind(function(resizedImage) { this.model.set('thumbnail', resizedImage.imageData); }, this)) .fail(function() { notification.alert('Failed to create thumbnail.', 'Error', 'Ok'); }); }
Listing 24. createThumbnail method
What happening in the method is obvious! We are reading the actual base64 image from the data property and passing it to the imageresizer's resize method. Note that, we've hardcoded the resize width to 42 pixels but this also can be moved to settings! Once the image is resized it's base64 string is set to the model's (Photo) thumbnail property. Though we've done all these stuff we still missed one thing! We haven't updated image element with the selected image. Actually we could do that directly in the addPhoto handler but in Backbone world usually we observe to the model changes and update the DOM on need basis. Let's listen to the data property of the file model and whenever it changes just update the image element. Go to the initialize method and add the below line after at the end.
this.listenTo(this.file, 'change:data', this.updateImage);
Listing 25. Listening to "change" event in initialize method
Here is the updateImage method definition, just an one liner!
updateImage: function () { this.$photo.attr('src', 'data:image/png;base64,' + this.file.get('data')); }
Listing 26. updateImage handler
It's time to see how things work in mobile before moving further. Wait a minute.. let's give a quick shot in the browser before that. Run the app in browser by running grunt serve. Once you click the placeholder image you should see it'll be replaced by a sample red car.
To run in your mobile, connect the mobile to your computer and fire the grunt deploy command with the appropriate platform parameter.
Touch the placeholder image to choose a photo from the library and verify the selected image is assigned to the placeholder.
Complete the save functionality
The user can save by the photo either by clicking the save button or by submitting the form. The save button is outside the form so we couldn't set the type to "submit". Instead we are gonna listen to the click event of the save button and submit the form. If you see the events object shown in listing 22. we've set a handler with name "submit" for the button and "save" for the form submit. I hope you have already created the empty handlers. Paste the below code inside the submit handler.
submit: function () { this.$form.submit(); }
Listing 27. submit handler
Submitting the form invokes the save function. There's quite some logic going to sit in this method. Before showing you the code I thought better show you a flow chart.
Here is the complete code of the save function.
save: function(e) { e.preventDefault(); // If the models are not valid display error. if (!(this.model.isValid(true) && this.file.isValid(true))) { notification.alert('Some of the required fields are missing.', 'Error', 'Ok'); return; } // Disable the button to avoid saving multiple times. this.$save.attr('disabled', 'disabled'); // Update the last saved date. this.model.set('lastSaved', Date(), { silent: true }); // Function to save the file in file-system. var saveFile = function() { // If it's in edit model set the 'id' parameter. if (!this.isEdit) { this.file.set('id', this.model.id, { silent: true }); } // Write the base64 content into the file system. this.file.save() .done(_.bind(function() { this.navigateTo(this.isEdit ? '#photos/' + this.model.id : '#photos'); }, this)) .fail(_.bind(error, this)); }; // Common error function. var error = function() { notification.alert('Failed to save photo. Please try again.', 'Error', 'Ok') .done(_.bind(function() { this.saving = false; this.$save.removeAttr('disabled'); }, this)); }; this.saving = true; // If it's edit save else create. if (this.model.id) { this.model.save() .done(_.bind(saveFile, this)) .fail(_.bind(error, this)); } else { this.model.localStorage = this.collection.localStorage; this.collection.create(this.model, { wait: true, success: _.bind(saveFile, this), error: _.bind(error, this) }); } }
Listing 28. save method
Go through the code if you've some questions please ask. Shall we work on the delete functionality now?
Complete the delete functionality
The delete functionality is not that hard. We've to delete the file, delete the model and navigate to home page. Here is the code.
delete: function() { this.$delete.attr('disabled', 'disabled'); notification.confirm('Are you sure want to delete this photo?') .done(_.bind(function(r) { if (r === 1) { var deleteError = function() { notification.alert('Failed to delete. Please try again.', 'Error', 'Ok') .done(_.bind(function() { this.$delete.removeAttr('disabled'); }, this)); }; this.file.destroy() .then(_.bind(function() { return this.model.destroy(); }, this), _.bind(deleteError, this)) .then(_.bind(function() { this.navigateTo('#photos'); }, this), _.bind(deleteError, this)); } else { this.$delete.removeAttr('disabled'); } }, this)); }
Listing 29. delete method
We've nearly completed the add/edit view except for a small thing - enabling/disabling the save button. The save button should be enabled only if both the models are valid and the saving flag is false. We can achieve this behavior by first listening to the change event of model.
Add the below line inside the initialize method.
this.listenTo(this.model, 'change', this.enableDone);
Listing 30. Listening to model change event in initialize method
Here is your enableDone code.
enableDone: function() { if (!this.saving && this.model.isValid(true) && this.file.isValid(true)) { this.$save.removeAttr('disabled'); } else { this.$save.attr('disabled', 'disabled'); } }
Listing 31. enableDone handler
So finally here is the complete code for add/edit view.
define([ 'underscore', 'backbone', 'adapters/camera', 'adapters/imageresizer', 'adapters/crypto', 'adapters/persistentfilestorage', 'adapters/notification', 'templates' ], function ( _, Backbone, camera, imageresizer, crypto, persistentfilestorage, notification, templates ) { 'use strict'; // Add/edit view. // Renders the view to add/edit photo. Handles all the UI logic associated with it. var AddEdit = Backbone.View.extend({ // Set the template. template: templates.addedit, // Hook handlers to events. events: { 'click #addphoto': 'addPhoto', 'click #save': 'submit', 'submit #addedit-form': 'save', 'click #delete': 'delete' }, // Input elements-model bindings. bindings: { '#description': 'description' }, initialize: function (options) { // If model is not passed throw error. if (!this.model) { throw new Error('model is required'); } // If file model is not passed throw error. if (!(options && options.file)) { throw new Error('file is required'); } this.file = options.file; // A flag to store whether the view is rendered to add or edit photo. this.isEdit = this.model.id ? true : false; // If collection is not passed for add throw error. if(!this.isEdit && !this.collection) { throw new Error('collection is required'); } // Call the enable 'done' button handler when user selects a photo. this.listenTo(this.file, 'change:data', this.updateImage); // On change of the model, validate the model and enable the 'done' button. this.listenTo(this.model, 'change', this.enableDone); }, render: function () { // Render the view. this.$el.html(this.template(this.model.toJSON())); // Set the bindings. this.stickit(); // Cache the DOM elements for easy access/manipulation. this.$save = this.$('#save'); this.$photo = this.$('#photo'); this.$delete = this.$('#delete'); this.$form = this.$('#addedit-form'); // If edit, load the image! if (this.isEdit) { this.file.fetch() .fail(function () { notification.alert('Failed to load image. Please try again.', 'Error', 'Ok'); }); } return this; }, // add photo event handler. addPhoto: function (e) { e.preventDefault(); camera.getPictureFromLibrary() .done(_.bind(function (base64image) { this.file.set('data', base64image); this.createThumbnail(); }, this)); }, submit: function () { this.$form.submit(); }, // Save the photo model. save: function (e) { e.preventDefault(); // If the models are not valid display error. if (!(this.model.isValid(true) && this.file.isValid(true))) { notification.alert('Some of the required fields are missing.', 'Error', 'Ok'); return; } // Disable the button to avoid saving multiple times. this.$save.attr('disabled', 'disabled'); // Update the last saved date. this.model.set('lastSaved', Date(), { silent: true }); // Function to save the file in file-system. var saveFile = function () { // If it's in edit model set the 'id' parameter. if (!this.isEdit) { this.file.set('id', this.model.id, { silent: true }); } // Write the base64 content into the file system. this.file.save() .done(_.bind(function () { this.navigateTo(this.isEdit ? '#photos/' + this.model.id : '#photos'); }, this)) .fail(_.bind(error, this)); }; // Common error function. var error = function () { notification.alert('Failed to save photo. Please try again.', 'Error', 'Ok') .done(_.bind(function () { this.saving = false; this.$save.removeAttr('disabled'); }, this)); }; this.saving = true; // If it's edit save else create. if (this.model.id) { this.model.save() .done(_.bind(saveFile, this)) .fail(_.bind(error, this)); } else { this.model.localStorage = this.collection.localStorage; this.collection.create(this.model, { wait: true, success: _.bind(saveFile, this), error: _.bind(error, this) }); } }, // Delete the model and the file. delete: function () { this.$delete.attr('disabled', 'disabled'); notification.confirm('Are you sure want to delete this photo?') .done(_.bind(function (r) { if (r === 1) { var deleteError = function () { notification.alert('Failed to delete. Please try again.', 'Error', 'Ok') .done(_.bind(function () { this.$delete.removeAttr('disabled'); }, this)); }; this.file.destroy() .then(_.bind(function () { return this.model.destroy(); }, this), _.bind(deleteError, this)) .then(_.bind(function () { this.navigateTo('#photos'); }, this), _.bind(deleteError, this)); } else { this.$delete.removeAttr('disabled'); } }, this)); }, // Update the image tag. updateImage: function () { this.$photo.attr('src', 'data:image/png;base64,' + this.file.get('data')); }, // Resize the image and create thumbnail. createThumbnail: function () { var imageData = this.file.get('data'), resizeWidth = 42; imageresizer.resize(imageData, resizeWidth, 0) .done(_.bind(function (resizedImage) { this.model.set('thumbnail', resizedImage.imageData); }, this)) .fail(function () { notification.alert('Failed to create thumbnail.', 'Error', 'Ok'); }); }, // Enable the 'done' button if the models are valid. enableDone: function () { if (!this.saving && this.model.isValid(true) && this.file.isValid(true)) { this.$save.removeAttr('disabled'); } else { this.$save.attr('disabled', 'disabled'); } } }); return AddEdit; });
Listing 32. The complete code of addedit.js
Before testing the add/edit functionality we've to do some pending work in the router.
Update the router
The first thing we should do is, update the getPhotos method to return the persisted photos instead of the hardcoded ones and don't forget to include the "addedit" view as dependency to the define statement.
getPhotos: function () { var d = $.Deferred(); if (photos) { photos.sort(); d.resolve(photos); } else { photos = new Photos(); photos.fetch().done(d.resolve).fail(d.reject); } return d.promise(); }
Listing 33. getPhotos method in router.js
Next configure routes for add/edit requests. For that we should add couple of entries to the routes object.
routes: { '': 'photos', 'photos': 'photos', 'photos/:id': 'photo', 'add': 'add', 'edit/:id': 'edit' }
Listing 34. Adding new routes to routes object
Finally create the handlers for those routes.
add: function () { this.getPhotos() .done(_.bind(function () { this.renderView(new AddEditView({ model: new Photo(), file: new File(), collection: photos })); }, this)) .fail(function () { notification.alert('Error occured while retrieving the associated collection. Please try again.', 'Error', 'Ok'); }); }, edit: function (id) { this.getPhotos() .done(_.bind(function () { var photo = photos.get(id); if (!photo) { notification.alert('Photo not exists.', 'Error', 'Ok'); return; } this.renderView(new AddEditView({ model: photo, file: new File({ id: id }) })); }, this)) .fail(function () { notification.alert('Failed to retrieve photo. Please try again.', 'Error', 'Ok'); }); }
Listing 35. The add and edit route handlers
That's it! We've completed the coding for the add/edit functionality. It's time to re-deploy to mobile and see how things works.
Summary
In this third part we've seen how to build the add/edit page. On the way of building this page we've seen how to create the adapters to interact with different components of mobile. We saw how to override the persistent strategy of Backbone at global and model level. I hope those things you learnt here will definitely helpful in other places as well. In the upcoming part we are going to see how to create the registration, login, change password and other pages. We'll also see how to do unit testing using Karma and Jasmine. Please share your thoughts and questions. Soon you are gonna get the fourth part.