diff --git a/bin/templates/cordova/Api.js b/bin/templates/cordova/Api.js index 30b1fbe9..dd4a2095 100644 --- a/bin/templates/cordova/Api.js +++ b/bin/templates/cordova/Api.js @@ -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, '') }; }); }); diff --git a/bin/templates/cordova/lib/PackageType.js b/bin/templates/cordova/lib/PackageType.js new file mode 100644 index 00000000..fd129f13 --- /dev/null +++ b/bin/templates/cordova/lib/PackageType.js @@ -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; diff --git a/bin/templates/cordova/lib/build.js b/bin/templates/cordova/lib/build.js index dec218ea..0695539e 100644 --- a/bin/templates/cordova/lib/build.js +++ b/bin/templates/cordova/lib/build.js @@ -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=\': Extra args to pass to the gradle command. Use one flag per arg. Ex. --gradleArg=-PcdvBuildMultipleApks=true'); + console.log(' \'--packageType=\': Builds an APK or a bundle'); console.log(''); console.log('Signed APK flags (overwrites debug/release-signing.proprties) :'); console.log(' \'--keystore=\': Key store used to build a signed archive. (Required)'); diff --git a/bin/templates/cordova/lib/builders/ProjectBuilder.js b/bin/templates/cordova/lib/builders/ProjectBuilder.js index f2a00665..35ce906b 100644 --- a/bin/templates/cordova/lib/builders/ProjectBuilder.js +++ b/bin/templates/cordova/lib/builders/ProjectBuilder.js @@ -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,24 +39,38 @@ 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'; + } + + args = [buildCmd, '-b', path.join(this.root, 'build.gradle')]; + } else { + let buildCmd; + if (cmd === 'release') { + buildCmd = 'cdvBuildRelease'; + } else if (cmd === 'debug') { + buildCmd = 'cdvBuildDebug'; + } + + args = [buildCmd, '-b', path.join(this.root, 'build.gradle')]; + + if (opts.arch) { + args.push('-PcdvBuildArch=' + opts.arch); + } + + args.push.apply(args, opts.extraArgs); } - let args = [cmd, '-b', path.join(this.root, 'build.gradle')]; - - 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; } diff --git a/bin/templates/cordova/lib/run.js b/bin/templates/cordova/lib/run.js index 221467bb..f1c70f22 100644 --- a/bin/templates/cordova/lib/run.js +++ b/bin/templates/cordova/lib/run.js @@ -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) { diff --git a/spec/unit/builders/ProjectBuilder.spec.js b/spec/unit/builders/ProjectBuilder.spec.js index 6dfd05f9..44769b5d 100644 --- a/spec/unit/builders/ProjectBuilder.spec.js +++ b/spec/unit/builders/ProjectBuilder.spec.js @@ -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 }); diff --git a/spec/unit/run.spec.js b/spec/unit/run.spec.js index 61a843c8..76081eba 100644 --- a/spec/unit/run.spec.js +++ b/spec/unit/run.spec.js @@ -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', () => {