Each component builds on its own, and stops reporting when it is done

A project's components were decoupled everywhere except the one place it
showed: there was a single build slot per project, and a single card state
in the app keyed by project alone. So pressing Update on one client of a
two-client project disabled the other client's button for the length of a
build it shares nothing with, drew this one's progress bar and download
percentage under the other's row, and -- had the button been pressable --
would have been a silent no-op on the server, since a second request while
one was running returned without starting anything.

The slot is now per component. `Inner` has no `building` flag; a
component's own `ComponentRun` with an open `step` is the answer, and
`claim` writes that entry synchronously under the lock the route answers
from, so nothing can read a just-claimed component as idle -- which the
phone would take for a build that had already finished. A failure is
recorded against the component whose command it was rather than in the
project's one error slot, which two components building at once cannot
share.

A pull stays exclusive with everything, because there is one checkout and
it rewrites the files every component builds from. Releasing it and
claiming what it decided to build happen under one lock: a phone polling in
the gap would find a project neither pulling nor building and call the run
over.

The app mirrors the split -- `ProjectState` for the pull and the
project-wide Rebuild, `ComponentState` keyed by component for everything
one component is asked to do. Two hierarchies rather than one keyed by a
pair, so a download has nowhere project-wide to be stored. A component's
failure is drawn in its own row beside the Retry that acts on it, which is
also where a failed service action now reports.

And a finished component shows nothing at all: the elapsed times are gone
from both halves of the wire, and its button simply goes back to being
pressable. A bar, a count and a last line all describe something happening
now, and left up they sit there looking live next to a sibling that
genuinely is.

Verified on the emulator against test-projects/two-clients, which exists
for this: while `tablet` built, its row alone carried the bar and its
button alone was disabled, `phone` stayed pressable and silent, and both
returned to normal with no timing left behind.
This commit is contained in:
iris committed 2026-09-01 03:09:33 -04:00
1 parent 4641b9ec9b
commit 90082bd286
6 files changed
+862 -318

No files matched your search

@@ -16,7 +16,17 @@ import org.json.JSONObject
// (needsBuild / canPull).
data class BuildStatus(
val stale: Boolean,
// Anything at all is happening for this project -- a pull, or any one
// of its components being built. The *project's* question, for the
// controls that act on the whole checkout. Anything about one
// component asks [component] instead and reads its `running`: this one
// says yes while a sibling builds, and waiting on it is what used to
// put one client's download behind the other client's build.
val building: Boolean,
// Why the last pull could not be made. Pulls only -- a build failure
// belongs to the component whose command it was, and is in
// [ComponentBuild.error], because components build at once and two of
// them can fail differently.
val error: String?,
// The failure above was a pull with no fast-forward to make, because
// the checkout on the build machine shares no commit with its
@@ -25,11 +35,10 @@ data class BuildStatus(
// wording is translated, so a button that matched on it would appear
// only on an English build machine.
val unrelatedHistories: Boolean,
// 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.
// What the whole *project* is doing ("fetching", "pulling"). 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. */
@@ -48,7 +57,6 @@ data class ComponentBuild(
// 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?,
@@ -76,7 +84,6 @@ private fun requestBuildStatus(path: String, method: String): BuildStatus =
error = if (json.isNull("error")) null else json.getString("error"),
unrelatedHistories = json.optBoolean("unrelatedHistories", false),
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)
@@ -86,7 +93,6 @@ private fun requestBuildStatus(path: String, method: String): BuildStatus =
step =
if (component.isNull("step")) null
else component.optString("step").ifEmpty { null },
elapsedMs = component.optLong("elapsedMs", 0),
progress =
component.optJSONObject("progress")?.let {
BuildProgressCount(
@@ -153,13 +153,26 @@ private sealed class ManifestState {
data class Error(val message: String) : ManifestState()
}
private sealed class CardState {
/**
* What a whole *project* is being made to do, as opposed to one of its components.
*
* Only the things that act on the checkout every component is built from, which is why they are the
* project's: a pull rewrites it, and Rebuild runs every component's command. Anything that belongs
* to one component is a [ComponentState] instead — held in a map keyed by component name, so
* starting one component's work cannot disable, describe or fail another's.
*
* Two hierarchies rather than one keyed by a pair, because they are not the same set of states: a
* download has no project-wide meaning and a pull has no component-wide one, and keeping them apart
* is what stops a component's progress being stored where every component would read it. That was
* the bug — one map keyed by project alone, so pressing Update on one client of a two-client
* project disabled the other's button and drew this one's download bar under it.
*/
private sealed class ProjectState {
/**
* [status] is the server's live progress, refreshed on every poll, so the card can say which
* step is running and how long it has taken rather than showing an unchanging spinner for the
* length of a build.
* step is running rather than showing an unchanging spinner for the length of a build.
*/
data class Pulling(val status: BuildStatus?) : CardState()
data class Pulling(val status: BuildStatus?) : ProjectState()
/**
* A build somebody asked for outright, as opposed to one a pull or a download brought about.
@@ -167,9 +180,23 @@ private sealed class CardState {
* nothing was pulled here, and a bar that says otherwise is the kind of small lie that makes a
* reader stop trusting the rest.
*/
data class Rebuilding(val status: BuildStatus?) : CardState()
data class Rebuilding(val status: BuildStatus?) : ProjectState()
data class Preparing(val status: BuildStatus?) : CardState()
/**
* Why the last thing asked of the whole project stopped.
*
* There is no "retry" recorded with it, because the control that would redo it is the one
* sitting beside the message: a project's actions are Pull and Rebuild, both in the card's own
* row, and both re-enabled by a failure. A component's failure is a [ComponentState.Error] and
* gets Retry on its own row, which is what stops a failed pull being retried as a download.
*/
data class Error(val message: String) : ProjectState()
}
/** What one component of a project is being made to do. */
private sealed class ComponentState {
/** The build machine is producing this component's build, before it can be downloaded. */
data class Preparing(val status: BuildStatus?) : ComponentState()
/**
* The request is out and the server hasn't started sending. For an app served as a stripped
@@ -179,19 +206,30 @@ private sealed class CardState {
* Named for the transport rather than "preparing", which is taken by the configured build step
* above; what a person reads is "preparing" either way.
*/
data object Fetching : CardState()
data object Fetching : ComponentState()
/** [progress] is null when the response gave no length to measure against. */
data class Downloading(val progress: Float?) : CardState()
data class Downloading(val progress: Float?) : ComponentState()
/**
* [retryPull] records which action failed, because Retry has to redo that one: a failed pull
* retried as a download would silently do something else than the button that produced the
* error.
*/
data class Error(val message: String, val retryPull: Boolean = false) : CardState()
/** Why the last thing this component was asked to do stopped. */
data class Error(val message: String) : ComponentState()
}
/**
* Whether this is work in progress, as opposed to the record of work that has stopped.
*
* The distinction every "should this control be disabled" question wants, and the reason a failure
* is a state here rather than a field on one: an error is something to read, not something to wait
* for, so a card holding one is idle. Held as a property of the state rather than tested at each
* site, because there are five of those and a missed one leaves a button dead until the screen is
* reloaded.
*/
private val ProjectState?.busy: Boolean
get() = this is ProjectState.Pulling || this is ProjectState.Rebuilding
private val ComponentState?.busy: Boolean
get() = this != null && this !is ComponentState.Error
/** How far the list is dimmed behind a modal. One value, since two of them dim it. */
private const val SCRIM_ALPHA = 0.6f
@@ -417,7 +455,15 @@ private fun AppListScreen(
val scope = rememberCoroutineScope()
var manifestState by remember { mutableStateOf<ManifestState>(ManifestState.Loading) }
var cardStates by remember { mutableStateOf<Map<String, CardState>>(emptyMap()) }
// What each project is doing, and separately what each of its
// components is: two levels, like installedTimes below and for the
// same reason. A project can build two clients, and one of them being
// updated says nothing about the other -- keyed by project alone, it
// said it about both.
var projectStates by remember { mutableStateOf<Map<String, ProjectState>>(emptyMap()) }
var componentStates by remember {
mutableStateOf<Map<String, Map<String, ComponentState>>>(emptyMap())
}
// Written only by updateInstalledState below, called from either of the
// two effects that follow -- so a fresh manifest, a package-change
// broadcast, and a return from the system installer all go through the
@@ -443,6 +489,26 @@ private fun AppListScreen(
// that is busy is the one that shows it.
var serviceBusy by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
/**
* Puts one component's state, or takes it away for null.
*
* The one place the two-level map is written, so a write for one component cannot drop the
* entry another one is keeping.
*/
fun setComponent(key: String, component: String, state: ComponentState?) {
val forProject = componentStates[key] ?: emptyMap()
val updated =
when (state) {
null -> forProject - component
else -> forProject + (component to state)
}
componentStates =
when {
updated.isEmpty() -> componentStates - key
else -> componentStates + (key to updated)
}
}
/**
* Replaces the list without showing it as loading, and keeps looking while the server says a
* remote check is still running.
@@ -486,7 +552,8 @@ private fun AppListScreen(
fun refreshByPull() {
if (pulling) return
pulling = true
cardStates = emptyMap()
projectStates = emptyMap()
componentStates = emptyMap()
scope.launch {
manifestState =
try {
@@ -500,7 +567,8 @@ private fun AppListScreen(
fun refresh() {
manifestState = ManifestState.Loading
cardStates = emptyMap()
projectStates = emptyMap()
componentStates = emptyMap()
scope.launch {
manifestState =
try {
@@ -596,7 +664,8 @@ private fun AppListScreen(
try {
awaitCheck(entry.key)
} catch (e: DownloadServerException) {
cardStates = cardStates + (entry.key to CardState.Error(e.message ?: "Failed"))
projectStates =
projectStates + (entry.key to ProjectState.Error(e.message ?: "Failed"))
}
}
}
@@ -620,27 +689,40 @@ private fun AppListScreen(
*/
suspend fun followBuild(
entry: ManifestEntry,
retryPull: Boolean,
start: suspend () -> BuildStatus,
progress: (BuildStatus?) -> CardState,
progress: (BuildStatus?) -> ProjectState,
) {
cardStates = cardStates + (entry.key to progress(null))
projectStates = projectStates + (entry.key to progress(null))
try {
var status = withContext(Dispatchers.IO) { start() }
while (status.building) {
cardStates = cardStates + (entry.key to progress(status))
projectStates = projectStates + (entry.key to progress(status))
delay(BUILD_POLL_INTERVAL_MS)
status = withContext(Dispatchers.IO) { buildStatus(entry.key) }
}
// A failure is reported where it happened. The project's own
// error is the pull's -- there is one checkout and one thing
// that could have gone wrong with it -- while a build that
// fell over belongs to the component whose command it was,
// since a run builds every component at once and two of them
// can fail differently.
val failure = status.error
if (failure != null) {
cardStates = cardStates + (entry.key to CardState.Error(failure, retryPull))
projectStates = projectStates + (entry.key to ProjectState.Error(failure))
// The card keeps the message either way -- the dialog is
// dismissible, and a failure that vanished with it would
// leave the card looking as though nothing had happened.
if (status.unrelatedHistories) forcePull = entry
return
}
// Kept after the project's own state is cleared below, so a
// component that failed still says so once the run it was
// part of is over.
for (component in status.components) {
component.error?.let {
setComponent(entry.key, component.name, ComponentState.Error(it))
}
}
// The APK's mtime is what decides "update available", so the
// list has to come from the server again rather than be
// guessed at here.
@@ -659,7 +741,11 @@ private fun AppListScreen(
repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt ->
try {
applyOne(entry.key)
cardStates = cardStates - entry.key
// The project's own state only. A component that
// failed inside this run has just been given its own,
// and clearing that here would take away the only
// thing saying so.
projectStates = projectStates - entry.key
// Building this app's own project is what produces the
// newer copy of it, and restarts the server it has to
// keep talking to. So this is the moment to offer it,
@@ -670,7 +756,7 @@ private fun AppListScreen(
return
} catch (e: DownloadServerException) {
if (attempt == REFRESH_ATTEMPTS_AFTER_PULL - 1) {
cardStates = cardStates - entry.key
projectStates = projectStates - entry.key
manifestState = ManifestState.Error(e.message ?: "Unknown error")
} else {
delay(RESTART_WAIT_MS)
@@ -678,8 +764,7 @@ private fun AppListScreen(
}
}
} catch (e: DownloadServerException) {
cardStates =
cardStates + (entry.key to CardState.Error(e.message ?: "Failed", retryPull))
projectStates = projectStates + (entry.key to ProjectState.Error(e.message ?: "Failed"))
}
}
@@ -693,9 +778,8 @@ private fun AppListScreen(
scope.launch {
followBuild(
entry,
retryPull = true,
start = { pullAndBuild(entry.key, force) },
progress = { CardState.Pulling(it) },
progress = { ProjectState.Pulling(it) },
)
}
}
@@ -708,55 +792,82 @@ private fun AppListScreen(
scope.launch {
followBuild(
entry,
retryPull = false,
start = { buildNow(entry.key) },
progress = { CardState.Rebuilding(it) },
progress = { ProjectState.Rebuilding(it) },
)
}
}
/**
* Builds one component if it needs it, downloads it, and hands it to the installer.
*
* Every step of it is recorded against that component and nothing else. This is the action the
* whole two-level map exists for: a project can build two independent clients, and updating one
* of them must leave the other's button pressable and its row silent — with one slot per
* project, this one's bar was drawn under both and both buttons went dead.
*/
fun startUpdate(entry: ManifestEntry, component: String) {
scope.launch {
if (entry.needsBuild) {
cardStates = cardStates + (entry.key to CardState.Preparing(null))
setComponent(entry.key, component, ComponentState.Preparing(null))
try {
var status = withContext(Dispatchers.IO) { prepareBuild(entry.key, component) }
while (status.building) {
cardStates = cardStates + (entry.key to CardState.Preparing(status))
// This component's own step, not the project's
// `building`: a sibling being built at the same time
// says yes to that one, and waiting on it would put
// this download behind a build it has nothing to do
// with -- which is the coupling this is here to end.
var started = false
while (status.component(component)?.running == true) {
started = true
setComponent(entry.key, component, ComponentState.Preparing(status))
delay(BUILD_POLL_INTERVAL_MS)
status = withContext(Dispatchers.IO) { buildStatus(entry.key) }
}
val buildError = status.error
// Only from a build this press actually started. The
// server keeps a component's last outcome until it is
// built again, so an older failure is still sitting
// there -- and reading that one would refuse a
// download because of something already dealt with.
val buildError = status.component(component)?.error?.takeIf { started }
if (buildError != null) {
cardStates = cardStates + (entry.key to CardState.Error(buildError))
setComponent(entry.key, component, ComponentState.Error(buildError))
return@launch
}
} catch (e: DownloadServerException) {
cardStates =
cardStates +
(entry.key to
CardState.Error(e.message ?: "Couldn't prepare phone build"))
setComponent(
entry.key,
component,
ComponentState.Error(e.message ?: "Couldn't prepare phone build"),
)
return@launch
}
}
// Not "downloading" until something is actually coming down:
// the server may still be producing what it is about to send.
cardStates = cardStates + (entry.key to CardState.Fetching)
setComponent(entry.key, component, ComponentState.Fetching)
val file =
try {
withContext(Dispatchers.IO) {
downloadApk(context, entry, component) { read, total ->
val progress = if (total > 0) read.toFloat() / total else null
cardStates = cardStates + (entry.key to CardState.Downloading(progress))
setComponent(
entry.key,
component,
ComponentState.Downloading(progress),
)
}
}
} catch (e: DownloadServerException) {
cardStates =
cardStates + (entry.key to CardState.Error(e.message ?: "Download failed"))
setComponent(
entry.key,
component,
ComponentState.Error(e.message ?: "Download failed"),
)
return@launch
}
cardStates = cardStates - entry.key
setComponent(entry.key, component, null)
install(file)
}
}
@@ -770,10 +881,11 @@ private fun AppListScreen(
withContext(Dispatchers.IO) { action() }
// This card's state only: another card's error is its own
// and has nothing to do with what just happened here.
cardStates = cardStates - entry.key
projectStates = projectStates - entry.key
if (removes) dropEntry(entry.key) else applyOne(entry.key)
} catch (e: DownloadServerException) {
cardStates = cardStates + (entry.key to CardState.Error(e.message ?: "Failed"))
projectStates =
projectStates + (entry.key to ProjectState.Error(e.message ?: "Failed"))
}
}
}
@@ -798,20 +910,23 @@ private fun AppListScreen(
withContext(Dispatchers.IO) {
serviceAction(entry.key, component, action, purge)
}
// Reported on the card the button was pressed on, because
// Reported in the row the button was pressed in, because
// the request succeeded -- the service is gone -- and what
// is left to say is that something is still on the build
// machine, which nobody can see from here otherwise.
cardStates =
if (result.leftBehind.isEmpty()) {
cardStates - entry.key
} else {
cardStates +
(entry.key to CardState.Error(result.leftBehind.joinToString("\n")))
}
// machine, which nobody can see from here otherwise. On
// the component rather than the card: a project can run
// more than one service, and this is about one of them.
setComponent(
entry.key,
component,
when {
result.leftBehind.isEmpty() -> null
else -> ComponentState.Error(result.leftBehind.joinToString("\n"))
},
)
applyOne(entry.key)
} catch (e: DownloadServerException) {
cardStates = cardStates + (entry.key to CardState.Error(e.message ?: "Failed"))
setComponent(entry.key, component, ComponentState.Error(e.message ?: "Failed"))
} finally {
serviceBusy = serviceBusy - entry.key
}
@@ -1020,7 +1135,10 @@ private fun AppListScreen(
// is not current yet.
val (needAttention, upToDate) =
entries.partition { entry ->
cardStates[entry.key].isBuilding() ||
isBuilding(
projectStates[entry.key],
componentStates[entry.key],
) ||
!entry.built ||
entry.newCommits ||
// Any client of the project being
@@ -1054,7 +1172,8 @@ private fun AppListScreen(
installedTimes = installedTimes[entry.key] ?: emptyMap(),
chosenVariants = chosenVariants[entry.key] ?: emptyMap(),
installedSizes = installedSizes[entry.key] ?: emptyMap(),
cardState = cardStates[entry.key],
projectState = projectStates[entry.key],
componentStates = componentStates[entry.key] ?: emptyMap(),
onUpdate = { updated, component ->
startUpdate(updated, component)
},
@@ -1195,7 +1314,10 @@ private fun AppCard(
installedTimes: Map<String, Long?>,
installedSizes: Map<String, Long?>,
chosenVariants: Map<String, String>,
cardState: CardState?,
/** What the whole project is doing: pulling, rebuilding, or why one of those failed. */
projectState: ProjectState?,
/** What each of its components is doing, by component name. */
componentStates: Map<String, ComponentState>,
onUpdate: (ManifestEntry, component: String) -> Unit,
onPull: () -> Unit,
onRebuild: () -> Unit,
@@ -1377,6 +1499,9 @@ private fun AppCard(
val installed = installedTimes[component.name]
val installedSize = installedSizes[component.name]
val chosenVariantPath = chosenVariants[component.name]
// This component's own, so nothing below can
// reach for a sibling's by accident.
val componentState = componentStates[component.name]
val upToDate =
component.apk?.let {
isUpToDate(it, installed, chosenVariantPath)
@@ -1406,14 +1531,21 @@ private fun AppCard(
// Stopping or uninstalling it is the one action
// here that cannot be undone from the phone.
isOwnServer = entry.builtIn,
build = componentBuild(cardState, component.name),
build =
componentBuild(
projectState,
componentState,
component.name,
),
// Being worked on right now, which the
// component's own slice of the build says
// directly rather than being inferred from a
// project-wide phase name.
working =
componentBuild(cardState, component.name)?.running == true,
componentBuild(projectState, componentState, component.name)
?.running == true,
busy = serviceBusy == component.name,
state = componentState,
onAction = { action, purge ->
onServiceAction(component.name, action, purge)
},
@@ -1433,11 +1565,11 @@ private fun AppCard(
needsBuild = entry.needsBuild,
installed = installed != null,
upToDate = upToDate,
cardState = cardState,
state = componentState,
projectState = projectState,
onUpdate = { onUpdate(entry, component.name) },
onPull = onPull,
)
ApkProgress(cardState)
ApkProgress(componentState)
}
},
)
@@ -1470,6 +1602,14 @@ private fun AppCard(
// then a card with no Pull reads the same whether the remote
// had nothing or was never asked. A failed check enables it
// again, since pressing Pull is how you find out.
// Both of the buttons below act on the whole checkout, so they
// really do wait on everything: a pull rewrites the files every
// component is built from, and Rebuild runs every command. That
// is the project's own coupling rather than the components' --
// it is why the server refuses them while anything is running,
// and why disabling them here is honest where disabling a
// component's own Update button was not.
val projectBusy = projectState.busy || componentStates.values.any { it.busy }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -1485,8 +1625,19 @@ private fun AppCard(
if (entry.canPull && !awaitingApproval) {
TextButton(
onClick = onPull,
// A failed check enables it, since pressing Pull is
// how you find out -- and so does a failed pull,
// for the same reason and because this is now the
// only control that redoes one. A fetch that fell
// over leaves `newCommits` saying whatever the last
// successful check said, which for a project nobody
// had checked yet is "nothing", so without this the
// message would sit above a button that could not
// be pressed to answer it.
enabled =
(entry.newCommits || entry.checkError != null) && cardState == null,
(entry.newCommits ||
entry.checkError != null ||
projectState is ProjectState.Error) && !projectBusy,
colors = ActionTone.Primary.colors(),
) {
Text("Pull & Build")
@@ -1499,7 +1650,7 @@ private fun AppCard(
Spacer(Modifier.weight(1f))
TextButton(
onClick = onRebuild,
enabled = cardState == null,
enabled = !projectBusy,
// The colour Restart and Reinstall wear: it
// certainly does something, and what it leaves
// behind is not obvious from here.
@@ -1535,24 +1686,26 @@ private fun AppCard(
// APK's bar sits under its button: a bar reports on a control,
// and one placed away from it belongs to nothing in particular.
// This had gone missing entirely when Pull moved up here.
if (cardState is CardState.Pulling) {
BuildProgress("Pulling and building", cardState.status)
if (projectState is ProjectState.Pulling) {
BuildProgress("Pulling and building", projectState.status)
}
if (cardState is CardState.Rebuilding) {
BuildProgress("Building", cardState.status)
if (projectState is ProjectState.Rebuilding) {
BuildProgress("Building", projectState.status)
}
// What is left to say about the card once its components have
// said their own part: a failure, or that there is no build to
// talk about yet. Progress is not here -- it belongs beside the
// button that asked for it, which is in the APK's own card.
// said their own part: a failure of the project's own, or that
// there is no build to talk about yet. Progress is not here --
// it belongs beside the button that asked for it, which is in
// the APK's own card. Nor is a component's failure, which is
// drawn in that component's row for the same reason.
//
// A failure is checked first: a project being built for the
// first time is both at once, and the failure is the more
// useful of the two.
when {
cardState is CardState.Error ->
Text(cardState.message, color = MaterialTheme.colorScheme.error)
projectState is ProjectState.Error ->
Text(projectState.message, color = MaterialTheme.colorScheme.error)
!entry.built && !awaitingApproval ->
Text(
@@ -1575,7 +1728,7 @@ private fun AppCard(
// Pull asks the same remote the check asked, so the two say the
// same paragraph twice, and the one that ran because somebody
// pressed a button is the one they are waiting to read.
if (cardState !is CardState.Error) {
if (projectState !is ProjectState.Error) {
entry.checkError?.let { reason ->
Text(
reason,
@@ -1652,18 +1805,18 @@ private fun ProjectSettingsDialog(
* started it, and one placed away from that control belongs to nothing in particular.
*/
@Composable
private fun ApkProgress(cardState: CardState?) {
when (cardState) {
is CardState.Preparing -> BuildProgress("Building for phone", cardState.status)
private fun ApkProgress(state: ComponentState?) {
when (state) {
is ComponentState.Preparing -> BuildProgress("Building for phone", state.status)
is CardState.Fetching -> {
is ComponentState.Fetching -> {
ProgressBar()
Spacer(Modifier.height(4.dp))
Text("Preparing the download...")
}
is CardState.Downloading -> {
val progress = cardState.progress
is ComponentState.Downloading -> {
val progress = state.progress
if (progress == null) {
ProgressBar()
} else {
@@ -1699,9 +1852,14 @@ private fun UpdateButton(
/** Whether this phone has the app at all, which decides "Install". */
installed: Boolean,
upToDate: Boolean,
cardState: CardState?,
/** What this component is doing, which is what decides whether the button can be pressed. */
state: ComponentState?,
/**
* What the whole project is doing, which also decides it — but only for the two things that act
* on the whole checkout. A sibling component being built is deliberately not in here.
*/
projectState: ProjectState?,
onUpdate: () -> Unit,
onPull: () -> Unit,
) {
// A project with nothing built still gets a button when its (accepted)
// build step is what would produce the first APK -- otherwise adding it
@@ -1709,13 +1867,11 @@ private fun UpdateButton(
// way to do it.
if (!built && !needsBuild) return
if (cardState is CardState.Error) {
TextButton(
onClick = { if (cardState.retryPull) onPull() else onUpdate() },
colors = ActionTone.Primary.colors(),
) {
Text("Retry")
}
// Retry redoes what failed, and what failed here was this component's
// own update -- a pull's failure is the project's and is retried from
// the project's own row.
if (state is ComponentState.Error) {
TextButton(onClick = onUpdate, colors = ActionTone.Primary.colors()) { Text("Retry") }
return
}
@@ -1740,9 +1896,16 @@ private fun UpdateButton(
// that reports it. A control that disappears takes the reader's
// bearings with it, and what it says is still what pressing it would
// have done.
//
// What counts as "running" is this component's own work, plus the two
// project-wide actions that would rebuild it out from under this
// press. A *sibling* component being built is deliberately not in
// here: that was the coupling this whole split is for, and with it
// included, updating one client of a two-client project killed the
// other's button for the length of a build it shares nothing with.
TextButton(
onClick = onUpdate,
enabled = cardState == null,
enabled = !state.busy && !projectState.busy,
colors = tone.colors(),
) {
Text(label)
@@ -1895,13 +2058,22 @@ private fun PendingDeclaration(requested: String) {
* them keeps the three in step, and there is nothing to show for the states that are not builds --
* a download or an install belongs to the APK's own controls.
*/
private fun componentBuild(cardState: CardState?, component: String): ComponentBuild? =
when (cardState) {
is CardState.Pulling -> cardState.status
is CardState.Rebuilding -> cardState.status
is CardState.Preparing -> cardState.status
else -> null
}?.component(component)
private fun componentBuild(
projectState: ProjectState?,
state: ComponentState?,
component: String,
): ComponentBuild? =
// Its own first: a build this component was asked for directly is the
// one being watched, and a project-wide run is only what to fall back
// on. Both carry the same shape, and either way the slice taken is
// this component's, so a run covering several never draws one
// component's work in another's row.
(state as? ComponentState.Preparing)?.status?.component(component)
?: when (projectState) {
is ProjectState.Pulling -> projectState.status
is ProjectState.Rebuilding -> projectState.status
else -> null
}?.component(component)
/**
* Says that installing this build would pair it with something older.
@@ -1948,6 +2120,14 @@ private fun ComponentCard(
build: ComponentBuild?,
working: Boolean,
busy: Boolean,
/**
* What this component has been asked to do, or why the last thing stopped.
*
* Here as well as inside [controls] because a failure has to be reported for both kinds, and
* only an APK has controls — a service action that failed would otherwise have nowhere to say
* so on the row it was pressed in.
*/
state: ComponentState?,
onAction: (String, Purge) -> Unit,
// Controls belonging to this component that only the caller can build
// -- an APK's Update button, which needs the project's build state.
@@ -2169,9 +2349,28 @@ private fun ComponentCard(
// order it happened -- and putting it above meant the buttons
// moved down the moment a build began, so the row somebody had
// just pressed slid out from under their finger.
build?.let {
Spacer(Modifier.height(6.dp))
ComponentBuildProgress(it)
//
// A component that has finished shows nothing at all: the
// control above has gone back to being pressable, which is
// what says the work is over, and a line reporting on it
// afterwards is one more thing to read that nobody acts on.
build
?.takeIf { it.running }
?.let {
Spacer(Modifier.height(6.dp))
ComponentBuildProgress(it)
}
// The other half of "a failure is reported where it happened":
// whatever this component was last asked to do and could not,
// whether that was its build, its download, or a service
// action. In its own row rather than at the foot of the card,
// which could only ever have been the project's.
(state as? ComponentState.Error)?.let {
Text(
it.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// Only while the old app is actually still there. The build
@@ -2485,9 +2684,6 @@ private fun VariantPicker(
* Only the steps that belong to the whole project. Work belonging to a component is drawn in that
* component's own row by [ComponentBuildProgress], because every component builds at once and a bar
* under the card could only say that something, somewhere, was happening.
*
* A build takes long enough that "is it stuck?" is a real question, so the elapsed time is shown
* rather than a bar that only moves.
*/
@Composable
private fun BuildProgress(label: String, status: BuildStatus?) {
@@ -2499,7 +2695,7 @@ private fun BuildProgress(label: String, status: BuildStatus?) {
val phase = status?.phase ?: return
ProgressBar()
Spacer(Modifier.height(4.dp))
Text("$label: $phase ${formatDuration(status.elapsedMs)}")
Text("$label: $phase")
}
/**
@@ -2510,66 +2706,46 @@ private fun BuildProgress(label: String, status: BuildStatus?) {
* real work, and being right most of the time is not something the person watching it can check. A
* command that says nothing gets a bar that says nothing.
*
* A component that has finished collapses to its duration alone. Its block stays -- a step that
* vanishes the instant it succeeds takes its own duration with it -- but the count and the last
* line it printed go, because every component builds at once and those two outlive the work they
* describe: a full bar's numbers and a frozen line of output sit there looking live next to a
* sibling that genuinely still is. What it took is the whole of what a finished component has left
* to say. The failed case keeps its error, which is an outcome rather than residue.
* A component that has finished shows nothing here at all -- the caller draws this only while
* [ComponentBuild.running]. Everything below is about work in flight: the bar, the step it is in,
* the count and the last line it printed all describe something happening now, and every one of
* them outlives the work it describes if left up. A full bar and a frozen line of output sit there
* looking live beside a sibling that genuinely is. What says the build is over is the button above
* going back to being pressable, and the card's own freshness saying the new build landed.
*
* The failure is the exception, and it is drawn by the caller rather than here: it is an outcome
* rather than residue, and it is the same message a download or a service action would leave, so it
* belongs in the one place that reports this component's failures.
*/
@Composable
private fun ComponentBuildProgress(build: ComponentBuild) {
val counted = build.progress?.takeIf { it.total > 0 }
val step = build.step
if (step != null) {
if (counted == null) {
ProgressBar()
} else {
ProgressBar(fraction = { counted.done.toFloat() / counted.total })
}
Spacer(Modifier.height(4.dp))
val step = build.step ?: return
if (counted == null) {
ProgressBar()
} else {
ProgressBar(fraction = { counted.done.toFloat() / counted.total })
}
Spacer(Modifier.height(4.dp))
Text(
when {
step != null ->
buildString {
append(step)
append(" ${formatDuration(build.elapsedMs)}")
if (counted != null) append(" ${counted.done}/${counted.total}")
}
// "done" for a component that fell over reads as a component
// that succeeded, and the error below it is the only thing
// saying otherwise -- so the word itself has to be able to say
// which of the two happened.
build.error != null -> "failed after ${formatDuration(build.elapsedMs)}"
else -> "done in ${formatDuration(build.elapsedMs)}"
buildString {
append(step)
if (counted != null) append(" ${counted.done}/${counted.total}")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
build.error?.let {
build.lastLine()?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (step != null) {
build.lastLine()?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun formatDuration(ms: Long): String =
if (ms < 10_000) "%.1fs".format(ms / 1000.0) else "${(ms + 500) / 1000}s"
/**
* Whether the build machine is producing a new build for this card.
*
@@ -2579,8 +2755,15 @@ private fun formatDuration(ms: Long): String =
* would take a card out of "Up to date" for the length of a download and put it straight back --
* the same jump this is here to stop.
*/
private fun CardState?.isBuilding(): Boolean =
this is CardState.Pulling || this is CardState.Preparing || this is CardState.Rebuilding
private fun isBuilding(
projectState: ProjectState?,
componentStates: Map<String, ComponentState>?,
): Boolean =
projectState is ProjectState.Pulling ||
projectState is ProjectState.Rebuilding ||
// Any one component being built is the card having something in
// flight, the same way any one component being behind is.
componentStates?.values.orEmpty().any { it is ComponentState.Preparing }
private fun isUpToDate(
apk: ComponentApk,