Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cbaa713
feat: android aab signing
mlisikbf Sep 29, 2025
3ec1556
feat: android aab signing - docs update
mlisikbf Sep 29, 2025
fc799c9
feat: android aab signing - fixes assets path
mlisikbf Sep 30, 2025
1962250
feat: android aab signing - changeset
mlisikbf Sep 30, 2025
c0410d4
feat: android aab signing - missing semicolon
mlisikbf Sep 30, 2025
18f59fb
feat: android aab signing - ensures non-empty alias
mlisikbf Sep 30, 2025
7a69dd5
feat: android aab signing - avoids shadowing node:path
mlisikbf Sep 30, 2025
6b51b87
feat: android aab signing - strips jarsigner password input
mlisikbf Sep 30, 2025
9f6b227
feat: android aab signing - corrects key-alias message
mlisikbf Sep 30, 2025
c3005d7
feat: android aab signing - uses input path for isAab checks
mlisikbf Sep 30, 2025
4f80eb2
feat: android aab signing - updates changeset
mlisikbf Oct 1, 2025
321b308
feat: android aab signing - adds note on sign/align sequence
mlisikbf Oct 1, 2025
eb5bbc6
Merge branch 'main' into feat/android-aab-signing
mlisikbf Oct 6, 2025
b3d4acf
Merge branch 'callstackincubator:main' into feat/android-aab-signing
mlisikbf Oct 17, 2025
2dd2769
feat: android aab signing - removes aab-specific signing flow
mlisikbf Oct 17, 2025
c617315
feat: android aab signing - minimizes changes
mlisikbf Oct 17, 2025
5863312
feat: android aab signing - adds --min-sdk-version arg
mlisikbf Oct 17, 2025
f7448cb
feat: android aab signing - adds --min-sdk-version note in the docs
mlisikbf Oct 17, 2025
2f5fcf7
Update packages/platform-android/src/lib/commands/signAndroid/signAnd…
mlisikbf Oct 17, 2025
d09a087
feat: android aab signing - adds applies prettier
mlisikbf Oct 17, 2025
6fcdad1
feat: android aab signing - moves doc comment
mlisikbf Oct 17, 2025
7bed7a2
feat: android aab signing - removes reduntant brace
mlisikbf Oct 17, 2025
829f423
feat: android aab signing - adds comment on default --min-sdk-version
mlisikbf Oct 17, 2025
83d7cd4
feat: android aab signing - removes --new-sdk-version from sign command
mlisikbf Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/whole-tools-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { signAndroid } from './signAndroid.js';

export type SignFlags = {
verbose?: boolean;
apk: string;
path: string;
output?: string;
keystore?: string;
keystorePassword?: string;
Expand All @@ -16,8 +16,8 @@ export type SignFlags = {

const ARGUMENTS = [
{
name: 'apk',
description: 'APK file path',
name: 'binaryPath',
description: 'Archive (apk or aab) file path',
},
];

Expand All @@ -44,7 +44,7 @@ const OPTIONS = [
},
{
name: '--output <string>',
description: 'Path to the output APK file.',
description: 'Path to the output APK/AAB file.',
},
{
name: '--build-jsbundle',
Expand All @@ -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,
Expand Down
159 changes: 117 additions & 42 deletions packages/platform-android/src/lib/commands/signAndroid/signAndroid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)) {
Expand Down Expand Up @@ -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 file.`);

// 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(
Expand All @@ -91,32 +93,51 @@ 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);
loader.stop(
`Created output APK file: ${colorLink(relativeToCwd(outputApkPath))}.`,
);
// 4. Align archive before signing if apk
const outputPath = options.outputPath ?? options.binaryPath;

const alignArchive = async () => {
loader.start('Aligning output file...');
await alignArchiveFile(tempArchivePath, outputPath);
loader.stop(
`Created output ${extension.toUpperCase()} file: ${colorLink(relativeToCwd(outputPath))}.`,
);
}

// 5. Sign APK file
loader.start('Signing the APK file...');
if (!isAab(outputPath)) {
await alignArchive()
}

// 5. Sign archive file
loader.start(`Signing the ${extension.toUpperCase()} file...`);
const keystorePath = options.keystorePath ?? 'android/app/debug.keystore';
await signApkFile({
apkPath: outputApkPath,

const signArgs = {
binaryPath: outputPath,
keystorePath,
keystorePassword: options.keystorePassword ?? 'pass:android',
keyAlias: options.keyAlias,
keyPassword: options.keyPassword,
});
loader.stop(`Signed the APK file with keystore: ${colorLink(keystorePath)}.`);
}

if (isAab(outputPath)) {
await signAab(signArgs);
} else {
await signApk(signArgs);
}
loader.stop(`Signed the ${extension.toUpperCase()} file with keystore: ${colorLink(keystorePath)}.`);

// 6. Align archive after signing if aab
if (isAab(outputPath)) {
await alignArchive()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: AAB Signing Flow Errors

The AAB signing flow has a few issues. signAab attempts to sign outputPath before the file exists, causing a "file not found" error. The post-signing alignment for AABs also overwrites the signed outputPath with the unsigned tempArchivePath, losing the signature. Finally, the file type (APK/AAB) is determined by outputPath instead of the input binaryPath, which can lead to incorrect processing.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, can you send me some materials on why AAB needs aligning after signing, while APK does it before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bot's comment addressed in c3005d7 - isAab is called in each case with input path (options.binaryPath).

here's a note on apksigner vs jarsigner and a caution on sequence with zipalign. will add that link to the note on point 5

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The post-signing alignment for AABs also overwrites the signed outputPath with the unsigned tempArchivePath, losing the signature

This bot's comment is actually incorrect, we can ignore that (LLM trash as usual). The issue is exactly why apksigner is preferred over jarsigner, because jarsigner means Android v1 signature. apksigner uses v2,3 or 4 signing which is whole-file signing and therefore there is no way to tamper with the file after signing and therefore signing is needed after alignment. However, if we move to apksigner (as I suggested in a comment below), we would then have to keep in mind the (only) right way would be align-then-sign.

also, can you send me some materials on why AAB needs aligning after signing, while APK does it before?

It's not that they need it that way, it results from the tool choice. Jarsigner (legacy, which constitutes the old Android singing scheme v1) only signs parts of the ZIP (APK / AAB), and the signatures don't cover ZIP metadata. Zipalign stores the actual alignment information in the ZIP metdata (not covered by the signatures) and this is why the signing should occur after alignment.

Apksigner signs according to v2+ Android signing schemes which treat the whole file as a blob (including ZIP metadata). Therefore, realigning means invalidating the signature.

Some information on that can be found here: https://source.android.com/docs/security/features/apksigning#v1


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) {
Expand All @@ -131,22 +152,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 },
);
}
Expand All @@ -159,7 +182,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(
Expand All @@ -177,34 +200,73 @@ 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 signAab({
binaryPath,
keystorePath,
keystorePassword,
keyAlias,
keyPassword,
}: SignOptions) {
if (!fs.existsSync(keystorePath)) {
throw new RockError(
`Keystore file not found "${keystorePath}". Provide a valid keystore path using the "--keystore" option.`,
);
}

if (!keyAlias) {
throw new RockError('Missing or empty alias. A valid alias must be provided as the last argument.')
}

// For AAB files, we use jarsigner instead of apksigner
// jarsigner -keystore "" -storepass "" -keypass "" <path> <alias>
const jarsignerArgs = [
"-keystore",
keystorePath,
"-storepass",
stripPassword(keystorePassword),
...(keyPassword ? ['-keypass', stripPassword(keyPassword)] : []),
binaryPath,
keyAlias
];

try {
await spawn('jarsigner', jarsignerArgs);
} catch (error) {
throw new RockError(
`Failed to sign AAB file: jarsigner ${jarsignerArgs.join(' ')}`,
{ cause: (error as SubprocessError).stderr },
);
}
}

async function signApk({
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.`,
Expand All @@ -230,7 +292,7 @@ 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,
binaryPath,
];

try {
Expand Down Expand Up @@ -261,6 +323,19 @@ function formatPassword(password: string) {
return `pass:${password}`;
}

/**
* jarsigner expects password info with no prefixes
*
* @see https://docs.oracle.com/javase/6/docs/technotes/tools/windows/jarsigner.html
*/
function stripPassword(password: string) {
return password.replace(/^(pass:|env:|file:)/, '');
}

function getSignOutputPath() {
return path.join(getDotRockPath(), 'android/sign');
}

function isAab(filePath: string): boolean {
return path.extname(filePath).toLowerCase() === '.aab';
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opted to switch on path extension rather than adding a separate --aab flag, as that should be enough to distinguish and the flag seemed superfluous.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense 👍🏼

Copy link
Contributor

@thymikee thymikee Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please run prettier here and there (this one is missing trailing newline)

6 changes: 3 additions & 3 deletions website/src/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,17 +230,17 @@ Same as for `build:android` and:

### `rock sign:android` Options

The `sign:android <binaryPath>` 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 <binaryPath>` 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 |
| `binaryPath` | Path to the APK or AAB file |

| Option | Description |
| :----------------------------- | :---------------------------------------- |
| `--keystore <string>` | Path to keystore file |
| `--keystore-password <string>` | Password for keystore file |
| `--output <string>` | Path to output APK file |
| `--output <string>` | Path to output APK or AAB file |
| `--build-jsbundle` | Build JS bundle before signing |
| `--jsbundle <string>` | Path to JS bundle to apply before signing |
| `--no-hermes` | Don't use Hermes for JS bundle |
Expand Down