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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-02 05:21:30 -04:00
1 parent 486842e4aa
commit 8750ff90cb
5 files changed
+97 -10

No files matched your search

+8
View File
@@ -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` 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 to produce the APK to install on a phone (through Dev Updater), or
`./run-android.sh` to build, install, and launch on the emulator. `./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, - **A row something is happening to is dimmed, drained of colour, inert,
and says which operation in a word** -- `BusyItem`, used by both the 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 session list and the import list so the appearance is learned once. The
+29 -2
View File
@@ -103,8 +103,35 @@ android {
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
} }
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } packaging {
buildTypes { getByName("release") { isMinifyEnabled = false } } 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21
@@ -1,5 +1,7 @@
package com.example.aiapp package com.example.aiapp
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
@@ -1195,7 +1197,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
debugReport( debugReport(
device = device =
"device: ${Build.MODEL} (${Build.MANUFACTURER})," + "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 = transcript =
listOf( listOf(
" ${items.size} events, ${rows.size} rows," + " ${items.size} events, ${rows.size} rows," +
@@ -2312,3 +2318,6 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
} }
} }
} }
private fun debuggable(context: Context) =
context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
+39 -6
View File
@@ -55,6 +55,37 @@ else
exit 1 exit 1
fi 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, # 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 # 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 # 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 # the total. The build then prints one "> Task :x" line per task as it
# goes, so counting those against it is the whole mechanism. # 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 # the wall clock -- so the bar moves unevenly. It is still counted work
# rather than a guess at how long last time took. # 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) | grep -c '^:[A-Za-z:]* SKIPPED' || true)
echo "==> Building" echo "==> Building"
if [ "${TASKS:-0}" -gt 0 ]; then if [ "${TASKS:-0}" -gt 0 ]; then
echo "@@progress 0/$TASKS" echo "@@progress 0/$TASKS"
DONE=0 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" echo "$line"
case "$line" in case "$line" in
"> Task "*) "> 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 # 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 # 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. # 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 else
./gradlew :androidApp:assembleDebug ./gradlew :androidApp:assembleRelease
fi 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
echo "==> Built $APK" echo "==> Built $APK"
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}' [ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
echo echo
echo "To get it onto the phone: add this project to Dev Updater (or hit" 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 "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 "Then start the backend and scan the enrollment QR it prints:"
echo " ./server/target/release/ai-server --rotate-token" echo " ./server/target/release/ai-server --rotate-token"
+11 -1
View File
@@ -162,7 +162,15 @@ PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
if [ -z "${KEEP:-}" ]; then if [ -z "${KEEP:-}" ]; then
rm -rf "$ROOT/home" "$ROOT/sessions" rm -rf "$ROOT/home" "$ROOT/sessions"
fi 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" mkdir -p "$PROJECTS" "$ROOT/sessions"
if [ -z "${KEEP:-}" ]; then if [ -z "${KEEP:-}" ]; then
@@ -231,6 +239,7 @@ awk -v mb="$BIG_MB" 'BEGIN {
}' > "$big" }' > "$big"
fi fi
if [ -n "$regen_config" ]; then
cat >"$ROOT/config.ron" <<RON cat >"$ROOT/config.ron" <<RON
tokens: [ tokens: [
( (
@@ -260,6 +269,7 @@ setups: [
], ],
sessions: [], sessions: [],
RON RON
fi
echo "sandbox: building" echo "sandbox: building"
(cd "$SERVER_DIR" && cargo build --quiet) (cd "$SERVER_DIR" && cargo build --quiet)