Skip to main content

Android and iOS Mobile Application Development (Part 3): Creating our first Mobile Application in Visual Studio 2015 using Apache Cordova

Welcome to the third part of the series on Android and iOS Mobile Application Development. Let's begin!

Creating our first Apache Cordova project

First, in Visual Studio 2015, we will need to select Other Languages > Javascript. From here, we can choose Blank App (Apache Cordova).

At first glance, we can see that the project structure is very much different than how we are used to seeing it. However, this project structure is pretty much in line with how we will be writing web applications in the future. Scott Hanselman has a pretty good article explaining the idea behind this which I encourage you to read if you are not already familiar with Bower and NPM.

For the purpose of this article, we are going to go straight into downloading the packages we need using Bower. Let's open up the bower.json file. We will be downloading AngularJs, Ratchet and something called Script.js Simply, Script.js helps us by being able to download our javascript files on-demand and loading them into the DOM for use. Let's modify our file like so. Note that you can keep the default name.

{
  "name": "Demo1",
  "dependencies": {
    "angular": "1.4.8",
    "angular-route": "1.4.8",
    "script.js": "2.5.8",
    "ratchet": "2.0.2"
  }
}

We can observe that once we save the bower.json file, our packages are automatically downloaded. Very cool!

Once all the packages are downloaded, we might imagine that the files are automatically deployed to the appropriate location. Actually, this is very different than Nuget. We will have to write the scripts using either Gulp or Grunt to deploy the files from the source location to the appropriate directories in WWW folder. This is where NPM comes into play.

We will have to use NPM to download Gulp or Grunt. For me, I have chosen Grunt but you are free to use Gulp if you like. To download, simply open packages.json and modify the file like so. Note that we are only adding the devDependencies section.

{
  "name": "Demo1",
  "version": "1.0.0",
  "dependencies": {
  },
  "devDependencies": {
    "grunt": "0.4.5",
    "grunt-contrib-copy": "0.8.2" 
  }
}

Once again, we can see that the packages are automatically downloaded after we click save, just like bower. Now that we have downloaded grunt, we will need to create a file called gruntfile.js and add the following content.

module.exports = function (grunt) {
    'use strict';
    grunt.initConfig({
        copy: {
            main: {
                files: [
                    {
                        expand: true,
                        cwd: 'bower_components/angular',
                        src: ['*.min.js'],
                        dest: 'www/scripts'
                    },
                    {
                        expand: true,
                        cwd: 'bower_components/angular-route',
                        src: ['*.min.js'],
                        dest: 'www/scripts'
                    },
                    {
                        expand: true,
                        cwd: 'bower_components/ratchet/dist/css',
                        src: ['*.min.css'],
                        dest: 'www/css'
                    },
                    {
                        expand: true,
                        cwd: 'bower_components/ratchet/dist/fonts',
                        src: ['*.*'],
                        dest: 'www/fonts'
                    },
                    {
                        expand: true,
                        cwd: 'bower_components/script.js/dist',
                        src: ['*.min.js'],
                        dest: 'www/scripts'
                    }
                ]
            }
        }
    });

    //Add all plugins that your project needs here
    grunt.loadNpmTasks('grunt-contrib-copy');
    grunt.registerTask('default', ['copy']);
};

On the bottom, you may notice something called Task Runner. Let's click on it. We can see that our gruntfile is seen there. We will need to click on the Refresh icon to load our gruntfile into Task Runner.

Let's right click on the copy:main node and click on Run. Check to make sure there is no errors. We can now expand the WWW node in our project structure. The necessary css, js and files are deployed. Now, we are ready to start writing some code!

Updating Apache Cordova index.html and index.js

Index.html is the initial default landing page of the Apache Cordova project. Let's open up index.html and add the necessary css and javascript file references as well as an angularjs declaration.

Add the following link to the styles section.

<link href="css/ratchet.min.css" rel="stylesheet" />

Add the following angularjs declaration to the body section. If you are already familiar with AngularJs, you may be thinking that we are missing something called ng-app. Well, this has to be done later and will be explained in a little bit.

<ng-view></ng-view>

Add the following scripts to the scripts section after the body.

<script src="scripts/angular.min.js"></script> 
<script src="scripts/angular-route.min.js"></script> 
<script src="scripts/script.min.js"></script>

After this is done, we are ready to start modifying our index.js file which, as you can see, is already referenced in the scripts section. We can see that the Cordova Apache project already has the code to listen for when the device is ready. This is the time we can execute our code. However, in AngularJs, our code will start execution if we are using the common ng-app approach. We want to defer execution until the device is ready. We are going to use angular.bootstrap api to control when to initialize.

(function () {
    "use strict";

    angular.element(document).ready(function () {
            document.addEventListener('deviceready', function () {

                angular.bootstrap(document.body, ['demo1']);
                // Handle the Cordova pause and resume events
                document.addEventListener('pause', onPause.bind(this), false);
                document.addEventListener('resume', onResume.bind(this), false);

            }, false);
    });

    function onPause() {
        // TODO: This application has been suspended. Save application state here.
    };

    function onResume() {
        // TODO: This application has been reactivated. Restore application state here.
    };
} )();

We are now ready to create the structure of our application. We can go ahead and create the following files as seen in the screenshot.

Let's take a look at what each file does and the contents within each file.

app.js

app.js is used to initialize our application. It contains the code for all the routes as well as a way to load those routes dynamically (via Script.js). This means we don't have to declare the .html and .js files in our index.html. They are loaded to the DOM on demand. Notice that we are also setting our up our $controllerProvider in our controllers config. This will be used as part of the code to dynamically load our .html and .js files.

(function () {
    'use strict';

    var resolveRouteByConvention = function (module) {
        return {
            controller: module + "Ctrl",
            templateUrl: "app/" + module + ".html",
            resolve: {
                deps: function ($q, $rootScope) {

                    var deferred = $q.defer();

                    $script(["app/" + module + ".js"], function () {
                        $rootScope.$apply(deferred.resolve);
                    });

                    return deferred.promise;
                }
            }
        };
    };

    var app = angular.module("demo1", ["ngRoute", "demo1.services", 'demo1.controllers', 'demo1.directives']);

    app.config(["$routeProvider",
    function ($routeProvider) {
        $routeProvider.when("/", resolveRouteByConvention("dashboard"));
    }]);

    app.run();

    angular.module('demo1.services', []);

    var controllers = angular.module('demo1.controllers', []);
    controllers.config(["$controllerProvider", function ($controllerProvider) {
        controllers.controllerProvider = $controllerProvider;
    }]);

    angular.module('demo1.directives', []);

})();

azureAuthentication.js

azureAuthentication.js is an AngularJs provider that is used to provide authentication services via Azure AD. It makes use of the Apache Cordova Browser Plugin - inAppBrowser, to redirect the user to the sign-in page. It then helps parse the code from the successful sign in and do a POST to get the authorization token.

There are also mechanisms to track the expiration of the authorization token and handle any token refresh. There are 4 variables to modify here per your application. The resourceUrl is the URL to your Web API endpoint. The client Id is the application Id for your native mobile application that you have created in Azure AD previously. The Resource Id is the Application Id of the Web API that you have created in Azure AD previously. The App Id is the application name you have given for your Active Directory.

Of significant, the resourceUrl is used as a way to append the base domain to each request. This means when you do a $http.get("abc"), it will append the resourceUrl so the get request would now be a full valid URL. You are free to change the implementation details for your needs but this is how we plan to demonstrate our example mobile application.

This provider will be used in httpRequestInterceptor.js which we will explain further.

(function () {
    'use strict';
    console.log("Load azureAuthentication.");
    var app = angular.module("demo1.services");

    app.factory('azureAuthentication',
    ['$log', "$q", "$window",
    function ($log, $q, $window) {

        var resourceUrl = "https://[MY_WEBAPI_SITE].azurewebsites.net/";
        var clientId = "NATIVE_APP_ID";
        var resourceId = "WEB_API_APP_ID";
        var appId = "[MY_APP_NAME].onmicrosoft.com";

        var azureTokenStorageName = "azureToken";

        var getEncodedUrl = function () {
            var encoded = encodeURIComponent(resourceUrl + "callback.html");
            return encoded;
        };

        var authenticationUrl = "https://login.microsoftonline.com/" + appId + "/oauth2/authorize?response_type=code&client_id=" + clientId + "&redirect_uri=" + getEncodedUrl();
        var accessTokenUrl = "https://login.microsoftonline.com/" + appId + "/oauth2/token";
        var logoutUrl = "https://login.microsoftonline.com/common/oauth2/logout";


        $log.debug('Initializing azure authentication...');

        var getAzureTokenFromStorage = function () {
            if ($window.localStorage) {
                var json = $window.localStorage.getItem(azureTokenStorageName);
                if (json) {

                    $log.debug("Retrieving Azure token from local storage.");

                    var token = angular.fromJson(json);

                    token.expiresOn = new Date(token.expiresOn);

                    return token;
                } else {
                    $log.debug("Json from local storage is invalid.");
                }
            } else {
                $log.debug("Local storage is not available.");
            }
            return null;
        };

        var setAzureTokenToStorage = function (token) {
            if ($window.localStorage) {
                $window.localStorage.setItem(azureTokenStorageName, angular.toJson(token));
                $log.debug("Azure token has been saved into local storage.");
            } else {
                $log.debug("Local storage is not available.");
            }
        };

        var removeTokenFromStorage = function () {
            if ($window.localStorage) {
                $window.localStorage.removeItem(azureTokenStorageName);
                $log.debug("Azure token has been removed from local storage.");
            } else {
                $log.debug("Local storage is not available.");
            }
        };

        var azureToken = getAzureTokenFromStorage();

        var parseKeysForCode = function (keys) {

            if (keys.length > 0) {
                for (var i = 0; i < keys.length; i++) {
                    var key = keys[i].split('=');
                    if (key[0] === "code") {

                        return key[1];
                    }
                }
            }

            return null;
        };

        var updateConfigWithAuthorization = function (config) {

            $log.debug("Updating authorization on config.");

            config.headers["Authorization"] = "Bearer " + azureToken.value;
        };

        var postData = function (data, config, deferred, isRefreshTokenAction) {

            $log.debug("Posting data.");

            var http = new XMLHttpRequest();

            http.open("POST", accessTokenUrl, true);

            //Send the proper header information along with the request
            http.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

            http.onreadystatechange = function () {//Call a function when the state changes.
                if (http.readyState === 4) {

                    $log.debug("POST response received:" + http.status);

                    if (http.status === 200) {
                        var token = angular.fromJson(http.responseText);

                        var expiresOn = new Date(parseInt(token.expires_on) * 1000);

                        azureToken = {
                            expiresOn: expiresOn,
                            value: token.access_token,
                            refreshToken: token.refresh_token
                        };

                        updateConfigWithAuthorization(config);

                        setAzureTokenToStorage(azureToken);

                        deferred.resolve(config);
                    } else {

                        if (isRefreshTokenAction) {

                            $log.debug("Failed to get new token from refresh token value. Attempting to get user to login.");

                            // It seems getting refresh token has failed. If this is the case,
                            // let's get the user to login.
                            removeTokenFromStorage();

                            authenticateAndGetAzureToken(config, deferred);
                        } else {
                            deferred.reject(config);
                        }

                    }
                }
            }
            http.send(data);
        };

        var getAccessToken = function (code, config, deferred) {

            $log.debug("Get access token.");

            var data = "client_id=" + clientId;
            data += "&code=" + code;
            data += "&grant_type=authorization_code"
            data += "&redirect_uri=" + getEncodedUrl()
            data += "&resource=" + resourceId;
            postData(data, config, deferred, false);
        };

        var refreshAzureToken = function (config, deferred) {

            $log.debug("Get refresh token.");

            var data = "client_id=" + clientId;
            data += "&grant_type=refresh_token"
            data += "&refresh_token=" + azureToken.refreshToken;
            data += "&resource=" + resourceId;
            postData(data, config, deferred, true);
        };

        var authenticateAndGetAzureToken = function (config, deferred) {

            var loadStop = function (event) {

                $log.debug("Accessing url: " + event.url);

                if (event.url.toLowerCase().indexOf(resourceUrl.toLowerCase()) === 0) {

                    $window.inAppBrowser.removeEventListener("loadstop", loadStop);
                    $window.inAppBrowser.removeEventListener("loaderror", loadError);
                    $window.inAppBrowser.close();

                    var parser = document.createElement('a');
                    parser.href = event.url;

                    var keys = parser.search.substr(1).split('&');
                    var code = parseKeysForCode(keys);

                    if (code !== null) {
                        getAccessToken(code, config, deferred);
                    }
                };
            };

            var loadError = function () {
                $window.inAppBrowser.removeEventListener("loadstop", loadStop);
                $window.inAppBrowser.removeEventListener("loaderror", loadError);
                $window.inAppBrowser.close();

                deferred.reject("An error has prevented sign-in. Please try again later.");
            };

            $window.inAppBrowser = $window.cordova.InAppBrowser.open(authenticationUrl, '_blank', 'location=no');
            $window.inAppBrowser.addEventListener("loadstop", loadStop);
            $window.inAppBrowser.addEventListener("loaderror".loadError);
        };

        var setConfigWithAzureAuthorizationToken = function (config, deferred) {

            // We only want to handle external request.
            config.url = resourceUrl + config.url;

            $log.debug('Updated request url: ' + config.url);

            if (azureToken !== null) {
                var date = new Date();

                $log.debug("Current date:" + date);
                $log.debug("Azure token date:" + azureToken.expiresOn);

                if (+azureToken.expiresOn > +date) {
                    updateConfigWithAuthorization(config);
                    deferred.resolve(config);
                } else {
                    refreshAzureToken(config, deferred);
                }
            } else {
                authenticateAndGetAzureToken(config, deferred);
            }
        };

        var signOut = function () {
            var deferred = $q.defer();

            if (azureToken !== null) {
                removeTokenFromStorage();

                var whenStop = function () {
                    $window.inAppBrowser.removeEventListener("loadstop", whenStop);
                    $window.inAppBrowser.close();
                    deferred.resolve();
                };

                $window.inAppBrowser = $window.cordova.InAppBrowser.open(logoutUrl, '_blank', 'hidden=yes');
                $window.inAppBrowser.addEventListener("loadstop", whenStop);
            } else {
                deferred.resolve();
            }

            return deferred.promise;
        };

        var instance = {
            setConfigWithAzureAuthorizationToken: setConfigWithAzureAuthorizationToken,
            signOut: signOut
        };

        return instance;
    }]);
})();

httpRequestInterceptor.js

httpRequestInterceptor.js is an AngularJs service that will hook on to any http outgoing request. The function is used to observe any request that does not contain app as part of the request URL and set authentication credentials to the outgoing request, which is handled by azureAuthentication.js.

(function () {
    'use strict';

    var app = angular.module("demo1.services");

    app.factory('httpRequestInterceptor',
    ['$log', "$q", "azureAuthentication",
    function ($log, $q, azureAuthentication) {

        var instance = {

            // optional method
            'request': function (config) {

                var deferred = $q.defer();

                if (config.url.indexOf("app/") === -1) {

                    azureAuthentication.setConfigWithAzureAuthorizationToken(config, deferred);

                } else {
                    deferred.resolve(config);
                }

                return deferred.promise;
            }
        };

        return instance;

    }]);

    app.config(['$httpProvider', function ($httpProvider) {
        $httpProvider.interceptors.push('httpRequestInterceptor');
    }]);
})();

dashboard js/html

dashboard.js/html is the landing page of your mobile application. Here, you can now write some code to access your Web API resource. For example, you may consider doing a $http.get.

(function () {
    "use strict"
    angular.module("demo1.controllers").controllerProvider.register("dashboardCtrl",
        ["$scope", "$http",
        function ($scope, $http) {
           //TOOD: Write some got to invoke a HTTP request to the Web API server which will kick off the azureAuthentication provider.
        }]);
})();

The most basic html we can use can be like so. For more examples, we can use this link.

<header class="bar bar-nav"> 
   <h1 class="title">Dashboard</h1> 
</header> 
<div class="content"> 
    <h1>You are now logged in!</h1>
</div>

Updating the index.html

Now, we can modify the index.html file with the new files we have created.

Add the following scripts to the scripts section after the body.

<script src="app/app.js"></script> 
<script src="app/azureAuthentication.js"></script> 
<script src="app/httpRequestInterceptor.js"></script>

There is a special security tag in the index.html called the Content-Security-Policy. It is used to enforced the URIs that are allowed to be invoked from the context of the index.html page. Here, we can see that we are allowing for our web api, and login from live.com and or microsoftonline.com.

<meta http-equiv="Content-Security-Policy" content="default-src 'self' https://[MY WEB API].azurewebsites.net https://login.microsoftonline.com https://login.live.com data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *">

We are almost ready to start debugging our mobile application. However, we will have to do some more security configurations next.

config.xml

The config.xml is the Apache Cordova configuration file. Double click on the config.xml and the following will be opened.

The package name is critical as this identifies your application. This is also required for when we deploy to iOS later. In domain access, we should should remove * and add the same 3 URIs - https://[My WEB API].azurewebsites.net, https://login.live.com, and https://login.microsoftonline.com. Next, we will need to click on Plugins and add InAppBrowser which is used to help us do the actual sign-in.

Start debugging!

Now we are ready to start debugging! When we select the run dropdown in Visual Studio 2015, we will see several options. Ripple is the recommended environment to run your code in if you are not using any plugins. It is basically a browser based environment to allow us to test our javascript code easily and also debug. Because we are running plugins (InAppBrowser), it is best to run with Google Android Emulator.

You may want to set some breakpoints in the areas you are interested in. After you hit run, note that it may take several minutes before the Google Android Emulator is finally started. If everything is successfully, you will see the following screen. You can use the account(s) you have created in your Azure AD to login.

After you have signed in successfully, you should be able to see your dashboard content.

That's it! We have successfully written our first mobile application with a Web API backend, and Web API authentication handled by Azure AD. Pretty cool right? I hope you have enjoyed the third part of the series on Android and iOS Mobile Application Development. In the fourth and final part of our series, we will take a look at how we can deploy our mobile application to Android and iOS devices.

Comments

  1. This is really helpful and informative, as this gave me more insight to create more

    ideas and solutions for my plan.keep update with your blog post.

    Website Design Company in Bangalore
    Website Development Company in Bangalore

    ReplyDelete

Post a Comment