Keep what a build recorded when a declaration is accepted

Accepting a project's declaration replaced its components with the
declared ones and carried across two of the three things this machine
knows about them -- the package read out of an APK and the mode somebody
picked here -- while dropping `builtFrom`. So every card of that project
went back to saying it had never been built here: no freshness, nothing
flagged, and Update with nothing to do, which is exactly what "I switched
branches on ai-app and nothing was flagged" looks like from the phone.

Silent, too, because "never built here" is a true sentence about plenty
of projects. The same field went missing from the built-in row's startup
reconciliation last week; two carry-across pairs remembered separately is
what produced both, so there is now one `CarriedOver` for all three and
both callers take it whole.

The built-in card's own update no longer goes through the self-update
dialog. Its Update pulls, builds and installs this app like any other
card's, downloading over the frozen /self/apk route -- the build that
produced the APK rebuilt this server too, so the one download that has to
survive the server changing underneath it takes the route nothing can
rename. It waits for the server to be answering again both before
fetching and before handing anything to the installer, since the restart
lands somewhere in that window either way.

The dialog stays for what it was written for: it is offered when the
built-in card is missing from the list, or when there is no list at all
because this app is too old to read what the server now sends. That is
the one case where nothing on screen can offer the update itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 22:08:27 -04:00
1 parent f359cd3edf
commit a8f75be190
3 files changed
+201 -91

No files matched your search

@@ -36,6 +36,18 @@ fun downloadApk(
// second phone must not have its download changed by it. The
// server checks the path against that component's builds, so a stale
// one falls back to its newest rather than naming a file.
// This app's own APK is the one download that has to survive the
// server changing underneath it: the build that produced it rebuilt
// the server too, and the process answering afterwards is a newer one
// than the app asking. So the built-in project goes over the frozen
// rescue route rather than the manifest's, which is the same route
// and the same file the self-update check offers -- the difference is
// only that nothing about it can be renamed. It carries no component
// or variant because it cannot: the built-in project has one APK, and
// which build of it to serve is the mode set on the build machine.
if (entry.builtIn) {
return downloadFromRoute(context, SELF_APK_ROUTE, "self", onProgress)
}
val query = StringBuilder("?component=").append(encode(component))
chosenVariant(context, entry.key, component)?.let {
query.append("&variant=").append(encode(it))
@@ -161,6 +161,17 @@ private const val REFRESH_ATTEMPTS_AFTER_PULL = 4
private const val WAKE_RETRY_MS = 600L
private const val RESTART_WAIT_MS = 1000L
/**
* How many of those to wait through when the built-in project's own update is what is happening.
*
* Longer than [REFRESH_ATTEMPTS_AFTER_PULL] because this is not a retry of a read that might
* succeed anyway — it is waiting out a restart that is definitely happening, and one going through
* a service manager is a stop and a start rather than an exec. Bounded all the same: the APK is
* already downloaded by the second of the two waits, and never installing it would be the worse
* failure.
*/
private const val RESTART_ATTEMPTS = 15
private sealed class ManifestState {
data object Loading : ManifestState()
@@ -311,12 +322,20 @@ fun UpdaterScreen(settingsVersion: Int) {
// not -- which is exactly when replacing this app matters most.
var selfUpdate by remember { mutableStateOf<SelfBuild?>(null) }
val selfUpdateContext = LocalContext.current
// On arrival, and again whenever this app's own project has just been
// built: the second is the moment the newer copy comes into existence,
// and the moment the server it has to keep talking to has just
// changed.
var selfUpdateTick by remember { mutableStateOf(0) }
LaunchedEffect(selfUpdateTick) { selfUpdate = selfUpdateAvailable(selfUpdateContext) }
// Offered when, and only when, the list cannot offer it: the built-in
// card is not there, or there is no list at all. That card's own
// Update now pulls, builds and installs this app like any other, and
// a dialog appearing over the card that is already doing it says the
// same thing twice -- worse, it says it in the one place a person
// cannot see what it is about. What is left is the case this screen
// was written for: a server that has changed what the manifest says
// into something this app is too old to read, which is exactly when
// replacing this app matters most and exactly when nothing on the
// list can say so.
var selfCardMissing by remember { mutableStateOf(false) }
LaunchedEffect(selfCardMissing) {
selfUpdate = if (selfCardMissing) selfUpdateAvailable(selfUpdateContext) else null
}
// Keys of apps added on the Add screen, waiting to be taken into the
// list one at a time.
var added by remember { mutableStateOf<List<String>>(emptyList()) }
@@ -331,7 +350,7 @@ fun UpdaterScreen(settingsVersion: Int) {
added = added,
onAddedApplied = { added = emptyList() },
onAdd = { adding = true },
onOwnProjectBuilt = { selfUpdateTick++ },
onSelfCardMissing = { selfCardMissing = it },
)
selfUpdate?.let { build ->
// Over the list for the same reason the Add screen is, and
@@ -500,12 +519,26 @@ private fun AppListScreen(
added: List<String>,
onAddedApplied: () -> Unit,
onAdd: () -> Unit,
onOwnProjectBuilt: () -> Unit,
/** Whether the built-in project's card is unavailable to offer this app's own update. */
onSelfCardMissing: (Boolean) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var manifestState by remember { mutableStateOf<ManifestState>(ManifestState.Loading) }
// Whether the built-in card is unavailable to offer this app's own
// update, which is the whole of what the screen above needs from this
// one. Derived here rather than asked for, so the rule sits beside the
// state it reads. Loading counts as present: not having found out yet
// is not the same as an answer, and a dialog that flashed up during
// every load would be exactly the noise it is there to avoid.
val selfCardMissing =
when (val state = manifestState) {
is ManifestState.Loaded -> state.manifest.entries.none { it.builtIn }
is ManifestState.Error -> true
ManifestState.Loading -> false
}
LaunchedEffect(selfCardMissing) { onSelfCardMissing(selfCardMissing) }
// 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
@@ -823,6 +856,31 @@ private fun AppListScreen(
}
}
/**
* Waits until the server is answering again, for a bounded time, and says whether it is.
*
* Only the built-in project needs it, and it needs it twice. Building this project rebuilds
* this server's binary and restarts it a moment later, so the two things that come after that
* build — fetching the new APK, and handing it to the system installer — both happen while the
* process on the other end may be exec-ing into its replacement. A download that dies mid
* transfer reads as the update having failed at the moment it was working, and an install
* offered during the restart replaces this app while the server it must talk to is down.
*
* Asked over `/self`, the same frozen route the rescue check uses: it is two numbers and no
* nesting, so it answers as soon as the new process is listening whatever else changed.
*
* Gives up rather than waiting for ever, and the caller carries on anyway: the APK is already
* on the phone by then, and refusing to install it because the server is down would strand
* somebody at the one moment a newer copy might be the fix.
*/
suspend fun awaitServerBack(): Boolean {
repeat(RESTART_ATTEMPTS) {
if (withContext(Dispatchers.IO) { runCatching { selfBuild() }.isSuccess }) return true
delay(RESTART_WAIT_MS)
}
return false
}
fun install(file: File) {
if (!canRequestInstall(context)) {
context.startActivity(requestInstallPermissionIntent(context))
@@ -851,13 +909,6 @@ private fun AppListScreen(
try {
applyOne(entry.key)
settle()
// 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,
// rather than at some later launch.
if (entry.builtIn) {
onOwnProjectBuilt()
}
return
} catch (e: DownloadServerException) {
if (attempt == REFRESH_ATTEMPTS_AFTER_PULL - 1) {
@@ -986,6 +1037,17 @@ private fun AppListScreen(
}
}
// The build that just ran rebuilt this server, so what is
// about to be asked for the APK is a process that may be
// replacing itself. Waited for here rather than retried after
// the fact: a download that dies partway is reported as a
// failure, and this is the update where that failure reads as
// the update itself having broken.
if (entry.builtIn) {
setComponent(entry.key, component, ComponentState.Busy("Waiting for the server"))
awaitServerBack()
}
// Not "downloading" until something is actually coming down:
// the server may still be producing what it is about to send.
setComponent(entry.key, component, ComponentState.Fetching)
@@ -1026,6 +1088,16 @@ private fun AppListScreen(
setComponent(entry.key, component, ComponentState.ReadyToInstall(file))
return@run false
}
// The other half of the wait above: the download itself holds
// the restart off while it runs -- the server counts what it
// is sending -- so the exec lands, if it lands at all, in the
// moment between the last byte and this install. Replacing
// this app then leaves the copy that starts next unable to
// reach anything, which reads as the update having broken it.
if (entry.builtIn) {
setComponent(entry.key, component, ComponentState.Busy("Waiting for the server"))
awaitServerBack()
}
setComponent(entry.key, component, null)
install(file)
true