A Rebuild on every component, and the keystore AGP actually uses

Two things, both from the same stuck download.

Each component row now carries its own Rebuild, which is /build scoped to
it -- the route already took ?component=, only the button was missing.
Update builds only what the staleness rules call behind, and those rules
cannot see a command reading undeclared files, an output changed
underneath this server, or a signing key replaced since the APK was made;
in all of those Update does nothing and the only force was a project-wide
Rebuild that rebuilds every sibling. Same action at two scales, so it
takes the project Rebuild's word and colour. hasBuild is new on the
manifest component so a component with no command of its own draws none,
which the project-level needsBuild could not answer.

And debug_keystore_path() now resolves the preferences directory the way
AGP does instead of assuming ~/.android: ANDROID_USER_HOME as-is, then
ANDROID_PREFS_ROOT and ANDROID_SDK_HOME with .android appended, then
$XDG_CONFIG_HOME/.android when that directory exists, then $HOME/.android.
Measured by running real builds against AGP 32.3.2, including the
existence test on the XDG step, which the documentation does not mention.
An ordinary desktop machine with XDG_CONFIG_HOME set signs its APKs with
a keystore this server never looked at, so it reported "no debug
keystore" about a machine that had one and was using it -- and this VM
sets no XDG_CONFIG_HOME, which is why it could not happen here. The
failure now names a keystore found further down the list, since an inert
one and an absent one are otherwise identical from a phone.

Verified on the emulator (component Rebuild: press, that component's own
progress, back to resting with no sibling moving) and against a
throwaway server for both keystore branches.

Reported by the tdep-survey session, which found the two keystores on the
serving host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-01 11:31:59 -04:00
1 parent b67c70fa2f
commit c7b3af4861
6 files changed
+358 -72

No files matched your search

@@ -141,11 +141,19 @@ fun prepareBuild(key: String, component: String): BuildStatus =
* 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.
*
* Whole-project on purpose, unlike [prepareBuild]: this is the project row's Rebuild, which sits
* beside Pull & Build and means the same "the whole checkout" that one does. Forcing just one
* component's build without touching the rest happens by pressing Update on that component, which
* runs [prepareBuild] scoped to it -- there is no second, component-scoped Rebuild.
* [component] is one component's own Rebuild; null is the project row's, which means the whole
* checkout the way Pull & Build does. Both exist because a component can need building with nothing
* to press: staleness is decided by commits under the paths a component declares, so a build that
* reads something nobody declared, an output changed underneath this server, or a signing key
* replaced since the APK was made leaves every rule saying "current" -- and then Update, which runs
* [prepareBuild], does nothing, and the project-wide Rebuild is a heavy way to get one component
* built.
*/
fun buildNow(key: String): BuildStatus = requestBuildStatus("/apps/$key/build", "POST")
fun buildNow(key: String, component: String? = null): BuildStatus =
requestBuildStatus(
"/apps/$key/build" +
(component?.let { "?component=${URLEncoder.encode(it, "UTF-8")}" } ?: ""),
"POST",
)
fun buildStatus(key: String): BuildStatus = requestBuildStatus("/apps/$key/status", "GET")
@@ -110,6 +110,12 @@ data class ProjectComponent(
// 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,
// This component has a command and is allowed to run it, so its row
// offers to build this one alone. Per component rather than the
// project's own needsBuild, which is the OR of every component's: on
// a project where only one of two builds, that one would draw the
// button on both rows and the other would do nothing when pressed.
val hasBuild: 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.
@@ -348,6 +354,7 @@ private fun readEntry(entry: JSONObject): ManifestEntry {
hasBuildLogs = component.optBoolean("hasBuildLogs", false),
hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false),
buildFailed = component.optBoolean("buildFailed", false),
hasBuild = component.optBoolean("hasBuild", false),
dataPath = component.optString("dataPath").ifEmpty { null },
configPath = component.optString("configPath").ifEmpty { null },
dataPresent = component.optBoolean("dataPresent", false),
@@ -757,6 +757,45 @@ private fun AppListScreen(
context.startActivity(installApkIntent(context, file))
}
/**
* Re-reads one entry once a build has finished, whatever asked for the build.
*
* 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.
*
* Retried, because building this server's own project rebuilds its binary and restarts it: the
* socket goes away for a moment, and reporting that as a failure would be telling somebody
* their update broke at the moment it worked.
*
* The card stays marked as being worked on until that entry lands, which is a second or several
* while this server restarts — [settle] is what clears it, and runs only once there is a fresh
* entry to clear it against. Clearing at the end of the build instead left the card reading as
* idle against a list still describing the state before it.
*/
suspend fun readBackAfterBuilding(entry: ManifestEntry, settle: () -> Unit) {
repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt ->
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) {
settle()
failure(e)?.let { manifestState = ManifestState.Error(it) }
} else {
delay(RESTART_WAIT_MS)
}
}
}
}
/** Pull acts on the build machine: fetch, fast-forward, rebuild. */
/**
* Runs a build on the build machine and follows it to the end, whichever button asked for it.
@@ -802,45 +841,12 @@ private fun AppListScreen(
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.
//
// Retried, because building this server's own project rebuilds
// its binary and restarts it: the socket goes away for a
// moment, and reporting that as a failure would be telling
// somebody their update broke at the moment it worked.
//
// The card stays marked as being worked on until that entry
// lands, which is a second or several while this server
// restarts. Clearing it at the end of the build instead left
// the card reading as idle against a list still describing the
// state before it -- the same wrong group, arrived at from the
// other side.
repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt ->
try {
applyOne(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.
setProject(entry.key, null)
// 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) {
setProject(entry.key, null)
failure(e)?.let { manifestState = ManifestState.Error(it) }
} else {
delay(RESTART_WAIT_MS)
}
}
readBackAfterBuilding(entry) {
// 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.
setProject(entry.key, null)
}
} catch (e: DownloadServerException) {
setProject(entry.key, failure(e)?.let(ProjectState::Error))
@@ -877,6 +883,53 @@ private fun AppListScreen(
}
}
/**
* Builds one component because somebody pressed its Rebuild, and installs nothing.
*
* The component-scoped twin of [startRebuild], and the counterpart to [startUpdate] that does
* not end at the phone: Update is "get me this build", which builds only when the rules say the
* output is behind, while this is "build it again" for the cases those rules cannot see — a
* command that reads files nobody declared, an output changed underneath this server, a signing
* key replaced since the APK was made. None of them move a commit, so nothing reads as stale
* and Update does nothing at all.
*
* Recorded against the component like every other action on one, so a sibling stays pressable
* and silent throughout.
*/
fun startComponentBuild(entry: ManifestEntry, component: String) {
scope.launch {
setComponent(entry.key, component, ComponentState.Preparing(null))
try {
var status = withContext(Dispatchers.IO) { buildNow(entry.key, component) }
// This component's own step, not the project's `building`,
// for the same reason the download path reads it: a
// sibling being built at the same time says yes to that
// one.
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.component(component)?.error?.takeIf { started }
if (buildError != null) {
setComponent(entry.key, component, ComponentState.Error(buildError))
return@launch
}
// What changed is this component's build: its size, its
// freshness, and whether the phone's copy is now the old
// one. Only the card can say, so it is read back rather
// than guessed at -- and the row stays busy until it
// lands, which for this server's own component is the
// second or two it spends restarting.
readBackAfterBuilding(entry) { setComponent(entry.key, component, null) }
} catch (e: DownloadServerException) {
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
}
}
}
/**
* Builds one component if it needs it, downloads it, and hands it to the installer.
*
@@ -1264,6 +1317,9 @@ private fun AppListScreen(
onUpdate = { updated, component ->
startUpdate(updated, component)
},
onComponentBuild = { built, component ->
startComponentBuild(built, component)
},
onPull = { startPull(entry) },
onRebuild = { startRebuild(entry) },
onRefresh = { refreshOne(entry) },
@@ -1406,6 +1462,8 @@ private fun AppCard(
/** What each of its components is doing, by component name. */
componentStates: Map<String, ComponentState>,
onUpdate: (ManifestEntry, component: String) -> Unit,
/** Build this one component again, whatever the staleness rules make of it. */
onComponentBuild: (ManifestEntry, component: String) -> Unit,
onPull: () -> Unit,
onRebuild: () -> Unit,
onRefresh: () -> Unit,
@@ -1653,9 +1711,11 @@ private fun AppCard(
?.running == true,
busy = serviceBusy == component.name,
state = componentState,
projectState = projectState,
onAction = { action, purge ->
onServiceAction(component.name, action, purge)
},
onBuild = { onComponentBuild(entry, component.name) },
controls = {
if (!component.isServer) {
// Said before the button, because it
@@ -1664,18 +1724,36 @@ private fun AppCard(
self = component,
others = components,
)
// The button that asked for it, then
// The buttons that asked for it, then
// how far along it is: a bar reports on
// the control above it.
UpdateButton(
built = component.apk?.built == true,
needsBuild = entry.needsBuild,
installed = installed != null,
upToDate = upToDate,
state = componentState,
projectState = projectState,
onUpdate = { onUpdate(entry, component.name) },
)
// the controls above it.
//
// Laid out as the project's own row is
// -- what you would press first on the
// left, Rebuild held to the right edge
// -- so the two scales of the same
// action are in the same place at both.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
UpdateButton(
built = component.apk?.built == true,
needsBuild = entry.needsBuild,
installed = installed != null,
upToDate = upToDate,
state = componentState,
projectState = projectState,
onUpdate = { onUpdate(entry, component.name) },
)
Spacer(Modifier.weight(1f))
ComponentBuildButton(
component = component,
state = componentState,
projectState = projectState,
onBuild = { onComponentBuild(entry, component.name) },
)
}
ApkProgress(componentState)
}
},
@@ -2016,6 +2094,42 @@ private fun UpdateButton(
}
}
/**
* Builds this one component again, whatever the staleness rules make of it.
*
* The project row's Rebuild at one component's scale, so it takes that button's word and its
* colour: the same consequence should look the same wherever it is, and what this leaves behind is
* no more obvious than what that one does. It sits at the right-hand end of the component's own
* action row for the same reason Rebuild sits at the right-hand end of the project's.
*
* Why it exists beside Update, which also builds: Update asks the server whether the output is
* behind and builds only then, which is right for "get me this build" and blind to everything a
* commit does not describe — a command that reads files nobody declared, an output changed
* underneath this server, a signing key replaced since the APK was made. In all of those nothing is
* stale, so Update does nothing, and before this the only force was the project-wide Rebuild.
*
* Absent rather than disabled for a component with no command, which is the one thing here that is
* not a state it is in: the project row hides its Rebuild on the same grounds, and a permanently
* dead button on every row of every hand-built project teaches nothing. Anything that *is* a state
* — busy, or the whole checkout busy — disables it instead, the same pair that disables Update.
*/
@Composable
private fun ComponentBuildButton(
component: ProjectComponent,
state: ComponentState?,
projectState: ProjectState?,
onBuild: () -> Unit,
) {
if (!component.hasBuild) return
TextButton(
onClick = onBuild,
enabled = !state.busy && !projectState.busy,
colors = ActionTone.Caution.colors(),
) {
Text("Rebuild")
}
}
/**
* The dot between a component's name and whatever is said about it.
*
@@ -2232,7 +2346,14 @@ private fun ComponentCard(
* so on the row it was pressed in.
*/
state: ComponentState?,
/**
* What the whole project is doing, which disables this component's Rebuild as well: a pull
* rewrites the files it would build from, and the server refuses a build while one is running.
*/
projectState: ProjectState?,
onAction: (String, Purge) -> Unit,
/** Build this component again. A server's is drawn here, beside its service buttons. */
onBuild: () -> Unit,
// Controls belonging to this component that only the caller can build
// -- an APK's Update button, which needs the project's build state.
// The service's own buttons are decided here, from the component.
@@ -2394,9 +2515,22 @@ private fun ComponentCard(
// script takes rather than leaving: a row that vanishes on
// every press makes its own presence the signal, and takes the
// card's height with it on the way out and back.
if (component.isServer && component.state != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!component.isInstalled) {
//
// Rebuild is in this row but outside that condition, because
// it is about the build rather than the service: a server
// whose script cannot be reached is still one this machine can
// compile, and hiding the way to do that until an unrelated
// question is answered would be the same absence-as-signal.
if (component.isServer && (component.state != null || component.hasBuild)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
if (component.state == null) {
// Nothing on the left yet, so the one button there
// is holds the right edge it will keep once the
// service answers.
} else if (!component.isInstalled) {
TextButton(
onClick = { onAction("install", Purge()) },
enabled = !busy,
@@ -2443,6 +2577,13 @@ private fun ComponentCard(
Text("Uninstall")
}
}
Spacer(Modifier.weight(1f))
ComponentBuildButton(
component = component,
state = state,
projectState = projectState,
onBuild = onBuild,
)
}
}