From 968deaaada1e5de98c3a45608d352db2d7ec2891 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 11:01:50 -0700 Subject: [PATCH] Add release APK build script and update README and .gitignore --- .gitignore | 3 ++ README.md | 17 +++++++ app/build.gradle.kts | 17 +++++++ scripts/build-release-apk.sh | 97 ++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100755 scripts/build-release-apk.sh diff --git a/.gitignore b/.gitignore index 34d9dec..18d89a8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ local.properties tmp/ CLAUDE.local.md + +# Release APKs from scripts/build-release-apk.sh +dist/ diff --git a/README.md b/README.md index fbfa994..338c73e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,23 @@ the core repo (tag + sha256 in `gradle.properties`) and unpacks the Pin a newer core release with `scripts/bump-jnilibs.sh ` (rewrites the tag, the sha256, and the app version in `gradle.properties`). +CI (`.github/workflows/ci.yml`) runs the tests and uploads the debug APK of +every push/PR as the `app-debug` workflow artifact. + +### Release APK + +```bash +scripts/build-release-apk.sh # → dist/ezvpn-android-.apk +scripts/build-release-apk.sh --unsigned # no signing (not installable as is) +``` + +The release key is a keystore outside the repo (default +`~/.config/ezvpn-android/release.jks`, override with `EZVPN_KEYSTORE`; +password from `EZVPN_KEYSTORE_PASSWORD` or prompted). The script creates it on +first use — back it up, devices only accept updates signed with the same key. +A release build cannot be installed over a debug build of the app (different +signature); uninstall the other one first. + ### Local FFI development To run against a local build of the core instead of the pinned release, build diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6135370..c576757 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,9 +94,26 @@ android { versionName = providers.gradleProperty("ezvpn.versionName").get() } + // Release signing comes from the environment (scripts/build-release-apk.sh + // sets it up): EZVPN_KEYSTORE (path), EZVPN_KEYSTORE_PASSWORD, optional + // EZVPN_KEY_ALIAS (default "ezvpn") and EZVPN_KEY_PASSWORD (defaults to the + // keystore password). Without EZVPN_KEYSTORE, assembleRelease produces an + // unsigned APK (app-release-unsigned.apk) that no device will install. + val releaseKeystore = System.getenv("EZVPN_KEYSTORE")?.takeIf { it.isNotBlank() } + if (releaseKeystore != null) { + signingConfigs.create("release") { + storeFile = file(releaseKeystore) + storePassword = System.getenv("EZVPN_KEYSTORE_PASSWORD")?.takeIf { it.isNotEmpty() } + ?: error("EZVPN_KEYSTORE is set but EZVPN_KEYSTORE_PASSWORD is not") + keyAlias = System.getenv("EZVPN_KEY_ALIAS")?.takeIf { it.isNotBlank() } ?: "ezvpn" + keyPassword = System.getenv("EZVPN_KEY_PASSWORD")?.takeIf { it.isNotEmpty() } ?: storePassword + } + } + buildTypes { release { isMinifyEnabled = false + signingConfig = signingConfigs.findByName("release") } } diff --git a/scripts/build-release-apk.sh b/scripts/build-release-apk.sh new file mode 100755 index 0000000..92e296a --- /dev/null +++ b/scripts/build-release-apk.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Build a signed release APK locally. +# +# The signing key lives outside the repo in a keystore (default +# ~/.config/ezvpn-android/release.jks, override with EZVPN_KEYSTORE). On the +# first run the keystore is created with keytool; the password is taken from +# EZVPN_KEYSTORE_PASSWORD or prompted for. Keep the keystore and password safe: +# a device only accepts updates signed with the same key, and there is no way +# to recover a lost keystore. +# +# The APK is written to app/build/outputs/apk/release/app-release.apk and copied +# to dist/ezvpn-android-.apk. The core (libezvpn.so) comes from the +# pinned ezvpn release unless EZVPN_LOCAL_JNILIBS=1 (see README). +# +# Usage: +# scripts/build-release-apk.sh # signed release APK +# scripts/build-release-apk.sh --unsigned # skip signing (app-release-unsigned.apk) +# EZVPN_KEYSTORE=/path/to/key.jks EZVPN_KEYSTORE_PASSWORD=... scripts/build-release-apk.sh +# +# Note: a release-signed build cannot be installed over a debug build of the +# same applicationId (and vice versa); uninstall the other one first. +# +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +SIGN=1 +for arg in "$@"; do + case "$arg" in + --unsigned) SIGN=0 ;; + -h|--help) sed -n '2,23p' "$0"; exit 0 ;; + *) echo "unknown option: $arg" >&2; exit 1 ;; + esac +done + +if [ "$SIGN" = 1 ]; then + keystore="${EZVPN_KEYSTORE:-$HOME/.config/ezvpn-android/release.jks}" + alias="${EZVPN_KEY_ALIAS:-ezvpn}" + password="${EZVPN_KEYSTORE_PASSWORD:-}" + + keytool=keytool + if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/keytool" ]; then + keytool="$JAVA_HOME/bin/keytool" + fi + + if [ ! -f "$keystore" ]; then + echo "== no keystore at $keystore; creating one" + if [ -z "$password" ]; then + read -r -s -p "New keystore password (min 6 chars): " password; echo + read -r -s -p "Repeat password: " again; echo + [ "$password" = "$again" ] || { echo "passwords do not match" >&2; exit 1; } + fi + [ "${#password}" -ge 6 ] || { echo "password must be at least 6 characters" >&2; exit 1; } + mkdir -p "$(dirname "$keystore")" + (umask 077 && "$keytool" -genkeypair -keystore "$keystore" -alias "$alias" \ + -keyalg RSA -keysize 4096 -validity 10000 \ + -dname "CN=ezvpn-android" \ + -storepass "$password" -keypass "$password" >/dev/null) + echo " created $keystore (alias $alias) — back it up, it cannot be regenerated" + elif [ -z "$password" ]; then + read -r -s -p "Password for $keystore: " password; echo + fi + + export EZVPN_KEYSTORE="$keystore" EZVPN_KEYSTORE_PASSWORD="$password" EZVPN_KEY_ALIAS="$alias" + apk=app/build/outputs/apk/release/app-release.apk +else + unset EZVPN_KEYSTORE EZVPN_KEYSTORE_PASSWORD + apk=app/build/outputs/apk/release/app-release-unsigned.apk +fi + +echo "== building release APK" +./gradlew :app:assembleRelease --console=plain +[ -f "$apk" ] || { echo "expected $apk after assembleRelease" >&2; exit 1; } + +if [ "$SIGN" = 1 ]; then + # Verify with apksigner from the newest installed build-tools, if any. + sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + if [ -z "$sdk" ] && [ -f local.properties ]; then + sdk="$(sed -n 's/^sdk\.dir=//p' local.properties)" + fi + apksigner="$(ls -d "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -n1 || true)" + if [ -n "$apksigner" ]; then + echo "== verifying signature" + "$apksigner" verify --print-certs "$apk" | grep -E 'certificate (DN|SHA-256)' + fi +fi + +version="$(sed -n 's/^ezvpn\.versionName=//p' gradle.properties)" +mkdir -p dist +if [ "$SIGN" = 1 ]; then + out="dist/ezvpn-android-$version.apk" +else + out="dist/ezvpn-android-$version-unsigned.apk" +fi +cp "$apk" "$out" +echo "== $out"