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:
1 parent
f359cd3edf
commit
a8f75be190
3 files changed
+201
-91
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user