diff --git a/cli/src/android/build.ts b/cli/src/android/build.ts new file mode 100644 index 000000000..de22a2ded --- /dev/null +++ b/cli/src/android/build.ts @@ -0,0 +1,87 @@ +import { join } from 'path'; + +import c from '../colors'; +import { runTask } from '../common'; +import type { Config } from '../definitions'; +import { logSuccess } from '../log'; +import type { BuildCommandOptions } from '../tasks/build'; +import { runCommand } from '../util/subprocess'; + +export async function buildAndroid( + config: Config, + buildOptions: BuildCommandOptions, +): Promise { + const releaseType = buildOptions.androidreleasetype ?? 'AAB'; + const releaseTypeIsAAB = releaseType === 'AAB'; + const arg = releaseTypeIsAAB ? ':app:bundleRelease' : 'assembleRelease'; + const gradleArgs = [arg]; + + if ( + !buildOptions.keystorepath || + !buildOptions.keystorealias || + !buildOptions.keystorealiaspass || + !buildOptions.keystorepass + ) { + throw 'Missing options. Please supply all options for android signing. (Keystore Path, Keystore Password, Keystore Key Alias, Keystore Key Password)'; + } + + try { + await runTask('Running Gradle build', async () => + runCommand('./gradlew', gradleArgs, { + cwd: config.android.platformDirAbs, + }), + ); + } catch (e) { + if ((e as any).includes('EACCES')) { + throw `gradlew file does not have executable permissions. This can happen if the Android platform was added on a Windows machine. Please run ${c.strong( + `chmod +x ./${config.android.platformDir}/gradlew`, + )} and try again.`; + } else { + throw e; + } + } + + const releasePath = join( + config.android.appDirAbs, + 'build', + 'outputs', + releaseTypeIsAAB ? 'bundle' : 'apk', + 'release', + ); + + const unsignedReleaseName = `app${ + config.android.flavor ? `-${config.android.flavor}` : '' + }-release${releaseTypeIsAAB ? '' : '-unsigned'}.${releaseType.toLowerCase()}`; + + const signedReleaseName = unsignedReleaseName.replace( + `-release${ + releaseTypeIsAAB ? '' : '-unsigned' + }.${releaseType.toLowerCase()}`, + `-release-signed.${releaseType.toLowerCase()}`, + ); + + const signingArgs = [ + '-sigalg', + 'SHA1withRSA', + '-digestalg', + 'SHA1', + '-keystore', + buildOptions.keystorepath, + '-keypass', + buildOptions.keystorealiaspass, + '-storepass', + buildOptions.keystorepass, + `-signedjar`, + `${join(releasePath, signedReleaseName)}`, + `${join(releasePath, unsignedReleaseName)}`, + buildOptions.keystorealias, + ]; + + await runTask('Signing Release', async () => { + await runCommand('jarsigner', signingArgs, { + cwd: config.android.platformDirAbs, + }); + }); + + logSuccess(`Successfully generated ${signedReleaseName} at: ${releasePath}`); +} diff --git a/cli/src/config.ts b/cli/src/config.ts index c6f75c34f..903b3619b 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -236,6 +236,14 @@ async function loadAndroidConfig( const buildOutputDir = `${apkPath}/debug`; const cordovaPluginsDir = 'capacitor-cordova-android-plugins'; const studioPath = lazy(() => determineAndroidStudioPath(cliConfig.os)); + const buildOptions = { + keystorePath: extConfig.android?.buildOptions?.keystorePath, + keystorePassword: extConfig.android?.buildOptions?.keystorePassword, + keystoreAlias: extConfig.android?.buildOptions?.keystoreAlias, + keystoreAliasPassword: + extConfig.android?.buildOptions?.keystoreAliasPassword, + releaseType: extConfig.android?.buildOptions?.releaseType, + }; return { name, @@ -261,6 +269,7 @@ async function loadAndroidConfig( buildOutputDir, buildOutputDirAbs: resolve(platformDirAbs, buildOutputDir), flavor, + buildOptions, }; } diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts index 8bdbcf225..6b9a316bb 100644 --- a/cli/src/declarations.ts +++ b/cli/src/declarations.ts @@ -225,6 +225,44 @@ export interface CapacitorConfig { * @default 60 */ minWebViewVersion?: number; + + buildOptions?: { + /** + * Path to your keystore + * + * @since 4.3.0 + */ + keystorePath?: string; + + /** + * Password to your keystore + * + * @since 4.3.0 + */ + keystorePassword?: string; + + /** + * Alias in the keystore to use + * + * @since 4.3.0 + */ + keystoreAlias?: string; + + /** + * Password for the alias in the keystore to use + * + * @since 4.3.0 + */ + keystoreAliasPassword?: string; + + /** + * Bundle type for your release build + * + * @since 4.3.0 + * @default "AAB" + */ + releaseType?: 'AAB' | 'APK'; + }; }; ios?: { diff --git a/cli/src/definitions.ts b/cli/src/definitions.ts index 445d5312b..a2e0b6d24 100644 --- a/cli/src/definitions.ts +++ b/cli/src/definitions.ts @@ -91,6 +91,13 @@ export interface AndroidConfig extends PlatformConfig { readonly buildOutputDirAbs: string; readonly apkName: string; readonly flavor: string; + readonly buildOptions: { + keystorePath?: string; + keystorePassword?: string; + keystoreAlias?: string; + keystoreAliasPassword?: string; + releaseType?: 'AAB' | 'APK'; + }; } export interface IOSConfig extends PlatformConfig { diff --git a/cli/src/index.ts b/cli/src/index.ts index acb6e135f..171329f75 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,4 +1,4 @@ -import { program } from 'commander'; +import { Option, program } from 'commander'; import c from './colors'; import { checkExternalConfig, loadConfig } from './config'; @@ -137,6 +137,51 @@ export function runProgram(config: Config): void { ), ); + program + .command('build ') + .description('builds the release version of the selected platform') + .option('--keystorepath ', 'Path to the keystore') + .option('--keystorepass ', 'Password to the keystore') + .option('--keystorealias ', 'Key Alias in the keystore') + .option( + '--keystorealiaspass ', + 'Password for the Key Alias', + ) + .addOption( + new Option( + '--androidreleasetype ', + 'Android release type; APK or AAB', + ) + .choices(['AAB', 'APK']) + .default('AAB'), + ) + .action( + wrapAction( + telemetryAction( + config, + async ( + platform, + { + keystorepath, + keystorepass, + keystorealias, + keystorealiaspass, + androidreleasetype, + }, + ) => { + const { buildCommand } = await import('./tasks/build'); + await buildCommand(config, platform, { + keystorepath, + keystorepass, + keystorealias, + keystorealiaspass, + androidreleasetype, + }); + }, + ), + ), + ); + program .command(`run [platform]`) .description( diff --git a/cli/src/tasks/build.ts b/cli/src/tasks/build.ts new file mode 100644 index 000000000..e57e54cf8 --- /dev/null +++ b/cli/src/tasks/build.ts @@ -0,0 +1,76 @@ +import { buildAndroid } from '../android/build'; +import { selectPlatforms, promptForPlatform } from '../common'; +import type { Config } from '../definitions'; +import { fatal, isFatal } from '../errors'; + +export interface BuildCommandOptions { + keystorepath?: string; + keystorepass?: string; + keystorealias?: string; + keystorealiaspass?: string; + androidreleasetype?: 'AAB' | 'APK'; +} + +export async function buildCommand( + config: Config, + selectedPlatformName: string, + buildOptions: BuildCommandOptions, +): Promise { + const platforms = await selectPlatforms(config, selectedPlatformName); + let platformName: string; + if (platforms.length === 1) { + platformName = platforms[0]; + } else { + platformName = await promptForPlatform( + platforms.filter(createBuildablePlatformFilter(config)), + `Please choose a platform to build for:`, + ); + } + + const buildCommandOptions: BuildCommandOptions = { + keystorepath: + buildOptions.keystorepath || config.android.buildOptions.keystorePath, + keystorepass: + buildOptions.keystorepass || config.android.buildOptions.keystorePassword, + keystorealias: + buildOptions.keystorealias || config.android.buildOptions.keystoreAlias, + keystorealiaspass: + buildOptions.keystorealiaspass || + config.android.buildOptions.keystoreAliasPassword, + androidreleasetype: + buildOptions.androidreleasetype || + config.android.buildOptions.releaseType, + }; + + try { + await build(config, platformName, buildCommandOptions); + } catch (e) { + if (!isFatal(e)) { + fatal((e as any).stack ?? e); + } + throw e; + } +} + +export async function build( + config: Config, + platformName: string, + buildOptions: BuildCommandOptions, +): Promise { + if (platformName == config.ios.name) { + throw `Platform "${platformName}" is not available in the build command.`; + } else if (platformName === config.android.name) { + await buildAndroid(config, buildOptions); + } else if (platformName === config.web.name) { + throw `Platform "${platformName}" is not available in the build command.`; + } else { + throw `Platform "${platformName}" is not valid.`; + } +} + +function createBuildablePlatformFilter( + config: Config, +): (platform: string) => boolean { + return platform => + platform === config.ios.name || platform === config.android.name; +}