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

+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
# Android SDK environment for this app's Gradle build: locates the SDK and
# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing
# Rust/NDK-specific belongs here.
#
# Source this directly for one-off commands instead of going through the
# full run-android.sh (which also creates/boots the emulator, builds,
# installs, and launches):
#
# . ./android-env.sh
# ./gradlew :androidApp:assembleDebug
# adb devices
#
# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this
# file is meant to be sourced into whatever shell is already running --
# including a long-lived one a session reuses for unrelated commands -- and
# changing that shell's error-handling options as a side effect of sourcing
# would be surprising. run-android.sh, which does want strict mode, sets its
# own `set -eu` before sourcing this.
# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't
# silently follow whatever that happens to be set to elsewhere -- e.g. this
# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a
# root-owned install this user can't write to. Everything needed lives under
# the path below instead, matching Android Studio's own default SDK location
# convention on Linux.
SDK_ROOT="$HOME/Android/Sdk"
ANDROID_HOME="$SDK_ROOT"
ANDROID_SDK_ROOT="$SDK_ROOT"
# ~/.local/bin is where the `android` CLI itself installs to (see its own
# installer); adding it here too means sourcing this script guarantees a
# working `android` command even in a shell that hasn't picked up
# ~/.profile yet.
PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH"
# Pin the AVD directory explicitly so avdmanager (creation) and the emulator
# binary (lookup at start time) are guaranteed to agree on where the AVD
# lives -- left to their own defaults they can resolve different locations
# and disagree on whether it exists.
ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
mkdir -p "$ANDROID_AVD_HOME"
export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH
echo "==> Ensuring required SDK packages are installed in $SDK_ROOT"
# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely
# installs anything missing rather than just probing for it -- still
# best-effort (`|| echo`) so a transient network hiccup doesn't abort a
# script sourcing this under `set -e`.
#
# build-tools is needed twice over: by Gradle for this app's own build, and
# by ../server at runtime for `aapt2` (reading a discovered APK's package
# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline).
android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \
"platforms/android-37.0" "build-tools/37.0.0" \
"system-images/android-36/google_apis/x86_64" \
|| echo " (non-fatal: see above)"
+126
View File
@@ -0,0 +1,126 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.ktfmt)
}
// See the root build script for why this style and not ktfmt's default.
ktfmt { kotlinLangStyle() }
// The CA this app pins is baked in at build time from the certificates on
// the machine doing the build -- `$XDG_CONFIG_HOME/dev-updater/certs/ca.pem`,
// which the server generates on first start. DEV_UPDATER_CA overrides it.
//
// Reading it rather than keeping a pasted copy in the source means the
// trust anchor follows the build machine, the CA's private key never has
// to exist anywhere near this repo, and regenerating a CA needs a rebuild
// instead of a paste -- so a stale constant can't quietly disagree with
// the server the app is trying to reach.
val pinnedCaPath: String =
System.getenv("DEV_UPDATER_CA")
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
"/dev-updater/certs/ca.pem"
abstract class GeneratePinnedCert : DefaultTask() {
/** Where the certificate is looked for, reported in failures. */
@get:Input abstract val caPath: Property<String>
/**
* The certificate itself, set only when it exists -- so a missing one produces this task's own
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
*/
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val caCertificate: RegularFileProperty
/** Wired by AGP through `addGeneratedSourceDirectory`. */
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val path = caPath.get()
val ca = File(path)
if (!ca.isFile) {
throw GradleException(
"No CA certificate at $path.\n" +
"Start the server once on this machine first -- it generates the CA the " +
"app pins, and the certificate has to exist before an APK can embed it.\n" +
"Set DEV_UPDATER_CA=/path/to/ca.pem to build against a different one."
)
}
val pem = ca.readText().trim()
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
throw GradleException("$path is not a PEM certificate.")
}
// The PEM must start immediately after the opening quotes: a
// leading newline costs Android's CertificateFactory its
// "-----BEGIN" sniff, so it tries DER instead and fails at runtime
// with an ASN.1 decode error, nowhere near this file.
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
file.parentFile.mkdirs()
file.writeText(
"""
|// Generated from $path by the generatePinnedCert task. Do not edit.
|package com.example.devupdater
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|""${'"'}
|
"""
.trimMargin()
)
}
}
val generatePinnedCert =
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
val ca = file(pinnedCaPath)
caPath.set(pinnedCaPath)
if (ca.isFile) {
caCertificate.set(ca)
}
}
// AGP 9 wants generated sources registered through the variant API rather
// than added to a source set, so the task dependency is carried properly.
androidComponents {
onVariants { variant ->
variant.sources.java?.addGeneratedSourceDirectory(
generatePinnedCert,
GeneratePinnedCert::outputDir,
)
}
}
android {
namespace = "com.example.devupdater"
compileSdk = 37
defaultConfig {
applicationId = "com.example.devupdater"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
buildTypes { getByName("release") { isMinifyEnabled = false } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.zxing.embedded)
// The half of this app that is the same as ai-app's: pinned TLS, the
// enrollment store, and the scanner.
implementation(project(":link"))
}
+101
View File
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Lets this app hand a downloaded .apk to the system installer via
an ACTION_VIEW intent; without it the intent silently fails on
Android 8+ (see ApkInstaller.kt's canRequestInstall()). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
targeting 37+ needs this runtime permission to reach *any* local
network address, including a plain socket to a LAN IP literal with
no discovery involved. Below 37 it doesn't exist and INTERNET
implicitly covers LAN access, which is why the other two apps here
(both still targeting 36) never needed it. Without it the traffic
is silently dropped, surfacing only as a connect timeout. See
MainActivity.kt's runtime request. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Package visibility (API 30+): without this, PackageManager.getPackageInfo()
for another app throws NameNotFoundException even when it's
installed, see InstalledBuilds.kt, which queries lastUpdateTime to
tell "already have this build" apart from "update available." The
normal fix is a static queries allowlist, but that means a
manifest edit and a rebuild of this app every time a new app is
added to serve_apk.py's /manifest, which defeats the point of
driving the app list from that manifest in the first place. This
is a Play Store *policy* restriction (apps requesting it without
an approved use case get rejected from the Store), not something
the OS itself enforces, so it's free to declare here since this
app is never distributed through Play. F-Droid, a comparable
sideloaded app store/updater, does the same for the same reason. -->
<!-- Suppressed on this one permission only, never on the file or the
project. Lint is right that a queries declaration is normally the
answer, and the paragraph above is why it cannot be one here: the
packages to ask about are whatever the server's manifest lists at
runtime, which nothing declared at compile time can name. Left
unsuppressed this is the only error in an otherwise clean lint
run, and a check that always fails is a check nobody runs. -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<!-- Self-lookups are always visible regardless of the above; no
queries entry needed for this app's own package. -->
<application
android:label="Dev Updater"
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enrollment: the server prints its devupdater://enroll QR to
the terminal. This intent filter is the fallback path for a
camera app that redirects a scanned devupdater:// URI here
directly; the "Scan QR code" button on the not-enrolled
screen (zxing-android-embedded) is the primary path and
needs no filter, since it decodes the QR itself and hands
the URI to parseEnrollmentUri in-process. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="devupdater" android:host="enroll" />
</intent-filter>
</activity>
<!-- The scanner behind the not-enrolled screen's "Scan QR code".
Declared here so it can drop the library CaptureActivity's
landscape pin: the code being scanned is usually on a monitor
in front of someone holding the phone upright.
zxing_CaptureTheme is the library's own fullscreen theme,
which is all the activity needs. -->
<activity
android:name="com.example.wgapplink.EnrollmentScanActivity"
android:clearTaskOnLaunch="true"
android:screenOrientation="fullSensor"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" />
<!-- Exposes downloaded APKs (private app storage, see
ApkInstaller.kt's downloadApk()) to the system package
installer without making them world-readable. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.example.devupdater.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,448 @@
package com.example.devupdater
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Adds an app to the list, by project path.
*
* Two ways in, because typing an absolute path on a phone keyboard is the real cost here: the
* suggestion list (what the server found under the configured repo roots) covers the normal case in
* one tap, and the free-text field below covers anything living outside those roots.
*
* The path is always to the *project*, never to an APK: the server rediscovers the build underneath
* it on every request, so a rebuild -- or one that lands in a different variant directory -- needs
* no reconfiguration here.
*
* Outcomes -- both the server's rejection messages and confirmations -- are reported through a
* snackbar rather than text on the screen: the triggering control can be anywhere in a long
* scrolling list, and by the time an "add" comes back the banner position is often scrolled out of
* view, so a message there would go unseen exactly when it matters.
*
* @param onChanged invoked after any successful change, so the list screen behind this one can
* refetch rather than guessing at the new state.
*/
@Composable
fun AddAppScreen(onAdded: (key: String) -> Unit, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var suggestions by remember { mutableStateOf<Suggestions?>(null) }
// Only the initial load reports failure on the screen itself: there is
// nothing else to show at that point, and it usually means the server
// is unreachable rather than one action having been refused.
var loadError by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
// The server is walking the repo roots. Shown rather than left silent
// because a rescan runs after every change here, and until it lands
// the list below is the state from *before* that change.
var scanning by remember { mutableStateOf(false) }
var newRoot by remember { mutableStateOf("") }
var pathText by remember { mutableStateOf("") }
fun loadSuggestions() {
scanning = true
scope.launch {
try {
val loaded = withContext(Dispatchers.IO) { fetchSuggestions() }
suggestions = loaded
loadError = null
} catch (e: DownloadServerException) {
loadError = e.message
} finally {
scanning = false
}
}
}
/**
* Runs one management call, then refetches so what's shown is the server's state, not a guess.
*
* [onSuccess] runs after [action] on the calling (main) dispatcher -- for UI state such as
* clearing a text field, which [action] itself must not touch since it runs on
* [Dispatchers.IO].
*/
fun run(describe: String? = null, onSuccess: () -> Unit = {}, action: () -> Unit) {
if (busy) return
busy = true
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
onSuccess()
loadSuggestions()
// Only where nothing on screen would otherwise show that
// it worked. Adding an app needs no announcement: the card
// it was added from says "Already added" the moment the
// rescan lands, which is the same fact sooner and without
// covering the list to say it.
describe?.let { snackbar.showSnackbar(it) }
} catch (e: DownloadServerException) {
// The server's message explains what to do about it ("point
// this at the app's project directory and build it once
// first"), so it's shown verbatim and kept up until
// dismissed rather than timing out mid-read.
snackbar.showSnackbar(
e.message ?: "Failed",
withDismissAction = true,
duration = androidx.compose.material3.SnackbarDuration.Indefinite,
)
} finally {
busy = false
}
}
}
LaunchedEffect(Unit) { loadSuggestions() }
Scaffold(
// imePadding so the snackbar rides above the on-screen keyboard,
// which is otherwise up whenever a text field is in use -- exactly
// when a message is most likely to arrive.
modifier = Modifier.imePadding(),
snackbarHost = { SnackbarHost(snackbar) },
) { insets ->
Column(Modifier.fillMaxSize().padding(insets).padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Add an app", style = MaterialTheme.typography.headlineSmall)
// An add, a remove or a roots change is in flight. The
// controls go disabled anyway, but that says "not now"
// rather than "working on it".
if (busy) {
Spacer(Modifier.width(8.dp))
Working()
}
}
Spacer(Modifier.height(8.dp))
loadError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
LazyColumn(Modifier.weight(1f)) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Found projects", style = MaterialTheme.typography.titleSmall)
// Only once there is a list to be stale: before
// that, the spinner below stands in for the whole
// section rather than sitting beside a heading with
// nothing under it.
if (scanning && suggestions != null) {
Spacer(Modifier.width(6.dp))
Working()
}
}
Spacer(Modifier.height(8.dp))
}
val found = suggestions
// What is already in the list isn't a suggestion: this
// section is for choosing something to add, and a row that
// can only tell you it has been added is one more thing to
// read past every time.
val addable = found?.projects.orEmpty().filterNot { it.added }
if (found == null) {
item { CircularProgressIndicator() }
} else if (addable.isEmpty()) {
item {
Text(
when {
found.roots.isEmpty() ->
"No directories to scan yet -- add one under \"Scan " +
"directories\" below."
// Told apart deliberately: "nothing here"
// and "you already have all of it" are
// different answers, and only one of them
// is a reason to go looking at the paths.
found.projects.isEmpty() ->
"Nothing found under the directories being scanned. A " +
"project shows up here once it has been built at " +
"least once, or as soon as it carries a " +
".dev-updater.ron of its own."
else ->
"Every project found under the directories being scanned " +
"has been added already."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
items(addable, key = { it.path }) { project ->
SuggestionCard(
project = project,
busy = busy,
onAdd = {
// The key comes back from the add so the
// list can fetch that one app rather than
// reloading itself to find it.
var key: String? = null
run(onSuccess = { key?.let(onAdded) }) {
key = addApp(project.path)
}
},
)
Spacer(Modifier.height(8.dp))
}
}
item {
Spacer(Modifier.height(16.dp))
Text("Or add by path", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
// The button beside the field rather than under it, so
// the pair reads as one control and the section is one
// row tall instead of three.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = pathText,
onValueChange = { pathText = it },
label = { Text("Project directory") },
placeholder = { Text("/path/to/project") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.weight(1f),
)
RowGlyphButton(
glyph = PLUS_GLYPH,
label = "Add this path",
enabled = !busy && pathText.isNotBlank(),
tone = ActionTone.Go,
onClick = {
val path = pathText.trim()
var key: String? = null
run(
onSuccess = {
pathText = ""
key?.let(onAdded)
}
) {
key = addApp(path)
}
},
)
}
}
// Last: this is set once and then rarely touched, unlike
// the two sections above it.
item {
Spacer(Modifier.height(24.dp))
Text("Scan directories", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
val roots = suggestions?.roots.orEmpty()
roots.forEach { root ->
// Editable in place: correcting a typo in a path
// otherwise meant deleting the row and typing the
// whole thing again. Keyed on what the server last
// said, so a saved edit is replaced by what was
// actually stored rather than left as typed.
var edited by remember(root) { mutableStateOf(root) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = edited,
onValueChange = { edited = it },
enabled = !busy,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
val wanted = edited.trim()
if (wanted.isNotEmpty() && wanted != root) {
run {
setRepoRoots(
roots.map { if (it == root) wanted else it }
)
}
}
}
),
modifier = Modifier.weight(1f),
)
RowGlyphButton(
glyph = TRASH_GLYPH,
label = "Stop scanning $root",
enabled = !busy,
tone = ActionTone.Destructive,
onClick = { run { setRepoRoots(roots.filterNot { it == root }) } },
)
}
Spacer(Modifier.height(4.dp))
}
// Adding is the text field plus the button beside it,
// so there is no separate save step to forget: the
// server rescans as part of the same call.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRoot,
onValueChange = { newRoot = it },
label = { Text("Add a directory") },
placeholder = { Text("~/repos") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.weight(1f),
)
RowGlyphButton(
glyph = PLUS_GLYPH,
label = "Scan this directory",
enabled = !busy && newRoot.isNotBlank(),
tone = ActionTone.Go,
onClick = {
val added = newRoot.trim()
run(onSuccess = { newRoot = "" }) { setRepoRoots(roots + added) }
},
)
}
}
}
// Its own row, outside the scrolling list, so the way out is in
// the same place however far down somebody has got -- a long
// list must never put it below the fold.
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth(),
) {
Button(onClick = onBack) { Text("Done") }
}
}
}
}
/**
* The control at the end of one of this screen's rows: a plus, or a trash can.
*
* All of them are the same width, so every text field beside them ends at the same place -- rows of
* fields that stop at different points read as accidental, and these three are the same kind of
* row.
*/
@Composable
private fun RowGlyphButton(
glyph: String,
label: String,
enabled: Boolean,
tone: ActionTone,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
enabled = enabled,
colors = tone.colors(),
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(ROW_CONTROL_WIDTH).semantics { contentDescription = label },
) {
Text(glyph, fontFamily = NerdIcons, fontSize = 22.sp)
}
}
/** Shared by every trailing control here, so the fields all end level. */
private val ROW_CONTROL_WIDTH = 56.dp
@Composable
private fun SuggestionCard(project: ProjectSuggestion, busy: Boolean, onAdd: () -> Unit) {
// Darker than the sheet it sits on, and outlined -- the same step the
// component cards make inside a project card. Filled, it was a shade
// off the surface behind it and read as part of it.
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
// The button is placed over the card rather than in a row with the
// build count. A Button is a good deal taller than the line beside
// it, so sharing a row made that row the button's height and left
// the count floating in the middle of it -- a card offering Add
// then didn't line up with the one above it saying "Already
// added". Out of the column's flow, the card reads the same
// whatever is in its corner.
Box(Modifier.fillMaxWidth().padding(12.dp)) {
Column {
Text(project.name, style = MaterialTheme.typography.titleMedium)
Text(
project.path,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
// Zero happens for a project that declares itself in a
// .dev-updater.ron without having been built yet --
// usually one whose build step is what produces the
// first APK.
when (project.apkCount) {
0 -> "Not built yet"
1 -> "1 build"
else -> "${project.apkCount} builds"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(
enabled = !busy,
onClick = onAdd,
modifier = Modifier.align(Alignment.BottomEnd),
) {
Text("Add")
}
}
}
}
@@ -0,0 +1,234 @@
package com.example.devupdater
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
/**
* Renders a log's ANSI escape sequences instead of showing them.
*
* Anything that writes to a terminal writes colour, and a service's output is a file only because
* something redirected it -- so the escapes arrive here whether or not anybody wanted them. Drawn
* rather than deleted, because the colour is information: it is how the process itself marked which
* lines are errors, and dropping it throws that away at the moment somebody is reading the log to
* find exactly those lines.
*
* The escapes are understood here rather than removed at the build machine because this is the end
* that knows what a colour should look like. The wire stays plain text, and [AnnotatedString.text]
* is the log without any of it -- which is what the Copy button puts on the clipboard, since escape
* codes are not what anyone wants to paste.
*
* **Sequences this does not understand are dropped, never printed.** Cursor movement, erase-line
* and the rest are meaningless without a terminal to act on, and leaving them in as text would be
* worse than the colours were: they would look like corruption in the log rather than like
* something the viewer chose not to do. So every escape is consumed; only the ones below have an
* effect.
*/
fun ansiAnnotated(text: String, base: Color): AnnotatedString {
val runs = parse(text, base)
return buildAnnotatedString {
for (run in runs) {
withStyle(run.style) { append(run.text) }
}
}
}
private const val ESC = ''
/** The final byte of a CSI sequence is in this range; everything before it is parameters. */
private val CSI_END = '@'..'~'
private class Run(val text: String, val style: SpanStyle)
/**
* The state a terminal carries between escapes.
*
* Held as fields rather than as a [SpanStyle] so that "no colour set" stays distinguishable from
* "the colour happens to be the default one" -- 39 (default foreground) has to be able to undo a
* previous 31 without knowing what 31 was.
*/
private data class Sgr(
val bold: Boolean = false,
val dim: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false,
val fg: Color? = null,
val bg: Color? = null,
) {
/**
* Dim has no weight of its own in Compose, so it is drawn as reduced opacity on whatever the
* colour would otherwise be -- including the body colour, which is why [base] is needed here
* rather than left to the caller.
*/
fun style(base: Color): SpanStyle {
val colour = fg ?: base
return SpanStyle(
color = if (dim) colour.copy(alpha = DIM_ALPHA) else colour,
background = bg ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else null,
fontStyle = if (italic) FontStyle.Italic else null,
textDecoration = if (underline) TextDecoration.Underline else null,
)
}
}
/** Enough to read as quieter than the text beside it, while staying legible. */
private const val DIM_ALPHA = 0.65f
private fun parse(text: String, base: Color): List<Run> {
val runs = mutableListOf<Run>()
val pending = StringBuilder()
var sgr = Sgr()
var index = 0
fun flush() {
if (pending.isNotEmpty()) {
runs.add(Run(pending.toString(), sgr.style(base)))
pending.clear()
}
}
while (index < text.length) {
val char = text[index]
if (char != ESC) {
pending.append(char)
index++
continue
}
val next = text.getOrNull(index + 1)
when (next) {
'[' -> {
var end = index + 2
while (end < text.length && text[end] !in CSI_END) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what reading the tail
// of a file does to whatever the first line was. There
// is nothing after it to draw, so there is nothing to
// decide.
index = text.length
} else {
if (text[end] == 'm') {
flush()
sgr = sgr.apply(text.substring(index + 2, end))
}
index = end + 1
}
}
// OSC: runs until BEL or the two-character string terminator.
// Consumed whole, since its payload is a window title or a
// hyperlink target rather than anything to show.
']' -> {
var end = index + 2
while (end < text.length && text[end] != '') {
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') break
end++
}
index = if (end >= text.length) text.length else end + 1
}
null -> index = text.length
// A two-character escape. Nothing here acts on one, so it goes.
else -> index += 2
}
}
flush()
return runs
}
/**
* Applies one SGR sequence's parameters.
*
* An empty parameter list means 0 (reset), which is what a bare `ESC[m` is; a code this does not
* know is skipped without disturbing the rest, so one unrecognised attribute cannot take the
* colours with it.
*/
private fun Sgr.apply(parameters: String): Sgr {
val codes = parameters.split(';').map { it.trim().toIntOrNull() ?: 0 }
var state = this
var index = 0
while (index < codes.size) {
when (val code = codes[index]) {
0 -> state = Sgr()
1 -> state = state.copy(bold = true)
2 -> state = state.copy(dim = true)
3 -> state = state.copy(italic = true)
4 -> state = state.copy(underline = true)
22 -> state = state.copy(bold = false, dim = false)
23 -> state = state.copy(italic = false)
24 -> state = state.copy(underline = false)
39 -> state = state.copy(fg = null)
49 -> state = state.copy(bg = null)
in 30..37 -> state = state.copy(fg = AnsiColors[code - 30])
in 90..97 -> state = state.copy(fg = AnsiColors[code - 90 + 8])
in 40..47 -> state = state.copy(bg = AnsiColors[code - 40])
in 100..107 -> state = state.copy(bg = AnsiColors[code - 100 + 8])
38,
48 -> {
val extended = extendedColour(codes, index)
if (extended == null) {
// Malformed: the rest of this sequence cannot be
// trusted to be parameters, so stop reading it rather
// than treat a colour component as a code of its own.
return state
}
state =
if (code == 38) state.copy(fg = extended.first)
else state.copy(bg = extended.first)
index = extended.second
}
else -> {} // Not understood, and so not applied.
}
index++
}
return state
}
/**
* Reads a `5;n` (256-colour) or `2;r;g;b` (24-bit) argument that follows a 38 or 48.
*
* Returns the colour and the index of its last parameter, or null if the sequence is too short to
* be either.
*/
private fun extendedColour(codes: List<Int>, at: Int): Pair<Color, Int>? =
when (codes.getOrNull(at + 1)) {
5 -> codes.getOrNull(at + 2)?.let { Pair(paletteColour(it), at + 2) }
2 -> {
val red = codes.getOrNull(at + 2)
val green = codes.getOrNull(at + 3)
val blue = codes.getOrNull(at + 4)
if (red == null || green == null || blue == null) null
else
Pair(
Color(red.coerceIn(0, 255), green.coerceIn(0, 255), blue.coerceIn(0, 255)),
at + 4,
)
}
else -> null
}
/**
* One of the 256 palette colours.
*
* The first sixteen are the named ones, and so come from [AnsiColors] for the same reason those do.
* The rest are defined by the standard as arithmetic -- a 6x6x6 cube and a 24-step grey ramp -- so
* they are computed rather than mapped: they are already exact values rather than names, and there
* is nothing to translate.
*/
private fun paletteColour(index: Int): Color =
when (index) {
in 0..15 -> AnsiColors[index]
in 16..231 -> {
val offset = index - 16
val steps = intArrayOf(0, 95, 135, 175, 215, 255)
Color(steps[offset / 36], steps[(offset / 6) % 6], steps[offset % 6])
}
in 232..255 -> {
val grey = 8 + (index - 232) * 10
Color(grey, grey, grey)
}
else -> AnsiColors[7]
}
@@ -0,0 +1,121 @@
package com.example.devupdater
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import java.io.File
import java.net.URLEncoder
private const val DOWNLOAD_READ_TIMEOUT_MS = 15000
// Downloads into this app's own private storage (`filesDir/apks/`, never
// the shared Downloads directory) so there's nothing left behind to clean
// up by hand -- each key's file is simply overwritten in place on the next
// update, and a partial download (`.part`) never gets handed to the
// installer.
//
// Never how a *first* install of this app happens: that goes over the
// server's separate plain-HTTP bootstrap port, which is the one thing this
// app's own code never talks to.
fun downloadApk(
context: Context,
entry: ManifestEntry,
onProgress: (bytesRead: Long, total: Long) -> Unit,
): File {
// The chosen build travels with the request rather than being
// stored on the server: it is this device's preference, and a
// second phone must not have its download changed by it. The
// server checks the path against the builds it can see, so a stale
// one falls back to the newest rather than naming a file.
val route =
when (val variant = chosenVariant(context, entry.key)) {
null -> entry.route
else -> "${entry.route}?variant=${URLEncoder.encode(variant, "UTF-8")}"
}
return downloadFromRoute(context, route, entry.key, onProgress)
}
/**
* Fetches one APK from [route] into private storage, named [name], and returns the file.
*
* Split from [downloadApk] because the app's own rescue path
* ([com.example.devupdater.SELF_APK_ROUTE]) must not need a [ManifestEntry] to reach it -- the
* manifest is exactly what an app too old to read it cannot use. One implementation so both get the
* same partial-file handling and the same progress reporting.
*/
fun downloadFromRoute(
context: Context,
route: String,
name: String,
onProgress: (bytesRead: Long, total: Long) -> Unit,
): File {
val dir = File(context.filesDir, "apks").apply { mkdirs() }
val dest = File(dir, "$name.apk")
val tmp = File(dir, "$name.apk.part")
try {
requestFromServer(route, readTimeoutMs = DOWNLOAD_READ_TIMEOUT_MS) { connection ->
val total = connection.contentLengthLong
connection.inputStream.use { input ->
tmp.outputStream().use { output ->
val buffer = ByteArray(65536)
var readTotal = 0L
while (true) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
readTotal += read
onProgress(readTotal, total)
}
}
}
}
} catch (e: DownloadServerException) {
// The half-written file is worthless and would otherwise sit in
// private storage until the next successful download overwrote it.
tmp.delete()
throw e
}
if (!tmp.renameTo(dest)) {
tmp.copyTo(dest, overwrite = true)
tmp.delete()
}
return dest
}
// Android 8+ requires this app to hold "install unknown apps" for itself
// specifically before the install intent below will do anything but bounce
// back to a settings prompt -- checked explicitly up front so the caller
// can send the user straight to that settings screen with a clear reason,
// instead of a silent no-op tap.
/**
* Asks the system to remove [packageName], which shows its own confirmation dialog before anything
* happens.
*
* ACTION_DELETE rather than PackageInstaller.uninstall(): it needs no permission at all, where the
* newer call wants REQUEST_DELETE_PACKAGES to put up the same dialog. Removing someone's app is not
* a thing to do quietly on their behalf, so the dialog is the point rather than a limitation being
* worked around.
*/
fun uninstallIntent(packageName: String): Intent =
Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName"))
fun canRequestInstall(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()
fun requestInstallPermissionIntent(context: Context): Intent =
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
fun installApkIntent(context: Context, apkFile: File): Intent {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
return Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@@ -0,0 +1,263 @@
package com.example.devupdater
import org.json.JSONArray
import org.json.JSONObject
// The routes behind the Add screen -- everything that *changes* which apps
// this server serves, as opposed to reading the current list (see
// UpdateManifest.kt) or downloading one (ApkInstaller.kt).
//
// All blocking network calls -- invoke from a background dispatcher. Each
// throws DownloadServerException on failure, carrying the server's own
// explanation ("no built APK found under ...", "already added as ...")
// rather than a status code, since that message is written to be read here
// and this screen is usually the only place it can be seen.
// A project the server found under one of the configured repo roots and
// can offer to add: one with a build in it, or one declaring itself in a
// .dev-updater.ron, which can be added before its first build.
data class ProjectSuggestion(
val path: String,
// The project directory's own name. The real label is read out of the
// APK when it's actually added, which is why it can differ from what
// the card ends up showing.
val name: String,
val apkCount: Int,
val mtime: Double,
// Already in the app list -- shown, but not offered again.
val added: Boolean,
)
data class Suggestions(
val roots: List<String>,
val projects: List<ProjectSuggestion>,
)
fun fetchSuggestions(): Suggestions =
requestFromServer("/suggestions") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
val roots = body.getJSONArray("roots")
val found = body.getJSONArray("suggestions")
Suggestions(
roots = (0 until roots.length()).map { roots.getString(it) },
projects =
(0 until found.length()).map { i ->
val item = found.getJSONObject(i)
ProjectSuggestion(
path = item.getString("path"),
name = item.getString("name"),
apkCount = item.getInt("apkCount"),
mtime = item.getDouble("mtime"),
added = item.getBoolean("added"),
)
},
)
}
/** Adds the project at [path], answering with the key it was given. */
fun addApp(path: String): String =
requestFromServer("/apps", method = "POST", jsonBody = jsonOf("path" to path)) { connection ->
JSONObject(connection.inputStream.bufferedReader().readText()).getString("key")
}
fun removeApp(key: String) {
requestFromServer("/apps/$key", method = "DELETE") {}
}
/**
* Accepts the build step [key]'s project asks for in its own `.dev-updater.ron`, after it has been
* shown on the card.
*
* Carries no body: the server re-reads the project's file and stores what it finds there, so this
* can only ever accept what the project actually asks for and never a command composed here.
*/
fun approveDeclaration(key: String) {
requestFromServer("/apps/$key/approve", method = "POST") {}
}
/** Replaces the directories the server scans for suggestions. */
fun setRepoRoots(roots: List<String>) {
val body = JSONObject().put("roots", JSONArray(roots)).toString()
requestFromServer("/roots", method = "PUT", jsonBody = body) {}
}
// Built through JSONObject rather than string interpolation so a path
// containing a quote or backslash can't produce a malformed request.
private fun jsonOf(vararg pairs: Pair<String, Any>): String =
JSONObject().apply { pairs.forEach { (key, value) -> put(key, value) } }.toString()
/**
* Asks a server component's script to do something, on the *build machine* -- these are the one set
* of buttons on this screen that don't act on this phone.
*
* Answers with the state the component ended up in, so the card can show the result of what was
* just done rather than waiting for the next background check to notice.
*/
fun serviceAction(
key: String,
component: String,
action: String,
purge: Purge = Purge(),
): ServiceActionResult {
val query =
if (purge.nothing) "" else "?logs=${purge.logs}&data=${purge.data}&config=${purge.config}"
return requestFromServer("/apps/$key/components/$component/$action$query", "POST") { connection
->
val answer = JSONObject(connection.inputStream.bufferedReader().readText())
val left = answer.optJSONArray("leftBehind")
ServiceActionResult(
state = answer.getString("state"),
leftBehind = (0 until (left?.length() ?: 0)).map { left!!.getString(it) },
)
}
}
/**
* What Uninstall should take away besides the service itself.
*
* All three default to off here, so a caller that says nothing gets what uninstalling has always
* done. The dialog's own defaults are its business, and they are not these -- logs start ticked
* there, because a record of what already happened is the cheap one to lose.
*/
data class Purge(
val logs: Boolean = false,
val data: Boolean = false,
val config: Boolean = false,
) {
val nothing: Boolean
get() = !logs && !data && !config
}
/**
* What a service action ended in.
*
* [leftBehind] is what Uninstall was asked to remove and could not, one line each, and it is empty
* for every other action. Not an error: the service is gone by then, so the request succeeded --
* what is reported is that something is still on disk, which is the thing the person would
* otherwise have to go to the build machine to find out.
*/
data class ServiceActionResult(val state: String, val leftBehind: List<String>)
/**
* Ask the server to check this one project's remote and services again.
*
* Returns as soon as the checks are started, not when they answer -- they run off the request path
* on the server, exactly as the ones a manifest fetch starts do, so the answer arrives through a
* later read of the card ([fetchApp]) or of the whole list.
*/
fun recheckApp(key: String) {
requestFromServer("/apps/$key/recheck", method = "POST") {}
}
/**
* Replaces this machine's preferences for one project.
*
* The whole set every time, not one field: the screen that sends this has just shown every setting
* there is, so it knows the complete answer, and a partial update would need a rule for what a
* missing field means.
*/
fun setAppSettings(key: String, gitIpv4: Boolean) {
requestFromServer(
"/apps/$key/settings",
method = "PUT",
jsonBody = jsonOf("gitIpv4" to gitIpv4),
) {}
}
/** One component's log, as the modal shows it. */
/**
* Which of a component's two logs to read.
*
* Two kinds rather than one list. They answer different questions -- [Build] is what the build
* machine captured while building this component, [Runtime] is what the component itself wrote
* while running -- and a single list indexed by generation could only ever reach the first of them.
*/
enum class LogKind(val wire: String) {
Build("build"),
Runtime("runtime"),
}
data class ComponentLog(
/** Where it came from, so somebody at the build machine can open the whole file. */
val path: String,
val text: String,
/**
* There is more than this. Said rather than implied: a silently shortened log reads as a
* complete one that simply fails to explain the crash.
*/
val truncated: Boolean,
/** How many generations exist, so the modal knows whether to offer a previous one at all. */
val generations: Int,
)
/**
* The tail of one component's log, from the build machine.
*
* [lines] counts back from the end, which is where anything interesting is; 0 asks for as much as
* the server is willing to read, and it says so through [ComponentLog.truncated] when that bites.
* [generation] is 0 for the current run and 1 for the one before it -- an index rather than a
* "previous" flag, because the script reports a list and this should not assume its length. It
* counts within a [kind]: the build log's previous generation is not the runtime log's, which is
* why switching kinds starts again at the current one.
*
* Given the manifest's timeout rather than the default: a large log is a real request that takes
* real time, and timing it out would report the server as unreachable when it is merely reading.
*/
fun componentLog(
key: String,
component: String,
lines: Int,
generation: Int,
kind: LogKind,
): ComponentLog =
requestFromServer(
"/apps/$key/components/$component/logs" +
"?lines=$lines&generation=$generation&kind=${kind.wire}",
readTimeoutMs = MANIFEST_READ_TIMEOUT_MS,
) { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
ComponentLog(
path = body.getString("path"),
text = body.getString("text"),
truncated = body.optBoolean("truncated", false),
generations = body.optInt("generations", 1),
)
}
// ---------------------------------------------------------------------------
// The rescue contract.
//
// These two routes are how this app replaces itself, and the server treats
// their shape as frozen -- see routes.rs. Everything else in this file
// reads the manifest, which is the thing that changes; an app too old to
// parse a new manifest cannot reach the button that would replace it, and
// the only way back from that is a reinstall over the plain-HTTP bootstrap
// port, by hand, at the machine.
//
// So nothing here may grow a dependency on the manifest, on a variant, or
// on any field that might be added later. Two numbers and some bytes.
/** Where the app's own build lives. A constant, because the whole point is that it never moves. */
const val SELF_APK_ROUTE = "/self/apk"
/** What the build machine has of this app: when it was built, and how big it is. */
data class SelfBuild(val mtimeMillis: Long, val sizeBytes: Long)
/**
* Asks whether the build machine has a copy of this app, and how new it is.
*
* Compared against `PackageInfo.lastUpdateTime` by the caller, which is the same freshness rule the
* list uses -- these are ad hoc rebuilds with nothing bumping a version code, so the timestamp is
* all there is.
*/
fun selfBuild(): SelfBuild =
requestFromServer("/self") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
SelfBuild(
// Seconds on the wire, because that is what the server's other
// timestamps use; milliseconds here, because that is what
// Android reports for an installed package.
mtimeMillis = (body.getDouble("mtime") * 1000).toLong(),
sizeBytes = body.getLong("size"),
)
}
@@ -0,0 +1,109 @@
package com.example.devupdater
import org.json.JSONObject
// The three routes that act on the *build machine* rather than this
// phone, all answering the same shape:
//
// POST /apps/{key}/pull fetch, fast-forward the branch, then build
// POST /apps/{key}/prepare build if the configured staleness rule says so
// POST /apps/{key}/build build whether or not anything looks stale
// GET /apps/{key}/status poll either of the above
//
// Only an entry with a configured build step has them; the others answer
// 404, which is why the screen offers them only when the manifest says so
// (needsBuild / canPull).
data class BuildStatus(
val stale: Boolean,
val building: Boolean,
val error: String?,
// What the whole *project* is doing ("fetching", "pulling"), and how
// long this run has taken. Work belonging to one component is in
// [components] instead, because that is where it is drawn.
val phase: String?,
val elapsedMs: Long,
val components: List<ComponentBuild>,
) {
/** This component's part of the run, if it has reached it yet. */
fun component(name: String): ComponentBuild? = components.firstOrNull { it.name == name }
}
/**
* One component's part of a build.
*
* Drawn inside that component's own row rather than under the project, because a bar under the
* whole card could only say that *something* was happening — and with every component building at
* once, that is exactly the question the reader has.
*/
data class ComponentBuild(
val name: String,
// What it is doing now ("building", "installing", "restarting"), or
// null once it has finished.
val step: String?,
val elapsedMs: Long,
val progress: BuildProgressCount?,
val log: List<String>,
val error: String?,
) {
/** The last thing it printed, if anything yet. */
fun lastLine(): String? = log.lastOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val running: Boolean
get() = step != null
}
data class BuildProgressCount(val done: Long, val total: Long)
// Blocking network calls -- invoke from a background dispatcher. The server
// answers both routes immediately either way -- a rebuild it kicks off runs
// in a background thread there, not inline with the request -- so neither
// needs the multi-minute read timeout an actual build would.
private fun requestBuildStatus(path: String, method: String): BuildStatus =
requestFromServer(path, method) { connection ->
val json = JSONObject(connection.inputStream.bufferedReader().readText())
val components = json.optJSONArray("components")
BuildStatus(
stale = json.getBoolean("stale"),
building = json.getBoolean("building"),
error = if (json.isNull("error")) null else json.getString("error"),
phase = if (json.isNull("phase")) null else json.optString("phase").ifEmpty { null },
elapsedMs = json.optLong("elapsedMs", 0),
components =
(0 until (components?.length() ?: 0)).map { index ->
val component = components!!.getJSONObject(index)
val log = component.optJSONArray("log")
ComponentBuild(
name = component.getString("name"),
step =
if (component.isNull("step")) null
else component.optString("step").ifEmpty { null },
elapsedMs = component.optLong("elapsedMs", 0),
progress =
component.optJSONObject("progress")?.let {
BuildProgressCount(
done = it.getLong("done"),
total = it.getLong("total"),
)
},
log = (0 until (log?.length() ?: 0)).map { line -> log!!.getString(line) },
error =
if (component.isNull("error")) null else component.getString("error"),
)
},
)
}
fun pullAndBuild(key: String): BuildStatus = requestBuildStatus("/apps/$key/pull", "POST")
fun prepareBuild(key: String): BuildStatus = requestBuildStatus("/apps/$key/prepare", "POST")
/**
* Builds because the person asked, not because anything looked stale.
*
* The staleness rules keep a download from rebuilding the world; they have no business overruling a
* button. This is also the only way a project already current with its checkout ever records what
* it was built from, which is what the "out of date" signal is compared against.
*/
fun buildNow(key: String): BuildStatus = requestBuildStatus("/apps/$key/build", "POST")
fun buildStatus(key: String): BuildStatus = requestBuildStatus("/apps/$key/status", "GET")
@@ -0,0 +1,363 @@
package com.example.devupdater
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** What the line field means when it is empty or zero: as much as the server will read. */
private const val ALL_LINES = 0
/** Enough to carry a stack trace and its cause, without being a file viewer. */
private const val DEFAULT_LINES = 100
/** Enough to hold a stack trace without being the whole screen. */
private val LOG_HEIGHT = 360.dp
/**
* The foot has four things in it -- a field, a generation toggle and two buttons -- and a dialog is
* narrow. Trimmed padding rather than smaller text, which would make these labels a different size
* from every other button in the app for a reason the reader cannot see.
*/
private val FOOT_BUTTON_PADDING = PaddingValues(horizontal = 8.dp)
/** Wide enough for four digits, which is more lines than anybody asks for by hand. */
private val LINES_FIELD_WIDTH = 76.dp
/**
* The log's own panel, a step *down* the surface ladder rather than up.
*
* Everything else in the app nests by getting lighter -- page at Base, a project's card at Surface
* 0 -- but a log is not another card. It is a slab of somebody else's output quoted inside this
* app, and the thing that says so in one glance is the darkness a terminal has. Crust
* (`surfaceContainerLowest`) against the dialog's `surfaceContainerHigh` is the widest step this
* palette offers, which is what makes the boundary legible without a border drawing it.
*
* It also keeps [AnsiColors] doing what it was chosen for: those are Mocha accents picked to sit
* against a dark Mocha surface, so a log's own colours land on the background they were matched to.
*/
private val LOG_PANEL_SHAPE = RoundedCornerShape(4.dp)
/** Keeps the first column off the panel's edge, at both ends of a horizontal scroll. */
private val LOG_PANEL_PADDING = 8.dp
/**
* One component's log, in full rather than as a snippet on the card.
*
* A modal because a log is something you go and read, not something a card should carry: a crash is
* usually a stack trace, which is unreadable in the three lines a card could spare and would push
* everything else off screen. The card says *that* it failed; this says what.
*
* Reachable whenever a component reports logs, not only after a failure — a running service's log
* is the thing you want while working out why it is behaving oddly, which is exactly when nothing
* has failed yet.
*/
@Composable
fun ComponentLogDialog(
entryKey: String,
component: ProjectComponent,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var lines by remember { mutableStateOf(DEFAULT_LINES.toString()) }
// What is actually being asked for, which is not what is being typed.
// Re-reading on every keystroke means "20" fetches 2 lines on the way
// to 20, and a slow read is started and abandoned for each digit --
// so the field is applied when it is submitted or left, not as it
// changes.
var requestedLines by remember { mutableStateOf(DEFAULT_LINES) }
val submitLines = { requestedLines = lines.toIntOrNull() ?: ALL_LINES }
var generation by remember { mutableStateOf(0) }
// Only a server runs here, so only a server can have a runtime log to
// switch to. An APK gets no tab row rather than a row of one, which
// would be a control that cannot do anything.
val hasBothKinds = component.kind == "server"
// What it is doing now is the usual question, so the runtime log is
// the default -- except for the component a build stopped at, where
// the thing worth reading is why it stopped, and for an APK, which
// has no runtime here and so no tab to escape to.
var kind by remember {
mutableStateOf(
if (!hasBothKinds || component.buildFailed) LogKind.Build else LogKind.Runtime
)
}
var log by remember { mutableStateOf<ComponentLog?>(null) }
// Rendered once per read rather than per recomposition, and kept out
// here so Copy can reach it: what goes on the clipboard is
// AnnotatedString's own plain text, which is the log with every escape
// already gone.
val bodyColour = MaterialTheme.colorScheme.onSurface
val rendered = remember(log, bodyColour) { log?.let { ansiAnnotated(it.text, bodyColour) } }
var failure by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
val available =
when (kind) {
LogKind.Build -> component.hasBuildLogs
LogKind.Runtime -> component.hasRuntimeLogs
}
// Re-read whenever any control moves. Keyed rather than driven by a
// callback so the two cannot disagree about what is on screen.
LaunchedEffect(entryKey, component.name, requestedLines, generation, kind) {
failure = null
// Nothing to fetch, and asking anyway would come back as a failure
// in red -- which is the wrong thing to say about a log that
// simply does not exist. The body explains it instead.
if (!available) {
log = null
loading = false
return@LaunchedEffect
}
loading = true
try {
log =
withContext(Dispatchers.IO) {
componentLog(
entryKey,
component.name,
requestedLines,
generation,
kind,
)
}
} catch (e: DownloadServerException) {
log = null
failure = e.message ?: "Couldn't read the log"
}
loading = false
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("${component.name} · log") },
text = {
Column {
if (hasBothKinds) {
// Primary rather than the plain `TabRow`, which is
// deprecated in favour of the two that say where they
// sit -- these are this dialog's top-level
// destinations. Matches what ai-app uses, so a tab row
// means the same thing in both apps.
//
// Transparent, because a tab row is not a surface of
// its own: the default paints `surface` behind the
// tabs, which inside a dialog at
// `surfaceContainerHigh` reads as a band of different
// background under the controls, saying a change of
// level that isn't there. The indicator and the label
// carry the selection; the background has no part in
// it.
PrimaryTabRow(
selectedTabIndex = if (kind == LogKind.Build) 0 else 1,
containerColor = Color.Transparent,
) {
// A generation is counted within a kind, so the
// build log's previous run is not the runtime
// log's. Switching kinds therefore starts again at
// the current one rather than carrying an index
// across to where it means something else.
Tab(
selected = kind == LogKind.Build,
onClick = {
kind = LogKind.Build
generation = 0
},
text = { Text("Build") },
)
Tab(
selected = kind == LogKind.Runtime,
onClick = {
kind = LogKind.Runtime
generation = 0
},
text = { Text("Runtime") },
)
}
}
Spacer(Modifier.height(8.dp))
// Indeterminate, because the server does not report how
// far through a read it is -- and a bar drawn from a
// guess is worse than one that only spins.
if (loading) {
ProgressBar()
Spacer(Modifier.height(8.dp))
}
failure?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
// Not an error, and not drawn like one: there is simply no
// such log. Which of the two reasons it is stays unsaid
// because the build machine cannot tell them apart either
// -- a script with no log yet and a script that does not
// offer them both answer by reporting nothing.
if (!available) {
Text(
when (kind) {
LogKind.Build ->
"No build log yet — this component hasn't been built from here."
LogKind.Runtime -> "This component reports no runtime log."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
log?.let { loaded ->
if (loaded.truncated) {
Text(
"Showing the end of the log; there is more above it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
loaded.path,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// A path is identified by its tail.
overflow = TextOverflow.StartEllipsis,
)
Spacer(Modifier.height(4.dp))
Text(
rendered.takeIf { loaded.text.isNotEmpty() }
?: AnnotatedString("(nothing in this log yet)"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Both directions: a log wraps badly and a stack
// trace is wide, so it scrolls rather than being
// reflowed into something harder to read.
//
// The panel is painted outside the two scrolls, so
// it is the window rather than the content: filled
// inside the scroll it would be the width of the
// longest line and slide away as the log was
// scrolled, leaving the dark behind the text
// rather than behind the area. `fillMaxWidth` for
// the same reason -- a short log would otherwise
// give a panel the width of its longest line, and
// the block would change shape as the reader
// paged through it.
modifier =
Modifier.fillMaxWidth()
.heightIn(max = LOG_HEIGHT)
.background(
MaterialTheme.colorScheme.surfaceContainerLowest,
LOG_PANEL_SHAPE,
)
.padding(LOG_PANEL_PADDING)
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState()),
)
}
}
},
// The whole foot is one row rather than the dialog's confirm and
// dismiss slots. Given two slots, Material stacks them the moment
// they do not fit, which put the controls *under* the buttons --
// so the row is built here and the dialog is handed one thing.
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = lines,
onValueChange = { entered -> lines = entered.filter { it.isDigit() } },
label = { Text("Lines") },
singleLine = true,
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submitLines() }),
modifier =
Modifier.width(LINES_FIELD_WIDTH)
// Leaving the field counts as submitting it:
// otherwise a number typed and then tapped
// away from sits there looking applied and
// isn't.
.onFocusChanged { focus -> if (!focus.isFocused) submitLines() },
)
// Only when there is one to switch to. A toggle that does
// nothing teaches the reader that toggles here do nothing.
if ((log?.generations ?: 1) > 1) {
TextButton(
onClick = { generation = if (generation == 0) 1 else 0 },
colors = ActionTone.Caution.colors(),
contentPadding = FOOT_BUTTON_PADDING,
) {
Text(if (generation == 0) "Current" else "Previous")
}
}
Spacer(Modifier.weight(1f))
TextButton(
enabled = rendered != null,
onClick = {
rendered?.let { copyToClipboard(context, component.name, it.text) }
},
contentPadding = FOOT_BUTTON_PADDING,
) {
Text("Copy")
}
TextButton(onClick = onDismiss, contentPadding = FOOT_BUTTON_PADDING) {
Text("Done")
}
}
},
)
}
/**
* Puts the log on the clipboard.
*
* No confirmation: Android shows its own on the versions that do not, and saying it again would be
* announcing what the screen already told them.
*/
private fun copyToClipboard(context: Context, label: String, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
}
@@ -0,0 +1,122 @@
package com.example.devupdater
import com.example.wgapplink.ServerSettings
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
// Where the server is and how this device authenticates to it, from the
// enrollment scan (see Link.kt). Held here because every call this
// app makes goes through requestFromServer below -- the manifest, the
// management routes behind the Add screen, the on-demand build routes, and
// every APK download including this app's own self-update -- and the
// screens would otherwise thread the same value through every one.
//
// All of it is HTTPS pinned against PINNED_CA_PEM (see Link.kt),
// because everything here either is, or decides, what this app hands to the
// system installer next. The server binds only the WireGuard interface, so
// reaching it at all requires being an enrolled tunnel peer; the token is
// what distinguishes this phone from anything else that is. The server's
// *other* port (8091) is a separate, plain-HTTP bootstrap link for a
// human's browser to install this app in the first place -- this app's own
// code never talks to that one, and it carries no token.
@Volatile private var settings: ServerSettings? = null
fun useServer(chosen: ServerSettings) {
settings = chosen
}
/** The configured server, or null before this device is enrolled. */
fun serverSettings(): ServerSettings? = settings
private fun requireServer(): ServerSettings =
settings
?: throw DownloadServerException(
"This device isn't enrolled yet. Scan the QR the server prints on startup."
)
private const val CONNECT_TIMEOUT_MS = 5000
/**
* Anything that went wrong talking to the download server, whichever route it was. One type rather
* than one per route: no caller ever needs to tell a failed `/manifest` fetch from a failed
* download by *type* -- each catches around the one call it made -- and the message already says
* which route and what happened.
*/
class DownloadServerException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Runs one request against the download server, with the pinned-TLS setup and the failure
* translation every route here needs. [readBody] gets the connected, already-status-checked
* connection to read from.
*
* Blocking -- invoke from a background dispatcher.
*
* @param jsonBody a request body to send, for the routes that change server state. Set separately
* from [method] because a method alone doesn't imply one -- DELETE here carries no body.
* @param readTimeoutMs how long to wait on the response body; a download's is necessarily longer
* than a JSON route's.
*/
fun <T> requestFromServer(
path: String,
method: String = "GET",
jsonBody: String? = null,
readTimeoutMs: Int = 5000,
readBody: (HttpURLConnection) -> T,
): T {
val server = requireServer()
val connection = URL("${server.baseUrl}$path").openConnection() as HttpURLConnection
try {
connection.applyPinnedTls()
connection.setRequestProperty("Authorization", "Bearer ${server.token}")
connection.requestMethod = method
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = readTimeoutMs
if (jsonBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
}
// The management routes answer 204 with no body, and the server
// reports a rejected request (a path with nothing built under it,
// an app that's already added) as a 4xx whose *body* is the
// explanation written for this screen -- so read it rather than
// reporting a bare status code the user can do nothing with.
if (connection.responseCode !in 200..299) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw DownloadServerException(
when {
connection.responseCode == 401 ->
"The server rejected this device's token. Re-enroll by scanning the " +
"QR it prints (or rotate with --rotate-token and scan the new one)."
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
}
)
}
return readBody(connection)
} catch (e: DownloadServerException) {
throw e
} catch (e: IOException) {
// Covers refused/timed-out connections as well as
// UnknownServiceException, which is what a cleartext-blocked
// request throws -- surfacing the real exception here (rather than
// a single canned message for every failure mode) is what actually
// lets this be diagnosed on a device with no logcat access.
throw DownloadServerException(
"Couldn't reach the server at ${server.baseUrl}$path " +
"(${e::class.simpleName}: ${e.message}) -- is dev-updater running, " +
"and is this device able to reach that address?",
e,
)
} catch (e: Exception) {
throw DownloadServerException(
"Reached ${server.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
)
} finally {
connection.disconnect()
}
}
@@ -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
}
@@ -0,0 +1,45 @@
package com.example.devupdater
import com.example.wgapplink.PinnedTls
import com.example.wgapplink.ServerStore
import java.net.HttpURLConnection
/**
* This app's two parameters to the shared link, and the objects built from them.
*
* Everything about reaching the build machine -- pinned TLS, the enrollment store, the scanner --
* is `wg-app-link`, shared with ai-app so a fix lands in both. What is left here is the two values
* that are genuinely per-app, in one place so nothing can disagree about them.
*/
private const val ENROLL_SCHEME = "devupdater"
/**
* The Android Keystore key the token is sealed under.
*
* **Persisted on the device, so this string is not free to change.** A new value means the app
* cannot unseal the token it already stored, and an enrolled phone silently reads as not enrolled
* with nothing on screen to say why -- unlike the scheme, where a mismatch shows up immediately as
* a scanned code doing nothing. Carried over unchanged from before the link was shared, which is
* the only reason enrolled devices survived that change.
*/
private const val TOKEN_KEY_ALIAS = "dev-updater-token-key"
/** Where this device's enrollment lives. Stateless, so one instance is all anything needs. */
val serverStore = ServerStore(ENROLL_SCHEME, TOKEN_KEY_ALIAS)
/**
* Built once and reused: the socket factory behind it is lazy, and every reconnect would otherwise
* redo the KeyStore and TrustManager setup.
*/
private val pinnedTls = PinnedTls(PINNED_CA_PEM)
/**
* Trusts this machine's CA and nothing else, including the system store -- so a genuine certificate
* issued for another host is refused exactly as firmly as a self-signed one.
*
* An extension rather than a call at each site, so no request can be made without it by forgetting
* a line.
*/
fun HttpURLConnection.applyPinnedTls() {
pinnedTls.applyTo(this)
}
@@ -0,0 +1,125 @@
package com.example.devupdater
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via a devupdater:// intent, so the
// composition below re-reads the stored settings.
private var settingsVersion by mutableStateOf(0)
// Registered up front since permission launchers must be registered
// before the activity reaches STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Window.setStatusBarColor is deprecated and a flat no-op on
// Android 15+ (status bar is always transparent there); relying on
// it left the strip behind the status bar showing this legacy
// platform theme's default -- Theme.Material's teal colorPrimaryDark
// -- on older versions instead of the app's actual background.
// enableEdgeToEdge() makes the status bar transparent on every
// supported version instead, so the Surface below (now filling the
// true full screen, with no inset) paints straight through
// underneath it and the two can never mismatch.
enableEdgeToEdge()
// The app is dark-only (DevUpdaterColors), so the status bar icons
// are forced light -- Android doesn't infer icon colour from the
// background it ends up over, and the Surface below paints straight
// through underneath the bar.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
false
// Mandatory from Android 17 (API 37) on for anything targeting 37+,
// see the AndroidManifest declaration. Requested up front rather
// than lazily on first fetch because a denial is invisible at the
// socket layer: the OS just drops the traffic, so the app would
// otherwise report an ordinary-looking connect timeout with no hint
// that a permission is what's missing.
//
// **Still required when the server is reached over WireGuard**,
// which is the only way this app reaches it. Worth stating because
// the platform's own Local Network Definition says a local network
// "excludes cellular (WWAN) or VPN connections", which reads as
// exempting a tunnelled 10.66.0.1 -- and on the phone this is
// installed on, it does not. Measured against a real tunnel after
// that reading suggested the declaration could be dropped; it
// cannot.
//
// That phone runs GrapheneOS, so it is possible stock Android
// matches its own documentation here and this is a hardened-OS
// difference. Untested either way, and it does not change the
// answer: the permission stays, because the device it has to work
// on is the one it was measured on. Nothing local can check it --
// the emulator this is developed against is API 36, where the
// permission is not enforced at all.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
// Before the first composition, so the screens can call the server
// straight away rather than racing a load.
serverStore.load(this)?.let(::useServer)
handleEnrollment(intent)
setContent {
MaterialTheme(colorScheme = DevUpdaterColors) {
// Fills the true full screen (behind the status bar too, per
// enableEdgeToEdge() above) so this Surface's own background
// is what shows there. The inset is applied one level in
// instead, via statusBarsPadding() on the Box wrapping
// UpdaterScreen() -- it reads the actual system inset rather
// than assuming a fixed height, so it stays correct across
// devices/orientations/font scales -- so it's only the
// *content* that starts below the status bar, not the
// background underneath it.
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
UpdaterScreen(settingsVersion)
}
}
}
}
}
// launchMode="singleTop": an enrollment scan while the app is open
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleEnrollment(intent)
}
private fun handleEnrollment(intent: Intent?) {
val uri = intent?.data ?: return
val settings = serverStore.parseEnrollmentUri(uri)
if (settings == null) {
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
return
}
serverStore.save(this, settings)
useServer(settings)
settingsVersion++
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,59 @@
package com.example.devupdater
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
/**
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
*
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
* set and kept in step by hand.
*
* Ordinary Unicode won't do it -- there is no character for a git branch, and the ones that exist
* for the rest aren't reliably in an Android system font, so they arrive as tofu boxes on
* somebody's phone. The font here is `app/build-icon-font.sh`'s output: eight glyphs, 2 KB, from
* the 2.5 MB symbols font. Adding one means adding its codepoint in *both* places -- a codepoint
* here that the script didn't subset is a glyph that silently isn't there.
*
* All Material Design Icons bar one, so they read as one family; the exception is noted where it is
* declared.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
/** `md-folder` -- a project's directory on the build machine. */
val FOLDER_GLYPH = glyph(0xF024B)
/** `md-source_branch` -- the branch that directory is on. */
val BRANCH_GLYPH = glyph(0xF062C)
/**
* `fa-server` -- a server component, running on the build machine.
*
* Font Awesome's rather than Material's: the `md-server` stack of three shelves is fussy at the
* size a component row draws it.
*/
val SERVER_GLYPH = glyph(0xF233)
/** `md-cog` -- a card's own settings. */
val SETTINGS_GLYPH = glyph(0xF0493)
/** `md-plus` -- add a project to the list. */
val PLUS_GLYPH = glyph(0xF0415)
/** `md-refresh` -- re-read the manifest. */
val REFRESH_GLYPH = glyph(0xF0450)
/**
* `fa-book` -- what a component wrote.
*
* Font Awesome's, like the server glyph: Material's book icons are open-book shapes that read as
* "read this" rather than "a record of what happened".
*/
val LOG_GLYPH = glyph(0xF02D)
/** `md-trash_can_outline` -- remove a scan directory. */
val TRASH_GLYPH = glyph(0xF0A7A)
@@ -0,0 +1,154 @@
package com.example.devupdater
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Whether the build machine has a newer copy of *this app* than the one running.
*
* The only check in the app that goes nowhere near the manifest. That is the point: when this
* server changes what the manifest says, an app too old to read it loses the list — and the list is
* where the button that would replace it lives. Recovering from that means a reinstall over the
* plain-HTTP bootstrap port, by hand, at the machine. This path keeps working because there is
* almost nothing in it to break.
*
* It does not survive a changed CA, port or token, which break the connection before any route is
* reached. Those stay one-way doors.
*
* Answers null when there is nothing to offer, including when the server cannot be reached — a
* failed check is not an update, and this is not the screen that reports the server being down.
*/
suspend fun selfUpdateAvailable(context: Context): SelfBuild? =
withContext(Dispatchers.IO) {
runCatching {
val build = selfBuild()
val installed = installedLastUpdateTimeMillis(context, context.packageName)
// Newer than what is installed, by the same rule the list
// uses. Equal counts as current: a build and an install
// landing in the same second is not an update.
if (installed == null || build.mtimeMillis > installed) build else null
}
.getOrNull()
}
/**
* Offers the newer build of this app, and installs it.
*
* A screen of its own rather than a row on the list, because the moment it matters most is the one
* where the list may not render at all. It asks for nothing but the two numbers
* [selfUpdateAvailable] already fetched.
*/
@Composable
fun SelfUpdateScreen(build: SelfBuild, onDismiss: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var downloading by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) }
var failure by remember { mutableStateOf<String?>(null) }
Column(modifier = Modifier.padding(24.dp)) {
Text("Update Dev Updater", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
Text(
"The build machine has a newer build of this app (${formatSize(build.sizeBytes)}).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
"Taking it now keeps this app able to talk to the server after the server changes. " +
"This check does not use the app list, so it keeps working when the list does not.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
failure?.let {
Spacer(Modifier.height(12.dp))
Text(it, color = MaterialTheme.colorScheme.error)
}
if (downloading) {
Spacer(Modifier.height(16.dp))
// Determinate, because the size is known before the first byte
// -- it came back with the check.
ProgressBar(fraction = { progress })
}
Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(enabled = !downloading, onClick = onDismiss) { Text("Not now") }
TextButton(
enabled = !downloading,
colors = ActionTone.Primary.colors(),
onClick = {
downloading = true
failure = null
scope.launch {
try {
val file =
withContext(Dispatchers.IO) {
downloadFromRoute(context, SELF_APK_ROUTE, "self") { read, total
->
progress = if (total > 0) read.toFloat() / total else 0f
}
}
// Checked rather than attempted, the same way
// the list does it: without the permission the
// installer bounces back a generic "not allowed
// to install unknown apps" dialog that names
// neither what was blocked nor what to do, and
// the settings screen it means is one intent
// away. Kept in step with `install` in
// UpdaterScreen.kt, which makes the same check
// for the same reason.
if (canRequestInstall(context)) {
// The system installer takes it from here
// and asks for its own confirmation; this
// app is replaced rather than told about it.
context.startActivity(installApkIntent(context, file))
} else {
failure =
"Android needs permission to install apps from Dev Updater. " +
"The settings screen for it is open now; allow it and " +
"press Update again."
context.startActivity(requestInstallPermissionIntent(context))
}
} catch (e: Exception) {
failure = e.message ?: "Couldn't download the update"
}
downloading = false
}
},
) {
Text("Update")
}
}
}
}
@@ -0,0 +1,210 @@
package com.example.devupdater
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
/**
* Catppuccin Mocha, as published in `catppuccin/palette`.
*
* Named rather than used as literals at the point of need, so the mapping below reads as the
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
* palette without reading the layout that uses it.
*/
private object Mocha {
val Rosewater = Color(0xFFF5E0DC)
val Mauve = Color(0xFFCBA6F7)
val Red = Color(0xFFF38BA8)
val Peach = Color(0xFFFAB387)
val Yellow = Color(0xFFF9E2AF)
val Green = Color(0xFFA6E3A1)
val Teal = Color(0xFF94E2D5)
val Sky = Color(0xFF89DCEB)
val Blue = Color(0xFF89B4FA)
val Lavender = Color(0xFFB4BEFE)
val Text = Color(0xFFCDD6F4)
val Subtext0 = Color(0xFFA6ADC8)
val Overlay0 = Color(0xFF6C7086)
val Surface2 = Color(0xFF585B70)
val Surface1 = Color(0xFF45475A)
val Surface0 = Color(0xFF313244)
val Base = Color(0xFF1E1E2E)
val Mantle = Color(0xFF181825)
val Crust = Color(0xFF11111B)
}
/**
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
* 0: one visible step up, which is the whole of what the nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
* than the near-white the roles default to.
*/
val DevUpdaterColors =
darkColorScheme(
primary = Mocha.Mauve,
onPrimary = Mocha.Crust,
primaryContainer = Mocha.Surface1,
onPrimaryContainer = Mocha.Mauve,
secondary = Mocha.Lavender,
onSecondary = Mocha.Crust,
secondaryContainer = Mocha.Surface1,
onSecondaryContainer = Mocha.Lavender,
tertiary = Mocha.Rosewater,
onTertiary = Mocha.Crust,
background = Mocha.Base,
onBackground = Mocha.Text,
surface = Mocha.Base,
onSurface = Mocha.Text,
surfaceVariant = Mocha.Surface0,
onSurfaceVariant = Mocha.Subtext0,
surfaceContainerLowest = Mocha.Crust,
surfaceContainerLow = Mocha.Mantle,
surfaceContainer = Mocha.Base,
surfaceContainerHigh = Mocha.Surface0,
surfaceContainerHighest = Mocha.Surface0,
inverseSurface = Mocha.Text,
inverseOnSurface = Mocha.Base,
inversePrimary = Mocha.Mauve,
outline = Mocha.Overlay0,
outlineVariant = Mocha.Surface2,
error = Mocha.Red,
onError = Mocha.Crust,
errorContainer = Mocha.Surface1,
onErrorContainer = Mocha.Red,
scrim = Mocha.Crust,
)
/**
* What pressing a button will do, said in colour.
*
* By consequence rather than by which component it sits on, so that the same consequence looks the
* same everywhere: Uninstall is the same red whether it takes away a service or an app.
*
* Each is a Mocha accent, which is the point of using a palette rather than picking shades: they
* were chosen to sit at one weight against a Mocha background, so no tone shouts over the others.
* They are all far too light to fill a button with, which is the thing to remember if one is ever
* reused as a container.
*/
enum class ActionTone {
/** Stop, Uninstall, Remove: takes something away. */
Destructive,
/** Restart, Reinstall: replaces what is there with the same thing. */
Caution,
/** Start, Install: brings up something that wasn't there. */
Go,
/** Update, Build, Pull: brings something new in. */
Primary,
}
/**
* A composable read rather than a constant on the enum, so [Caution] can be the scheme's own accent
* -- the colour the corner controls already use -- instead of a copy of it that drifts the first
* time the scheme changes.
*/
val ActionTone.color: Color
@Composable
get() =
when (this) {
ActionTone.Destructive -> Mocha.Red
ActionTone.Caution -> MaterialTheme.colorScheme.primary
ActionTone.Go -> Mocha.Green
ActionTone.Primary -> Mocha.Blue
}
@Composable
fun ActionTone.colors(): ButtonColors = ButtonDefaults.textButtonColors(contentColor = color)
/**
* "There is something here": a service that is up. A branch with commits waiting takes
* [ActionTone.Primary] instead, because what it is really saying is that Pull would do something --
* so it is that button's colour.
*/
val runningColor: Color
@Composable get() = ActionTone.Go.color
/**
* "This went wrong on its own": a service that fell over.
*
* The scheme's error colour rather than [ActionTone.Destructive], which happens to be the same red.
* They are the same red for different reasons, and a state is not an action -- Destructive means
* *this button takes something away*, and nothing here is a button.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* The sixteen ANSI colours, in their standard order, as this palette's nearest members.
*
* A log carries colour as an index rather than a value -- "red", not a hex triple -- so what red
* *is* remains a decision for whoever draws it. Answering with Mocha's red rather than the VGA one
* keeps a log looking like part of the app instead of a terminal pasted into it, and keeps every
* colour legible against this background, which raw ANSI black on a dark surface is not.
*
* Indices 0-7 are the normal set and 8-15 the bright one. Mocha has no bright/normal pairs, so a
* bright colour is the same hue: the distinction exists in terminals to buy contrast this palette
* already has, and inventing a second shade for it would be inventing a difference the log does not
* mean.
*/
val AnsiColors: List<Color> =
listOf(
Mocha.Overlay0, // black -- not actual black, which would be invisible here
Mocha.Red,
Mocha.Green,
Mocha.Yellow,
Mocha.Blue,
Mocha.Mauve, // magenta
Mocha.Teal, // cyan
Mocha.Subtext0, // white
Mocha.Surface2, // bright black
Mocha.Red,
Mocha.Green,
Mocha.Peach, // bright yellow, warmed so it is not the same swatch twice
Mocha.Sky,
Mocha.Mauve,
Mocha.Teal,
Mocha.Text, // bright white
)
/**
* The progress bar, everywhere this app draws one.
*
* Blue, which is [ActionTone.Primary] -- the colour of bringing something new in, and that is what
* every bar here is waiting on: a build, a download, a log being read. Consequence rather than
* location, the same rule the buttons follow, so a bar means the same thing wherever it appears.
*
* One composable because six places draw one, and six copies of a colour is five chances to
* disagree.
*
* [fraction] is null when there is nothing honest to draw a proportion from, which is most of the
* time: an estimated bar looks exactly like a measured one and the person watching cannot tell them
* apart, so a command that reports no count gets a bar that claims none.
*/
@Composable
fun ProgressBar(modifier: Modifier = Modifier, fraction: (() -> Float)? = null) {
if (fraction == null) {
LinearProgressIndicator(
color = ActionTone.Primary.color,
modifier = modifier.fillMaxWidth(),
)
} else {
LinearProgressIndicator(
progress = fraction,
color = ActionTone.Primary.color,
modifier = modifier.fillMaxWidth(),
)
}
}
@@ -0,0 +1,354 @@
package com.example.devupdater
import org.json.JSONObject
data class ManifestEntry(
val key: String,
// Server-provided display name -- this app renders whatever /manifest
// sends rather than keeping its own hardcoded per-app label list, which
// is what lets the app list be edited at runtime from the Add screen
// with no rebuild here.
val label: String,
val filename: String,
val route: String,
// Null until this project has been built at least once -- there is no
// APK to read an identity out of before then, and inventing one would
// make the installed-version check compare against nothing.
val packageName: String?,
// What this project's APK used to install over, when it has been
// renamed. Android treats a renamed applicationId as an unrelated app,
// so that one is still installed and nothing will ever replace it --
// the card offers to remove it, but only while it is actually there.
val previousPackageName: String?,
// The project directory this app was added by. Shown on the card so
// it's possible to tell two similarly-named apps apart, and to spot an
// entry pointing somewhere unexpected.
val projectPath: String,
// Epoch seconds of the raw build's mtime, straight from the server --
// these are ad hoc local rebuilds with no CI bumping a version, so
// build freshness is the only meaningful signal, not a version code.
val mtime: Double,
val size: Long,
// True for an entry with an on-demand build step (see BuildStatus.kt)
// -- only then does this app call that entry's prepare/status routes,
// which 404 for an entry without one.
val needsBuild: Boolean,
// True for the server's own updater app, which can't be removed: it's
// the only route by which this app can ever replace itself.
val builtIn: Boolean,
// Force git's remote commands onto IPv4 for this project -- this
// machine's choice, editable from the card's settings.
val gitIpv4: Boolean,
// False when the project has no APK yet (never built, or cleaned).
// Such an entry is still listed rather than silently dropped -- it was
// added deliberately, so saying so beats it disappearing.
val built: Boolean,
// Every build discovered under the project, so a different one can be
// selected without another round trip.
val variants: List<ApkVariant>,
// What this project produces, in build order. One is the ordinary case
// and the card stays flat; more than one is drawn as a nested list, so
// a project that also runs a server says so without every single-app
// card growing a level of nesting to hold one thing.
val components: List<ProjectComponent>,
// The project's checkout, when it is in a git repository at all.
val git: GitStatus?,
// True when this app offers a Pull button (gitPull on the server, and
// a branch that tracks something). Pull acts on the build machine;
// Update acts on this phone.
val canPull: Boolean,
// Whether the remote has something this checkout doesn't, as of the
// server's last check. Not a count: the server asks what the remote
// has without downloading it, and counting needs the objects.
val newCommits: Boolean,
// This checkout's remote is being asked right now, so newCommits is
// the previous answer. Per app, so the card that is actually waiting
// is the one that says so.
val checkPending: Boolean,
// Why the last check produced no answer, when it produced none: the
// server's reason for a check that failed, or this app's own for one
// it stopped waiting on (see checksUnfinished). Null is the ordinary
// case. Kept apart from newCommits because "we don't know" and "the
// remote had nothing" are different answers, and only one of them is
// safe to show as an unremarkable branch name.
val checkError: String? = null,
// The build step this project asks for in its own .dev-updater.ron,
// which nobody has accepted yet -- RON, the form it was written in,
// shown verbatim for a person to read before it is allowed to run.
// Null once accepted, or for a project that asks for nothing. While it
// is set the server runs no build step for this app at all.
val pendingDeclaration: String?,
)
// A project's checkout, all read locally on the server.
data class GitStatus(
val branch: String,
val dirty: Boolean,
val upstream: String?,
// The top of the checkout, already tilde-contracted by the server.
// What a person calls the project, and what Pull acts on -- the
// project path is a directory somewhere inside this.
val root: String,
)
// One thing a project produces. [kind] is "apk" (installed on this phone)
// or "server" (installed and run on the build machine).
//
// [state] is only ever set for a server, and only once its script has been
// asked: "running", "stopped", "failed", "notInstalled". Null with [checking] true
// means the answer is still coming; null with [error] set means the script
// could not say, which is a different thing from a service being down.
data class ProjectComponent(
val name: String,
val kind: String,
val state: String?,
val checking: Boolean,
val error: String?,
// Whether what is built is current with the checkout: "current",
// "behind", or "unknown". Unknown is a real answer, not a fallback --
// never built here, no checkout to compare against, or uncommitted
// work in this component's directory, which makes the comparison
// unreliable rather than negative.
val freshness: String,
// There is at least one log of either kind, so the card offers the
// button that opens them.
val hasLogs: Boolean,
// The build machine wrote a build log for this component. False until
// it has been built there at least once.
val hasBuildLogs: Boolean,
// This component's script reports a runtime log. False for an APK,
// which does not run there, and false for a script that does not
// implement `logs` -- which is a first-class answer, not a failure,
// and is why the modal says which of the two it is rather than
// showing an empty log either way.
val hasRuntimeLogs: Boolean,
// The last build stopped at this component, which is the one case
// where its build log matters more than what it is doing now.
val buildFailed: Boolean,
// Where Uninstall's "remove data" and "remove config" would delete,
// and whether anything is there. Null for an APK, which keeps nothing
// on the build machine.
//
// The path is shown beside its toggle rather than kept for the log:
// the server removes a declared path wherever it points, with no
// check that it sits under the XDG directories, so this display is
// the only thing between an accepted declaration and the wrong
// directory. Do not reduce it to "data" and "config".
val dataPath: String?,
val configPath: String?,
val dataPresent: Boolean,
val configPresent: Boolean,
// A null path has three causes and they are different things to do
// about it, so the server says which: still reading the project's
// resources, could not read them, or the project simply does not say.
// The last is the ordinary case and not a fault.
val resourcesChecking: Boolean,
val resourcesError: String?,
) {
// Only "behind" is worth saying. "Current" is what a card already
// implies, and "unknown" said out loud would be on most rows most of
// the time, which is how a mark stops meaning anything.
val isBehind: Boolean
get() = freshness == "behind"
val isServer: Boolean
get() = kind == "server"
val isInstalled: Boolean
get() = state != null && state != "notInstalled"
val isRunning: Boolean
get() = state == "running"
// Fell over rather than being stopped by anyone. Installed, so it
// still offers Start and Uninstall -- what changes is what the row
// says, not what it lets you do.
val isFailed: Boolean
get() = state == "failed"
}
// One discovered build of an app. `variant` is the Gradle-style build
// variant name ("debug", "freeRelease") taken from the output directory.
data class ApkVariant(
val path: String,
val variant: String,
val mtime: Double,
)
// The whole /manifest response. The repo roots ride along with the app list
// rather than needing their own fetch, since the Add screen shows both and
// they change together.
data class Manifest(
val entries: List<ManifestEntry>,
val repoRoots: List<String>,
/**
* The server is still asking the git remotes what they have, so [ManifestEntry.newCommits] may
* change shortly. The list deliberately does not wait for that answer -- it would put a round
* trip in front of every reopen -- so this is the cue to look once more.
*/
val checksPending: Boolean,
)
// In the units PackageInfo.lastUpdateTime reports, which is what this is
// ever compared against (see InstalledBuilds.kt).
fun ManifestEntry.mtimeMillis(): Long = (mtime * 1000).toLong()
// When this device has pinned a build, that build's timestamp is the one
// freshness is about -- the newest build being newer than the installed
// copy says nothing when the newest is not what would be installed. Falls
// back to the entry's own when the pinned one is gone, which is the same
// build the server would fall back to serving.
fun ManifestEntry.mtimeMillisFor(chosenVariantPath: String?): Long =
variants.firstOrNull { it.path == chosenVariantPath }?.let { (it.mtime * 1000).toLong() }
?: mtimeMillis()
// Whether anything about this card is still being worked out on the build
// machine: its remote, or a service being asked what it is doing. Both
// land after the response that started them, so a screen waiting on this
// card waits on either -- stopping at the remote alone leaves a component
// with no state, which is drawn as a row with no buttons, since which
// buttons to offer is what the answer decides.
val ManifestEntry.checksOutstanding: Boolean
get() = checkPending || components.any { it.checking }
// The same list with every outstanding check marked as unfinished, for a
// screen that has stopped waiting for the answers. The server may well
// still be asking -- the next refresh or resume collects whatever it
// landed on -- so this says only that nothing here is listening any more,
// and the cards say "couldn't check" rather than falling back to the
// blank line that means the remote answered and had nothing.
fun Manifest.checksUnfinished(): Manifest =
copy(checksPending = false, entries = entries.map { it.checkUnfinished() })
// The same for one entry, for a caller that was only ever waiting on one.
//
// A component being asked what it is doing is one of these too: its
// spinner has to come down with the polling that fed it, or it turns for
// ever promising an answer nothing is collecting -- and a row left with
// no state and no explanation is a row with no buttons for a reason the
// reader cannot see.
fun ManifestEntry.checkUnfinished(): ManifestEntry =
copy(
checkPending = false,
checkError = if (checkPending) STOPPED_WAITING else checkError,
components =
components.map { component ->
if (component.checking) {
component.copy(checking = false, error = STOPPED_WAITING)
} else {
component
}
},
)
private const val STOPPED_WAITING = "the check was still running when this app stopped waiting"
// Longer than the other calls: answering asks the filesystem about every
// app, and for one served as a stripped copy that can mean rebuilding the
// slim APK first. Timing that out would report "couldn't reach the server"
// about a server that is working.
// Also used by a log read, which is slow for the same kind of reason: real
// work on the build machine, not an unreachable server.
internal const val MANIFEST_READ_TIMEOUT_MS = 15000
// Blocking network call -- invoke from a background dispatcher. Returns a
// List so the cards render in the order the server sent them.
//
// [recheck] false collects an answer already being worked on without
// asking the git remotes again -- what a poll wants. Asking again on every
// poll would leave an answer outstanding forever, so the loop would never
// end (see UpdaterScreen's load()).
fun fetchManifest(recheck: Boolean = true): Manifest =
requestFromServer(
if (recheck) "/manifest" else "/manifest?recheck=false",
readTimeoutMs = MANIFEST_READ_TIMEOUT_MS,
) { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
val apps = body.getJSONArray("apps")
Manifest(
entries = (0 until apps.length()).map { i -> readEntry(apps.getJSONObject(i)) },
checksPending = body.optBoolean("checksPending", false),
repoRoots =
body.getJSONArray("repoRoots").let { roots ->
(0 until roots.length()).map { roots.getString(it) }
},
)
}
/**
* One app, for a card that has just acted and wants only itself back.
*
* The whole manifest would do -- and did, before the server grew this route -- but fetching every
* app to use one of them asks the build machine to stat every APK it serves to answer a question
* about one.
*/
fun fetchApp(key: String): ManifestEntry =
requestFromServer("/apps/$key", readTimeoutMs = MANIFEST_READ_TIMEOUT_MS) { connection ->
readEntry(JSONObject(connection.inputStream.bufferedReader().readText()))
}
/** One app as the server describes it, shared by both reads above. */
private fun readEntry(entry: JSONObject): ManifestEntry {
val components = entry.optJSONArray("components")
val variants = entry.getJSONArray("variants")
return ManifestEntry(
key = entry.getString("key"),
label = entry.getString("label"),
filename = entry.getString("filename"),
route = entry.getString("route"),
packageName = entry.optString("package").ifEmpty { null },
previousPackageName = entry.optString("previousPackage").ifEmpty { null },
projectPath = entry.getString("projectPath"),
mtime = entry.getDouble("mtime"),
size = entry.getLong("size"),
needsBuild = entry.getBoolean("needsBuild"),
builtIn = entry.getBoolean("builtIn"),
gitIpv4 = entry.getBoolean("gitIpv4"),
built = entry.getBoolean("built"),
git =
entry.optJSONObject("git")?.let { git ->
GitStatus(
branch = git.getString("branch"),
dirty = git.getBoolean("dirty"),
upstream = git.optString("upstream").ifEmpty { null },
root = git.getString("root"),
)
},
canPull = entry.optBoolean("canPull", false),
newCommits = entry.optBoolean("newCommits", false),
checkError = entry.optString("checkError").ifEmpty { null },
checkPending = entry.optBoolean("checkPending", false),
pendingDeclaration = entry.optString("pendingDeclaration").ifEmpty { null },
components =
(0 until (components?.length() ?: 0)).map { j ->
val component = components!!.getJSONObject(j)
ProjectComponent(
name = component.getString("name"),
kind = component.optString("kind"),
state = component.optString("state").ifEmpty { null },
checking = component.optBoolean("checking", false),
error = component.optString("error").ifEmpty { null },
freshness = component.optString("freshness").ifEmpty { "unknown" },
hasLogs = component.optBoolean("hasLogs", false),
hasBuildLogs = component.optBoolean("hasBuildLogs", false),
hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false),
buildFailed = component.optBoolean("buildFailed", false),
dataPath = component.optString("dataPath").ifEmpty { null },
configPath = component.optString("configPath").ifEmpty { null },
dataPresent = component.optBoolean("dataPresent", false),
configPresent = component.optBoolean("configPresent", false),
resourcesChecking = component.optBoolean("resourcesChecking", false),
resourcesError = component.optString("resourcesError").ifEmpty { null },
)
},
variants =
(0 until variants.length()).map { j ->
val variant = variants.getJSONObject(j)
ApkVariant(
path = variant.getString("path"),
variant = variant.getString("variant"),
mtime = variant.getDouble("mtime"),
)
},
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
package com.example.devupdater
import android.content.Context
/*
* Which build of an app this device wants, when it wants a particular one.
*
* Per device, not per server. Two phones enrolled against one build
* machine each look at the same projects, and one of them picking a
* release build has no business changing what the other is offered -- so
* the choice lives here and travels with the download request, rather than
* being written into the server's config.
*
* Keyed by the project key, which the server promises never to change: it
* is the same identifier the downloaded file is named after.
*
* The path is the server's, not this device's, and is checked there against
* the builds it can actually see. Nothing here can name a file into
* existence; a stale choice -- a variant deleted by a `gradlew clean` --
* falls back to the newest build rather than failing.
*/
private const val PREFS_NAME = "variants"
/** The build [key] is pinned to on this device, or null for "the newest". */
fun chosenVariant(context: Context, key: String): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, null)
/** Passing null goes back to "whatever is newest", which is the default. */
fun chooseVariant(context: Context, key: String, path: String?) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (path == null) {
prefs.edit().remove(key).apply()
} else {
prefs.edit().putString(key, path).apply()
}
}
/**
* Forgets a project's choice, for one being removed -- otherwise a key reused by a later project
* would inherit a preference nobody set.
*/
fun forgetVariant(context: Context, key: String) = chooseVariant(context, key, null)
@@ -0,0 +1,25 @@
package com.example.devupdater
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* "The server hasn't finished working this out."
*
* Small enough to sit inline beside the thing it qualifies, so it can be put next to whichever
* value is still provisional rather than covering a whole screen. Anything the server decides in
* the background gets one -- a value shown without it is meant to be read as settled.
*
* One composable rather than a size and a stroke width repeated per site, so every one of them
* reads as the same mark.
*/
@Composable
fun Working(modifier: Modifier = Modifier) {
CircularProgressIndicator(
modifier = modifier.size(12.dp),
strokeWidth = 1.5.dp,
)
}
Binary file not shown.
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="apks" path="apks/" />
</paths>
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
# Builds the updater app's own APK.
#
# ./build-apk.sh
#
# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/dev-updater/certs/ca.pem,
# or DEV_UPDATER_CA), so build it on the machine that runs the server: an app
# built somewhere else trusts a CA that server can't present, and simply won't
# connect. Start dev-updater 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.
# This is the updater itself, so a fresh install can't come *through* the
# updater: serve it over the plain-HTTP bootstrap port instead
# (dev-updater --download) and open http://<this machine>:8091 on the phone.
set -eu
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="${DEV_UPDATER_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/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: it should match the CA the server
# you are going to talk to generated, which is the one in the directory
# named above unless DEV_UPDATER_CA points elsewhere.
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 -- run the server on this machine" >&2
echo "first, or set DEV_UPDATER_CA to one. The APK has to embed it at build time." >&2
exit 1
fi
echo "==> Building"
# Dev Updater ships a wrapper that turns a Gradle build into the
# `@@progress done/total` lines its cards draw a real bar from, and points
# $DEV_UPDATER_PROGRESS at it when it is the one running this. Gradle
# cannot report a count any other way -- see the wrapper's own header --
# and having it there rather than here is what stops every project copying
# the same twenty-five lines of counting. Built by hand, the variable is
# unset and the build simply runs.
if [ -x "${DEV_UPDATER_PROGRESS:-}" ]; then
"$DEV_UPDATER_PROGRESS" gradle ./gradlew :androidApp:assembleDebug
else
./gradlew :androidApp:assembleDebug
fi
APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk"
echo
echo "==> Built $APK"
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
echo
echo "To get it onto the phone:"
echo " - if the installed copy still trusts this CA, hit Update on the"
echo " 'Dev Updater' entry in the app itself;"
echo " - if the CA was regenerated, the installed copy can no longer reach"
echo " the server, so reinstall over the bootstrap port instead:"
echo " dev-updater --download"
echo " then open http://<this machine>:8091 in the phone's browser."
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Rebuilds androidApp/src/main/res/font/nerd_icons.ttf.
#
# The app draws a handful of icons -- a folder, a git branch, a server, a
# cog -- as text in a Nerd Fonts glyph, rather than as vector assets or as
# ordinary Unicode. Unicode has no character for most of these, and the
# ones it does have are not reliably in an Android system font, so they
# land as tofu boxes on somebody's phone.
#
# The whole symbols font is 2.5 MB for the handful below, so what is
# committed is a subset. Add a codepoint to GLYPHS below and to NerdIcons.kt (the two
# lists have to agree -- a codepoint in the Kotlin but not here is a glyph
# that silently doesn't exist), then run this and commit the result.
#
# Needs python3 and network access; fontTools is fetched into a temporary
# venv, so nothing has to be installed on the machine.
set -euo pipefail
# Codepoint, then the Nerd Fonts glyph name it came from. All from the
# Material Design Icons set bar one, so they look like one family; the
# exception is noted on its own line.
GLYPHS=(
U+F024B # md-folder
U+F062C # md-source_branch
U+F233 # fa-server
U+F0493 # md-cog
U+F0415 # md-plus
U+F0450 # md-refresh
U+F0A7A # md-trash_can_outline
U+F02D # fa-book
)
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
out="$(cd "$(dirname "$0")" && pwd)/androidApp/src/main/res/font/nerd_icons.ttf"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
echo "Fetching $url"
curl -fsSL -o "$work/nf.zip" "$url"
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
python3 -m venv "$work/venv"
"$work/venv/bin/pip" -q install fonttools
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
mkdir -p "$(dirname "$out")"
# The Mono face: every glyph gets the same advance, so an icon occupies the
# same width whichever one it is and a row of them lines up with the row
# above. The proportional face varies the advance per glyph, which puts the
# text after each icon at a slightly different place.
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
--unicodes="$unicodes" \
--layout-features= \
--drop-tables+=DSIG \
--output-file="$out"
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
+17
View File
@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
// Applied here as well as in :androidApp, because a project only
// formats its own files -- this one owns the two build scripts at the
// root, and that module owns the Kotlin.
alias(libs.plugins.ktfmt)
}
// ktfmt offers exactly two styles. This is the one in kotlinlang.org's
// coding conventions; the other is Google's 2-space internal style.
// Picked as the language's own standard rather than because it matches
// what is here -- matching is a consequence, and would be the wrong
// reason (code rule 27).
ktfmt { kotlinLangStyle() }
+181
View File
@@ -0,0 +1,181 @@
#!/bin/sh
# Enrols the Android emulator against this machine's dev-updater, without a
# camera and without a QR code.
#
# ./app/enroll-emulator.sh [--host 10.0.2.2] [--port 8090] [--avd dev-updater]
#
# Enrolling normally means scanning the QR the server prints. There is no
# camera on a headless emulator, so this uses the other path the app already
# supports: the `devupdater://enroll` intent that MainActivity handles for
# camera apps that redirect a scanned URI. Firing it with `am start` is the
# whole trick.
#
# It is idempotent. A token for the emulator is generated once and kept in
# $XDG_DATA_HOME/dev-updater/emulator-token; later runs reuse it, so
# re-enrolling after reinstalling the app costs nothing and never touches
# the token belonging to a real phone.
#
# Two things that cost an afternoon each, written down so they don't again:
#
# 1. **`adb shell am start -d "...?a=1&b=2"` silently loses everything
# after the first `&`.** The URI is handed to the *device's* shell,
# which treats `&` as "run in background" no matter how carefully it was
# quoted on this side. The symptom is an intent that starts the app and
# enrols nothing, with no error anywhere. Escape them: `\&`.
#
# 2. **More than one emulator can be attached, and then bare `adb` fails.**
# Every `adb` call here names a device, because `adb get-state` and
# `adb shell` both exit 1 with "more than one device/emulator" the
# moment a second AVD is running -- and the old version read that as
# "no emulator is running", which is the opposite of what happened and
# sends you off to start a third. The device is chosen by AVD *name*
# rather than by taking the only one attached, so it cannot enrol
# somebody else's emulator by accident.
#
# 3. **The server has to be listening somewhere the emulator can reach.**
# Inside the emulator, 10.0.2.2 is this machine. dev-updater binds wg0
# and nothing else by default, which the emulator has no route to, so
# the app reports the server as unreachable. Start it with
# `--bind 0.0.0.0` for the duration of the test. The CA already covers
# 10.0.2.2 -- `local_addresses` in main.rs puts it there deliberately --
# so TLS is not the problem, and a TLS error means something else.
set -eu
HOST=10.0.2.2
PORT=8090
# Defaulted from the environment the same way run-android.sh reads it, so
# the two agree about which emulator "the emulator" means; the flag is here
# because this script already takes its other settings that way.
AVD_NAME="${AVD_NAME:-dev-updater}"
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST=$2; shift 2 ;;
--port) PORT=$2; shift 2 ;;
--avd) AVD_NAME=$2; shift 2 ;;
*) echo "usage: $0 [--host H] [--port P] [--avd NAME]" >&2; exit 2 ;;
esac
done
PACKAGE=com.example.devupdater
CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/config.ron"
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater"
TOKEN_FILE="$STATE_DIR/emulator-token"
command -v adb >/dev/null 2>&1 || {
echo "adb is not on PATH -- add \$HOME/Android/Sdk/platform-tools." >&2
exit 1
}
# Prints the adb serial of a running instance of AVD "$1", or nothing.
# Lifted from run-android.sh, which has always had to do this: `adb -e`
# works only when exactly one emulator is attached and cannot tell ours
# apart from somebody else's.
avd_serial() {
for s in $(adb devices | awk '$2 == "device" {print $1}'); do
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
echo "$s"
return 0
fi
done
}
SERIAL=$(avd_serial "$AVD_NAME")
if [ -z "$SERIAL" ]; then
# Three different situations, and saying the wrong one costs an
# afternoon: nothing running, something running that isn't this AVD,
# or adb itself not answering. The list is what tells them apart, so
# it is printed rather than described.
attached=$(adb devices | awk '$2 == "device" {print $1}')
if [ -z "$attached" ]; then
echo "No emulator is running. Start one with app/run-android.sh first." >&2
else
echo "No emulator named '$AVD_NAME' is running. These are attached:" >&2
for s in $attached; do
echo " $s ($(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r'))" >&2
done
echo "Pass --avd NAME to pick one, or start '$AVD_NAME' with app/run-android.sh." >&2
fi
exit 1
fi
echo "==> Using $SERIAL (AVD '$AVD_NAME')"
# Every adb call from here names the device: with a second emulator
# attached, a bare one exits 1 rather than picking.
adb() { command adb -s "$SERIAL" "$@"; }
# Generated once and kept, so this script can be run again after a
# reinstall without adding a second entry to the server's config every time.
if [ ! -f "$TOKEN_FILE" ]; then
mkdir -p "$STATE_DIR"
# Alphanumeric only: the token goes in a URI, and anything needing
# percent-encoding would have to survive two shells to get there.
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
echo "==> Generated an emulator token in $TOKEN_FILE"
fi
TOKEN=$(cat "$TOKEN_FILE")
HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)
# Only the hash is stored server-side, so this is what the config needs.
RESTART_NEEDED=no
if [ ! -f "$CONFIG" ]; then
echo "No config at $CONFIG -- start dev-updater once to create it." >&2
exit 1
elif grep -q "$HASH" "$CONFIG"; then
echo "==> The server already knows this token"
else
# Inserted rather than replacing the token list: a real phone's
# enrolment lives in the same list and must survive this.
tmp=$(mktemp)
awk -v hash="$HASH" '
{ print }
/^tokens: \[/ && !done {
print " ("
print " name: \"emulator\","
print " sha256: \"" hash "\","
print " ),"
done = 1
}
' "$CONFIG" > "$tmp"
cp "$tmp" "$CONFIG"
rm -f "$tmp"
chmod 600 "$CONFIG"
echo "==> Added an 'emulator' token to $CONFIG"
RESTART_NEEDED=yes
fi
if [ "$RESTART_NEEDED" = yes ]; then
# The server reads its config at startup, so a token added underneath a
# running one is not yet a token it will accept.
echo "==> Restart dev-updater now so it reads the new token, then re-run this."
echo " (the running server, if any, still has the old list in memory)"
exit 1
fi
# force-stop first: an already-running activity receives this through
# onNewIntent, and whether that path enrols is not something to depend on.
adb shell am force-stop "$PACKAGE" >/dev/null 2>&1 || true
before=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none)
# The backslashes are load-bearing -- see the note at the top.
adb shell am start -a android.intent.action.VIEW \
-d "devupdater://enroll?host=$HOST\&port=$PORT\&token=$TOKEN" >/dev/null 2>&1
# The app seals the token under a Keystore key before writing it, so the
# stored blob differs even for the same token. Changed is the signal; equal
# means the intent never landed.
attempt=0
while [ "$attempt" -lt 15 ]; do
sleep 1
after=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none)
if [ "$after" != "$before" ]; then
echo "==> Enrolled against $HOST:$PORT"
echo " If the app still says the server is unreachable, it is listening"
echo " on the wrong interface: restart it with --bind 0.0.0.0."
exit 0
fi
attempt=$((attempt + 1))
done
echo "The app's stored settings did not change, so the intent did not land." >&2
echo "Check that $PACKAGE is installed (app/run-android.sh) and try again." >&2
exit 1
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
+42
View File
@@ -0,0 +1,42 @@
[versions]
agp = "9.3.2"
kotlin = "2.4.10"
compose-multiplatform = "1.12.0"
# Compose Multiplatform's material3 is on its own release train, so it is
# pinned separately rather than following the version above. Checked
# 2026-08-28: the newest material3 is 1.12.0-alpha03, so 1.9.0 is still
# the current *stable* one while runtime/foundation/ui are stable at
# 1.12.0. Not a stale pin -- the two trains are simply this far apart.
compose-material3 = "1.9.0"
# The Gradle wrapper around ktfmt. Checked against the Gradle Plugin
# Portal 2026-08-28: 0.27.0 is the latest, published 2026-08-03.
ktfmt-gradle = "0.27.0"
androidx-activityCompose = "1.13.0"
# Checked against Maven Central 2026-08-25.
zxing-embedded = "4.3.0"
androidx-core-ktx = "1.17.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
# In-app QR scanner: a ready-made scanning Activity (camera preview, runtime
# permission prompt, flashlight toggle) reached through the AndroidX Activity
# Result API (ScanContract, added in 4.3.0). Fully offline -- no Play
# Services / ML Kit model download involved.
zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing-embedded" }
# Required by the shared link library, which uses the KTX `edit` block.
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
# Declared directly rather than through the `compose.*` accessors, which
# Compose Multiplatform 1.11 deprecates.
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
# For the shared link subproject: a subproject resolves plugin versions
# from the build including it rather than from its own.
androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+130
View File
@@ -0,0 +1,130 @@
#!/bin/sh
# Builds and runs this app on an emulator, creating/booting the AVD first if
# it isn't already up.
#
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
# which can also be sourced directly for one-off commands; see its header
# comment. The emulator handling below is inline rather than in its own
# shared script because this repo has exactly one app, so there's no second
# caller to share it with.
#
# Goes through classic avdmanager/emulator/adb rather than the newer
# `android` CLI's emulator subsystem: that one manages its own AVD pool and
# would start a *second* instance alongside whatever is already running.
set -eu
APP_ID="com.example.devupdater"
# One AVD per repository, named after it, which is how the other Android
# projects on this machine are set up: two sessions working in two repos
# otherwise fight over one emulator instance, and neither can tell that the
# app it just installed was replaced by the other one's.
# This app installs *other* projects' builds, so exercising it needs
# something to install -- but that something should be a throwaway app you
# control, added to the list like any other project, not another repo's real
# app sharing this emulator. AVD_NAME=... overrides the name if you do need
# a second one.
AVD_NAME="${AVD_NAME:-dev-updater}"
DEVICE_PROFILE="${DEVICE_PROFILE:-pixel_10}"
SYSTEM_IMAGE="${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
# shellcheck source=./android-env.sh
. ./android-env.sh
# Prints the adb serial of a running instance of AVD "$1", or nothing. Unlike
# `adb -e` (which only works when exactly one emulator is attached, and
# can't tell $AVD_NAME apart from some other AVD attached separately), this
# checks by name so it can't mistake someone else's emulator for ours.
avd_serial() {
for s in $(adb devices | awk '$2 == "device" {print $1}'); do
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
echo "$s"
return 0
fi
done
}
echo "==> Ensuring emulator system image is installed"
android sdk install emulator "$SYSTEM_IMAGE" || echo " (non-fatal: see above)"
if [ ! -f "$ANDROID_AVD_HOME/$AVD_NAME.ini" ]; then
echo "==> Creating AVD '$AVD_NAME' ($DEVICE_PROFILE, $SYSTEM_IMAGE)"
echo no | avdmanager create avd \
-n "$AVD_NAME" \
-k "$SYSTEM_IMAGE" \
--device "$DEVICE_PROFILE" \
--sdcard 512M
else
echo "==> Reusing existing AVD '$AVD_NAME'"
fi
# avdmanager defaults new AVDs to hw.keyboard=no, which disables forwarding
# the host keyboard into the emulator and leaves you dependent on the
# on-screen keyboard. Force it on so typing works -- this app has a
# free-text path field, so that matters more here than most.
CONFIG_INI="$ANDROID_AVD_HOME/$AVD_NAME.avd/config.ini"
if [ -f "$CONFIG_INI" ]; then
grep -v '^hw\.keyboard=' "$CONFIG_INI" >"$CONFIG_INI.tmp"
echo "hw.keyboard=yes" >>"$CONFIG_INI.tmp"
mv "$CONFIG_INI.tmp" "$CONFIG_INI"
fi
SERIAL=$(avd_serial "$AVD_NAME")
if [ -n "$SERIAL" ]; then
echo "==> Emulator '$AVD_NAME' already running ($SERIAL)"
else
# Clean up a stray/crashed process for this AVD, if any, so it doesn't
# end up with two instances fighting over the same AVD directory. The
# bracketed first character keeps the pattern from matching the shell
# running this script, which has the pattern text on its own command
# line -- unbracketed, this kills that shell mid-run.
pkill -f "[e]mulator.*-avd $AVD_NAME" >/dev/null 2>&1 || true
EMU_LOG="/tmp/$AVD_NAME-emulator.log"
: >"$EMU_LOG"
# Real GPU acceleration when a display is available; headless software
# rendering otherwise (e.g. a VM with no display attached).
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
echo "==> Starting emulator '$AVD_NAME' with GPU acceleration (-gpu host)"
emulator -avd "$AVD_NAME" -gpu host -no-audio >"$EMU_LOG" 2>&1 &
else
echo "==> No display available (DISPLAY/WAYLAND_DISPLAY unset) -- starting" \
"emulator '$AVD_NAME' headless with software rendering (-gpu swiftshader_indirect)"
emulator -avd "$AVD_NAME" -gpu swiftshader_indirect -no-audio -no-window \
>"$EMU_LOG" 2>&1 &
fi
EMU_PID=$!
i=0
booted=""
while [ "$i" -lt 150 ]; do
if ! kill -0 "$EMU_PID" 2>/dev/null; then
echo "Emulator process exited unexpectedly. Log output:" >&2
cat "$EMU_LOG" >&2
exit 1
fi
SERIAL=$(avd_serial "$AVD_NAME")
if [ -n "$SERIAL" ]; then
booted=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
[ "$booted" = "1" ] && break
fi
i=$((i + 1))
sleep 2
done
if [ "$booted" != "1" ]; then
echo "Emulator did not finish booting in time. Log output:" >&2
cat "$EMU_LOG" >&2
exit 1
fi
fi
echo "==> Building debug APK"
./gradlew :androidApp:assembleDebug
APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk"
echo "==> Installing and launching $APK"
adb -s "$SERIAL" install -r "$APK"
adb -s "$SERIAL" shell am start -n "$APP_ID/.MainActivity"
+25
View File
@@ -0,0 +1,25 @@
rootProject.name = "DevUpdater"
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
include(":androidApp")
// The shared link, as a subproject of this build rather than a published
// artifact, so it stays locked to whatever commit the submodule points at
// -- the same arrangement the Rust half uses with a path dependency.
include(":link")
project(":link").projectDir = file("../vendor/wg-app-link/app")