dev-updater: build an app on the machine, install it on the phone

A Rust backend that discovers Android projects under configured roots,
builds one on request, and serves the APK over pinned TLS on a WireGuard
interface; an Android client that lists what is buildable, watches a build,
and installs the result. Enrolment carries the token and the CA, so the
phone trusts exactly the machine that issued it and nothing else.

`AGENTS.md` is the working guide and `README.md` the configuration
reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`,
which ai-app uses too.

History before this point was squashed away, and a stale `config.json` went
with it: nothing had read that file since the config moved to RON outside
the checkout, and what it still held was one machine's absolute paths and
the names of projects on it.
This commit is contained in:
iris committed 2026-08-31 20:31:08 -04:00
commit b0e83059a3
82 files changed
+20372

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Template. build-apk.sh substitutes __PACKAGE__ and __LABEL__; the SDK
levels are passed to aapt2 on the command line rather than written
here, so there is one place they are set for every test app. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="__PACKAGE__">
<application android:label="__LABEL__">
<activity
android:name="com.example.dutest.stub.StubActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+63
View File
@@ -0,0 +1,63 @@
package com.example.dutest.stub;
import android.app.Activity;
import android.content.pm.PackageInfo;
import android.os.Bundle;
import android.view.Gravity;
import android.widget.TextView;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* The whole of every test app: one screen naming which app this is and when
* the installed copy landed.
*
* One class shared by all of them rather than one per project, because what
* differs between the test projects is what Dev Updater has to *do* with
* them -- how many variants they build, whether the build fails, whether
* they declare a service -- and none of that is a difference in the app.
* The manifest names this class fully qualified, so each APK can carry its
* own application id while the code keeps one package.
*
* It shows lastUpdateTime deliberately: that is the exact value Dev
* Updater's freshness check compares an APK's mtime against, so the screen
* shows what the card is reasoning about rather than a separate version
* string that could agree with it by luck.
*/
public class StubActivity extends Activity {
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
TextView view = new TextView(this);
view.setGravity(Gravity.CENTER);
view.setTextSize(20f);
view.setLineSpacing(0f, 1.3f);
view.setPadding(64, 64, 64, 64);
// Catppuccin Mocha Base and Text, matching the updater's own theme
// so a screenshot of one does not look like a different machine.
view.setBackgroundColor(0xFF1E1E2E);
view.setTextColor(0xFFCDD6F4);
view.setText(describe());
setContentView(view);
}
private String describe() {
CharSequence label = getApplicationInfo().loadLabel(getPackageManager());
return label + "\n\n" + getPackageName() + "\n\ninstalled " + installedAt();
}
/**
* When the package manager says this copy was installed, or why that
* could not be read -- never a stand-in that reads like an answer.
*/
private String installedAt() {
try {
PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0);
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.format(new Date(info.lastUpdateTime));
} catch (Exception failure) {
return "unknown (" + failure.getClass().getSimpleName() + ")";
}
}
}
+136
View File
@@ -0,0 +1,136 @@
#!/bin/sh
# Builds one test app's APK. Run from the test project's own directory,
# which is where Dev Updater runs a component's build from.
#
# ../lib/build-apk.sh --package com.example.dutest.hello --label "Hello" \
# [--variant debug]
#
# Deliberately not Gradle, which is the obvious way to build an Android app
# and the wrong one here for two reasons. A Gradle daemon is around a
# gigabyte resident, and several test projects each holding one is how this
# machine ran itself out of memory on 2026-08-30 -- the emulators alone are
# already close to the limit. And a Gradle build takes tens of seconds to do
# what this does in about two, which matters because the thing under test is
# Dev Updater, not the app: a fixture you wait for stops getting used.
#
# What it costs is that this is a small build system rather than a
# declaration. It is kept to one file, and the whole of it is: link a
# manifest, compile one class, dex it, add it, align, sign. Nothing here is
# a general Android build and it should not grow into one -- a test project
# needing more than a screen with its own name on it wants a real project.
#
# The signing key lives outside the repository
# ($XDG_DATA_HOME/dev-updater/test-projects/debug.keystore) because the
# repository is a mount shared with the host, and because a stable key is
# what lets a rebuilt APK install *over* the copy already on the emulator
# rather than being refused for a signature mismatch. Deleting it is how you
# produce that refusal on purpose.
set -eu
PACKAGE=
LABEL=
VARIANT=debug
while [ $# -gt 0 ]; do
case "$1" in
--package) PACKAGE=$2; shift 2 ;;
--label) LABEL=$2; shift 2 ;;
--variant) VARIANT=$2; shift 2 ;;
*) echo "usage: $0 --package ID --label TEXT [--variant NAME]" >&2; exit 2 ;;
esac
done
[ -n "$PACKAGE" ] || { echo "$0: --package is required" >&2; exit 2; }
[ -n "$LABEL" ] || { echo "$0: --label is required" >&2; exit 2; }
LIB=$(cd "$(dirname "$0")" && pwd)
PROJECT=$(pwd)
NAME=$(basename "$PROJECT")
# Same cascade as app/build-apk.sh: the host and this VM do not keep the SDK
# in the same place, and the ambient ANDROID_HOME points at one with no
# build-tools under it.
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}/build-tools" ]; then
SDK="$ANDROID_HOME"
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}/build-tools" ]; then
SDK="$ANDROID_SDK_ROOT"
elif [ -d "$HOME/Android/Sdk/build-tools" ]; then
SDK="$HOME/Android/Sdk"
else
echo "No Android SDK with build-tools found. Set ANDROID_HOME to one." >&2
exit 1
fi
# Newest of whatever is installed, rather than a pinned version this script
# would have to be edited to follow.
TOOLS="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -n1)"
PLATFORM="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -n1)"
JAR="$PLATFORM/android.jar"
[ -f "$JAR" ] || { echo "No android.jar under $PLATFORM -- install a platform." >&2; exit 1; }
MIN_SDK=24
TARGET_SDK=$(basename "$PLATFORM" | sed 's/^android-//; s/\..*//')
KEYSTORE="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater/test-projects/debug.keystore"
# Six steps, counted out for the progress bar. Emitted directly rather than
# through $DEV_UPDATER_PROGRESS: that wrapper exists to count Gradle tasks
# for a build that cannot report its own, and this one knows exactly what it
# is doing. The format is the same either way -- see server/src/build_state.rs.
STEPS=6
step() {
echo "@@progress $1/$STEPS"
echo "==> $2"
}
BUILD="$PROJECT/app/build"
GEN="$BUILD/gen"
OUT="$BUILD/outputs/apk/$VARIANT"
# `app/` is not decoration. Dev Updater matches APKs at up to two directories
# below a project root, so a test project building straight into
# test-projects/<name>/build/ would also be found from the repository root --
# i.e. offered as a build of Dev Updater itself, and being newest, served as
# the default. One more level puts it out of that reach while keeping it one
# level below the test project, where its own patterns find it.
rm -rf "$GEN"
mkdir -p "$GEN/classes" "$OUT"
step 1 "Writing the manifest for $PACKAGE"
sed -e "s|__PACKAGE__|$PACKAGE|" -e "s|__LABEL__|$LABEL|" \
"$LIB/AndroidManifest.xml" >"$GEN/AndroidManifest.xml"
step 2 "Linking resources (aapt2)"
"$TOOLS/aapt2" link \
-I "$JAR" \
--manifest "$GEN/AndroidManifest.xml" \
--min-sdk-version "$MIN_SDK" \
--target-sdk-version "$TARGET_SDK" \
-o "$GEN/linked.apk"
step 3 "Compiling StubActivity"
javac -nowarn -Xlint:-options --release 17 \
-classpath "$JAR" -d "$GEN/classes" "$LIB/StubActivity.java"
step 4 "Dexing"
find "$GEN/classes" -name '*.class' -print0 | xargs -0 \
"$TOOLS/d8" --release --lib "$JAR" --min-api "$MIN_SDK" --output "$GEN"
step 5 "Packaging and aligning"
(cd "$GEN" && jar --update --file linked.apk classes.dex)
"$TOOLS/zipalign" -p -f 4 "$GEN/linked.apk" "$GEN/aligned.apk"
step 6 "Signing"
if [ ! -f "$KEYSTORE" ]; then
echo " (generating a signing key at $KEYSTORE)"
mkdir -p "$(dirname "$KEYSTORE")"
chmod 700 "$(dirname "$KEYSTORE")"
keytool -genkeypair -keystore "$KEYSTORE" \
-storepass android -keypass android -alias test \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=dev-updater test projects" >/dev/null
fi
"$TOOLS/apksigner" sign \
--ks "$KEYSTORE" --ks-pass pass:android --key-pass pass:android \
--ks-key-alias test --min-sdk-version "$MIN_SDK" \
--out "$OUT/$NAME-$VARIANT.apk" "$GEN/aligned.apk"
echo "@@progress $STEPS/$STEPS"
echo "==> Built $OUT/$NAME-$VARIANT.apk"