Skip to content
Permalink
Browse files
feat: Build app bundles (.aab files) (#764)
* (android) Added android bundle support

  with some corrected tests

  added bundle specific output

* with --packageType flag to have consistency with cordova-ios

* warn about missing required signing params only if at least one signing param is present

* produce error on run if packageType = bundle

* added comments relating to shelljs as suggested

* unit test case added by @brodybits - Chris Brody

* Filled in error message and unit test spec

Primary author: @breautek - Norman Breau <norman@normanbreau.com>

Co-authored-by: Norman Breau <norman@normanbreau.com>
Co-authored-by: Chris Brody <chris@brody.consulting>
  • Loading branch information
breautek and Chris Brody committed Aug 8, 2019
1 parent b3b8690 commit bd1697dbd27411d8b12d9ad0902babaaae327efa
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 22 deletions.
@@ -302,12 +302,12 @@ Api.prototype.build = function (buildOptions) {
return require('./lib/build').run.call(self, buildOptions);
}).then(function (buildResults) {
// Cast build result to array of build artifacts
return buildResults.apkPaths.map(function (apkPath) {
return buildResults.paths.map(function (apkPath) {
return {
buildType: buildResults.buildType,
buildMethod: buildResults.buildMethod,
path: apkPath,
type: 'apk'
type: path.extname(apkPath).replace(/\./g, '')
};
});
});
@@ -0,0 +1,25 @@
/**
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF 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://www.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.
*/

const PackageType = {
APK: 'apk',
BUNDLE: 'bundle'
};

module.exports = PackageType;
@@ -30,6 +30,7 @@ var builders = require('./builders/builders');
var events = require('cordova-common').events;
var spawn = require('cordova-common').superspawn.spawn;
var CordovaError = require('cordova-common').CordovaError;
var PackageType = require('./PackageType');

module.exports.parseBuildOptions = parseOpts;
function parseOpts (options, resolvedTarget, projectRoot) {
@@ -45,7 +46,8 @@ function parseOpts (options, resolvedTarget, projectRoot) {
alias: String,
storePassword: String,
password: String,
keystoreType: String
keystoreType: String,
packageType: String
}, {}, options.argv, 0);

// Android Studio Build method is the default
@@ -68,14 +70,14 @@ function parseOpts (options, resolvedTarget, projectRoot) {

if (options.argv.keystore) { packageArgs.keystore = path.relative(projectRoot, path.resolve(options.argv.keystore)); }

['alias', 'storePassword', 'password', 'keystoreType'].forEach(function (flagName) {
['alias', 'storePassword', 'password', 'keystoreType', 'packageType'].forEach(function (flagName) {
if (options.argv[flagName]) { packageArgs[flagName] = options.argv[flagName]; }
});

var buildConfig = options.buildConfig;

// If some values are not specified as command line arguments - use build config to supplement them.
// Command line arguemnts have precedence over build config.
// Command line arguments have precedence over build config.
if (buildConfig) {
if (!fs.existsSync(buildConfig)) {
throw new Error('Specified build config file does not exist: ' + buildConfig);
@@ -93,7 +95,7 @@ function parseOpts (options, resolvedTarget, projectRoot) {
events.emit('log', 'Reading the keystore from: ' + packageArgs.keystore);
}

['alias', 'storePassword', 'password', 'keystoreType'].forEach(function (key) {
['alias', 'storePassword', 'password', 'keystoreType', 'packageType'].forEach(function (key) {
packageArgs[key] = packageArgs[key] || androidInfo[key];
});
}
@@ -105,11 +107,38 @@ function parseOpts (options, resolvedTarget, projectRoot) {
}

if (!ret.packageInfo) {
if (Object.keys(packageArgs).length > 0) {
// The following loop is to decide whether to print a warning about generating a signed archive
// We only want to produce a warning if they are using a config property that is related to signing, but
// missing the required properties for signing. We don't want to produce a warning if they are simply
// using a build property that isn't related to signing, such as --packageType
let shouldWarn = false;
const signingKeys = ['keystore', 'alias', 'storePassword', 'password', 'keystoreType'];

for (let key in packageArgs) {
if (!shouldWarn && signingKeys.indexOf(key) > -1) {
// If we enter this condition, we have a key used for signing a build,
// but we are missing some required signing properties
shouldWarn = true;
}
}

if (shouldWarn) {
events.emit('warn', '\'keystore\' and \'alias\' need to be specified to generate a signed archive.');
}
}

if (packageArgs.packageType) {
const VALID_PACKAGE_TYPES = [PackageType.APK, PackageType.BUNDLE];
if (VALID_PACKAGE_TYPES.indexOf(packageArgs.packageType) === -1) {
events.emit('warn', '"' + packageArgs.packageType + '" is an invalid packageType. Valid values are: ' + VALID_PACKAGE_TYPES.join(', ') + '\nDefaulting packageType to ' + PackageType.APK);
ret.packageType = PackageType.APK;
} else {
ret.packageType = packageArgs.packageType;
}
} else {
ret.packageType = PackageType.APK;
}

return ret;
}

@@ -148,10 +177,17 @@ module.exports.run = function (options, optResolvedTarget) {
return;
}
return builder.build(opts).then(function () {
var apkPaths = builder.findOutputApks(opts.buildType, opts.arch);
events.emit('log', 'Built the following apk(s): \n\t' + apkPaths.join('\n\t'));
var paths;
if (opts.packageType === PackageType.BUNDLE) {
paths = builder.findOutputBundles(opts.buildType);
events.emit('log', 'Built the following bundle(s): \n\t' + paths.join('\n\t'));
} else {
paths = builder.findOutputApks(opts.buildType, opts.arch);
events.emit('log', 'Built the following apk(s): \n\t' + paths.join('\n\t'));
}

return {
apkPaths: apkPaths,
paths: paths,
buildType: opts.buildType
};
});
@@ -273,6 +309,7 @@ module.exports.help = function () {
console.log(' \'--maxSdkVersion=#\': Override maxSdkVersion for this build. (Not Recommended)');
console.log(' \'--targetSdkVersion=#\': Override targetSdkVersion for this build.');
console.log(' \'--gradleArg=<gradle command line arg>\': Extra args to pass to the gradle command. Use one flag per arg. Ex. --gradleArg=-PcdvBuildMultipleApks=true');
console.log(' \'--packageType=<apk|bundle>\': Builds an APK or a bundle');
console.log('');
console.log('Signed APK flags (overwrites debug/release-signing.proprties) :');
console.log(' \'--keystore=<path to keystore>\': Key store used to build a signed archive. (Required)');
@@ -27,6 +27,7 @@ var spawn = require('cordova-common').superspawn.spawn;
var events = require('cordova-common').events;
var CordovaError = require('cordova-common').CordovaError;
var check_reqs = require('../check_reqs');
var PackageType = require('../PackageType');
const compareFunc = require('compare-func');

const MARKER = 'YOUR CHANGES WILL BE ERASED!';
@@ -38,23 +39,37 @@ const TEMPLATE =
class ProjectBuilder {
constructor (rootDirectory) {
this.root = rootDirectory || path.resolve(__dirname, '../../..');
this.binDir = path.join(this.root, 'app', 'build', 'outputs', 'apk');
this.apkDir = path.join(this.root, 'app', 'build', 'outputs', 'apk');
this.aabDir = path.join(this.root, 'app', 'build', 'outputs', 'bundle');
}

getArgs (cmd, opts) {
if (cmd === 'release') {
cmd = 'cdvBuildRelease';
} else if (cmd === 'debug') {
cmd = 'cdvBuildDebug';
}
let args;
if (opts.packageType === PackageType.BUNDLE) {
let buildCmd;
if (cmd === 'release') {
buildCmd = ':app:bundleRelease';
} else if (cmd === 'debug') {
buildCmd = ':app:bundleDebug';
}

let args = [cmd, '-b', path.join(this.root, 'build.gradle')];
args = [buildCmd, '-b', path.join(this.root, 'build.gradle')];
} else {
let buildCmd;
if (cmd === 'release') {
buildCmd = 'cdvBuildRelease';
} else if (cmd === 'debug') {
buildCmd = 'cdvBuildDebug';
}

if (opts.arch) {
args.push('-PcdvBuildArch=' + opts.arch);
}
args = [buildCmd, '-b', path.join(this.root, 'build.gradle')];

args.push.apply(args, opts.extraArgs);
if (opts.arch) {
args.push('-PcdvBuildArch=' + opts.arch);
}

args.push.apply(args, opts.extraArgs);
}

return args;
}
@@ -288,7 +303,11 @@ class ProjectBuilder {
}

findOutputApks (build_type, arch) {
return findOutputApksHelper(this.binDir, build_type, arch).sort(apkSorter);
return findOutputApksHelper(this.apkDir, build_type, arch).sort(apkSorter);
}

findOutputBundles (build_type) {
return findOutputBundlesHelper(this.aabDir, build_type);
}

fetchBuildResults (build_type, arch) {
@@ -359,6 +378,36 @@ function findOutputApksHelper (dir, build_type, arch) {
return ret;
}

// This method was a copy of findOutputApksHelper and modified to look for bundles
// While replacing shell with fs-extra, it might be a good idea to see if we can
// generalise these findOutput methods.
function findOutputBundlesHelper (dir, build_type) {
// This is an unused variable that was copied from findOutputApksHelper
// we are pretty sure it was meant to reset shell.config.silent back to
// the original value. However shell is planned to be replaced,
// it was left as is to avoid unintended consequences.
const shellSilent = shell.config.silent;
shell.config.silent = true;

// list directory recursively
const ret = shell.ls('-R', dir).map(function (file) {
return path.join(dir, file); // ls does not include base directory
}).filter(function (file) {
return file.match(/\.aab?$/i); // find all bundles
}).filter(function (candidate) {
// Need to choose between release and debug bundle.
if (build_type === 'debug') {
return /debug/.exec(candidate);
}
if (build_type === 'release') {
return /release/.exec(candidate);
}
return true;
});

return ret;
}

function isAutoGenerated (file) {
return fs.existsSync(file) && fs.readFileSync(file, 'utf8').indexOf(MARKER) > 0;
}
@@ -23,6 +23,7 @@ var path = require('path');
var emulator = require('./emulator');
var device = require('./device');
var Q = require('q');
var PackageType = require('./PackageType');
var events = require('cordova-common').events;

function getInstallTarget (runOptions) {
@@ -104,6 +105,14 @@ module.exports.run = function (runOptions) {
return new Promise((resolve) => {
const builder = require('./builders/builders').getBuilder();
const buildOptions = require('./build').parseBuildOptions(runOptions, null, self.root);

// Android app bundles cannot be deployed directly to the device
if (buildOptions.packageType === PackageType.BUNDLE) {
const packageTypeErrorMessage = 'Package type "bundle" is not supported during cordova run.';
events.emit('error', packageTypeErrorMessage);
throw packageTypeErrorMessage;
}

resolve(builder.fetchBuildResults(buildOptions.buildType, buildOptions.arch));
}).then(function (buildResults) {
if (resolvedTarget && resolvedTarget.isEmulator) {
@@ -66,6 +66,34 @@ describe('ProjectBuilder', () => {
expect(args[0]).toBe('cdvBuildDebug');
});

it('should set apk release', () => {
const args = builder.getArgs('release', {
packageType: 'apk'
});
expect(args[0]).withContext(args).toBe('cdvBuildRelease');
});

it('should set apk debug', () => {
const args = builder.getArgs('debug', {
packageType: 'apk'
});
expect(args[0]).withContext(args).toBe('cdvBuildDebug');
});

it('should set bundle release', () => {
const args = builder.getArgs('release', {
packageType: 'bundle'
});
expect(args[0]).withContext(args).toBe(':app:bundleRelease');
});

it('should set bundle debug', () => {
const args = builder.getArgs('debug', {
packageType: 'bundle'
});
expect(args[0]).withContext(args).toBe(':app:bundleDebug');
});

it('should add architecture if it is passed', () => {
const arch = 'unittest';
const args = builder.getArgs('debug', { arch });
@@ -196,6 +196,18 @@ describe('run', () => {
expect(emulatorSpyObj.install).toHaveBeenCalledWith(emulatorTarget, { apkPaths: [], buildType: 'debug' });
});
});

it('should fail with the error message if --packageType=bundle setting is used', () => {
const deviceList = ['testDevice1', 'testDevice2'];
getInstallTargetSpy.and.returnValue(null);

deviceSpyObj.list.and.returnValue(Promise.resolve(deviceList));

return run.run({ argv: ['--packageType=bundle'] }).then(
() => fail('Expected error to be thrown'),
err => expect(err).toContain('Package type "bundle" is not supported during cordova run.')
);
});
});

describe('help', () => {

0 comments on commit bd1697d

Please sign in to comment.