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

@@ -0,0 +1,93 @@
package com.example.devupdater
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import java.io.File
// These are ad hoc local rebuilds with no CI bumping a version code, so it
// can't tell "already have this build" apart from "update available" --
// during active development the version code routinely stays put across
// many rebuilds. PackageManager tracks something better for this purpose
// regardless: `lastUpdateTime`, the epoch millis of when the currently
// installed copy was actually installed, maintained by the OS itself on
// every install (including a plain `adb install -r`, unlike a
// download-tracked-in-SharedPreferences approach, which would only learn
// about installs that went through this app's own download button).
// Compared directly against `/manifest`'s build-mtime epoch (see
// UpdateManifest.kt) -- both are wall-clock timestamps, so as long as the
// device and dev machine roughly agree on the time (true for an emulator
// or a phone on the same LAN), "installed after the currently-served build
// was produced" is a reliable proxy for "already have that build."
//
// Querying another app's PackageInfo needs package visibility on API 30+,
// normally granted per-package via this app's own <queries> in
// AndroidManifest.xml -- but that would mean a manifest edit (and a
// rebuild) every time a new app is added to the server's /manifest. This
// app instead holds QUERY_ALL_PACKAGES, which lets it query any installed
// package by name with no such declaration. That permission is a Play
// Store *policy* restriction, not something the OS itself enforces, so
// it's free to use here since this app is never distributed through Play (F-Droid
// takes the same approach for the same reason -- see AndroidManifest.xml).
fun installedLastUpdateTimeMillis(context: Context, packageName: String): Long? =
try {
context.packageManager.getPackageInfo(packageName, 0).lastUpdateTime
} catch (_: PackageManager.NameNotFoundException) {
null
}
// Whether this package is on this device at all -- same query, asked as
// the question the caller actually has.
fun isInstalled(context: Context, packageName: String): Boolean =
installedLastUpdateTimeMillis(context, packageName) != null
// The installed APK's own file size, for showing "old size -> new size" next
// to an available update -- same package-visibility caveat as above.
fun installedApkSizeBytes(context: Context, packageName: String): Long? =
try {
val sourceDir =
context.packageManager.getPackageInfo(packageName, 0).applicationInfo?.sourceDir
sourceDir?.let { File(it).length() }
} catch (_: PackageManager.NameNotFoundException) {
null
}
// PACKAGE_ADDED/PACKAGE_REPLACED are protected system broadcasts -- only
// the OS can send them -- fired the moment PackageManager finishes
// registering an install, which happens before the installer's own "App
// installed" confirmation screen appears. That makes this a strictly
// earlier and more precise signal than polling or waiting for this app's
// activity to next resume (the latter only happens once the user backs out
// of that confirmation screen). Context-registered rather than
// manifest-declared since this app only cares about it while some screen
// is actually observing install state, not for the whole time it's
// installed -- see the paired unregisterReceiver call at the caller's
// DisposableEffect.
//
// RECEIVER_NOT_EXPORTED is correct, not just required (API 33+ rejects a
// context-registered receiver with neither flag): nothing but the system
// can send this broadcast regardless, so there's no legitimate case for
// another app to inject it here.
fun registerPackageChangeReceiver(
context: Context,
onPackageChanged: (packageName: String) -> Unit,
): BroadcastReceiver {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val packageName = intent.data?.schemeSpecificPart ?: return
onPackageChanged(packageName)
}
}
val filter =
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addDataScheme("package")
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
return receiver
}