Making customized (white label) builds of a Cordova application–Android
Imagine that you are developing a super useful Android application (backed by Cordova or Ionic) and several your customers would like to have it. However, they want to put their logo and are willing to pay extra for this. Of course, we do not want to have a different code base for each client, and it would be nice to make all required changing during the build process. Here is a guide on how to do this for Cordova Android applications.
White label software is a software that can be completely rebranded. This means that we are going to need to change the application logo, splash screens, application name, application ID, probably some of the application content or assets, and even sign it with a different certificate, so it could be uploaded to a different Play Store account.
Basically, this is how we are going to achieve the goal:
- Use a custom command line argument to cordova build command which will look like
--whitelabel=CompanyName
- Create a custom Cordova hook
- Create a folder for white label configuration and customized assets
- React to the custom command line argument in our custom hook
- Read the configuration file
- Make the necessary operations before the application build
Eventually, you will be able to make a branded app build by running a command line command like this:
cordova build android --release --whitelabel=COMPANY_NAME
Getting Prepared
To keep all the information and required builds in one place, let us create whitelabel folder in the root of our project. Inside, create a JSON file named whitelabel.json
. In this example, we will be configuring a build for a hypothetical company named Mape.
{
"Mape": {
"name": "Mape",
"filesToReplace": [
{
"src": "whitelabel/Mape/assets/logo.png",
"dest": "images/logo.png"
},
{
...
}
],
"androidIcons": "whitelabel/Mape/res/icons/android",
"androidSplashScreens": "whitelabel/Mape/res/screens/android",
"androidAppId": "com.web-mystery.app",
"androidStoreFile": "whitelabel/Mape/keystore.jks",
"androidStorePassword": "storepassword",
"androidStoreType": "jks",
"androidKeyAlias": "keyalias",
"androidKeyPassword": "keypassword"
},
"AnotherCompany": {
...
}
}
The keys in the root of the JSON file represent company names, so we’ll have quick access to their configuration. Let’s quickly go over the configuration keys for each company:
- name–Updated name of the application. Will be displayed on the Android home screen and other places.
- filesToReplace–A list of the files that should be replaced in the build within the www directory. You can put images, sounds, and other things. Source path is relative to the project root folder whereas the destination path is relative to
platforms/android/app/src/main/assets/www
. - andoidIcons and androidSplashScreens–paths to the folders that contain customized app icon and loading screen.
- androidAppId–the unique application ID that will be used for the customized application
- androidStoreFile, androidStorePassword, androidStoreType, androidKeyAlias, androidKeyPassword–the path to the keystore and credentials required to sign application release builds.
Creating a Cordova Hook
Cordova allows to execute Node JS files during different stages of build process. Thus, you can hook into the build process and make some necessary changes. This function exported from the hook file receives a context object as an argument, and we have a chance to modify it and affect the further build process.
Create android-whitelabel.js
file in the hooks folder (hooks/android-whitelabel.js
).
Now modify the config.xml and insert the following line within the tag:
<hook src="./hooks/android-whitelabel.js" type="after_prepare" />
The after_prepare
hook fires right after all www contents and resources are copied to the destination folder, but the native build process has not started. It’s the perfect timing to rebrand the application.
In our android-whitelabel.js
add the following code:
const fs = require('fs');
const path = require('path');
module.exports = function(context) {
}
We will use Node’s native fs
module to play around with files and path
to help us generate right file paths.
First, let’s check if the current build is actually meant to be white label. Remember, we said that we’d use a custom command line argument? Parsed arguments are available under context.opts.options object:
const {whiteLabel} = context.opts.options;
if (!context.opts.options.whitelabel) {
console.log('This is not a white label build. Doing nothing.');
return;
}
…And add a little check so we could be sure that we are currently working with an Android (not iOS) application build.
if (!context.opts.platforms.includes('android')) {
console.log('This is not an Android build. Doing nothing.');
return;
}
Now, when we are sure that we’re going to need this, let us define necessary constants within the hook function:
const projectRoot = context.opts.projectRoot;
const whiteLabelConfig
= require(`${projectRoot}/whitelabel/whitelabel.json`)[whiteLabel];
Note that this are further example assume that you are running Cordova Android 7.1.4 or later. Previous and future version might have slightly different file structure, so inspect it manually and make necessary changes in script if you run into the corresponding error.
Updating the App Signing Options
To modify the signing settings in release builds, we will modify the options object that we receive in the build context.
const {
androidStoreFile,
androidStorePassword,
androidStoreType,
androidKeyAlias,
androidKeyPassword
} = whitelabelConfig;
if (context.opts.options.release) {
androidStoreFile && context.opts.options.argv.push(`--keystore=${androidStoreFile}`);
androidStoreType && context.opts.options.argv.push(`--keystoreType=${androidStoreType}`);
androidStorePassword && context.opts.options.argv.push(`--storePassword=${androidStorePassword}`);
androidKeyAlias && context.opts.options.argv.push(`--alias=${androidKeyAlias}`);
androidKeyPassword && context.opts.options.argv.push(`--password=${androidKeyPassword}`);
}
First, we read the signing information from the white label config. Second, we check if this is the release build–this flag is available under context.opts.options.release path. And third–update the build options.
Cordova will throw an error if something goes wrong while accessing the keystore.
Replacing Application Icon and Launch Screen
In this section, I will assume that you generated your assets using https://www.resource-generator.com/. Indeed, it’s not the only way to generate resources, but it provides conventional output file naming as well as XML snipped to paste in config.xml
, so I find it quite useful. Initially, you keep the generated icons in res/icons/android
and screens in /res/screens/android
. After the prepare stage, both icons and splashscreens are moved to platforms/android/app/src/main/res
. There are some naming quirks though, so we will write two separate functions.
const {androidIcons, androidSplashScreens} = whitelabel;
const resDest = path.join(projectRoot, 'platforms/android/app/src/main/res');
if (androidIcons) {
replaceIcons(androidIcons, resDest);
}
if (androidSplashScreens) {
replaceSplashScreens(androidSplashScreens, resDest);
}
/**
* Replaces image in the android platform icons, so it'd be used before build.
*
* @param {String} sourcePath
* @param {String} destinationPath
*/
function replaceIcons(sourcePath, destinationPath) {
if (!fs.existsSync(sourcePath)) {
console.warn(`Directory ${sourcePath} does not exist. Doing nothing`);
return;
}
if (!fs.existsSync(destinationPath)) {
console.warn(`Directory ${destinationPath} does not exist. Doing nothing`);
return;
}
const icons = fs.readdirSync(sourcePath);
console.log(`Icons to replace: ${JSON.stringify(icons, null, 2)}`);
icons.forEach((icon) => {
const dirName = `mipmap-${icon.split('-').find((s) => s.endsWith('dpi'))}`;
const destDir = `${destinationPath}/${dirName}`;
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
const src = `${sourcePath}/${icon}`;
const dest = `${destDir}/icon.png`;
console.log(`Copying ${src} to ${dest}`);
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
fs.copyFileSync(src, dest);
});
}
/**
* Replaces image in the android platform splashscreens, so it'd be used before build.
*
* @param {String} sourcePath
* @param {String} destinationPath
*/
function replaceSplashScreens(sourcePath, destinationPath) {
if (!fs.existsSync(sourcePath)) {
console.warn(`Directory ${sourcePath} does not exist. Doing nothing`);
return;
}
if (!fs.existsSync(destinationPath)) {
console.warn(`Directory ${destinationPath} does not exist. Doing nothing`);
return;
}
const screens = fs.readdirSync(sourcePath);
console.log(`SplashScreens to replace: ${JSON.stringify(screens, null, 2)}`);
screens.forEach((screen) => {
const dirName = screen.split('.').slice(0, -1).join('.').replace('-screen', '');
const destDir = `${destinationPath}/${dirName}`;
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
const src = `${sourcePath}/${screen}`;
const dest = `${destDir}/screen.png`;
console.log(`Copying ${src} to ${dest}`);
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
fs.copyFileSync(src, dest);
});
}
As previously mentioned, we defined the paths to the branded image assets in whitelabel.json
. Here, we will use them to read the contents of the given folders and substitute the corresponding assets in Android platform folder before the build.
Basically, both of these functions do the following:
- Retrieve the list of files in the source directory.
- Loop over the array of file names and generate the destination paths based on the file type and name.
- Create the destination directory in case it does not exist.
- Replace the destination file so the branded file will be included in the build.
Replacing the Web Application files
Similarly, let us replace files that are specified in whitelabel.json
under filesToReplace
key:
const {filesToReplace} = whitelabel;
replaceFiles(filesToReplace);
function replaceFiles(filesToReplace = []) {
filesToReplace.forEach((file) => {
let {src, dest} = file;
src = path.join(projectRoot, src);
dest = path.join(projectRoot, 'platforms/android/app/src/main/assets/www', dest);
if (!fs.existsSync(src)) {
console.warn(`Directory ${src} does not exist. Skipping`);
return;
}
const pathArr = dest.split(path.sep);
pathArr.pop();
const destDir = pathArr.join(path.sep);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
fs.copyFileSync(src, dest);
})
}
In this function, we loop through the array of objects retrieved from whitelabel.json
, perform file existence check and eventually replace the initial files with their updated versions.
Replacing Name and Application ID
For Android platform, Application name and Application ID should be replaced in two files: AndroidManifest.xml
and config.xml
, both of them located within the platform/android
folder.
You might want to install some XML-parser and do this in a cleaner way, but for now let’s get by with a simple string replacement. Let’s create a generic function for this:
function replaceString(path, search, replacement) {
console.log(`Attempting to replace ${search} with ${replacement} in ${path}`);
try {
fs.accessSync(path, fs.F_OK);
} catch (e) {
console.log(`Could not read file ${path}`);
return;
}
let contents = fs.readFileSync(path, 'UTF-8');
const re = new RegExp(search, 'g');
contents = contents.replace(re, replacement);
fs.writeFileSync(path, contents);
}
This function reads a file on a given path, replaces all search string occurrences in its contents and rewrites it on the storage. Let us see it in action:
// These two can be read from root config.xml instead of hard-coding them.
const EXISTING_APP_ID = 'com.web-mystery.app';
const EXISTING_APP_NAME = 'ExistingAppName';
const {name, androidAppId} = whitelabel;
const manifestPath = path.join(projectRoot, 'platforms', 'android', 'app/src/main/AndroidManifest.xml');
const configPath = path.join(projectRoot, 'platforms', 'android', 'app/src/main/res/xml/config.xml');
const stringsPath = path.join(projectRoot, 'platforms', 'android', 'app/src/main/res/values/strings.xml');
replaceString(manifestPath, EXISTING_APP_ID, androidAppId);
replaceString(manifestPath, EXISTING_APP_NAME, name);
This will replace old application id and name with new ones. Awesome! Let’s do the same for the rest of the files:
replaceString(configPath, EXISTING_APP_ID, androidAppId);
replaceString(configPath, EXISTING_APP_NAME, name);
replaceString(stringsPath, EXISTING_APP_ID, androidAppId);
replaceString(stringsPath, EXISTING_APP_NAME, name);
In the same way, you can replace any contents of the web part of application–for instance, a company name if it is mentioned in HTML templates or JavaScript files. After Cordova prepare stage, they are located in the platforms/android/app/src/main/assets/www
directory.
Updating Main Activity
Things are a little bit different with MainActivity.java
. Android Application App ID aka package name serve a package name for main activity. So, in order to be discovered by compiler, it should be available under a certain path. For example, if your app identifier is com.web-mystery.app
, your MainActivity.java
should be available in platform/android/app/src/main/java/com.web-mystery/app/MainActivity.java
(or platforms/android/src/main/java/com.web-mystery/app/MainActivity.java
in older versions of Cordova Android platform) and should have package com.web-mystery.app. Typically, these path and packages changes are made during prepare phase if application id is changed in the config.xml file. However, since we are operating after prepare stage and do not want to modify config.xml
in the project root, let us do these steps manually:
createMainActivity(androidAppId, EXISTING_APP_ID);
/**
* Copies MainActivity.java from original to new destination. Modifies the package name.
*
* @param {String} appId
* @param {String} existingAppId
*/
function createMainActivity(appId, existingAppId) {
const sourcePath = path.join(projectRoot, 'platforms', 'android', `app/src/main/java/${existingAppId.split('.').join('/')}/MainActivity.java`);
const destDir = path.join(projectRoot, 'platforms', 'android/app/src/main/java', appId.split('.').join('/'));
const destPath = path.join(destDir, 'MainActivity.java');
console.log(`Creating Main Activity replacement on path ${destPath}`);
if (fs.existsSync(destPath)) {
console.log('MainActivity already exists, removing...');
fs.unlinkSync(destPath);
console.log('Removed existing MainActivity');
} else {
console.log(`Creating destination directory ${destDir}`);
fs.mkdirSync(destDir, {recursive: true});
}
fs.copyFileSync(sourcePath, destPath);
let contents = fs.readFileSync(destPath, 'UTF-8');
const idRe = new RegExp(existingAppId, 'g');
contents = contents.replace(idRe, `${appId};`);
fs.writeFileSync(destPath, contents);
console.log(`Copied and updated MainActivity for ${appId}`);
}
In this function, we:
- Check if the Main Activity already exists in the destination path and remove it if it does. Create target directory recursively if it does not exist.
- Read the contents of existing Main Activity and replace its package name with new name.
- Write the updated Main Activity on the new destination.
Node that fs.mkdirSync
with recursive option is available since Node JS 10. If you are using an earlier version, check the solution in this Stackoverflow thread.
These manipulations will prevent the white label application from crashing on the start.
Conclusion
After going through all the steps above, you will be able to easily build a white label application. For our fictional example, the command will be:
cordova build android --release --whitelabel=Mape
Keep in mind some of the modified files might not be reverted during the next build. It is important to run git reset --hard -f -d
after you are done–just to be sure that nothing was replaced permanently. As the output, you will receive a ready to upload APK file, with custom icon, custom splash screen and signed with a different signing certificate.
Here is the final version of android-whitelabel.js
:
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
module.exports = function(context) {
const {whitelabel} = context.opts.options;
if (!context.opts.options.whitelabel) {
console.log('This is not a white label build. Doing nothing.');
return;
}
if (!context.opts.platforms.includes('android')) {
console.log('This is not an Android build. Doing nothing.');
return;
}
const projectRoot = context.opts.projectRoot;
const whitelabelConfig = require(`${projectRoot}/whitelabel/whitelabel.json`)[whitelabel];
const {
name,
androidAppId,
androidIcons,
androidSplashScreens,
filesToReplace,
androidStoreFile,
androidStorePassword,
androidStoreType,
androidKeyAlias,
androidKeyPassword
} = whitelabelConfig;
if (context.opts.options.release) {
androidStoreFile && context.opts.options.argv.push(`--keystore=${androidStoreFile}`);
androidStoreType && context.opts.options.argv.push(`--keystoreType=${androidStoreType}`);
androidStorePassword && context.opts.options.argv.push(`--storePassword=${androidStorePassword}`);
androidKeyAlias && context.opts.options.argv.push(`--alias=${androidKeyAlias}`);
androidKeyPassword && context.opts.options.argv.push(`--password=${androidKeyPassword}`);
}
const resDest = path.join(projectRoot, 'platforms/android/app/src/main/res');
if (androidIcons) {
replaceIcons(androidIcons, resDest);
}
if (androidSplashScreens) {
replaceSplashScreens(androidSplashScreens, resDest);
}
// These two can be read from root config.xml instead of hard-coding them.
const EXISTING_APP_ID = 'com.web-mystery.app';
const EXISTING_APP_NAME = 'ExistingAppName';
const manifestPath = path.join(projectRoot, 'platforms', 'android', 'app/src/main/AndroidManifest.xml');
const configPath = path.join(projectRoot, 'platforms', 'android', 'app/src/main/res/xml/config.xml');
const stringsPath = path.join(projectRoot, 'platforms', 'android', 'app/src/main/res/values/strings.xml');
replaceString(manifestPath, EXISTING_APP_ID, androidAppId);
replaceString(manifestPath, EXISTING_APP_NAME, name);
replaceString(configPath, EXISTING_APP_ID, androidAppId);
replaceString(configPath, EXISTING_APP_NAME, name);
replaceString(stringsPath, EXISTING_APP_ID, androidAppId);
replaceString(stringsPath, EXISTING_APP_NAME, name);
createMainActivity(androidAppId, EXISTING_APP_ID);
replaceFiles(filesToReplace);
function replaceString(path, search, replacement) {
console.log(`Attempting to replace ${search} with ${replacement} in ${path}`);
try {
fs.accessSync(path, fs.F_OK);
} catch (e) {
console.log(`Could not read file ${path}`);
return;
}
let contents = fs.readFileSync(path, 'UTF-8');
const re = new RegExp(search, 'g');
contents = contents.replace(re, replacement);
fs.writeFileSync(path, contents);
}
/**
* Replaces image in the android platform icons, so it'd be used before build.
*
* @param {String} sourcePath
* @param {String} destinationPath
*/
function replaceIcons(sourcePath, destinationPath) {
if (!fs.existsSync(sourcePath)) {
console.warn(`Directory ${sourcePath} does not exist. Doing nothing`);
return;
}
if (!fs.existsSync(destinationPath)) {
console.warn(`Directory ${destinationPath} does not exist. Doing nothing`);
return;
}
const icons = fs.readdirSync(sourcePath);
console.log(`Icons to replace: ${JSON.stringify(icons, null, 2)}`);
icons.forEach((icon) => {
const dirName = `mipmap-${icon.split('-').find((s) => s.endsWith('dpi'))}`;
const destDir = `${destinationPath}/${dirName}`;
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
const src = `${sourcePath}/${icon}`;
const dest = `${destDir}/icon.png`;
console.log(`Copying ${src} to ${dest}`);
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
fs.copyFileSync(src, dest);
});
}
/**
* Replaces image in the android platform splashscreens, so it'd be used before build.
*
* @param {String} sourcePath
* @param {String} destinationPath
*/
function replaceSplashScreens(sourcePath, destinationPath) {
if (!fs.existsSync(sourcePath)) {
console.warn(`Directory ${sourcePath} does not exist. Doing nothing`);
return;
}
if (!fs.existsSync(destinationPath)) {
console.warn(`Directory ${destinationPath} does not exist. Doing nothing`);
return;
}
const screens = fs.readdirSync(sourcePath);
console.log(`SplashScreens to replace: ${JSON.stringify(screens, null, 2)}`);
screens.forEach((screen) => {
const dirName = screen.split('.').slice(0, -1).join('.').replace('-screen', '');
const destDir = `${destinationPath}/${dirName}`;
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
const src = `${sourcePath}/${screen}`;
const dest = `${destDir}/screen.png`;
console.log(`Copying ${src} to ${dest}`);
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
fs.copyFileSync(src, dest);
});
}
/**
* Copies MainActivity.java from original to new destination. Modifies the package name.
*
* @param {String} appId
* @param {String} existingAppId
*/
function createMainActivity(appId, existingAppId) {
const sourcePath = path.join(projectRoot, 'platforms', 'android', `app/src/main/java/${existingAppId.split('.').join('/')}/MainActivity.java`);
const destDir = path.join(projectRoot, 'platforms', 'android/app/src/main/java', appId.split('.').join('/'));
const destPath = path.join(destDir, 'MainActivity.java');
console.log(`Creating Main Activity replacement on path ${destPath}`);
if (fs.existsSync(destPath)) {
console.log('MainActivity already exists, removing...');
fs.unlinkSync(destPath);
console.log('Removed existing MainActivity');
} else {
console.log(`Creating destination directory ${destDir}`);
fs.mkdirSync(destDir, {recursive: true});
}
fs.copyFileSync(sourcePath, destPath);
let contents = fs.readFileSync(destPath, 'UTF-8');
const idRe = new RegExp(existingAppId, 'g');
contents = contents.replace(idRe, `${appId};`);
fs.writeFileSync(destPath, contents);
console.log(`Copied and updated MainActivity for ${appId}`);
}
function replaceFiles(filesToReplace = []) {
console.log(`Replacing files... ${JSON.stringify(filesToReplace, null, 2)}`);
filesToReplace.forEach((file) => {
let {src, dest} = file;
src = path.join(projectRoot, src);
dest = path.join(projectRoot, 'platforms/android/app/src/main/assets/www', dest);
console.log(src, dest);
if (!fs.existsSync(src)) {
console.warn(`Directory ${src} does not exist. Skipping`);
return;
}
const pathArr = dest.split(path.sep);
pathArr.pop();
const destDir = pathArr.join(path.sep);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
fs.copyFileSync(src, dest);
})
}
};
Building a white label iOS application require a slightly different approach–mostly because of their different mechanism of uploading to the AppStore–so I will describe it in a separate article.