diff --git a/.changeset/whole-tools-occur.md b/.changeset/whole-tools-occur.md new file mode 100644 index 000000000..67bbf5296 --- /dev/null +++ b/.changeset/whole-tools-occur.md @@ -0,0 +1,5 @@ +--- +'@rock-js/platform-android': minor +--- + +feat: android aab signing diff --git a/packages/platform-android/src/lib/commands/signAndroid/command.ts b/packages/platform-android/src/lib/commands/signAndroid/command.ts index 0da6e63ab..ff1aa4501 100644 --- a/packages/platform-android/src/lib/commands/signAndroid/command.ts +++ b/packages/platform-android/src/lib/commands/signAndroid/command.ts @@ -3,7 +3,7 @@ import { signAndroid } from './signAndroid.js'; export type SignFlags = { verbose?: boolean; - apk: string; + path: string; output?: string; keystore?: string; keystorePassword?: string; @@ -16,8 +16,8 @@ export type SignFlags = { const ARGUMENTS = [ { - name: 'apk', - description: 'APK file path', + name: 'binaryPath', + description: 'Archive (apk or aab) file path', }, ]; @@ -44,7 +44,7 @@ const OPTIONS = [ }, { name: '--output ', - description: 'Path to the output APK file.', + description: 'Path to the output APK/AAB file.', }, { name: '--build-jsbundle', @@ -66,9 +66,9 @@ export const registerSignCommand = (api: PluginApi) => { description: 'Sign the Android app with modified JS bundle.', args: ARGUMENTS, options: OPTIONS, - action: async (apkPath, flags: SignFlags) => { + action: async (binaryPath, flags: SignFlags) => { await signAndroid({ - apkPath, + binaryPath, keystorePath: flags.keystore, keystorePassword: flags.keystorePassword, keyAlias: flags.keyAlias, diff --git a/packages/platform-android/src/lib/commands/signAndroid/signAndroid.ts b/packages/platform-android/src/lib/commands/signAndroid/signAndroid.ts index 1f642a0d8..87f930749 100644 --- a/packages/platform-android/src/lib/commands/signAndroid/signAndroid.ts +++ b/packages/platform-android/src/lib/commands/signAndroid/signAndroid.ts @@ -16,7 +16,7 @@ import { findAndroidBuildTool, getAndroidBuildToolsPath } from '../../paths.js'; import { buildJsBundle } from './bundle.js'; export type SignAndroidOptions = { - apkPath: string; + binaryPath: string; keystorePath?: string; keystorePassword?: string; keyAlias?: string; @@ -30,7 +30,9 @@ export type SignAndroidOptions = { export async function signAndroid(options: SignAndroidOptions) { validateOptions(options); - intro(`Modifying APK file`); + const extension = path.extname(options.binaryPath).slice(1); + + intro(`Modifying ${extension.toUpperCase()} file`); const tempPath = getSignOutputPath(); if (fs.existsSync(tempPath)) { @@ -60,28 +62,28 @@ export async function signAndroid(options: SignAndroidOptions) { options.jsBundlePath = bundleOutputPath; } - // 2. Initialize temporary APK file - const tempApkPath = path.join(tempPath, 'output-app.apk'); + // 2. Initialize temporary archive file + const tempArchivePath = path.join(tempPath, `output-app.${extension}`); - loader.start('Initializing output APK...'); + loader.start(`Initializing output ${extension.toUpperCase()}...`); try { - const zip = new AdmZip(options.apkPath); + const zip = new AdmZip(options.binaryPath); // Remove old signature files zip.deleteFile('META-INF/*'); - zip.writeZip(tempApkPath); + zip.writeZip(tempArchivePath); } catch (error) { throw new RockError( - `Failed to initialize output APK file: ${options.outputPath}`, + `Failed to initialize output file: ${options.outputPath}`, { cause: (error as SubprocessError).stderr }, ); } - loader.stop(`Initialized output APK.`); + loader.stop(`Initialized output ${extension.toUpperCase()}`); // 3. Replace JS bundle if provided if (options.jsBundlePath) { loader.start('Replacing JS bundle...'); await replaceJsBundle({ - apkPath: tempApkPath, + archivePath: tempArchivePath, jsBundlePath: options.jsBundlePath, }); loader.stop( @@ -91,32 +93,34 @@ export async function signAndroid(options: SignAndroidOptions) { ); } - // 4. Align APK file - loader.start('Aligning output APK file...'); - const outputApkPath = options.outputPath ?? options.apkPath; - await alignApkFile(tempApkPath, outputApkPath); + // 4. Align archive + loader.start('Aligning output file...'); + const outputPath = options.outputPath ?? options.binaryPath; + await alignArchiveFile(tempArchivePath, outputPath); loader.stop( - `Created output APK file: ${colorLink(relativeToCwd(outputApkPath))}.`, + `Created output ${extension.toUpperCase()} file: ${colorLink(relativeToCwd(outputPath))}.`, ); - // 5. Sign APK file - loader.start('Signing the APK file...'); + // 5. Sign archive file + loader.start(`Signing the ${extension.toUpperCase()} file...`); const keystorePath = options.keystorePath ?? 'android/app/debug.keystore'; - await signApkFile({ - apkPath: outputApkPath, + await signArchive({ + binaryPath: outputPath, keystorePath, keystorePassword: options.keystorePassword ?? 'pass:android', keyAlias: options.keyAlias, keyPassword: options.keyPassword, }); - loader.stop(`Signed the APK file with keystore: ${colorLink(keystorePath)}.`); + loader.stop( + `Signed the ${extension.toUpperCase()} file with keystore: ${colorLink(keystorePath)}.`, + ); outro('Success 🎉.'); } function validateOptions(options: SignAndroidOptions) { - if (!fs.existsSync(options.apkPath)) { - throw new RockError(`APK file not found "${options.apkPath}"`); + if (!fs.existsSync(options.binaryPath)) { + throw new RockError(`File not found "${options.binaryPath}"`); } if (options.buildJsBundle && options.jsBundlePath) { @@ -131,22 +135,24 @@ function validateOptions(options: SignAndroidOptions) { } type ReplaceJsBundleOptions = { - apkPath: string; + archivePath: string; jsBundlePath: string; }; async function replaceJsBundle({ - apkPath, + archivePath, jsBundlePath, }: ReplaceJsBundleOptions) { try { - const zip = new AdmZip(apkPath); - zip.deleteFile('assets/index.android.bundle'); - zip.addLocalFile(jsBundlePath, 'assets', 'index.android.bundle'); - zip.writeZip(apkPath); + const zip = new AdmZip(archivePath); + const assetsPath = isAab(archivePath) ? 'base/assets' : 'assets'; + + zip.deleteFile(path.join(assetsPath, 'index.android.bundle')); + zip.addLocalFile(jsBundlePath, assetsPath, 'index.android.bundle'); + zip.writeZip(archivePath); } catch (error) { throw new RockError( - `Failed to replace JS bundle in destination file: ${apkPath}}`, + `Failed to replace JS bundle in destination file: ${archivePath}`, { cause: error }, ); } @@ -159,7 +165,7 @@ function isSdkGTE35(versionString: string) { return match[1].localeCompare('35.0.0', undefined, { numeric: true }) >= 0; } -async function alignApkFile(inputApkPath: string, outputApkPath: string) { +async function alignArchiveFile(inputArchivePath: string, outputPath: string) { const zipAlignPath = findAndroidBuildTool('zipalign'); if (!zipAlignPath) { throw new RockError( @@ -177,34 +183,34 @@ Please follow instructions at: https://reactnative.dev/docs/set-up-your-environm '-f', // Overwrites existing output file. '-v', // Overwrites existing output file. '4', // alignment in bytes, e.g. '4' provides 32-bit alignment - inputApkPath, - outputApkPath, + inputArchivePath, + outputPath, ]; try { await spawn(zipAlignPath, zipalignArgs); } catch (error) { throw new RockError( - `Failed to align APK file: ${zipAlignPath} ${zipalignArgs.join(' ')}`, + `Failed to align archive file: ${zipAlignPath} ${zipalignArgs.join(' ')}`, { cause: (error as SubprocessError).stderr }, ); } } -type SignApkOptions = { - apkPath: string; +type SignOptions = { + binaryPath: string; keystorePath: string; keystorePassword: string; keyAlias?: string; keyPassword?: string; }; -async function signApkFile({ - apkPath, +async function signArchive({ + binaryPath, keystorePath, keystorePassword, keyAlias, keyPassword, -}: SignApkOptions) { +}: SignOptions) { if (!fs.existsSync(keystorePath)) { throw new RockError( `Keystore file not found "${keystorePath}". Provide a valid keystore path using the "--keystore" option.`, @@ -230,7 +236,8 @@ Please follow instructions at: https://reactnative.dev/docs/set-up-your-environm formatPassword(keystorePassword), ...(keyAlias ? ['--ks-key-alias', keyAlias] : []), ...(keyPassword ? ['--key-pass', formatPassword(keyPassword)] : []), - apkPath, + ...(isAab(binaryPath) ? ['--min-sdk-version', '36'] : []), + binaryPath, ]; try { @@ -264,3 +271,7 @@ function formatPassword(password: string) { function getSignOutputPath() { return path.join(getDotRockPath(), 'android/sign'); } + +function isAab(filePath: string): boolean { + return path.extname(filePath).toLowerCase() === '.aab'; +} diff --git a/website/src/docs/cli/introduction.md b/website/src/docs/cli/introduction.md index 88427de4b..a5f840d68 100644 --- a/website/src/docs/cli/introduction.md +++ b/website/src/docs/cli/introduction.md @@ -244,17 +244,17 @@ Same as for `build:android` and: ### `rock sign:android` Options -The `sign:android ` command signs your Android app with a keystore, producing a signed APK file ready for distribution. It allows for replacing the JS bundle with a new version. +The `sign:android ` command signs your Android app with a keystore, producing a signed APK or AAB file ready for distribution. It allows for replacing the JS bundle with a new version. -| Argument | Description | -| :----------- | :------------------- | -| `binaryPath` | Path to the APK file | +| Argument | Description | +| :----------- | :-------------------------- | +| `binaryPath` | Path to the APK or AAB file | | Option | Description | | :----------------------------- | :---------------------------------------- | | `--keystore ` | Path to keystore file | | `--keystore-password ` | Password for keystore file | -| `--output ` | Path to output APK file | +| `--output ` | Path to output APK or AAB file | | `--build-jsbundle` | Build JS bundle before signing | | `--jsbundle ` | Path to JS bundle to apply before signing | | `--no-hermes` | Don't use Hermes for JS bundle |