Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
150 changes: 150 additions & 0 deletions client/build/android_universal_apk.mjs
Original file line number Diff line number Diff line change
@@ -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:<password>`:
// 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'));
}
12 changes: 11 additions & 1 deletion client/build/get_build_parameters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions client/capacitor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<dsn> npm run action client/capacitor/web_build android -- --buildMode=release --versionName=<version>
```

### 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
Expand Down Expand Up @@ -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=<version>
```

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**:
Expand Down
52 changes: 46 additions & 6 deletions client/capacitor/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
}
}

Expand Down
Loading
Loading