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
+348 -62

No files matched your search

+39 -4
View File
@@ -255,6 +255,23 @@ mutable at runtime from the phone.
that silence. It is part of the acceptance gate like every other that silence. It is part of the acceptance gate like every other
declared field -- `same_declaration` destructures exhaustively, which is declared field -- `same_declaration` destructures exhaustively, which is
what forced the decision when the field was added. what forced the decision when the field was added.
- **The debug keystore is not `~/.android/debug.keystore`** -- that is the
last of five places, and `sdk::android_prefs_dirs` resolves it the way
AGP does, because the only thing that makes it the right key is being
the file Gradle signed with. Measured against AGP 32.3.2 by running real
builds: `$ANDROID_USER_HOME` is the preferences directory itself with no
`.android` under it, `$ANDROID_PREFS_ROOT` and `$ANDROID_SDK_HOME` get
`.android` appended, then `$XDG_CONFIG_HOME/.android` **but only when
that directory already exists** (the bytecode tests it and falls
through), then `$HOME/.android`. That fourth step is the whole bug: an
ordinary desktop Linux 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 the case could not appear here.
The failure names a keystore found further down the list, because an
inert one and an absent one look identical from a phone: mistaking one
for the other is what turned "this machine has none" into a signature
mismatch nobody could explain.
- **A stripped copy is only serveable if it is signed by the key the - **A stripped copy is only serveable if it is signed by the key the
build was.** `strip.rs` re-signs with `~/.android/debug.keystore`, and build was.** `strip.rs` re-signs with `~/.android/debug.keystore`, and
nothing about a debug keystore says which builds it made -- it is per nothing about a debug keystore says which builds it made -- it is per
@@ -633,10 +650,28 @@ mutable at runtime from the phone.
`BuildState::{trigger_if_needed,build_now,run_build,is_stale}` all take `BuildState::{trigger_if_needed,build_now,run_build,is_stale}` all take
the same `Option<&str>` -- `None` still means the whole project, which the same `Option<&str>` -- `None` still means the whole project, which
is what `Pull & Build`, the project-row `Rebuild`, and every project is what `Pull & Build`, the project-row `Rebuild`, and every project
with a single component keep doing. There is deliberately no with a single component keep doing. **Each component's row carries its
component-scoped Rebuild: forcing one component's build without own Rebuild**, which is `/build` scoped to it. That used to be
touching the rest happens by pressing Update on it, which runs deliberately absent, on the grounds that Update already builds a
`/prepare` scoped to that component. component that needs it -- but Update runs `/prepare`, which builds only
when the staleness rules say the output is behind, and those rules are
blind to everything a commit does not describe: a command reading files
nobody declared, an output changed underneath this server, a signing key
replaced since the APK was made. In all of them nothing reads as stale,
so Update does nothing, and the only force was a project-wide Rebuild
that rebuilds every sibling too. Iris asked for the per-component
button on 2026-09-01. The two are the same action at two scales, so
they share a word and a colour, and each sits at the right-hand end of
its own row.
A component with no command of its own draws no Rebuild -- `hasBuild`
on the manifest component says so per component, which the project's
own `needsBuild` (the OR of every component's) cannot. Absent rather
than disabled, matching the project row, because having no build step
is not a state a component is in.
A server's Rebuild sits in its service row but outside the condition
that draws the service buttons: those wait for the script's answer,
while a server whose script cannot be reached is still one this machine
can compile.
**Where `component.dir()` belongs is now one definition** **Where `component.dir()` belongs is now one definition**
(`Component::dir` in `config.rs`), because a second one very nearly (`Component::dir` in `config.rs`), because a second one very nearly
shipped a real bug: `component_is_stale`'s "never built at all" check shipped a real bug: `component_is_stale`'s "never built at all" check
@@ -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 * 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. * 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 * [component] is one component's own Rebuild; null is the project row's, which means the whole
* beside Pull & Build and means the same "the whole checkout" that one does. Forcing just one * checkout the way Pull & Build does. Both exist because a component can need building with nothing
* component's build without touching the rest happens by pressing Update on that component, which * to press: staleness is decided by commits under the paths a component declares, so a build that
* runs [prepareBuild] scoped to it -- there is no second, component-scoped Rebuild. * 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") 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 // The last build stopped at this component, which is the one case
// where its build log matters more than what it is doing now. // where its build log matters more than what it is doing now.
val buildFailed: Boolean, 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, // Where Uninstall's "remove data" and "remove config" would delete,
// and whether anything is there. Null for an APK, which keeps nothing // and whether anything is there. Null for an APK, which keeps nothing
// on the build machine. // on the build machine.
@@ -348,6 +354,7 @@ private fun readEntry(entry: JSONObject): ManifestEntry {
hasBuildLogs = component.optBoolean("hasBuildLogs", false), hasBuildLogs = component.optBoolean("hasBuildLogs", false),
hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false), hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false),
buildFailed = component.optBoolean("buildFailed", false), buildFailed = component.optBoolean("buildFailed", false),
hasBuild = component.optBoolean("hasBuild", false),
dataPath = component.optString("dataPath").ifEmpty { null }, dataPath = component.optString("dataPath").ifEmpty { null },
configPath = component.optString("configPath").ifEmpty { null }, configPath = component.optString("configPath").ifEmpty { null },
dataPresent = component.optBoolean("dataPresent", false), dataPresent = component.optBoolean("dataPresent", false),
@@ -757,6 +757,45 @@ private fun AppListScreen(
context.startActivity(installApkIntent(context, file)) 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. */ /** 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. * 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)) setComponent(entry.key, component.name, ComponentState.Error(it))
} }
} }
// The APK's mtime is what decides "update available", so the readBackAfterBuilding(entry) {
// list has to come from the server again rather than be // The project's own state only. A component that failed
// guessed at here. // inside this run has just been given its own, and
// // clearing that here would take away the only thing
// Retried, because building this server's own project rebuilds // saying so.
// 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) 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)
}
}
} }
} catch (e: DownloadServerException) { } catch (e: DownloadServerException) {
setProject(entry.key, failure(e)?.let(ProjectState::Error)) 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. * 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 -> onUpdate = { updated, component ->
startUpdate(updated, component) startUpdate(updated, component)
}, },
onComponentBuild = { built, component ->
startComponentBuild(built, component)
},
onPull = { startPull(entry) }, onPull = { startPull(entry) },
onRebuild = { startRebuild(entry) }, onRebuild = { startRebuild(entry) },
onRefresh = { refreshOne(entry) }, onRefresh = { refreshOne(entry) },
@@ -1406,6 +1462,8 @@ private fun AppCard(
/** What each of its components is doing, by component name. */ /** What each of its components is doing, by component name. */
componentStates: Map<String, ComponentState>, componentStates: Map<String, ComponentState>,
onUpdate: (ManifestEntry, component: String) -> Unit, onUpdate: (ManifestEntry, component: String) -> Unit,
/** Build this one component again, whatever the staleness rules make of it. */
onComponentBuild: (ManifestEntry, component: String) -> Unit,
onPull: () -> Unit, onPull: () -> Unit,
onRebuild: () -> Unit, onRebuild: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
@@ -1653,9 +1711,11 @@ private fun AppCard(
?.running == true, ?.running == true,
busy = serviceBusy == component.name, busy = serviceBusy == component.name,
state = componentState, state = componentState,
projectState = projectState,
onAction = { action, purge -> onAction = { action, purge ->
onServiceAction(component.name, action, purge) onServiceAction(component.name, action, purge)
}, },
onBuild = { onComponentBuild(entry, component.name) },
controls = { controls = {
if (!component.isServer) { if (!component.isServer) {
// Said before the button, because it // Said before the button, because it
@@ -1664,9 +1724,19 @@ private fun AppCard(
self = component, self = component,
others = components, 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 // how far along it is: a bar reports on
// the control above it. // 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( UpdateButton(
built = component.apk?.built == true, built = component.apk?.built == true,
needsBuild = entry.needsBuild, needsBuild = entry.needsBuild,
@@ -1676,6 +1746,14 @@ private fun AppCard(
projectState = projectState, projectState = projectState,
onUpdate = { onUpdate(entry, component.name) }, onUpdate = { onUpdate(entry, component.name) },
) )
Spacer(Modifier.weight(1f))
ComponentBuildButton(
component = component,
state = componentState,
projectState = projectState,
onBuild = { onComponentBuild(entry, component.name) },
)
}
ApkProgress(componentState) 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. * 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. * so on the row it was pressed in.
*/ */
state: ComponentState?, 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, 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 // Controls belonging to this component that only the caller can build
// -- an APK's Update button, which needs the project's build state. // -- an APK's Update button, which needs the project's build state.
// The service's own buttons are decided here, from the component. // 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 // script takes rather than leaving: a row that vanishes on
// every press makes its own presence the signal, and takes the // every press makes its own presence the signal, and takes the
// card's height with it on the way out and back. // card's height with it on the way out and back.
if (component.isServer && component.state != null) { //
Row(verticalAlignment = Alignment.CenterVertically) { // Rebuild is in this row but outside that condition, because
if (!component.isInstalled) { // 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( TextButton(
onClick = { onAction("install", Purge()) }, onClick = { onAction("install", Purge()) },
enabled = !busy, enabled = !busy,
@@ -2443,6 +2577,13 @@ private fun ComponentCard(
Text("Uninstall") Text("Uninstall")
} }
} }
Spacer(Modifier.weight(1f))
ComponentBuildButton(
component = component,
state = state,
projectState = projectState,
onBuild = onBuild,
)
} }
} }
+21 -2
View File
@@ -283,6 +283,15 @@ struct ManifestComponent {
/// succeeded, which is what makes the runtime log the ordinary /// succeeded, which is what makes the runtime log the ordinary
/// default. /// default.
build_failed: bool, build_failed: bool,
/// This component has a command to run and is allowed to run it, so
/// the card can offer to build this one alone.
///
/// Per component rather than read off the project's `needsBuild`,
/// which is the OR of every component's: a project where only one of
/// two has a build step would otherwise draw the button on both, and
/// the one that cannot build would answer a press by doing nothing
/// at all.
has_build: bool,
/// Whether what is built is current with the checkout. /// Whether what is built is current with the checkout.
/// ///
/// Sent for every component, including the unknown case, because /// Sent for every component, including the unknown case, because
@@ -338,6 +347,10 @@ impl ManifestComponent {
key: &str, key: &str,
entry: &AppEntry, entry: &AppEntry,
component: &crate::config::Component, component: &crate::config::Component,
// Whether this project's build step may run at all -- false while
// a declaration is waiting to be accepted, which is the project's
// answer rather than this component's.
may_build: bool,
) -> Result<Self, ApiError> { ) -> Result<Self, ApiError> {
let name = component.name().to_string(); let name = component.name().to_string();
let is_server = matches!(component, crate::config::Component::Server { .. }); let is_server = matches!(component, crate::config::Component::Server { .. });
@@ -376,6 +389,7 @@ impl ManifestComponent {
.build .build
.as_ref() .as_ref()
.is_some_and(|build| build.build_failed(&name)), .is_some_and(|build| build.build_failed(&name)),
has_build: may_build && !component.build().is_empty(),
freshness: entry freshness: entry
.build .build
.as_ref() .as_ref()
@@ -626,9 +640,15 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
// A loop rather than a map because each component's APK is a `stat`, // A loop rather than a map because each component's APK is a `stat`,
// and they are described in declaration order -- which is build order, // and they are described in declaration order -- which is build order,
// and the order the card draws them in. // and the order the card draws them in.
// Read before the components, because each of them reports whether it
// can be built and none of them can while this is set: an unaccepted
// declaration is exactly the command that must not run.
let pending = entry.pending_declaration();
let mut components = Vec::with_capacity(entry.components.len()); let mut components = Vec::with_capacity(entry.components.len());
for component in &entry.components { for component in &entry.components {
components.push(ManifestComponent::read(state, &entry.key, entry, component).await?); components.push(
ManifestComponent::read(state, &entry.key, entry, component, pending.is_none()).await?,
);
} }
let git = crate::git::status(&entry.project_path); let git = crate::git::status(&entry.project_path);
@@ -638,7 +658,6 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
// twice -- "no upstream" on the branch line, and the check's own // twice -- "no upstream" on the branch line, and the check's own
// complaint about the same thing underneath it. // complaint about the same thing underneath it.
let can_pull = entry.git_pull && git.as_ref().is_some_and(|git| git.upstream.is_some()); let can_pull = entry.git_pull && git.as_ref().is_some_and(|git| git.upstream.is_some());
let pending = entry.pending_declaration();
Ok(ManifestApp { Ok(ManifestApp {
// Read from the cache a refresh populated, rather than asking here. // Read from the cache a refresh populated, rather than asking here.
new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path), new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path),
+84 -8
View File
@@ -184,18 +184,94 @@ pub fn llvm_strip_path() -> Result<PathBuf> {
} }
} }
/// Where the Android tools keep their preferences, resolved the way the
/// Android Gradle plugin resolves it -- because the only thing that makes
/// the keystore below the right one is that it is the same file Gradle
/// signed with.
///
/// `~/.android` is what this used to assume, and it is only the last of
/// five answers. Measured against AGP 32.3.2's `AbstractAndroidLocations`
/// rather than read off a document, by running real builds here:
///
/// * `$ANDROID_USER_HOME` is the preferences directory itself, with no
/// `.android` under it (`ANDROID_USER_HOME=/tmp/auh` put the keystore at
/// `/tmp/auh/debug.keystore`);
/// * `$ANDROID_PREFS_ROOT` and the older `$ANDROID_SDK_HOME` get
/// `.android` appended (`/tmp/ash` -> `/tmp/ash/.android/debug.keystore`);
/// * `$XDG_CONFIG_HOME/.android` comes next **but only when that directory
/// already exists** -- the bytecode tests it and falls through
/// otherwise, and a build here with `XDG_CONFIG_HOME` pointed at a fresh
/// directory did indeed keep using `~/.android`;
/// * `$HOME/.android` otherwise.
///
/// That fourth step is the one that cost a day. A desktop Linux machine
/// with `XDG_CONFIG_HOME` set and a `.android` under it -- which is
/// ordinary -- 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. This VM does not set `XDG_CONFIG_HOME` at all, which is
/// why the missing case never showed up here.
fn android_prefs_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = std::env::var_os("ANDROID_USER_HOME") {
dirs.push(PathBuf::from(home));
}
for root in ["ANDROID_PREFS_ROOT", "ANDROID_SDK_HOME"] {
if let Some(root) = std::env::var_os(root) {
dirs.push(PathBuf::from(root).join(".android"));
}
}
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
let dir = PathBuf::from(xdg).join(".android");
// Existence is the condition AGP applies, so applying it here is
// what keeps the two agreeing about which file is in use.
if dir.is_dir() {
dirs.push(dir);
}
}
dirs.push(home_dir().join(".android"));
dirs
}
/// The debug keystore every locally-built debug APK is already signed with, /// The debug keystore every locally-built debug APK is already signed with,
/// reused to re-sign a stripped copy so it still installs over the original. /// reused to re-sign a stripped copy so it still installs over the original.
///
/// The first candidate is the one Gradle would use, so it is the one taken
/// even if a keystore exists somewhere further down the list: a file in a
/// place AGP would not look is not the key anything here was signed with.
/// The failure names those anyway, because an inert keystore and an absent
/// one look identical from a phone, and mistaking one for the other is
/// what turned "this machine has no keystore" into a signature mismatch
/// nobody could explain.
pub fn debug_keystore_path() -> Result<PathBuf> { pub fn debug_keystore_path() -> Result<PathBuf> {
let path = home_dir().join(".android/debug.keystore"); let candidates: Vec<PathBuf> = android_prefs_dirs()
if !path.is_file() { .iter()
bail!( .map(|dir| dir.join("debug.keystore"))
"debug keystore not found at {} -- build any Android app once to have \ .collect();
Gradle create it", let (wanted, elsewhere) = candidates
path.display(), .split_first()
); .expect("home is always a candidate");
if wanted.is_file() {
return Ok(wanted.clone());
} }
Ok(path) let found: Vec<String> = elsewhere
.iter()
.filter(|path| path.is_file())
.map(|path| path.display().to_string())
.collect();
bail!(
"debug keystore not found at {} -- build any Android app once to have Gradle create \
it{}",
wanted.display(),
if found.is_empty() {
String::new()
} else {
format!(
". There is one at {}, but that is not where the Android tools look on this \
machine, so nothing here was signed with it",
found.join(", "),
)
},
)
} }
/// Runs `cmd`, turning a nonzero exit into an error naming the program and /// Runs `cmd`, turning a nonzero exit into an error naming the program and