Give each component build modes and a settings sheet

A component now declares its ways of being built in one list -- `modes:
["release", "debug"]`, the first the default -- and every command it runs
is handed the mode as its last argument, so a project whose script takes
`release` or `debug` names that script once. A field may instead be
written per mode, which is the escape hatch for the commands that cannot
take the word: cargo takes `--release` or nothing, and its profile for
the unoptimised build is called `dev` while the directory it writes is
called `debug`, so no single word serves as both the flag and the path.
A command written per mode is not handed the word as well; it already is
the answer, and a stray argument to a service binary is a process that
will not start.

One list rather than gathering names from whichever fields happened to
mention them is what makes a mode missing from one part unsayable: every
per-mode map is checked against it, so a gap is named rather than
resolved to some other mode's command.

Which mode to build in and whether to strip are the build machine's --
there is one checkout and one set of outputs, so a per-device mode would
have two phones rebuilding over each other silently. Which of the
finished builds a phone installs stays that phone's. Neither choice is
part of the acceptance gate, so both have to be carried across the
components list being rewritten, by acceptance and by the self entry's
startup reconciliation alike; without the second a mode chosen for this
server's own component would not survive the restart that applies it.

A mode switch moves no commit, so `builtMode` is recorded beside
`builtFrom`. Without it a component built in debug and switched to
release reads as current and serves the debug build for ever -- and for
an APK nothing else notices, because the "never built at all" check finds
any variant under the component's directory.

Each component card gains a settings sheet behind a gear at the row's
right-hand end, holding the mode, which build to install, strip, and an
Enrol button. The variant picker moved into it: on the card the two read
as one choice, both saying debug and release, and they are not. The row's
text is now bounded and truncates, so a long status can no longer push
the log and settings buttons off the edge.

`enroll:` is a declared command whose one line of stdout is a URL for the
phone to open after installing -- generic on purpose, run per press since
such a link is one-shot and carries a credential, and in the acceptance
gate because it runs on the build machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 06:22:05 -04:00
1 parent 0b7164bb30
commit 17d873ab7c
16 files changed
+2225 -220

No files matched your search

@@ -1,5 +1,7 @@
package com.example.devupdater
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.SystemClock
@@ -219,6 +221,16 @@ private sealed class ComponentState {
/** [progress] is null when the response gave no length to measure against. */
data class Downloading(val progress: Float?) : ComponentState()
/**
* A short call to the build machine that is neither a build nor a download -- fetching an
* enrolment link is the one so far.
*
* Carries its own words because the card has no other way to say which call it is waiting on,
* and "working" over a bar that could be any of three things is what makes a screen feel like
* it is doing something at random.
*/
data class Busy(val what: String) : ComponentState()
/** Why the last thing this component was asked to do stopped. */
data class Error(val message: String) : ComponentState()
}
@@ -1013,6 +1025,61 @@ private fun AppListScreen(
}
}
/**
* The same as [manage], for a call that is about one component rather than the whole project.
*
* A failure belongs where the press happened: reported on the card, one component's settings
* write would blame the project and go quiet on the row somebody was actually looking at.
*/
fun manageComponent(entry: ManifestEntry, component: String, action: () -> Unit) {
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
setComponent(entry.key, component, null)
applyOne(entry.key)
} catch (e: DownloadServerException) {
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
}
}
}
/**
* Asks the build machine for this component's link and opens it here.
*
* Fetched on every press rather than carried on the manifest: what a project mints is
* ordinarily one-shot and carries a credential, so a link this app had been holding since the
* last refresh would be the wrong one.
*
* Nothing here inspects the URL. Which app answers it is Android's business, and a link the
* phone has nothing to open is reported as such rather than swallowed -- an action whose whole
* effect is elsewhere has to say when it did not happen.
*/
fun openEnrollmentLink(entry: ManifestEntry, component: String) {
scope.launch {
setComponent(entry.key, component, ComponentState.Busy("Getting the link"))
try {
val url = withContext(Dispatchers.IO) { enrollmentLink(entry.key, component) }
try {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
setComponent(entry.key, component, null)
} catch (e: ActivityNotFoundException) {
setComponent(
entry.key,
component,
ComponentState.Error(
"Nothing on this phone opens $url" + (e.message?.let { " ($it)" } ?: "")
),
)
}
} catch (e: DownloadServerException) {
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
}
}
}
/**
* Runs one of a server component's actions on the build machine.
*
@@ -1356,6 +1423,24 @@ private fun AppListScreen(
(component to variant.path)
})
},
// The build machine's, not this
// device's: a mode decides what
// gets built there, so it is a
// round trip and a refetch rather
// than a preference written here.
onComponentSettings = { component, mode, strip ->
manageComponent(entry, component) {
setComponentSettings(
entry.key,
component,
mode,
strip,
)
}
},
onEnroll = { component ->
openEnrollmentLink(entry, component)
},
serviceBusy = serviceBusy[entry.key],
onServiceAction = { component, action, purge ->
runServiceAction(entry, component, action, purge)
@@ -1471,6 +1556,10 @@ private fun AppCard(
onApprove: () -> Unit,
onRemove: () -> Unit,
onSelectVariant: (component: String, ApkVariant?) -> Unit,
/** This machine's settings for one component: which mode it builds in, and whether to strip. */
onComponentSettings: (component: String, mode: String?, strip: Boolean?) -> Unit,
/** Fetch this component's link from the build machine and open it here. */
onEnroll: (component: String) -> Unit,
// Which component this card is running a service action for, if any --
// so the one being acted on is the one that shows it, rather than
// every row going quiet together.
@@ -1692,6 +1781,10 @@ private fun AppCard(
},
chosenVariantPath = chosenVariantPath,
onSelectVariant = { onSelectVariant(component.name, it) },
onSettings = { mode, strip ->
onComponentSettings(component.name, mode, strip)
},
onEnroll = { onEnroll(component.name) },
// This app reaches the server through this server.
// Stopping or uninstalling it is the one action
// here that cannot be undone from the phone.
@@ -2020,6 +2113,12 @@ private fun ApkProgress(state: ComponentState?) {
Text("Preparing the download...")
}
is ComponentState.Busy -> {
ProgressBar()
Spacer(Modifier.height(4.dp))
Text("${state.what}...")
}
is ComponentState.Downloading -> {
val progress = state.progress
if (progress == null) {
@@ -2356,6 +2455,10 @@ private fun ComponentCard(
/** Which of this component's builds this device is pinned to, if any. */
chosenVariantPath: String? = null,
onSelectVariant: (ApkVariant?) -> Unit = {},
/** Save this machine's settings for this component. */
onSettings: (mode: String?, strip: Boolean?) -> Unit = { _, _ -> },
/** Ask the build machine for this component's link and open it. */
onEnroll: () -> Unit = {},
isOwnServer: Boolean,
/** This component's part of a build in progress, if it has one. */
build: ComponentBuild?,
@@ -2387,6 +2490,7 @@ private fun ComponentCard(
// a second thing to remember to clear.
var confirming by remember { mutableStateOf<String?>(null) }
var showingLog by remember { mutableStateOf(false) }
var showingSettings by remember { mutableStateOf(false) }
// What Uninstall has been asked to take away as well. Logs start
// ticked and the other two do not: the dialog's defaults, deliberately
// different from `Purge()`'s, which is what a caller with no dialog
@@ -2407,116 +2511,149 @@ private fun ComponentCard(
// same size as a line of this text anyway, so the row comes out
// the same height without being told.
Row(verticalAlignment = Alignment.CenterVertically) {
// Both kinds get the same square, so a server's glyph and
// an app's icon are the same size as each other -- one
// drawn smaller than the other reads as the row meaning
// less, rather than as a different kind of thing.
Box(
Modifier.size(COMPONENT_ICON_SIZE),
contentAlignment = Alignment.Center,
// Everything the row *says* lives inside one weighted
// child, so it can only ever have the space the controls
// on the right are not using. Without it a long name or a
// long status pushed the log and settings buttons off the
// edge -- and a control that leaves because the text grew
// is one the reader cannot get back to.
Row(
Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
if (component.isServer) {
Text(
SERVER_GLYPH,
fontFamily = NerdIcons,
// Larger than the square in font terms: a glyph
// is drawn well inside its line box, so matching
// the numbers would draw it noticeably smaller
// than the icon beside it.
fontSize = COMPONENT_GLYPH_SIZE,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Which means its line box is taller than the
// square, and text clips to the height it is
// given. Measured unbounded and drawn centred
// instead: the row keeps the icon's height and
// the glyph keeps all of itself.
modifier = Modifier.wrapContentSize(unbounded = true),
)
} else {
// The app's own icon, the same one the project card
// shows: this row is about the thing that gets
// installed, and that is what it looks like.
AppIcon(
packageName,
size = COMPONENT_ICON_SIZE,
glyphSize = COMPONENT_GLYPH_SIZE,
)
// Both kinds get the same square, so a server's glyph and
// an app's icon are the same size as each other -- one
// drawn smaller than the other reads as the row meaning
// less, rather than as a different kind of thing.
Box(
Modifier.size(COMPONENT_ICON_SIZE),
contentAlignment = Alignment.Center,
) {
if (component.isServer) {
Text(
SERVER_GLYPH,
fontFamily = NerdIcons,
// Larger than the square in font terms: a glyph
// is drawn well inside its line box, so matching
// the numbers would draw it noticeably smaller
// than the icon beside it.
fontSize = COMPONENT_GLYPH_SIZE,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Which means its line box is taller than the
// square, and text clips to the height it is
// given. Measured unbounded and drawn centred
// instead: the row keeps the icon's height and
// the glyph keeps all of itself.
modifier = Modifier.wrapContentSize(unbounded = true),
)
} else {
// The app's own icon, the same one the project card
// shows: this row is about the thing that gets
// installed, and that is what it looks like.
AppIcon(
packageName,
size = COMPONENT_ICON_SIZE,
glyphSize = COMPONENT_GLYPH_SIZE,
)
}
}
}
Spacer(Modifier.width(6.dp))
Text(
component.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Nothing at all until its script has been asked -- an
// unknown state is not a state, and a dot introducing
// nothing is worse than no dot.
// Said in words, not by colour alone: "behind the
// checkout" is a difference in kind from "running", and a
// reader has no way to learn a colour that means it.
val behind = component.isBehind
val status =
when {
!component.isServer -> null
component.error != null -> "couldn't check"
component.state == null -> null
component.isRunning -> "running"
// Not folded into "stopped": stopped is a state
// somebody chose, and calling a crash that sends
// the reader looking for who chose it.
component.isFailed -> "failed"
component.isInstalled -> "stopped"
// Nothing for a service that isn't installed: the row
// offers Install and nothing else, which says it more
// plainly than a state would, and saying both makes the
// absence of a thing look like a condition it is in.
else -> null
}
status?.let {
Separator()
Spacer(Modifier.width(6.dp))
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color =
when {
component.isRunning -> runningColor
component.isFailed -> failedColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
if (behind) {
Separator()
Text(
"out of date",
style = MaterialTheme.typography.bodyMedium,
// The colour Pull & Build wears, because that is
// the button this is telling you to press.
color = ActionTone.Primary.color,
)
}
sizeText?.let {
Separator()
Text(
it,
component.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// One line and an ellipsis, on this and on every
// reading beside it: a row that wraps grows the
// card, and one that overflows silently loses its
// right-hand end without saying it was cut.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Nothing at all until its script has been asked -- an
// unknown state is not a state, and a dot introducing
// nothing is worse than no dot.
// Said in words, not by colour alone: "behind the
// checkout" is a difference in kind from "running", and a
// reader has no way to learn a colour that means it.
val behind = component.isBehind
val status =
when {
!component.isServer -> null
component.error != null -> "couldn't check"
component.state == null -> null
component.isRunning -> "running"
// Not folded into "stopped": stopped is a state
// somebody chose, and calling a crash that sends
// the reader looking for who chose it.
component.isFailed -> "failed"
component.isInstalled -> "stopped"
// Nothing for a service that isn't installed: the row
// offers Install and nothing else, which says it more
// plainly than a state would, and saying both makes the
// absence of a thing look like a condition it is in.
else -> null
}
status?.let {
Separator()
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color =
when {
component.isRunning -> runningColor
component.isFailed -> failedColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (behind) {
Separator()
Text(
"out of date",
style = MaterialTheme.typography.bodyMedium,
// The colour Pull & Build wears, because that is
// the button this is telling you to press.
color = ActionTone.Primary.color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
sizeText?.let {
Separator()
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Outside the weighted text, so the spinner is never the
// thing that gets truncated: it is the one mark on the row
// that says something is happening right now.
if (working || busy || component.checking) {
Spacer(Modifier.width(6.dp))
Working()
}
// These belong to the component, not to whatever the row
// happens to say about it, so they hold the same corner
// whatever the text does. Unconditional, so the settings
// button sits in the same place whether or not there is a
// log beside it -- the log's own absence must not move it.
if (component.hasLogs) {
// Pushed to the far edge rather than following the
// text: it belongs to the component, not to whatever
// the row happens to say about it, and a control that
// slides about as the state changes is harder to find
// than one always in the same corner.
Spacer(Modifier.weight(1f))
IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true }
}
// Always drawn, including for a component with a single
// build mode and nothing to strip: what a component can be
// told is part of what it is, and a control that comes and
// goes makes its own presence the signal. What it opens
// says which of its settings this component has.
IconGlyphButton(SETTINGS_GLYPH, "${component.name} settings") {
showingSettings = true
}
}
// The service script's own words about why it could not answer,
@@ -2663,27 +2800,35 @@ private fun ComponentCard(
}
}
// Only worth a row when there is actually a choice, which is
// rare -- the usual case is a single debug build, and an empty
// row here was leaving a band of space at the foot of every
// card for a control almost none of them have.
//
// Beside the build it picks, which is what makes it answerable
// for a project with two clients: the choice is this
// component's, and a picker at the foot of the card could only
// have been the project's.
val variants = component.apk?.variants.orEmpty()
if (variants.size > 1) {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth(),
) {
VariantPicker(variants, chosenVariantPath, onSelectVariant)
}
}
// The variant picker used to sit here. It moved into the
// settings sheet, where the build mode is: on the card the two
// read as the same choice -- both say "debug" and "release" --
// and they are not. One decides what the build machine
// *builds*; the other decides which of the finished builds
// this phone installs.
}
}
if (showingSettings) {
ComponentSettingsDialog(
component = component,
chosenVariantPath = chosenVariantPath,
// A mode changes what the build machine runs, and the server
// replaces the state a running build reports through. So the
// picker goes quiet exactly while this component cannot be
// acted on anyway -- the same pair of conditions that disables
// its Update button and withholds its freshness.
modeSettled = !(busy || working || projectState.busy),
onSelectVariant = onSelectVariant,
onEnroll = onEnroll,
onApply = { mode, strip ->
showingSettings = false
onSettings(mode, strip)
},
onDismiss = { showingSettings = false },
)
}
if (showingLog) {
ComponentLogDialog(
entryKey = entryKey,
@@ -2764,6 +2909,194 @@ private fun ComponentCard(
}
}
/**
* Everything one component can be told, in one sheet: how the build machine builds it, which of the
* finished builds this phone installs, whether it is stripped on the way, and the link a project
* hands over after an install.
*
* The two "debug or release" choices in here are deliberately separated and labelled, because they
* are not the same question and they used to sit on the card looking as though they were. **Build
* mode** is the build machine's: there is one checkout and one set of outputs there, so choosing it
* changes what *every* enrolled phone is offered, which is why the sheet says so rather than
* leaving it to be discovered. **Install** is this device's alone, and picks among builds that
* already exist.
*
* Applied on Save rather than as each control moves, matching the project's own settings sheet: a
* settings write is a round trip that rebuilds the entry on the server, and one per control touched
* while making up your mind is a lot of them. Enrol is the exception and is not a setting -- it is
* an action, it happens on the press, and it is drawn apart from the rest for that reason.
*
* A component with nothing to choose still gets its sections, saying so. Removing them would make
* the sheet's shape the signal, and "this project declares one way of building" and "we could not
* tell" would then look identical -- which is the whole reason the empty cases are written out here
* rather than skipped.
*/
@Composable
private fun ComponentSettingsDialog(
component: ProjectComponent,
chosenVariantPath: String?,
/** False while a build is in flight, when the mode must not be changed underneath it. */
modeSettled: Boolean,
onSelectVariant: (ApkVariant?) -> Unit,
onEnroll: () -> Unit,
onApply: (mode: String?, strip: Boolean?) -> Unit,
onDismiss: () -> Unit,
) {
// Keyed on what the server last said, so reopening after a save shows
// the saved values rather than stale local ones.
var mode by remember(component.mode) { mutableStateOf(component.mode) }
var strip by remember(component.apk?.strip) { mutableStateOf(component.apk?.strip ?: false) }
// The variant is this device's, so it is applied locally on Save
// rather than sent anywhere; held here so Cancel really cancels.
var variantPath by remember(chosenVariantPath) { mutableStateOf(chosenVariantPath) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(component.name) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
SettingsHeading("Build mode")
when {
component.modes.isEmpty() ->
SettingsNote(
"This project declares one way of building ${component.name}, so there " +
"is nothing to choose."
)
else -> {
SettingsNote(
"Chosen on the build machine, so it is what every phone here is " +
"offered -- not just this one."
)
// A dropdown rather than a row of radios, matching
// the build picker below it: they are the same
// shape of question -- one of a short list -- and
// two different controls for that in one sheet
// makes them look like different kinds of choice.
ModePicker(component.modes, mode, modeSettled) { mode = it }
if (!modeSettled) {
SettingsNote(
"Not while this component is busy: changing it now would leave " +
"the build that is running building the other one."
)
}
}
}
// Only an APK has anything below this. A server is not
// installed here, so there is no build for this phone to
// pick and nothing to strip on the way to it.
component.apk?.let { apk ->
Spacer(Modifier.height(16.dp))
SettingsHeading("Install")
when {
apk.variants.isEmpty() ->
SettingsNote("Nothing is built yet, so there is nothing to install.")
apk.variants.size == 1 ->
SettingsNote(
"One build so far (${apk.variants.first().variant}), so there is " +
"nothing to choose between."
)
else -> {
SettingsNote(
"Which of the builds on the machine this phone installs. Only " +
"this phone's."
)
VariantPicker(apk.variants, variantPath) { variantPath = it?.path }
}
}
Spacer(Modifier.height(16.dp))
SettingsHeading("Transfer")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Strip debug symbols",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
Switch(checked = strip, onCheckedChange = { strip = it })
}
// Said only when the two disagree. A machine that
// follows the checkout has nothing to report, and a
// line under every switch saying "as declared" is one
// more thing to read on every card.
if (strip != apk.stripDeclared) {
SettingsNote(
if (apk.stripDeclared) {
"The project asks for stripping; this machine is set not to."
} else {
"The project does not ask for stripping; this machine is set to."
}
)
}
}
if (component.hasEnrollLink) {
Spacer(Modifier.height(16.dp))
SettingsHeading("Enrolment")
SettingsNote(
"Asks the build machine for a fresh link and opens it here. Do this after " +
"installing, or after reinstalling over a different signing key -- " +
"either loses whatever enrolment the app had."
)
// Outside the Save/Cancel bargain the rest of the
// sheet makes, because it is not a setting: pressing
// it does the thing, now, and there is nothing about
// it left to save.
TextButton(
onClick = {
onDismiss()
onEnroll()
}
) {
Text("Enrol this app")
}
}
}
},
confirmButton = {
TextButton(
onClick = {
// The variant is this device's, so it is written here
// and not sent; the other two are the build machine's.
if (variantPath != chosenVariantPath) {
onSelectVariant(
component.apk?.variants?.firstOrNull { it.path == variantPath }
)
}
onApply(mode, strip)
}
) {
Text("Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun SettingsHeading(text: String) {
Text(text, style = MaterialTheme.typography.titleSmall)
}
/**
* A line of explanation under a control, or in place of one that has nothing to offer.
*
* Its own composable so that "there is nothing to choose here" looks the same wherever it is said
* -- four sections each phrasing their empty case differently is how a reader learns to skip them.
*/
@Composable
private fun SettingsNote(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* The three things Uninstall can take away besides the service.
*
@@ -2903,6 +3236,35 @@ private fun PurgeToggle(
}
}
@Composable
private fun ModePicker(
modes: List<String>,
chosen: String?,
enabled: Boolean,
onSelect: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { expanded = true }, enabled = enabled) {
// Never "none": unlike the build picker there is always an
// answer here, because a component with modes is always being
// built in one of them -- the first, when nobody has chosen.
Text(chosen ?: modes.firstOrNull().orEmpty())
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
modes.forEach { candidate ->
DropdownMenuItem(
text = { Text(if (candidate == chosen) "$candidate" else candidate) },
onClick = {
expanded = false
onSelect(candidate)
},
)
}
}
}
}
@Composable
private fun VariantPicker(
variants: List<ApkVariant>,