Files
ai-app/app/build-apk.sh
T
irisandClaude Fable 5.1 68e77c7b37 Let build-apk.sh build either variant: ./build-apk.sh [release|debug]
Dev Updater is gaining build modes declared per component as complete
command lines, so the variant is a positional word and nothing else.
Release stays the default and the only one that signs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:33:50 -04:00

154 lines
6.8 KiB
Bash
Executable File

#!/bin/sh
# Builds the app's APK, ready to install on a phone through Dev Updater.
#
# ./build-apk.sh the release build, signed (what the phone runs)
# ./build-apk.sh debug the debug build, for reproducing something the
# emulator scripts would build anyway
#
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
# build modes, one command line each; it passes nothing else, so the word
# here is the whole interface.
#
# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/ai-app/certs/ca.pem,
# or AI_APP_CA), so build it on the machine that runs the backend: an app
# built somewhere else trusts a CA that backend can't present, and simply
# won't connect. Start ai-server once first if there are no certificates
# yet -- it generates them; the build stops with that instruction if it
# can't find one.
#
# Unlike ./run-android.sh, this touches no emulator: it only produces the
# file. Installing on a real phone goes through Dev Updater, which serves
# whatever is under this project's build directory.
set -eu
VARIANT=${1:-release}
case "$VARIANT" in
release) TASK=assembleRelease ;;
debug) TASK=assembleDebug ;;
*)
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2
exit 2
;;
esac
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
# Prefer an SDK this machine has already configured -- the host and the dev
# VM don't keep it in the same place, and android-env.sh is written for the
# VM's layout (it also installs missing packages, which isn't wanted here).
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
echo "==> Using ANDROID_HOME=$ANDROID_HOME"
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
ANDROID_HOME="$ANDROID_SDK_ROOT"
export ANDROID_HOME
echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
elif [ -d "$HOME/Android/Sdk" ]; then
ANDROID_HOME="$HOME/Android/Sdk"
ANDROID_SDK_ROOT="$ANDROID_HOME"
export ANDROID_HOME ANDROID_SDK_ROOT
echo "==> Using $ANDROID_HOME"
else
echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2
echo "(Android Studio's default location is ~/Android/Sdk)." >&2
exit 1
fi
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
if [ -f "$CA" ]; then
# Printed so a wrong or stale certificate is visible here rather than
# as a handshake failure on the phone -- compare it against the CA the
# backend is actually presenting.
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
| openssl pkey -pubin -outform der 2>/dev/null \
| openssl dgst -sha256 -binary 2>/dev/null \
| openssl base64 2>/dev/null || echo "(openssl unavailable)")
echo "==> Pinning the CA at $CA"
echo " fingerprint: $FINGERPRINT"
else
echo "No CA certificate at $CA -- start ai-server once on this machine" >&2
echo "(it generates them), or set AI_APP_CA. The APK embeds it at build time." >&2
exit 1
fi
# The phone runs the release build. A debuggable build runs Compose at a
# fraction of the speed -- ART keeps the process debugger-friendly and the
# compiler leaves its inspection hooks in -- so a frame time measured on one
# says little about the app; that cost a day of tuning against the wrong
# number. A release build must be signed, and the key is what the phone
# recognises the app by, so it lives beside the CA, outside any checkout,
# and is generated once here. Switching from an installed debug build means
# uninstalling it first: the signatures differ, and Android refuses to
# update across them.
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
KEYTOOL="${KEYTOOL:-keytool}"
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
echo "No signing key at $KEYSTORE and no keytool to make one -- set" >&2
echo "JAVA_HOME to the JDK Gradle uses, or AI_APP_KEYSTORE to an existing key." >&2
exit 1
fi
echo "==> No signing key at $KEYSTORE -- generating one"
mkdir -p "$(dirname "$KEYSTORE")"
PASSWORD=$(head -c 24 /dev/urandom | base64 | tr -d '/+=')
(umask 077 && printf '%s\n' "$PASSWORD" > "$KEYSTORE.password")
(umask 077 && "$KEYTOOL" -genkeypair -keystore "$KEYSTORE" -alias ai-app \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
fi
if [ "$VARIANT" = release ]; then
AI_APP_KEYSTORE="$KEYSTORE"
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
echo "==> Signing with $KEYSTORE"
fi
# Dev Updater draws a real progress bar from "@@progress done/total" lines,
# and ignores anything that isn't exactly that shape. Gradle can't be asked
# for this directly: an init script using taskGraph.afterTask is rejected
# outright by the configuration cache, and whenReady never fires on a cache
# hit. --dry-run costs about a second, is cache-friendly, and prints one
# ":task SKIPPED" line per task the real build will run, which is exactly
# the total. The build then prints one "> Task :x" line per task as it
# goes, so counting those against it is the whole mechanism.
#
# Task count is not time -- the Kotlin compile and dexBuilder are most of
# the wall clock -- so the bar moves unevenly. It is still counted work
# rather than a guess at how long last time took.
TASKS=$(./gradlew :androidApp:$TASK --dry-run --console=plain 2>/dev/null \
| grep -c '^:[A-Za-z:]* SKIPPED' || true)
echo "==> Building"
if [ "${TASKS:-0}" -gt 0 ]; then
echo "@@progress 0/$TASKS"
DONE=0
./gradlew :androidApp:$TASK --console=plain 2>&1 | while IFS= read -r line; do
echo "$line"
case "$line" in
"> Task "*)
DONE=$((DONE + 1))
echo "@@progress $DONE/$TASKS"
;;
esac
done
# The pipeline's exit status is the shell's, not gradle's, so ask
# gradle again rather than reporting a failed build as a success. It is
# up to date by now, so this is a second or two.
./gradlew :androidApp:$TASK --console=plain >/dev/null
else
./gradlew :androidApp:$TASK
fi
APK="$SCRIPT_DIR/androidApp/build/outputs/apk/$VARIANT/androidApp-$VARIANT.apk"
echo
echo "==> Built $APK"
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
echo
echo "To get it onto the phone: add this project to Dev Updater (or hit"
echo "Update on it if it's already there) and install from there."
echo "Debug and release are signed with different keys, so switching from"
echo "one to the other means uninstalling the installed one first."
echo "Then start the backend and scan the enrollment QR it prints:"
echo " ./server/target/release/ai-server --rotate-token"