diff --git a/client/build/android_universal_apk.mjs b/client/build/android_universal_apk.mjs new file mode 100644 index 0000000000..5f105f81f9 --- /dev/null +++ b/client/build/android_universal_apk.mjs @@ -0,0 +1,150 @@ +// Copyright 2026 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Build tooling shared by the Cordova (client/src/cordova/build.action.mjs) +// and Capacitor (client/capacitor/build.action.mjs) Android release builds. +// It lives here only while both exist: once the Cordova client is deleted, +// merge it back into the Capacitor build. + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import {downloadHttpsFile} from '@outline/infrastructure/build/download_file.mjs'; +import {spawnStream} from '@outline/infrastructure/build/spawn_stream.mjs'; + +// bundletool turns the release AAB into the installable universal APK we ship +// to S3 (an AAB itself cannot be installed). We need a modern version: since +// bundletool 1.17.0 it 16 KB-page-aligns the uncompressed native libraries in +// the generated APK ("Default page size is now set to 16 KB"), which Android +// 15+ and the Play Store require; the previously pinned 1.8.2 aligned them to +// 4 KB. https://github.com/google/bundletool/releases/tag/1.17.0 +const JAVA_BUNDLETOOL_VERSION = '1.18.3'; +const JAVA_BUNDLETOOL_RESOURCE_URL = `https://github.com/google/bundletool/releases/download/${JAVA_BUNDLETOOL_VERSION}/bundletool-all-${JAVA_BUNDLETOOL_VERSION}.jar`; + +/** + * Verifies that the native libraries in an APK are aligned for 16 KB memory + * pages, as required by Android 15+ and the Play Store. Throws (failing the + * build) if any `.so` is misaligned, so a bundletool/packaging regression can + * never ship silently again. + * + * @param {string} apkPath path to the APK to check. + */ +async function verify16kAlignment(apkPath) { + const androidHome = process.env.ANDROID_HOME; + if (!androidHome) { + throw new ReferenceError( + 'ANDROID_HOME must be defined in the environment to verify APK alignment!' + ); + } + + // zipalign lives in the build-tools; pick the newest installed version. + const buildToolsDir = path.resolve(androidHome, 'build-tools'); + const buildToolsVersions = (await fs.readdir(buildToolsDir)) + .filter(name => /^\d+\./.test(name)) + .sort((a, b) => a.localeCompare(b, undefined, {numeric: true})); + if (buildToolsVersions.length === 0) { + throw new ReferenceError( + `No Android build-tools found under ${buildToolsDir} to run zipalign!` + ); + } + const zipalignPath = path.resolve( + buildToolsDir, + buildToolsVersions.at(-1), + 'zipalign' + ); + + // `-c` checks (does not modify), `-P 16` requires 16 KB page alignment for + // shared libraries, `4` is the alignment for all other entries, `-v` is + // verbose. zipalign exits non-zero (making spawnStream throw) if misaligned. + await spawnStream(zipalignPath, '-c', '-P', '16', '-v', '4', apkPath); +} + +/** + * Builds the signed universal APK from a signed release AAB with bundletool, + * and verifies that it is 16 KB aligned. Leaves `universal.apk` in the output + * directory, along with `Outline.zip`, the bundletool `.apks` archive it was + * extracted from. + * + * @param {object} options + * @param {string} options.bundlePath path to the release AAB. + * @param {string} options.outputDir directory that receives `universal.apk` + * and `Outline.zip` (and the downloaded bundletool.jar). + * @param {string} options.keystorePath path to the PKCS#12 signing keystore. + * @param {string} options.ksPassword password of the keystore and its key. + * @param {string} options.javaPath the JAVA_HOME of the JDK that runs bundletool. + */ +export async function buildUniversalApkSet({ + bundlePath, + outputDir, + keystorePath, + ksPassword, + javaPath, +}) { + const bundletoolPath = path.resolve(outputDir, 'bundletool.jar'); + await downloadHttpsFile(JAVA_BUNDLETOOL_RESOURCE_URL, bundletoolPath); + + const outputPath = path.resolve(outputDir, 'Outline.apks'); + + // Pass the keystore password through a file rather than `pass:`: + // spawnStream echoes the full command line, and argv is visible to other + // processes via `ps`. bundletool reads only the first line of the file. + if (/[\r\n]/.test(ksPassword)) { + throw new TypeError( + 'ANDROID_KEY_STORE_PASSWORD must not contain newline characters!' + ); + } + + // A unique 0700 temp directory per invocation, so concurrent builds + // cannot overwrite or delete each other's password file. + const ksPasswordDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'outline-android-signing-') + ); + const ksPasswordPath = path.join(ksPasswordDir, 'keystore.pass'); + + try { + await fs.writeFile(ksPasswordPath, ksPassword, {mode: 0o600}); + + await spawnStream( + path.resolve(javaPath, 'bin', 'java'), + '-jar', + bundletoolPath, + 'build-apks', + `--bundle=${bundlePath}`, + `--output=${outputPath}`, + '--mode=universal', + `--ks=${keystorePath}`, + `--ks-pass=file:${ksPasswordPath}`, + '--ks-key-alias=privatekey', + `--key-pass=file:${ksPasswordPath}` + ); + } finally { + await fs.rm(ksPasswordDir, {recursive: true, force: true}); + } + + // The universal `.apks` archive is a zip holding `universal.apk`. Extract it + // next to the bundle, and assert its native libraries are 16 KB aligned + // before we ship it. + await spawnStream( + 'unzip', + '-o', + outputPath, + 'universal.apk', + '-d', + outputDir + ); + await verify16kAlignment(path.resolve(outputDir, 'universal.apk')); + + return fs.rename(outputPath, path.resolve(outputDir, 'Outline.zip')); +} diff --git a/client/build/get_build_parameters.mjs b/client/build/get_build_parameters.mjs index 42fb6af674..4e6adb9942 100644 --- a/client/build/get_build_parameters.mjs +++ b/client/build/get_build_parameters.mjs @@ -85,10 +85,20 @@ export function getBuildParameters(cliArguments) { versionName = '0.0.0', sentryDsn = process.env.SENTRY_DSN, arch = '', + // The build number identifies one build across the actions that make it + // up (the web bundle, the native app). An action that runs other actions + // passes its own number down, so that they all agree even when the build + // crosses an hour boundary. + buildNumber = Math.floor(Date.now() / MS_PER_HOUR), } = minimist(cliArguments); assertOneOf(platform, VALID_PLATFORMS, 'Platform'); assertOneOf(buildMode, VALID_BUILD_MODES, 'Build mode'); + if (!Number.isInteger(buildNumber) || buildNumber <= 0) { + throw new TypeError( + `Build number "${buildNumber}" is not valid. Must be a positive integer.` + ); + } const build = resolveBuild(platform, arch); return { @@ -98,7 +108,7 @@ export function getBuildParameters(cliArguments) { versionName: buildMode === 'release' ? versionName : `${versionName}-${buildMode}`, sentryDsn, - buildNumber: Math.floor(Date.now() / MS_PER_HOUR), + buildNumber, arch, goArch: build?.goArch, // The Taskfile parameterizes linux/windows tasks by arch diff --git a/client/capacitor/README.md b/client/capacitor/README.md index 4a9620ace2..971bc66528 100644 --- a/client/capacitor/README.md +++ b/client/capacitor/README.md @@ -38,14 +38,18 @@ The web build action accepts **`browser`** (default), **`ios`**, or **`android`* npm run action client/capacitor/web_build ``` -**Note:** The Capacitor browser build is **debug-only**. Passing `--buildMode=release` is rejected by `web_build.action.mjs`. +**Release:** pass `--buildMode=release` and a version, with `SENTRY_DSN` set in the environment. This builds the bundle with webpack in production mode: + +```sh +SENTRY_DSN= npm run action client/capacitor/web_build android -- --buildMode=release --versionName= +``` ### Output Artifacts land in **`client/capacitor/www/`**, including for example: - `index.html`, `bundle.js` -- `environment.json` (version and build numbers) +- `environment.json` (version and build numbers, and the Sentry DSN if set) - Copied assets: `messages/`, `assets/`, etc. (see `webpack.config.js`) ## App icons and splash screens @@ -174,6 +178,16 @@ npm run action client/capacitor/build android This runs the full build: the web bundle, `cap sync android` (tun2socks + native sync), and `gradlew assembleDebug`. It is what CI runs. To also install and launch the app on a device, use the steps below instead. +### Build the release + +Releases are built and published by the scripts in [outline-release](https://github.com/OutlineFoundation/outline-release), which supply the signing keystore and the Sentry DSN and call this action: + +```sh +npm run action client/capacitor/build android -- --buildMode=release --versionName= +``` + +It reads `SENTRY_DSN`, `ANDROID_KEY_STORE_CONTENTS` (a base64-encoded PKCS#12 keystore whose key alias is `privatekey`), `ANDROID_KEY_STORE_PASSWORD` and `JAVA_HOME` (JDK 21) from the environment. It leaves the signed `app-release.aab` and `universal.apk` (built from the AAB with [bundletool](https://developer.android.com/tools/bundletool), and checked for 16 KB alignment) in `client/capacitor/android/app/build/outputs/bundle/release/`. + ### Steps to build and start the app 1. **Build the web bundle** (`www/`), from the **repository root**: diff --git a/client/capacitor/android/app/build.gradle b/client/capacitor/android/app/build.gradle index 2cab90913b..d3fba3e53a 100644 --- a/client/capacitor/android/app/build.gradle +++ b/client/capacitor/android/app/build.gradle @@ -17,7 +17,23 @@ apply plugin: 'com.android.application' android { namespace "org.outline.client" compileSdk 36 - + + // AGP strips the debug symbols from the native libraries it packages + // (tun2socks' libgojni.so, the Sentry native libs), but only when it can + // find this NDK's llvm-strip. Without the pin it ships the unstripped + // ~12MB-per-ABI libgojni.so. The unstripped copy survives in the tun2socks + // .aar, which is where upload_debug_symbols reads it for Sentry. + ndkVersion "28.2.13676358" + + // 16KB page-size support: package native libraries uncompressed and + // page-aligned. This is the AGP default, set explicitly to guard against + // the packaging regressing to legacy behavior. + packaging { + jniLibs { + useLegacyPackaging false + } + } + defaultConfig { // Ship under the same package id as the existing Cordova client so the // Play Store delivers this as an in-place update. That is a hard @@ -29,14 +45,38 @@ android { applicationId "org.outline.android.client" minSdk rootProject.ext.minSdkVersion targetSdk 36 - versionCode 1 - versionName "1.0" + // Release builds get their version from client/capacitor/build.action.mjs, + // which passes it in as Gradle project properties. + versionCode((findProperty('outlineVersionCode') ?: 1) as Integer) + versionName(findProperty('outlineVersionName') ?: "1.0") } - + + signingConfigs { + release { + // Only client/capacitor/build.action.mjs sets this, for release + // builds. The password is read from the environment so that it + // never shows up in the Gradle command line or the build logs. + if (project.hasProperty('outlineKeystorePath')) { + storeFile file(project.property('outlineKeystorePath')) + storeType 'pkcs12' + storePassword System.getenv('ANDROID_KEY_STORE_PASSWORD') + keyAlias 'privatekey' + keyPassword System.getenv('ANDROID_KEY_STORE_PASSWORD') + } + } + } + buildTypes { release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (project.hasProperty('outlineKeystorePath')) { + signingConfig signingConfigs.release + } + // The keep rules for our reflectively-instantiated, JNI and AIDL + // classes come from the consumer rules of OutlineAndroidLib and of + // capacitor-android. + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } diff --git a/client/capacitor/build.action.mjs b/client/capacitor/build.action.mjs index 4de3f00127..d0af86bf63 100644 --- a/client/capacitor/build.action.mjs +++ b/client/capacitor/build.action.mjs @@ -12,12 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +import fs from 'fs/promises'; +import os from 'os'; import path from 'path'; import url from 'url'; import {runAction} from '@outline/infrastructure/build/run_action.mjs'; import {spawnStream} from '@outline/infrastructure/build/spawn_stream.mjs'; +import {buildUniversalApkSet} from '../build/android_universal_apk.mjs'; import {getBuildParameters} from '../build/get_build_parameters.mjs'; const capacitorDir = path.dirname(url.fileURLToPath(import.meta.url)); @@ -31,7 +34,8 @@ const CAPACITOR_PLATFORMS = ['android', 'ios']; * @param {string[]} parameters */ export async function main(...parameters) { - const {platform, buildMode, verbose} = getBuildParameters(parameters); + const {platform, buildMode, verbose, versionName, buildNumber} = + getBuildParameters(parameters); if (!CAPACITOR_PLATFORMS.includes(platform)) { throw new TypeError( @@ -39,16 +43,42 @@ export async function main(...parameters) { ); } - // TODO: Support a release build once we're ready to migrate to Capacitor. - if (buildMode !== 'debug') { + // TODO: Support an iOS release build once we're ready to migrate to Capacitor. + if (buildMode === 'release' && platform !== 'android') { throw new TypeError( `Capacitor ${platform} build supports only debug mode, got "${buildMode}".` ); } + // Check the release signing inputs before the (slow) web and Go builds. + if (platform === 'android' && buildMode === 'release') { + if (!process.env.JAVA_HOME) { + throw new ReferenceError( + 'JAVA_HOME must be defined in the environment to build an Android Release!' + ); + } + + if ( + !( + process.env.ANDROID_KEY_STORE_PASSWORD && + process.env.ANDROID_KEY_STORE_CONTENTS + ) + ) { + throw new ReferenceError( + "Both 'ANDROID_KEY_STORE_PASSWORD' and 'ANDROID_KEY_STORE_CONTENTS' must be defined in the environment to build an Android Release!" + ); + } + } + // Build the web bundle (client/capacitor/www/) that `cap sync` copies into - // the native project. - await runAction('client/capacitor/web_build', ...parameters); + // the native project. Pass our build number down, so that environment.json + // carries the same number as the native app's version. (Replace rather than + // repeat the flag: minimist turns a repeated flag into an array.) + await runAction( + 'client/capacitor/web_build', + ...withoutBuildNumber(parameters), + `--buildNumber=${buildNumber}` + ); // `cap sync` first runs the capacitor:sync:before hook (see package.json in // this directory), which builds the tun2socks native library for the @@ -60,19 +90,44 @@ export async function main(...parameters) { process.chdir(capacitorDir); await spawnStream('npx', 'cap', 'sync', platform); - switch (platform) { - case 'android': + switch (platform + buildMode) { + case 'android' + 'debug': return androidDebug(verbose); - case 'ios': + case 'android' + 'release': + return androidRelease( + process.env.ANDROID_KEY_STORE_PASSWORD, + process.env.ANDROID_KEY_STORE_CONTENTS, + process.env.JAVA_HOME, + versionName, + buildNumber, + verbose + ); + case 'ios' + 'debug': return iosDebug(verbose); } } +/** + * Returns the parameters without any --buildNumber flag, whether given as + * `--buildNumber=N` or as `--buildNumber N`. + */ +function withoutBuildNumber(parameters) { + const result = []; + for (let i = 0; i < parameters.length; i++) { + if (parameters[i] === '--buildNumber') { + i++; // Skip its value too. + } else if (!parameters[i].startsWith('--buildNumber=')) { + result.push(parameters[i]); + } + } + return result; +} + +const androidDir = path.resolve(capacitorDir, 'android'); + async function androidDebug(verbose) { // `cap build` only produces signed release builds, so invoke Gradle // directly for the debug APK — the same target `cap run` uses. - // TODO: Migrate to a release Gradle target once we have a production build. - const androidDir = path.resolve(capacitorDir, 'android'); await spawnStream( path.join(androidDir, 'gradlew'), '-p', @@ -82,6 +137,65 @@ async function androidDebug(verbose) { ); } +/** + * Builds the signed release AAB (for the Play Store) and, from it, the signed + * universal APK (for direct download). Both land in + * android/app/build/outputs/bundle/release/. + */ +async function androidRelease( + ksPassword, + ksContents, + javaPath, + versionName, + buildNumber, + verbose +) { + // Decode the keystore into a private (0700) temp directory rather than the + // source tree, and remove it as soon as the build is done. + const keystoreDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'outline-android-keystore-') + ); + const keystorePath = path.join(keystoreDir, 'keystore.p12'); + + try { + await fs.writeFile(keystorePath, Buffer.from(ksContents, 'base64'), { + mode: 0o600, + }); + + // app/build.gradle versions and signs the release from these properties. + // It reads the keystore password from ANDROID_KEY_STORE_PASSWORD in the + // environment, because spawnStream echoes the command line. + await spawnStream( + path.join(androidDir, 'gradlew'), + '-p', + androidDir, + verbose ? '--info' : '--quiet', + 'bundleRelease', + `-PoutlineVersionName=${versionName}`, + `-PoutlineVersionCode=${buildNumber}`, + `-PoutlineKeystorePath=${keystorePath}` + ); + + const bundleDir = path.resolve( + androidDir, + 'app', + 'build', + 'outputs', + 'bundle', + 'release' + ); + await buildUniversalApkSet({ + bundlePath: path.resolve(bundleDir, 'app-release.aab'), + outputDir: bundleDir, + keystorePath, + ksPassword, + javaPath, + }); + } finally { + await fs.rm(keystoreDir, {recursive: true, force: true}); + } +} + async function iosDebug(verbose) { // `cap build` only produces signed release builds, so invoke xcodebuild // directly for an unsigned debug build, the same way the Cordova iOS build diff --git a/client/capacitor/web_build.action.mjs b/client/capacitor/web_build.action.mjs index 6c10e9eb05..51bf6fca6a 100644 --- a/client/capacitor/web_build.action.mjs +++ b/client/capacitor/web_build.action.mjs @@ -19,6 +19,7 @@ import url from 'url'; import webpackConfig from './webpack.config.js'; import {writeEnvironmentJson} from './write_environment.mjs'; import {getBuildParameters} from '../build/get_build_parameters.mjs'; +import {getWebpackBuildMode} from '../build/get_webpack_build_mode.mjs'; import {runWebpack} from '../build/run_webpack.mjs'; const capacitorDir = path.dirname(url.fileURLToPath(import.meta.url)); @@ -32,7 +33,7 @@ const SUPPORTED_PLATFORMS = new Set(['browser', 'android', 'ios']); * @param {string[]} parameters */ export async function main(...parameters) { - const {platform, buildMode, versionName, buildNumber} = + const {platform, buildMode, versionName, buildNumber, sentryDsn} = getBuildParameters(parameters); if (!SUPPORTED_PLATFORMS.has(platform)) { @@ -41,18 +42,32 @@ export async function main(...parameters) { ); } - if (buildMode !== 'debug') { - throw new TypeError( - `Capacitor ${platform} build supports only debug mode, got "${buildMode}".` - ); + if (buildMode === 'release') { + if (versionName === '0.0.0') { + throw new TypeError( + 'Release builds require a valid versionName, but it is set to 0.0.0.' + ); + } + + if (!sentryDsn) { + throw new TypeError( + 'Release builds require SENTRY_DSN, but it is not defined.' + ); + } + + try { + new URL(sentryDsn); + } catch { + throw new TypeError(`The sentryDsn ${sentryDsn} is not a valid URL!`); + } } const outputDir = path.resolve(capacitorDir, 'www'); await fs.rm(outputDir, {recursive: true, force: true}); await fs.mkdir(outputDir, {recursive: true}); - await writeEnvironmentJson(capacitorDir, versionName, buildNumber); - await runWebpack({...webpackConfig, mode: 'development'}); + await writeEnvironmentJson(capacitorDir, versionName, buildNumber, sentryDsn); + await runWebpack({...webpackConfig, mode: getWebpackBuildMode(buildMode)}); } if (import.meta.url === url.pathToFileURL(process.argv[1]).href) { diff --git a/client/capacitor/write_environment.mjs b/client/capacitor/write_environment.mjs index b176d96f24..9e44946927 100644 --- a/client/capacitor/write_environment.mjs +++ b/client/capacitor/write_environment.mjs @@ -18,7 +18,8 @@ import path from 'path'; export async function writeEnvironmentJson( capacitorDir, versionName, - buildNumber + buildNumber, + sentryDsn ) { const outputPath = path.resolve(capacitorDir, 'www', 'environment.json'); await fs.mkdir(path.dirname(outputPath), {recursive: true}); @@ -26,6 +27,7 @@ export async function writeEnvironmentJson( outputPath, JSON.stringify( { + SENTRY_DSN: sentryDsn, APP_VERSION: versionName, APP_BUILD_NUMBER: String(buildNumber), }, diff --git a/client/src/cordova/build.action.mjs b/client/src/cordova/build.action.mjs index 96f3c85344..254714f97b 100644 --- a/client/src/cordova/build.action.mjs +++ b/client/src/cordova/build.action.mjs @@ -13,11 +13,9 @@ // limitations under the License. import fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import url from 'url'; -import {downloadHttpsFile} from '@outline/infrastructure/build/download_file.mjs'; import {getRootDir} from '@outline/infrastructure/build/get_root_dir.mjs'; import {runAction} from '@outline/infrastructure/build/run_action.mjs'; import {spawnStream} from '@outline/infrastructure/build/spawn_stream.mjs'; @@ -26,6 +24,7 @@ import * as dotenv from 'dotenv'; const {cordova} = cordovaLib; +import {buildUniversalApkSet} from '../../build/android_universal_apk.mjs'; import {getBuildParameters} from '../../build/get_build_parameters.mjs'; const CORDOVA_PLATFORMS = ['android', 'ios', 'macos']; @@ -187,53 +186,6 @@ async function androidDebug(verbose) { }); } -// bundletool turns the release AAB into the installable universal APK we ship -// to S3 (an AAB itself cannot be installed). We need a modern version: since -// bundletool 1.17.0 it 16 KB-page-aligns the uncompressed native libraries in -// the generated APK ("Default page size is now set to 16 KB"), which Android -// 15+ and the Play Store require; the previously pinned 1.8.2 aligned them to -// 4 KB. https://github.com/google/bundletool/releases/tag/1.17.0 -const JAVA_BUNDLETOOL_VERSION = '1.18.3'; -const JAVA_BUNDLETOOL_RESOURCE_URL = `https://github.com/google/bundletool/releases/download/${JAVA_BUNDLETOOL_VERSION}/bundletool-all-${JAVA_BUNDLETOOL_VERSION}.jar`; - -/** - * Verifies that the native libraries in an APK are aligned for 16 KB memory - * pages, as required by Android 15+ and the Play Store. Throws (failing the - * build) if any `.so` is misaligned, so a bundletool/packaging regression can - * never ship silently again. - * - * @param {string} apkPath path to the APK to check. - */ -async function verify16kAlignment(apkPath) { - const androidHome = process.env.ANDROID_HOME; - if (!androidHome) { - throw new ReferenceError( - 'ANDROID_HOME must be defined in the environment to verify APK alignment!' - ); - } - - // zipalign lives in the build-tools; pick the newest installed version. - const buildToolsDir = path.resolve(androidHome, 'build-tools'); - const buildToolsVersions = (await fs.readdir(buildToolsDir)) - .filter(name => /^\d+\./.test(name)) - .sort((a, b) => a.localeCompare(b, undefined, {numeric: true})); - if (buildToolsVersions.length === 0) { - throw new ReferenceError( - `No Android build-tools found under ${buildToolsDir} to run zipalign!` - ); - } - const zipalignPath = path.resolve( - buildToolsDir, - buildToolsVersions.at(-1), - 'zipalign' - ); - - // `-c` checks (does not modify), `-P 16` requires 16 KB page alignment for - // shared libraries, `4` is the alignment for all other entries, `-v` is - // verbose. zipalign exits non-zero (making spawnStream throw) if misaligned. - await spawnStream(zipalignPath, '-c', '-P', '16', '-v', '4', apkPath); -} - async function androidRelease(ksPassword, ksContents, javaPath, verbose) { const androidBuildPath = path.resolve( getRootDir(), @@ -265,75 +217,21 @@ async function androidRelease(ksPassword, ksContents, javaPath, verbose) { }, }); - const bundletoolPath = path.resolve(androidBuildPath, 'bundletool.jar'); - await downloadHttpsFile(JAVA_BUNDLETOOL_RESOURCE_URL, bundletoolPath); - - const outputPath = path.resolve(androidBuildPath, 'Outline.apks'); - - // Pass the keystore password through a file rather than `pass:`: - // spawnStream echoes the full command line, and argv is visible to other - // processes via `ps`. bundletool reads only the first line of the file. - if (/[\r\n]/.test(ksPassword)) { - throw new TypeError( - 'ANDROID_KEY_STORE_PASSWORD must not contain newline characters!' - ); - } - - // A unique 0700 temp directory per invocation, so concurrent builds - // cannot overwrite or delete each other's password file. - const ksPasswordDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'outline-android-signing-') - ); - const ksPasswordPath = path.join(ksPasswordDir, 'keystore.pass'); - - try { - await fs.writeFile(ksPasswordPath, ksPassword, {mode: 0o600}); - - await spawnStream( - path.resolve(javaPath, 'bin', 'java'), - '-jar', - bundletoolPath, - 'build-apks', - `--bundle=${path.resolve( - androidBuildPath, - 'app', - 'build', - 'outputs', - 'bundle', - 'release', - 'app-release.aab' - )}`, - `--output=${outputPath}`, - '--mode=universal', - `--ks=${keystorePath}`, - `--ks-pass=file:${ksPasswordPath}`, - '--ks-key-alias=privatekey', - `--key-pass=file:${ksPasswordPath}` - ); - } finally { - await fs.rm(ksPasswordDir, {recursive: true, force: true}); - } - - // The universal `.apks` archive is a zip holding `universal.apk`. Extract it - // and assert its native libraries are 16 KB aligned before we ship it. - const extractDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'outline-android-align-') - ); - try { - await spawnStream( - 'unzip', - '-o', - outputPath, - 'universal.apk', - '-d', - extractDir - ); - await verify16kAlignment(path.resolve(extractDir, 'universal.apk')); - } finally { - await fs.rm(extractDir, {recursive: true, force: true}); - } - - return fs.rename(outputPath, path.resolve(androidBuildPath, 'Outline.zip')); + return buildUniversalApkSet({ + bundlePath: path.resolve( + androidBuildPath, + 'app', + 'build', + 'outputs', + 'bundle', + 'release', + 'app-release.aab' + ), + outputDir: androidBuildPath, + keystorePath, + ksPassword, + javaPath, + }); } if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {