nodekit-cli
Version:
A command-line tool for creating {NK} NodeKit applications
432 lines (376 loc) • 18 kB
JavaScript
/**
Licensed to OffGrid Networks (OGN) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. OGN licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
var Q = require('q');
var fs = require('fs');
var path = require('path');
var shell = require('shelljs');
var events = require('nodekit-cli')['nodekit-cli-common'].events;
var AndroidManifest = require('./AndroidManifest');
var xmlHelpers = require('nodekit-cli')['nodekit-cli-common'].xmlHelpers;
var NodeKitError = require('nodekit-cli')['nodekit-cli-common'].NodeKitError;
var ConfigParser = require('nodekit-cli')['nodekit-cli-common'].ConfigParser;
var FileUpdater = require('nodekit-cli')['nodekit-cli-common'].FileUpdater;
var PlatformJson = require('nodekit-cli')['nodekit-cli-common'].PlatformJson;
var PlatformMunger = require('nodekit-cli')['nodekit-cli-common'].ConfigChanges.PlatformMunger;
var PluginInfoProvider = require('nodekit-cli')['nodekit-cli-common'].PluginInfoProvider;
module.exports.prepare = function (nodekitProject, options) {
var self = this;
var platformJson = PlatformJson.load(this.locations.root, this.platform);
var munger = new PlatformMunger(this.platform, this.locations.root, platformJson, new PluginInfoProvider());
this._config = updateConfigFilesFrom(nodekitProject.projectConfig, munger, this.locations);
// Update own app dir with project's app assets and plugins' assets and js-files
return Q.when(updateApp(nodekitProject, this.locations))
.then(function () {
// update project according to nodekit.json changes.
return updateProjectAccordingTo(self._config, self.locations);
})
.then(function () {
updateIcons(nodekitProject, path.relative(nodekitProject.root, self.locations.res));
updateSplashes(nodekitProject, path.relative(nodekitProject.root, self.locations.res));
})
.then(function () {
events.emit('verbose', 'Prepared android project successfully');
});
};
module.exports.clean = function (options) {
// A nodekitProject isn't passed into the clean() function, because it might have
// been called from the platform shell script rather than the CLI. Check for the
// noPrepare option passed in by the non-CLI clean script. If that's present, or if
// there's no nodekit.json found at the project root, then don't clean prepared files.
var projectRoot = path.resolve(this.root, '../..');
if ((options && options.noPrepare) || !fs.existsSync(this.locations.nodekitJson) ||
!fs.existsSync(this.locations.nodekitJson)) {
return Q();
}
var projectConfig = new ConfigParser(this.locations.nodekitJson);
var self = this;
return Q().then(function () {
cleanApp(projectRoot, self.locations);
cleanIcons(projectRoot, projectConfig, path.relative(projectRoot, self.locations.res));
cleanSplashes(projectRoot, projectConfig, path.relative(projectRoot, self.locations.res));
});
};
/**
* Updates config files in project based on app's nodekit.json and config munge,
* generated by plugins.
*
* @param {ConfigParser} sourceConfig A project's configuration that will
* be merged into platform's nodekit.json
* @param {ConfigChanges} configMunger An initialized ConfigChanges instance
* for this platform.
* @param {Object} locations A map of locations for this platform
*
* @return {ConfigParser} An instance of ConfigParser, that
* represents current project's configuration. When returned, the
* configuration is already dumped to appropriate nodekit.json file.
*/
function updateConfigFilesFrom(sourceConfig, configMunger, locations) {
events.emit('verbose', 'Generating platform-specific nodekit.json from defaults for android at ' + locations.nodekitJson);
// First cleanup current config and merge project's one into own
// Overwrite platform nodekit.json with defaults_nodekit.json.
shell.cp('-f', locations.defaultNodeKitJson, locations.nodekitJson);
// Then apply config changes from global munge to all config files
// in project (including project's config)
configMunger.reapply_global_munge().save_all();
events.emit('verbose', 'Merging project\'s nodekit.json into platform-specific android nodekit.json');
// Merge changes from app's nodekit.json into platform's one
var config = new ConfigParser(locations.nodekitJson);
xmlHelpers.mergeXml(sourceConfig.doc.getroot(),
config.doc.getroot(), 'android', /*clobber=*/true);
config.write();
return config;
}
/**
* Logs all file operations via the verbose event stream, indented.
*/
function logFileOp(message) {
events.emit('verbose', ' ' + message);
}
/**
* Updates platform 'app' directory by replacing it with contents of
* 'platform_app' and project app. Also copies project's overrides' folder into
* the platform 'app' folder
*
* @param {Object} nodekitProject An object which describes nodekit project.
* @param {Object} destinations An object that contains destination
* paths for app files.
*/
function updateApp(nodekitProject, destinations) {
var sourceDirs = [
path.relative(nodekitProject.root, path.join(nodekitProject.locations.app, 'dist')),
/* , NodeKit removed platform_app */
];
// If project contains 'merges' for our platform, use them as another overrides
var merges_path = path.join(nodekitProject.root, 'merges', 'android');
if (fs.existsSync(merges_path)) {
events.emit('verbose', 'Found "merges/android" folder. Copying its contents into the android project.');
sourceDirs.push(path.join('merges', 'android'));
}
var targetDir = path.relative(nodekitProject.root, destinations.app);
events.emit(
'verbose', 'Merging and updating files from [' + sourceDirs.join(', ') + '] to ' + targetDir);
FileUpdater.mergeAndUpdateDir(
sourceDirs, targetDir, { rootDir: nodekitProject.root }, logFileOp);
}
/**
* Cleans all files from the platform 'app' directory.
*/
function cleanApp(projectRoot, locations) {
var targetDir = path.relative(projectRoot, locations.app);
events.emit('verbose', 'Cleaning ' + targetDir);
// No source paths are specified, so mergeAndUpdateDir() will clear the target directory.
FileUpdater.mergeAndUpdateDir(
[], targetDir, { rootDir: projectRoot, all: true }, logFileOp);
}
/**
* Updates project structure and AndroidManifest according to project's configuration.
*
* @param {ConfigParser} platformConfig A project's configuration that will
* be used to update project
* @param {Object} locations A map of locations for this platform
*/
function updateProjectAccordingTo(platformConfig, locations) {
// Update app name by editing res/values/strings.xml
var name = platformConfig.name();
var strings = xmlHelpers.parseElementtreeSync(locations.strings);
strings.find('string[@name="app_name"]').text = name.replace(/\'/g, '\\\'');
fs.writeFileSync(locations.strings, strings.write({indent: 4}), 'utf-8');
events.emit('verbose', 'Wrote out android application name "' + name + '" to ' + locations.strings);
// Java packages cannot support dashes
var pkg = (platformConfig.android_packageName() || platformConfig.packageName()).replace(/-/g, '_');
var manifest = new AndroidManifest(locations.manifest);
var orig_pkg = manifest.getPackageId();
manifest.getActivity()
.setOrientation(platformConfig.getPreference('orientation'))
.setLaunchMode(findAndroidLaunchModePreference(platformConfig));
manifest.setVersionName(platformConfig.version())
.setVersionCode(platformConfig.android_versionCode() || default_versionCode(platformConfig.version()))
.setPackageId(pkg)
.setMinSdkVersion(platformConfig.getPreference('android-minSdkVersion', 'android'))
.setMaxSdkVersion(platformConfig.getPreference('android-maxSdkVersion', 'android'))
.setTargetSdkVersion(platformConfig.getPreference('android-targetSdkVersion', 'android'))
.write();
var javaPattern = path.join(locations.root, 'src', orig_pkg.replace(/\./g, '/'), '*.java');
var java_files = shell.ls(javaPattern).filter(function(f) {
return shell.grep(/extends\s+NodeKitActivity/g, f);
});
if (java_files.length === 0) {
throw new NodeKitError('No Java files found that extend NodeKitActivity.');
} else if(java_files.length > 1) {
events.emit('log', 'Multiple candidate Java files that extend NodeKitActivity found. Guessing at the first one, ' + java_files[0]);
}
var destFile = path.join(locations.root, 'src', pkg.replace(/\./g, '/'), path.basename(java_files[0]));
shell.mkdir('-p', path.dirname(destFile));
shell.sed(/package [\w\.]*;/, 'package ' + pkg + ';', java_files[0]).to(destFile);
events.emit('verbose', 'Wrote out Android package name "' + pkg + '" to ' + destFile);
if (orig_pkg !== pkg) {
// If package was name changed we need to remove old java with main activity
shell.rm('-Rf',java_files[0]);
// remove any empty directories
var currentDir = path.dirname(java_files[0]);
var sourcesRoot = path.resolve(locations.root, 'src');
while(currentDir !== sourcesRoot) {
if(fs.existsSync(currentDir) && fs.readdirSync(currentDir).length === 0) {
fs.rmdirSync(currentDir);
currentDir = path.resolve(currentDir, '..');
} else {
break;
}
}
}
}
// Consturct the default value for versionCode as
// PATCH + MINOR * 100 + MAJOR * 10000
// see http://developer.android.com/tools/publishing/versioning.html
function default_versionCode(version) {
var nums = version.split('-')[0].split('.');
var versionCode = 0;
if (+nums[0]) {
versionCode += +nums[0] * 10000;
}
if (+nums[1]) {
versionCode += +nums[1] * 100;
}
if (+nums[2]) {
versionCode += +nums[2];
}
events.emit('verbose', 'android-versionCode not found in nodekit.json. Generating a code based on version in nodekit.json (' + version + '): ' + versionCode);
return versionCode;
}
function getImageResourcePath(resourcesDir, type, density, name, sourceName) {
if (/\.9\.png$/.test(sourceName)) {
name = name.replace(/\.png$/, '.9.png');
}
var resourcePath = path.join(resourcesDir, (density ? type + '-' + density : type), name);
return resourcePath;
}
function updateSplashes(nodekitProject, platformResourcesDir) {
var resources = nodekitProject.projectConfig.getSplashScreens('android');
// if there are "splash" elements in nodekit.json
if (resources.length === 0) {
events.emit('verbose', 'This app does not have splash screens defined');
return;
}
var resourceMap = mapImageResources(nodekitProject.root, platformResourcesDir, 'drawable', 'screen.png');
var hadMdpi = false;
resources.forEach(function (resource) {
if (!resource.density) {
return;
}
if (resource.density == 'mdpi') {
hadMdpi = true;
}
var targetPath = getImageResourcePath(
platformResourcesDir, 'drawable', resource.density, 'screen.png', path.basename(resource.src));
resourceMap[targetPath] = resource.src;
});
// There's no "default" drawable, so assume default == mdpi.
if (!hadMdpi && resources.defaultResource) {
var targetPath = getImageResourcePath(
platformResourcesDir, 'drawable', 'mdpi', 'screen.png', path.basename(resources.defaultResource.src));
resourceMap[targetPath] = resources.defaultResource.src;
}
events.emit('verbose', 'Updating splash screens at ' + platformResourcesDir);
FileUpdater.updatePaths(
resourceMap, { rootDir: nodekitProject.root }, logFileOp);
}
function cleanSplashes(projectRoot, projectConfig, platformResourcesDir) {
var resources = projectConfig.getSplashScreens('android');
if (resources.length > 0) {
var resourceMap = mapImageResources(projectRoot, platformResourcesDir, 'drawable', 'screen.png');
events.emit('verbose', 'Cleaning splash screens at ' + platformResourcesDir);
// No source paths are specified in the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
}
}
function updateIcons(nodekitProject, platformResourcesDir) {
var icons = nodekitProject.projectConfig.getIcons('android');
// if there are icon elements in nodekit.json
if (icons.length === 0) {
events.emit('verbose', 'This app does not have launcher icons defined');
return;
}
var resourceMap = mapImageResources(nodekitProject.root, platformResourcesDir, 'mipmap', 'icon.png');
var android_icons = {};
var default_icon;
// http://developer.android.com/design/style/iconography.html
var sizeToDensityMap = {
36: 'ldpi',
48: 'mdpi',
72: 'hdpi',
96: 'xhdpi',
144: 'xxhdpi',
192: 'xxxhdpi'
};
// find the best matching icon for a given density or size
// @output android_icons
var parseIcon = function(icon, icon_size) {
// do I have a platform icon for that density already
var density = icon.density || sizeToDensityMap[icon_size];
if (!density) {
// invalid icon defition ( or unsupported size)
return;
}
var previous = android_icons[density];
if (previous && previous.platform) {
return;
}
android_icons[density] = icon;
};
// iterate over all icon elements to find the default icon and call parseIcon
for (var i=0; i<icons.length; i++) {
var icon = icons[i];
var size = icon.width;
if (!size) {
size = icon.height;
}
if (!size && !icon.density) {
if (default_icon) {
events.emit('verbose', 'Found extra default icon: ' + icon.src + ' (ignoring in favor of ' + default_icon.src + ')');
} else {
default_icon = icon;
}
} else {
parseIcon(icon, size);
}
}
// The source paths for icons and splashes are relative to
// project's nodekit.json location, so we use it as base path.
for (var density in android_icons) {
var targetPath = getImageResourcePath(
platformResourcesDir, 'mipmap', density, 'icon.png', path.basename(android_icons[density].src));
resourceMap[targetPath] = android_icons[density].src;
}
// There's no "default" drawable, so assume default == mdpi.
if (default_icon && !android_icons.mdpi) {
var defaultTargetPath = getImageResourcePath(
platformResourcesDir, 'mipmap', 'mdpi', 'icon.png', path.basename(default_icon.src));
resourceMap[defaultTargetPath] = default_icon.src;
}
events.emit('verbose', 'Updating icons at ' + platformResourcesDir);
FileUpdater.updatePaths(
resourceMap, { rootDir: nodekitProject.root }, logFileOp);
}
function cleanIcons(projectRoot, projectConfig, platformResourcesDir) {
var icons = projectConfig.getIcons('android');
if (icons.length > 0) {
var resourceMap = mapImageResources(projectRoot, platformResourcesDir, 'mipmap', 'icon.png');
events.emit('verbose', 'Cleaning icons at ' + platformResourcesDir);
// No source paths are specified in the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
}
}
/**
* Gets a map containing resources of a specified name from all drawable folders in a directory.
*/
function mapImageResources(rootDir, subDir, type, resourceName) {
var pathMap = {};
shell.ls(path.join(rootDir, subDir, type + '-*'))
.forEach(function (drawableFolder) {
var imagePath = path.join(subDir, path.basename(drawableFolder), resourceName);
pathMap[imagePath] = null;
});
return pathMap;
}
/**
* Gets and validates 'AndroidLaunchMode' prepference from nodekit.json. Returns
* preference value and warns if it doesn't seems to be valid
*
* @param {ConfigParser} platformConfig A configParser instance for
* platform.
*
* @return {String} Preference's value from nodekit.json or
* default value, if there is no such preference. The default value is
* 'singleTop'
*/
function findAndroidLaunchModePreference(platformConfig) {
var launchMode = platformConfig.getPreference('AndroidLaunchMode');
if (!launchMode) {
// Return a default value
return 'singleTop';
}
var expectedValues = ['standard', 'singleTop', 'singleTask', 'singleInstance'];
var valid = expectedValues.indexOf(launchMode) >= 0;
if (!valid) {
// Note: warn, but leave the launch mode as developer wanted, in case the list of options changes in the future
events.emit('warn', 'Unrecognized value for AndroidLaunchMode preference: ' +
launchMode + '. Expected values are: ' + expectedValues.join(', '));
}
return launchMode;
}