-
Notifications
You must be signed in to change notification settings - Fork 32
feat: android aab signing #593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
cbaa713
3ec1556
fc799c9
1962250
c0410d4
18f59fb
7a69dd5
6b51b87
9f6b227
c3005d7
4f80eb2
321b308
eb5bbc6
b3d4acf
2dd2769
c617315
5863312
f7448cb
2f5fcf7
d09a087
6fcdad1
7bed7a2
829f423
83d7cd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 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( | ||
|
@@ -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() | ||
artus9033 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
} | ||
|
||
// 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() | ||
artus9033 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
} | ||
|
||
|
||
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 +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'; | ||
thymikee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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}}`, | ||
mlisikbf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
{ cause: error }, | ||
); | ||
} | ||
|
@@ -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( | ||
|
@@ -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); | ||
|
||
} catch (error) { | ||
throw new RockError( | ||
`Failed to sign AAB file: jarsigner ${jarsignerArgs.join(' ')}`, | ||
{ cause: (error as SubprocessError).stderr }, | ||
); | ||
} | ||
} | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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.`, | ||
|
@@ -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 { | ||
|
@@ -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'; | ||
} | ||
|
Uh oh!
There was an error while loading. Please reload this page.