NOTE: Post in progress! Pending images and GitHub repositories.

Table of contents

  1. Install Apache Cordova
  2. Create your new app project

In this post we'll learn how to build our own customized Kusama app using Polkadot JS Apps and Apache Cordova, an awesome open source framework that let you code mobile apps with HTML, CSS & JS and target multiple platforms with one code base.

To make things  more interesting, we'll configure our app to connect to Kusama network using our own Kusama node throught a secure web socket. Find how to setup it in our previous articles: https://blog.colmenalabs.org/running-polkadot-kusama/ and https://blog.colmenalabs.org/build-your-own-kusama-block-explorer/

Also we'll customize the Polkadot JS Apps modules to be included in our app, letting us select the funcionalities we want like:

  • Explorer
  • Accounts
  • Address book
  • Claim Tokens
  • Transfer
  • Staking
  • Democracy
  • Council
  • Treasury
  • Parachains
  • Chain state
  • Extrinsics
  • Settings
  • Toolbox
  • Javascript

Install Apache Cordova

Apache Cordova command-line runs on Node.js and is available on NPM, so first we need to install it. You can download Node.js for your platform here: https://nodejs.org/es/download/.

NOTE: Node >=10.13.0 is recommended

In example, to install the current release (12.x) of Node.js in Ubuntu 18.04 LTS we can just type (as root):

apt install -y curl
curl -sL https://deb.nodesource.com/setup_12.x | bash -
apt install -y nodejs

Then install Apache Cordova globally:

npm install -g cordova

Create your new app project

cordova create kusamawallet com.mariopino.kusamawallet KusamaWallet
cd kusamawallet
# Add support for Android and IOS
cordova platform add android
cordova platform add ios

Customize your app!

We'r going to customize the app to add an icon and add a nice splash screen for the Android platform. You can find more info about this topic here.

In our example our config.xml file look like:

<?xml version='1.0' encoding='utf-8'?>
<widget id="com.mariopino.kusamawallet" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
    <name>Kusama Wallet</name>
    <description>
        Kusama network custom Wallet
    </description>
    <author email="mariopino@protonmail.com" href="https://mariopino.es">
        Mario Pino
    </author>
    <content src="index.html" />
    <plugin name="cordova-plugin-whitelist" spec="1" />
    <access origin="*" />
    <allow-intent href="http://*/*" />
    <allow-intent href="https://*/*" />
    <allow-intent href="tel:*" />
    <allow-intent href="sms:*" />
    <allow-intent href="mailto:*" />
    <allow-intent href="geo:*" />
    <platform name="android">
        <allow-intent href="market:*" />
		<icon src="res/mipmap-mdpi/kusamawallet.png" density="mdpi" />
		<icon src="res/mipmap-hdpi/kusamawallet.png" density="hdpi" />
		<icon src="res/mipmap-xhdpi/kusamawallet.png" density="xhdpi" />
		<icon src="res/mipmap-xxhdpi/kusamawallet.png" density="xxhdpi" />
		<icon src="res/mipmap-xxxhdpi/kusamawallet.png" density="xxxhdpi" />
    </platform>
	<platform name="android">
		<splash src="res/screen/android/splash-land-xxxhdpi.png" />
	</platform>	
    <preference name="SplashScreenDelay" value="3000" />
    <preference name="AutoHideSplashScreen" value="true" />	
    <platform name="ios">
        <allow-intent href="itms:*" />
        <allow-intent href="itms-apps:*" />
    </platform>
</widget>

Please note that you will have to create the needed folders and copy the png files inside res/ folder.

They are useful online tools to help you create the icon set like Android Asset Studio.

Build Polkadot JS Apps

Install yarn globally (>=1.10.1 is required):

npm install -g yarn

Build Polkadot JS Apps:

cd ..
git clone https://github.com/polkadot-js/apps kusamawallet-ui
cd kusamawallet-ui
yarn
yarn run start

If all goes well you can just open http://localhost:3000 with your browser to test your Polkadot JS Apps instance.

Customize Polkadot JS Apps

Now we can start to customize the UI!

Configure our Kusama node/s as the default connection method

Edit node_modules/@polkadot/ui-settings/defaults.js:

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.UITHEMES = exports.UITHEME_DEFAULT = exports.UIMODES = exports.UIMODE_DEFAULT = exports.PREFIXES = exports.PREFIX_DEFAULT = exports.LOCKING = exports.LOCKING_DEFAULT = exports.LANGUAGES = exports.LANGUAGE_DEFAULT = exports.ENDPOINTS = exports.ENDPOINT_DEFAULT = exports.CRYPTOS = void 0;
// Copyright 2017-2019 @polkadot/ui-settings authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
// matches https://polkadot.js.org & https://*.polkadot.io
const isPolkadot = typeof window !== 'undefined' && window.location.host.indexOf('polkadot') !== -1;
const WSS_NODES = {
  colmena: {
    hosted: 'hosted by Colmena Labs',
    nodes: {
      kusama: 'wss://ksmrpc.colmenalabs.org'
    }
  }
};
const LANGUAGE_DEFAULT = 'default';
exports.LANGUAGE_DEFAULT = LANGUAGE_DEFAULT;
const LOCKING_DEFAULT = 'session';
exports.LOCKING_DEFAULT = LOCKING_DEFAULT;
const CRYPTOS = [{
  info: 'ed25519',
  text: 'Edwards (ed25519)',
  value: 'ed25519'
}, {
  info: 'sr25519',
  text: 'Schnorrkel (sr25519)',
  value: 'sr25519'
}];
exports.CRYPTOS = CRYPTOS;
const ENDPOINTS = [{
  info: 'kusama',
  text: "Kusama (Polkadot Canary, ".concat(WSS_NODES.colmena.hosted, ")"),
  value: WSS_NODES.colmena.nodes.kusama
}];
exports.ENDPOINTS = ENDPOINTS;
const LANGUAGES = [{
  info: 'detect',
  text: 'Default browser language (auto-detect)',
  value: LANGUAGE_DEFAULT
}];
exports.LANGUAGES = LANGUAGES;
const LOCKING = [{
  info: 'session',
  text: 'Once per session',
  value: 'session'
}, {
  info: 'tx',
  text: 'On each transaction',
  value: 'tx'
}];
exports.LOCKING = LOCKING;
const PREFIXES = [{
  info: 'default',
  text: 'Default for the connected node',
  value: -1
}, {
  info: 'substrate',
  text: 'Substrate (development)',
  value: 42
}, {
  info: 'kusama',
  text: 'Kusama (canary)',
  value: 2
}, {
  info: 'polkadot',
  text: 'Polkadot (live)',
  value: 0
}];
exports.PREFIXES = PREFIXES;
const UIMODES = [{
  info: 'full',
  text: 'Fully featured',
  value: 'full'
}, {
  info: 'light',
  text: 'Basic features only',
  value: 'light'
}];
exports.UIMODES = UIMODES;
const UITHEMES = [{
  info: 'polkadot',
  text: 'Polkadot',
  value: 'polkadot'
}, {
  info: 'substrate',
  text: 'Substrate',
  value: 'substrate'
}];
exports.UITHEMES = UITHEMES;
const ENDPOINT_DEFAULT = WSS_NODES.colmena.nodes.kusama;
exports.ENDPOINT_DEFAULT = ENDPOINT_DEFAULT;
const PREFIX_DEFAULT = -1;
exports.PREFIX_DEFAULT = PREFIX_DEFAULT;
const UITHEME_DEFAULT = 'polkadot';
exports.UITHEME_DEFAULT = UITHEME_DEFAULT;
const UIMODE_DEFAULT = !isPolkadot && typeof window !== 'undefined' && window.location.host.indexOf('ui-light') !== -1 ? 'light' : 'full';
exports.UIMODE_DEFAULT = UIMODE_DEFAULT;

Remove the modules we don't want to show in th UI

In our case: Settings, Toolbox and Javascript.

Edit packages/apps-routing/src/index.ts:

// Copyright 2017-2019 @polkadot/apps-routing authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.

import { Routing, Routes } from './types';

import appSettings from '@polkadot/ui-settings';

import template from './123code';
import accounts from './accounts';
import addressbook from './addressbook';
import claims from './claims';
import contracts from './contracts';
import council from './council';
// import dashboard from './dashboard';
import democracy from './democracy';
import explorer from './explorer';
import extrinsics from './extrinsics';
//import js from './js';
import parachains from './parachains';
import settings from './settings';
import staking from './staking';
import storage from './storage';
import sudo from './sudo';
//import toolbox from './toolbox';
import transfer from './transfer';
import treasury from './treasury';

const routes: Routes = appSettings.uiMode === 'light'
  ? ([] as Routes).concat(
    // dashboard,
    explorer,
    accounts,
    addressbook,
    claims,
    transfer,
    null,
    staking,
    democracy,
    council,
    // TODO Not sure about the inclusion of treasury & parachains here
    null,
    settings
  )
  : ([] as Routes).concat(
    // dashboard,
    explorer,
    accounts,
    addressbook,
    claims,
    transfer,
    null,
    staking,
    democracy,
    council,
    treasury,
    parachains,
    null,
    contracts,
    storage,
    extrinsics,
    sudo,
    null,
    //settings,
    //toolbox,
    //js,
    template
  );

const setup: Routing = {
  default: 'explorer',
  routes
};

export default setup;

We'll also remove the GitHub and Wiki sidebar links, to do this set the contents of the file packages/apps/src/SideBar/index.tsx:

/* eslint-disable @typescript-eslint/camelcase */
// Copyright 2017-2019 @polkadot/apps authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.

import { Route } from '@polkadot/apps-routing/types';
import { ApiProps } from '@polkadot/react-api/types';
import { I18nProps } from '@polkadot/react-components/types';
import { SIDEBAR_MENU_THRESHOLD } from '../constants';

import './SideBar.css';

import React from 'react';
import styled from 'styled-components';
import { Responsive } from 'semantic-ui-react';
import routing from '@polkadot/apps-routing';
import { withCalls, withMulti } from '@polkadot/react-api';
import { Button, ChainImg, Icon, Menu, media } from '@polkadot/react-components';
import { classes } from '@polkadot/react-components/util';
import { BestNumber, Chain } from '@polkadot/react-query';

import translate from '../translate';
import Item from './Item';
import NodeInfo from './NodeInfo';

interface Props extends ApiProps, I18nProps {
  className?: string;
  collapse: () => void;
  handleResize: () => void;
  isCollapsed: boolean;
  menuOpen: boolean;
  system_chain?: string;
  toggleMenu: () => void;
}

interface State {
  modals: Record<string, boolean>;
}

class SideBar extends React.PureComponent<Props, State> {
  public state: State;

  public constructor (props: Props) {
    super(props);

    // setup modals for each of the actual modal routes
    this.state = {
      modals: routing.routes.reduce((result, route): Record<string, boolean> => {
        if (route && route.Modal) {
          result[route.name] = false;
        }

        return result;
      }, {} as unknown as Record<string, boolean>)
    };
  }

  public render (): React.ReactNode {
    const { className, handleResize, isCollapsed, toggleMenu, menuOpen } = this.props;

    return (
      <Responsive
        onUpdate={handleResize}
        className={classes(className, 'apps-SideBar-Wrapper', isCollapsed ? 'collapsed' : 'expanded')}
      >
        <ChainImg
          className={`toggleImg ${menuOpen ? 'closed' : 'open delayed'}`}
          onClick={toggleMenu}
        />
        {this.renderModals()}
        <div className='apps--SideBar'>
          <Menu
            secondary
            vertical
          >
            <div className='apps-SideBar-Scroll'>
              {this.renderLogo()}
              {this.renderRoutes()}

              /*
              <Menu.Divider hidden />
              {this.renderGithub()}
              {this.renderWiki()}
              */
              <Menu.Divider hidden />
              {
                isCollapsed
                  ? undefined
                  : <NodeInfo />
              }
            </div>
            {this.renderCollapse()}
          </Menu>
          <Responsive minWidth={SIDEBAR_MENU_THRESHOLD}>
            <div
              className='apps--SideBar-toggle'
              onClick={this.props.collapse}
            />
          </Responsive>
        </div>
      </Responsive>
    );
  }

  private renderCollapse (): React.ReactNode {
    const { isCollapsed } = this.props;

    return (
      <Responsive
        minWidth={SIDEBAR_MENU_THRESHOLD}
        className={`apps--SideBar-collapse ${isCollapsed ? 'collapsed' : 'expanded'}`}
      >
        <Button
          icon={`angle double ${isCollapsed ? 'right' : 'left'}`}
          isBasic
          isCircular
          onClick={this.props.collapse}
        />
      </Responsive>
    );
  }

  private renderLogo (): React.ReactNode {
    const { api, isApiReady } = this.props;

    return (
      <div className='apps--SideBar-logo'>
        <ChainImg />
        <div className='info'>
          <Chain className='chain' />
          {isApiReady &&
            <div className='runtimeVersion'>version {api.runtimeVersion.specVersion.toNumber()}</div>
          }
          <BestNumber label='#' />
        </div>
      </div>
    );
  }

  private renderModals (): React.ReactNode {
    const { modals } = this.state;
    const filtered = routing.routes.filter((route): any => route && route.Modal) as Route[];

    return filtered.map(({ name, Modal }): React.ReactNode => (
      Modal && modals[name]
        ? (
          <Modal
            key={name}
            onClose={this.closeModal(name)}
          />
        )
        : <div key={name} />
    ));
  }

  private renderRoutes (): React.ReactNode {
    const { handleResize, isCollapsed } = this.props;

    return routing.routes.map((route, index): React.ReactNode => (
      route
        ? (
          <Item
            isCollapsed={isCollapsed}
            key={route.name}
            route={route}
            onClick={
              route.Modal
                ? this.openModal(route.name)
                : handleResize
            }
          />
        )
        : (
          <Menu.Divider
            hidden
            key={index}
          />
        )
    ));
  }

  private renderGithub (): React.ReactNode {
    return (
      <Menu.Item className='apps--SideBar-Item'>
        <a
          className='apps--SideBar-Item-NavLink'
          href='https://github.com/polkadot-js/apps'
          rel='noopener noreferrer'
          target='_blank'
        >
          <Icon name='github' /><span className='text'>GitHub</span>
        </a>
      </Menu.Item>
    );
  }

  private renderWiki (): React.ReactNode {
    return (
      <Menu.Item className='apps--SideBar-Item'>
        <a
          className='apps--SideBar-Item-NavLink'
          href='https://wiki.polkadot.network'
          rel='noopener noreferrer'
          target='_blank'
        >
          <Icon name='book' /><span className='text'>Wiki</span>
        </a>
      </Menu.Item>
    );
  }

  private closeModal = (name: string): () => void => {
    return (): void => {
      this.setState(({ modals }): State => ({
        modals: {
          ...modals,
          [name]: false
        }
      }));
    };
  }

  private openModal = (name: string): () => void => {
    return (): void => {
      this.setState(({ modals }): State => ({
        modals: {
          ...modals,
          [name]: true
        }
      }));
    };
  }
}

export default withMulti(
  styled(SideBar)`
    .toggleImg {
      cursor: pointer;
      height: 2.75rem;
      left: 0.9rem;
      opacity: 0;
      position: absolute;
      top: 0px;
      transition: opacity 0.2s ease-in, top 0.2s ease-in;
      width: 2.75rem;

      &.delayed {
        transition-delay: 0.4s;
      }

      &.open {
        opacity: 1;
        top: 0.9rem;
      }

      ${media.DESKTOP`
        opacity: 0 !important;
        top: -2.9rem !important;
      `}
    }
  `,
  translate,
  withCalls<Props>(
    'rpc.system.chain'
  )
);

Build the customized Polkadot JS Apps

Go to project root folder and execute:

yarn run build

Copy the UI files in Apache Cordova project

Now, you need to copy the contents of folder packages/apps/build  to your Apache Cordova project www folder.

Build and test the app in Android platform

Now we are ready to build and test your app! in Android.

For testing the app you will need either an Android device connected to your computer with USB debugging enabled (info) or the Android emulator provided by Android Studio.

Go to your Apache Cordova project folder and execute:

cordova run android

This command will build and deploy your app to either your Android device or the emulator. Tha's all, now you can enjoy your new awesome Kusama mobile app!

Build and test the app in IOS platform

Build and test the app in IOS is out of the scope of this post, but you can find info about here.

Now what? :-)

Well, it's time to distribute your new wallet! So for that you have two ways:

  • In Android you can distribute your apk file directly: This is the easy way but you'll need root access to the device to install unsigned apps (sometimes not!).
  • The other slow/expensive alternative is to publish it using the standard channels, so you'll need to get a Android Developer account or their Apple equivalent  to be able to  publish your App in both platforms.

References