From 8750ff90cb90ec0b84a680670a82edabe566a04a Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Wed, 2 Sep 2026 05:21:30 -0400 Subject: [PATCH] Build the phone's APK as a signed release, and say so in the render report The phone has been running the debug build: build-apk.sh assembled it, and nothing in the app or its report said which build a frame time came from. A debuggable build runs Compose at a fraction of release speed, so the tuning so far was measured against the wrong number. On the emulator, the same fixture and gestures: measure 1.4ms mean / 14.7ms worst on debug, 0.8ms / 6.6ms on release. build-apk.sh now assembles the release variant, signed with a key it generates once under ~/.config/ai-app (beside the pinned CA, outside any checkout). The report's header names the build. The one native library is declared kept-with-symbols so packaging stops warning about an NDK the build does not need. Also: ui-sandbox.sh keep now keeps the config too. The server appends spawned sessions and enrolled tokens to it, so regenerating it left the transcripts on disk and the registry empty. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 8 ++++ app/androidApp/build.gradle.kts | 31 ++++++++++++- .../kotlin/com/example/aiapp/SessionScreen.kt | 11 ++++- app/build-apk.sh | 45 ++++++++++++++++--- app/ui-sandbox.sh | 12 ++++- 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4769ecf..2dcc842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,14 @@ first if a remote spawn ever mangles an argument. and lint, the app-side equivalent of the line above. Then `./build-apk.sh` to produce the APK to install on a phone (through Dev Updater), or `./run-android.sh` to build, install, and launch on the emulator. + **The phone gets the release build**, signed with a key the script + generates once under `~/.config/ai-app/release.jks` (never in the repo). + The emulator scripts stay on the debug build; a debuggable build runs + Compose at a fraction of release speed, so never read a frame time from + one as the app's -- the render report now says which build it came from. + Dev Updater lists every variant under `build/outputs/apk`, so pick + `release` there; a phone still holding the debug build has to uninstall + it first, since the two are signed differently. - **A row something is happening to is dimmed, drained of colour, inert, and says which operation in a word** -- `BusyItem`, used by both the session list and the import list so the appearance is learned once. The diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index 7081068..c016cac 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -103,8 +103,35 @@ android { versionCode = 1 versionName = "1.0" } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } - buildTypes { getByName("release") { isMinifyEnabled = false } } + packaging { + resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } + // The one native library here is AndroidX's, a few hundred kilobytes with its symbols. + // Stripping them needs an NDK the release build would otherwise not use; keeping them + // is declared so AGP stops warning that it could not. + jniLibs { keepDebugSymbols += "**/libandroidx.graphics.path.so" } + } + // A release build must be signed, and the key is per machine rather than per repo: it is + // what the phone recognises the app by, and a secret never lives in a checkout (the mount is + // shared with an untrusted VM). build-apk.sh keeps it beside the pinned CA and points here + // through the environment; without it the release build is unsigned, which is fine for + // everything except installing. + val keystore = System.getenv("AI_APP_KEYSTORE") + signingConfigs { + if (keystore != null) { + create("release") { + storeFile = file(keystore) + storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") + keyAlias = "ai-app" + keyPassword = storePassword + } + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + if (keystore != null) signingConfig = signingConfigs.getByName("release") + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 2ed8c15..a3d16be 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1,5 +1,7 @@ package com.example.aiapp +import android.content.Context +import android.content.pm.ApplicationInfo import android.os.Build import android.os.SystemClock import android.util.Log @@ -1195,7 +1197,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () debugReport( device = "device: ${Build.MODEL} (${Build.MANUFACTURER})," + - " Android ${Build.VERSION.RELEASE}", + " Android ${Build.VERSION.RELEASE}\n" + + // A debuggable build runs Compose at a fraction of + // release speed, so a report that did not say which + // it came from was read as the app's own cost. + "build: ${if (debuggable(context)) "debug" else "release"}", transcript = listOf( " ${items.size} events, ${rows.size} rows," + @@ -2312,3 +2318,6 @@ private fun PickerButton(current: String, options: List, onPick: (String } } } + +private fun debuggable(context: Context) = + context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 diff --git a/app/build-apk.sh b/app/build-apk.sh index eae66a9..e617daa 100755 --- a/app/build-apk.sh +++ b/app/build-apk.sh @@ -55,6 +55,37 @@ else 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 [ ! -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 +AI_APP_KEYSTORE="$KEYSTORE" +AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password") +export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD +echo "==> Signing with $KEYSTORE" + # 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 @@ -64,17 +95,17 @@ fi # 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 -- compileDebugKotlin and dexBuilder are most of +# Task count is not time -- compileReleaseKotlin 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:assembleDebug --dry-run --console=plain 2>/dev/null \ +TASKS=$(./gradlew :androidApp:assembleRelease --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:assembleDebug --console=plain 2>&1 | while IFS= read -r line; do + ./gradlew :androidApp:assembleRelease --console=plain 2>&1 | while IFS= read -r line; do echo "$line" case "$line" in "> Task "*) @@ -86,17 +117,19 @@ if [ "${TASKS:-0}" -gt 0 ]; then # 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:assembleDebug --console=plain >/dev/null + ./gradlew :androidApp:assembleRelease --console=plain >/dev/null else - ./gradlew :androidApp:assembleDebug + ./gradlew :androidApp:assembleRelease fi -APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk" +APK="$SCRIPT_DIR/androidApp/build/outputs/apk/release/androidApp-release.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 "If the phone still has the old debug build, uninstall that first:" +echo "it is signed with a different key, so Android will refuse the update." echo "Then start the backend and scan the enrollment QR it prints:" echo " ./server/target/release/ai-server --rotate-token" diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index 6382e6f..a9a8aaa 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -162,7 +162,15 @@ PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox if [ -z "${KEEP:-}" ]; then rm -rf "$ROOT/home" "$ROOT/sessions" fi -rm -f "$ROOT/config.ron" +# The server appends to this file: the tokens of phones enrolled against it +# and every session spawned. Regenerating it under `keep` was what un-kept +# the sessions -- their transcripts survived on disk while the registry that +# lists them went back to empty -- so a kept sandbox keeps its config too. +regen_config=1 +if [ -n "${KEEP:-}" ] && [ -f "$ROOT/config.ron" ]; then + regen_config="" +fi +[ -n "$regen_config" ] && rm -f "$ROOT/config.ron" mkdir -p "$PROJECTS" "$ROOT/sessions" if [ -z "${KEEP:-}" ]; then @@ -231,6 +239,7 @@ awk -v mb="$BIG_MB" 'BEGIN { }' > "$big" fi +if [ -n "$regen_config" ]; then cat >"$ROOT/config.ron" <