Cross-Platform mobile applications with NativeScript Core and TypeScript – Part 3

In the first part of this tutorial, we learned how to get started with NativeScript Core, while in the second part, we learned how to exploit the most important features of this framework by testing this example application. In the third (and last) part of this tutorial, we are going to take a look at the internal infrastructure, to understand how the main components of the framework and the support structures work.

Template

The <Frame> component is the basis of the view template mechanism. Inside the app folder, the app-root.xml file contains the root view of the application:

<!-- app-root.xml -->
<Page xmlns="http://schemas.nativescript.org/tns.xsd">
    <!-- We are using a GridLayout -->
    <GridLayout columns="*" rows="auto, *">
        <!-- Header -->
        <Frame id="header" row="0" col="0" defaultPage="/pages/header/header-page">
        </Frame>
        <!-- Body -->
        <Frame id="body" row="1" col="0" defaultPage="/pages/body/body-page">
        </Frame>
    </GridLayout>
</Page>

NativeScript UI provides different types of Layout Containers. As you can see, the root view of the example application uses a GridLayout, which expands to fill the entire page. Inside the layout, it’s possible to see two different <Frame> components. The first one is used to render the header page, defined inside the app/pages/header/header-page.xml file, while the second one is used to render the body page, defined inside the app/pages/body/body-page.xml file. Obviously, this is a trivial template configuration which could be changed. For example, someone may need to add a new <Frame> component as a footer.

Even the body page, in turn, works as a template:

<!-- body-page.xml -->
<Page xmlns="http://schemas.nativescript.org/tns.xsd" 
      xmlns:sd="nativescript-ui-sidedrawer" 
      actionBarHidden="true" navigatingTo="navigatingTo" 
      class="page">
    <sd:RadSideDrawer id="sideDrawer">
        <sd:RadSideDrawer.drawerContent>
            <!-- Left menu content here -->
        </sd:RadSideDrawer.drawerContent>
        <sd:RadSideDrawer.mainContent>
            <!-- Dynamic render page -->
            <Frame defaultPage="{{ accessPage }}">
            </Frame>
        </sd:RadSideDrawer.mainContent>
    </sd:RadSideDrawer>
</Page>

The <sd:RadSideDrawer.drawerContent> tag allows defining the toggle menu content that will be available on any page, while the <sd:RadSideDrawer.mainContent> tag allows rendering dynamic content through the <Frame> component, depending on the value of the accessPage parameter. By starting from the login page, defined inside the app/pages/login/login-page.xml file, the NativeScript Navigation mechanism will redirect any content inside the frame above. The final result is a set composed of static and dynamic pages contained inside the same final page.

Dependency Injection

IoC is an increasingly widespread development pattern, based on the concept of inversion of control. Dependency Injection (DI) is a technique that implements this pattern, which provides for the injection of the dependencies into an object, instead of their instantiation inside it. Frameworks like Angular provide a native DI mechanism. Contrariwise, in this case, we need to create our personal system.

Inside the app/infrastructure folder, the ioc-container.ts file contains the methods that allow managing a set of object instances to inject:

export class IoCContainer {

    private static container: Map<string, any> = new Map<string, any>();

    // Register a new dependency 
    public static register<T>(type: (new () => T)) : void {
        this.container.set(type.name, new type());
    }

    // Bind a new dependency to an object instance
    public static bind(type: Function, target: Object) : void {
        this.container.set(type.name, target);
    }

    // Get a registered dependency
    public static get(type: Function) : any {
        return this.container.get(type.name);
    }

}

The register function allows us to register a new instance of an object by passing the class type. Contrariwise, the bind function allows us to register a new dependency by passing the class type and the instance of the object to bind. Here the difference is simple. In the first case, the dependency is instantiated by calling the default constructor. In the second case, it’s possible to call a different constructor and after that, register the initialised object. Here below, is an example of dependency registration:

import { Logger } from "~/infrastructure/logger";
import { Encryptor } from "~/infrastructure/encryptor";
import * as CryptoJS from "crypto-js";

public static bind(): void {

      // Register the 'Logger' class as a new dependency
      IoCContainer.register(Logger);

      // Bind the 'Encryptor' class to a new instance
      IoCContainer.bind(Encryptor, new Encryptor(CryptoJS.AES));

      // Other dependencies...

}

Check out the bind static method, defined inside the app/config/bindings.ts file, to see how the dependencies of the example application are registered.

The @Injectable property decorator, defined inside the app/infrastructure/injectable-decorator.ts file, allows injecting a dependency into an object instance by looking at the decorated property type. In this way, the property will be referenced to the registered dependency associated with it:

import { Observable } from "tns-core-modules/data/observable";
import { Injectable } from "~/infrastructure/injectable-decorator";
import { Logger } from "~/infrastructure/logger";

export class HomeViewModel extends Observable {

    @Injectable
    logger: Logger; // The 'Logger' dependency is injected

    // Other properties and methods...

}

Storage

The implemented storage system exploits the native storage mechanism and adds new functionalities. Essentially, the storage system allows storing particular kind of objects, named Storable, defined inside the app/infrastructure/storable.ts file:

import { IStorable } from "./interfaces/istorable";
import { Storage } from "./storage";
import { STORAGE_KEYS } from "~/config/enums";

export abstract class Storable<T> implements IStorable {

    private storage: Storage; // Low-level storage system
    private storageKey: STORAGE_KEYS; // Object's storage key
    private encryptionKey: string; // Object's ecryption key
    private volatile: boolean; // Store the object as a volatile

    constructor(storage: Storage, storageKey: STORAGE_KEYS, encryptionKey?: string, volatile?: boolean) {
        this.storage = storage;
        this.storageKey = storageKey;
        this.encryptionKey = encryptionKey;
        this.volatile = volatile;
        this.load(this.storage.load(storageKey, encryptionKey, volatile));
    }

    // Load the object properties from the storage
    protected abstract load(storable?: any): void;

    // Save or update the object into the storage
    public update() {
        this.storage.store(this, this.storageKey, this.encryptionKey, this.volatile);
    }

    // Delete the object from the storage and reload it
    public delete() {
        this.storage.delete(this.storageKey, this.volatile);
        this.load();
    }

}

Any object that extends and implements the Storable abstract class becomes a storable object. These kinds of objects provide for load, update and delete methods. The load method allows loading the object model from the internal storage, while the update method allows updating and saving it. Finally, the delete method allows removing it from the internal storage. Any storable object can be stored inside the permanent storage or the volatile one through a storage key, which works as an identification key for the object itself. Obviously, any object stored inside the volatile storage will be lost after the application restart. Also, a storable object can be encrypted before being stored by specifying an encryption key.

The Storage class, defined inside the app/infrastructure/storage.ts file, provides low-level methods that allow storing, loading and deleting the storable objects:

import { IStorage } from "./interfaces/istorage";
import * as appSettings from "tns-core-modules/application-settings";
import { Encryptor } from "./encryptor";
import { Injectable } from "./injectable-decorator";
import { STORAGE_KEYS } from "~/config/enums";

export class Storage implements IStorage {

    @Injectable
    encryptor: Encryptor;

    private volatileStorage: Map<string, any>;

    constructor() {
        this.volatileStorage = new Map<string, any>();
    }

    // Save or update a storable object
    public store(data: any, storageKey: STORAGE_KEYS, encryptionKey?: string, volatile?: boolean): void {
        if (!volatile) {
            appSettings.setString(storageKey, encryptionKey ? this.encryptor.encrypt(JSON.stringify(data), encryptionKey) : JSON.stringify(data));
            appSettings.flush();
        } else this.volatileStorage.set(storageKey, encryptionKey ? this.encryptor.encrypt(JSON.stringify(data), encryptionKey) : JSON.stringify(data));
    }

    // Load a storable object
    public load(storageKey: STORAGE_KEYS, encryptionKey?: string, volatile?: boolean): any {
        var encryptedString: string;
        if (volatile) encryptedString = this.volatileStorage.get(storageKey);
        else encryptedString = appSettings.getString(storageKey);
        if(!encryptedString) return null;
        return encryptionKey ? JSON.parse(this.encryptor.decrypt(encryptedString, encryptionKey)) : JSON.parse(encryptedString);
    }

    // Remove a storable object
    public delete(storageKey: STORAGE_KEYS, volatile?: boolean): void {
        if (volatile) this.volatileStorage.delete(storageKey);
        else {
            appSettings.remove(storageKey);
            appSettings.flush();
        }
    }

}

This class exploits the native storage mechanism, provided by the NativeScript ApplicationSettings module, and adds new features like volatile data storage and encryption. The storage encryption engine is injected as a dependency. The example application uses AES (Advanced Encryption Standard) as an encryption method, but you can specify a different encryption algorithm by passing it to the Encryption class constructor, inside the bind static method of the Bindings class.

The app/models folder contains the storable objects used by the example application:

  • Settings: is used to contain all the runtime settings of your application, including configuration parameters, and so on.
  • User: is used to storing the information of the logged user (username, authentication token, rooms, etc…), which are obtained once the login has been performed.
  • Identity: is used to store the authorisation roles of the logged user, which define the rules for the resources access.

A storable object isn’t directly injected as a dependency. Rather, an injectable service exposes it to the external. The app/services folder contains this kind of services:

  • SettingsService: exposes the Settings storable object. It provides the save method, which allows updating the settings into the storage.
  • UserService: exposes the Users storable object. It provides login and logout methods, which allow authenticating the user.
  • IdentityService: exposes the Identity storable object. It provides loadIdentity and unloadIdenity methods, which allow authorising the user to access specific resources.

Take a look at the storable objects of the example application, and at the associated services, to get more details about how the storage system works.

HTTP Client & Token-based Authentication

The HttpClient class, defined inside the app/infrastructure/http-client.ts file, allows performing REST (Representational State Transfer) HTTP requests towards one or more API (Application Programming Interface) endpoints by implementing the most common CRUD operations: PUT (Create), GET (Read), POST (Update), DELETE (Delete).

This client can operate in two different modalities: default and authenticated. With the first one, it works like a standard HTTP client, while with the second one, it checks at any request if the current user is logged in. This modality can be activated by calling the setAuthenticationUser method, and by passing the User storable object as a parameter. In this case, the method getHeaders initialises the Authorization parameter of the request Headers with the user’s authentication token, stored inside the User storable object. Any response is intercepted by the fetchResponse method, allowing the client to check the response status code. If the 401 status code is obtained (Unauthorized), the refreshToken method will try to obtain a new authentication token via the authentication server and then, it will repeat the main request. If we will obtain the 401 status code a second time, the main request will be marked as failed. Finally, by calling the removeAuthenticationUser method, the client can operate in the default modality again.

To cast into a specific type, any object returned by the methods of the HttpClient class needs to implement the IBuildable interface, defined inside the app/infrastructure/interfaces/ibuildable.ts file.

User Authorisation

As we have seen, the authentication process is performed by the login service. Once logged in, the user needs to be authorised to access specific resources. The authorisation process uses a list of roles to define which resources can be accessed by the user. For instance, a user with the “guest“‘ role could only see some limited information, while a user with the “admin” role could change the application settings.

The IdentityService class, defined inside the app/services/identity-service.ts file, allows calling an external authorisation endpoint to obtain the user roles for the running application. The list of the user roles and the allowed parameter, which determines if the user is enabled to use the running application, are contained inside the Identity storable object, defined inside the app/models/identity.ts file.

The reason to have different authentication and authorisation processes is simple. The first one can be implemented by an authentication server, giving us an authentication (and refresh) token valid for any our application. The second one can be implemented by a specific server, defining the user roles (identity) for the running application. In this way, it’s possible to implement the SSO (Single Sign-On) authentication system between our application.

File System Extension

The file-system structure provided by the tns-core-modules allows us to easily manage file and folders. Here below are some examples of usage:

import { knownFolders, Folder, File } from "tns-core-modules/file-system";

// Get a known folder
const documents: Folder = <Folder>knownFolders.documents();

// Create a subfolder if doesn't exist
const folder: Folder = <Folder>documents.getFolder("folder");

// Create a file
const file: File = <File>folder.getFile("file.txt");

// Write into a file and read from it
file.writeText("text").then(() => {
    file.readText().then((res) => {
        console.log(res);           
    });
}).catch((err) => {
    console.log(err);
});

However, inside the example application, an extension module has been added, which allows defining new utility methods on top of the file-system:

import { File, Folder } from "tns-core-modules/file-system";

declare module 'tns-core-modules/file-system' {
    export interface File {
        copy(name?: string, destination?: Folder): File;
    }
}

// We are defining a new function for the 'File' class
File.prototype.copy = function (this: File, name?: string, destination?: Folder): File {
    var text = this.readTextSync();
    if (name == null) name = this.name + ' (Copy)' + this.extension;
    if (destination == null) destination = this.parent;
    var target = destination.getFile(name);
    target.writeTextSync(text);
    return target;
};

The new method can be used to copy a file as follow:

import { File, Folder, knownFolders } from "tns-core-modules/file-system";
import "~/infrastructure/file-system-extension"; // Important!

const documents: Folder = <Folder>knownFolders.documents();
const folder: Folder = <Folder>documents.getFolder("folder");
const file: File = <File>documents.getFile("file.txt");

// Copy the file to another folder with a new name
file.copy('new_name.txt', folder);

Check out the file-system-extension.ts file, inside the app/infrastructure folder, to get more details.

Event System

The LiteEvent template class, defined inside the app/infrastructure/lite-event.ts, allows implementing an event system on any kind of object:

import { ILiteEvent } from "./interfaces/ilite-event";

export class LiteEvent<T> implements ILiteEvent<T> {

    private handlers: { (data?: T): void; }[] = [];

    // Add a new listener for a specific event
    public on(handler: { (data?: T): void }): void {
        this.handlers.push(handler);
    }

    // Remove a listener for a specific event
    public off(handler: { (data?: T): void }): void {
        this.handlers = this.handlers.filter(h => h !== handler);
    }

    // Fire an event
    public trigger(data?: T) {
        this.handlers.slice(0).forEach(h => h(data));
    }

}

By using the trigger method, it’s possible to fire an event interceptable through the on callback. The template class defines the type of object passed to the callback. Here below, is an example of usage:

// This event return a 'SocketUser' object when fired
onUserConnected = new LiteEvent<SocketUser>();

// Listen the event
onUsersList.on((user: SocketUser) => {
    // Callback action
});

// Trigger the event
onUserConnected.trigger(user)

The GlobalEventsDispatcher class, defined inside the app/infrastructure/global-event-dispatcher.ts file, allows defining global events, which can be fired and intercepted from any object:

import { LiteEvent } from "~/infrastructure/lite-event";
import { IGlobalEventsDispatcher } from "./interfaces/iglobal-events-dispatcher";
import { EVENTS } from "~/config/enums";

export class GlobalEventsDispatcher implements IGlobalEventsDispatcher {

    private events: Map<string, LiteEvent<any>>;

    constructor() {
        this.events = new Map<string, LiteEvent<any>>();
    }

    // Add a new event
    public addEvent<T>(id: EVENTS) : void {
        this.events.set(id, new LiteEvent<T>());
    }
 
    // Listen a specific event
    public listenEvent<T>(id: EVENTS, callback: (payload: T) => void) : void {
        (this.events.get(id) as LiteEvent<T>).on(callback);
    }

    // Fire a specific event
    public triggerEvent(id: EVENTS) : void {
        this.events.get(id).trigger();
    }

    // Remove a specific event
    public unlistenEvent(id: EVENTS) : void {
        this.events.delete(id);
    }

    // Remove all events
    public clearEvents() : void {
        this.events.clear();
    }

}

The global events, defined inside the GetGlobalEventsDispatcher static method of the Bindings class, are added at application startup:

import { EVENTS } from "./enums";
import { GlobalEventsDispatcher } from "~/infrastructure/global-events-dispatcher";

export class Bindings {

    public static bind(): void {

        // Bind the 'GlobalEventsDispatcher' class to a new instance
        IoCContainer.bind(GlobalEventsDispatcher, this.GetGlobalEventsDispatcher());

        // Other dependencies...
        
    }

    /* Register here your globals events */
    private static GetGlobalEventsDispatcher() : GlobalEventsDispatcher {
        var globalsEventsDispather = new GlobalEventsDispatcher();
        globalsEventsDispather.addEvent(EVENTS.TOGGLE_MENU);
        return globalsEventsDispather;
    }

}

In this example, any object that uses the GlobalEventsDispatcher as a dependency can listen or trigger the “TOGGLE_MENU” event, in this way:

// Listen an event
this.globalEventsDispatcher.listenEvent(EVENTS.TOGGLE_MENU, () => {
    // Callback action
});

// Fire the event
this.globalEventsDispatcher.triggerEvent(EVENTS.TOGGLE_MENU);

Unit Testing

As explained by this tutorial, NativeScript lets you choose between three different unit testing frameworks: JasmineMocha with Chai and QUnit.
To initialise a new project for unit testing with Jasmine, as in the example application, you need to run the following commands inside the root folder:

npm i @types/jasmine --save-dev
tns test init

This operation will create the app/tests folder, containing an example file of unit tests. By taking a look at app/tests folder of the example application, it’s possible to see how to test a component. Here below, are the tests of the login page:

import { LoginViewModel } from "~/pages/login/login-view-model";
import { LoginService } from "~/services/login-service";
import { Storage } from "~/infrastructure/storage";
import { Button } from "tns-core-modules/ui/button";
import { Page } from "tns-core-modules/ui/page";
import { RadDataForm } from "nativescript-ui-dataform";
import { EventData } from "tns-core-modules/data/observable";
import { Frame } from "tns-core-modules/ui/frame/frame";
import { IoCContainer } from "~/infrastructure/ioc-container";

// More details here: https://docs.nativescript.org/tooling/testing/testing
describe("Login Page", () => {

  let loginService: LoginService;
  let loginViewModel: LoginViewModel;
  let page: Page;

  // We need to register our dependencies before the tests execution
  beforeEach(() => {
    var storage = new Storage();
    IoCContainer.bind(Storage, storage);
    loginService = new LoginService();
    IoCContainer.bind(LoginService, loginService);
    var radDataForm = new RadDataForm();
    spyOn(radDataForm, 'validateAll').and.returnValue(Promise.resolve(true));
    page = new Page();
    spyOn(page, 'getViewById').withArgs('loginForm').and.returnValue(radDataForm);
    loginViewModel = new LoginViewModel(page);
  });

  // We want to test the 'Login' button tap action
  it("Should call the login service after tapping the 'Login' button", async () => {
    var frame = new Frame();
    spyOn(frame, 'navigate').withArgs("/pages/home/home-page");
    spyOnProperty(page, 'frame').and.returnValue(frame);
    var button = new Button();
    spyOnProperty(button, "page").and.returnValue(page);
    var login = spyOn(loginService, 'login');
    loginViewModel.loginForm.username = "Username";
    loginViewModel.loginForm.password = "Password";
    loginViewModel.loginForm.rememberMe = true;
    var args = { eventName: 'event', object: button } as EventData;
    await loginViewModel.onLoginButtonTap(args);
    expect(login).toHaveBeenCalledWith("Username", "Password", true);
  });

});  

Inside the beforeEach method are registered all the dependencies required by the component being test. The it method allows us to define a new test, which will check any assertion implemented by using the expect method. Finally, with the spyOn method, it’s possible to mock any function to return a specific value. I suggest you take a look at the Jasmine official documentation to get more details.

To execute the tests, you need to plug your phone into the computer, or to use an emulator. From the root folder of the application, run the following commands to execute the tests on an Android device:

tns device
tns test android

Now, inside the console used to run the commands, it’s possible to see the tests execution results.

Environment Variables

You may need to have environment variables with different values, depending on the application execution mode (Debug or Release). Inside the app/environments folder, you can define the values of the environment variables for both the modalities. The existing variables have the following purpose:

  • tokenEndPoint: HTTP endpoint used by the token requests. The grant_type request parameter defines which kind of token we need from the server (authentication or refresh).
  • socketURL: URL of the Socket.IO server.
  • apiPath: API base path for the HTTP client. Any request address (except the token request) will start with this path.
  • clientId: A string that identifies the client type (mobile-app, web-app, desktop-app, etc…). It’s used by the token requests.
  • clientSecret: A secret key associated with the clientId. It’s used by the token requests and as a default storage key.

Logging

The Logger class, defined inside the app/infrastructure/logger.ts file, provides the methods for logging at different levels:

import { ILogger } from './interfaces/ilogger';

export class Logger implements ILogger {

    // Information level
    info(message: any): void {
        console.log(message);
    }

    // Error level
    error(message: any): void {
        console.log(message);
    }

    // Fatal level
    fatal(message: any): void {
        console.log(message);
    }

    // Warning level
    warn(message: any): void {
        console.log(message);
    }

}

The implemented version redirects the logs to the output console, which can be intercepted by NativeScript Sidekick during the software development. If you need a different way to redirect your logs (for instance a file), you can easily define a new version of the logger by implementing the ILogger interface, and then by registering it as a new dependency inside the IoC container.

What’s next?

In this article, we have seen how the main components of the NativeScript framework and the support structures work. Before concluding, I suggest you take a look at the following plugins, which allow exploiting the Bluetooth and the NFC features.

Happy coding!