Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
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 using the "--key-alias" option.')
}

// 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);
Copy link
Contributor

@artus9033 artus9033 Oct 14, 2025

Choose a reason for hiding this comment

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

I think it could be better to use apksigner actually. Jarsigner is not recommended in the docs and it was not created for signing APKs, therefore e.g. it:

  • does not know that APKs for Android <= 17 cannot be signed with SHA-256 digests ([major] - I also don't see the code that would adjust that), while apktool detects & adjusts that automatically
  • [major] there are security concerns that come from jarsigner, which constitues Android's v1 signing scheme: it only signs parts of the ZIP (APK / AAB), e.g. ZIP metadata is not signed

Copy link
Author

@mlisikbf mlisikbf Oct 15, 2025

Choose a reason for hiding this comment

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

That's interesting. I assumed apksigner won't work based on bundletool docs explicitly calling out not to use it. Testing locally, it works and only requires a min-sdk-version param, and using bundletool I can get apks out of the resigned bundle that pass apksigner verify (and install & run fine).

Will need to test it in our pipeline (with play store test track submission), and come back.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great to hear that! Sure, let's wait for the result - thanks for checking this.

Copy link
Contributor

Choose a reason for hiding this comment

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

If the min-sdk-version is required, you can take default version from the template (currently 24) and add a --min-sdk-version flag where users will be able to overwrite it when necessary. Btw can you check if the tool fails when sdk mismatches? that would be ideal as we could point users to that flag to avoid confusion.

Copy link
Author

Choose a reason for hiding this comment

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

Not sure this indicates we shouldn't use apksigner, or just that it's API is tailored for use with apks.

I've tested a few combinations with --min-sdk-version, and in each one, I can successfully produce apks from the bundle locally using bundletool -- and by uploading the bundle to play console and downloading an apk from there. It would seem that the value of --min-sdk-version is irrelevant when signing an aab:

  • project with min sdk 34, --min-sdk-version lower - 0, 24
  • project with min sdk 24, --min-sdk-version higher - 34, 36
  • project with min sdk 34, --min-sdk-version higher - 36

All tested on a device with sdk 34 / Android 14, using bundletool build-apks --connected-device and bundletool install-apks to test locally.

In apksigner docs, there's this note on --min-sdk-version:

Higher values allow the tool to use stronger security parameters when signing the app but limit the APK's availability to devices running more recent versions of Android

In our case, I can install on lower version when setting --min-sdk-version high for aab signing, because at a later point - when prepared by play store or built by bundletool, the individual apks will be signed again (and will read sdk versions from manifest)

Updated the PR to remove jarsigner-related bits. This reduces the change set to extension-agnostic naming, different asset path and --min-sdk-version added for aabs.

Added a --min-sdk-version arg to the command. Set it to default to 36 for aabs, just so that stronger security parameters are used for signing. User can alternatively provide an override.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for checking!
I'm looking at apksigner help around this flag:

--max-sdk-version     Highest API Level on which this APK's signatures will be
                      verified. By default, the highest possible value is used.

maybe we don't need to expose it? We can add it later if someone requests

Copy link
Author

Choose a reason for hiding this comment

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

The one you link is max not min -- but it did cross my mind that it might be better no to expose --min-sdk-version and just have a default for aabs. Will remove it just to keep the PR focused on aabs.

Copy link
Contributor

Choose a reason for hiding this comment

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

You're right, let's do that.

} 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
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