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:
1 parent
b67c70fa2f
commit
c7b3af4861
6 files changed
+348
-62
No files matched your search
@@ -255,6 +255,23 @@ mutable at runtime from the phone.
|
||||
that silence. It is part of the acceptance gate like every other
|
||||
declared field -- `same_declaration` destructures exhaustively, which is
|
||||
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
|
||||
build was.** `strip.rs` re-signs with `~/.android/debug.keystore`, and
|
||||
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
|
||||
the same `Option<&str>` -- `None` still means the whole project, which
|
||||
is what `Pull & Build`, the project-row `Rebuild`, and every project
|
||||
with a single component keep doing. There is deliberately no
|
||||
component-scoped Rebuild: forcing one component's build without
|
||||
touching the rest happens by pressing Update on it, which runs
|
||||
`/prepare` scoped to that component.
|
||||
with a single component keep doing. **Each component's row carries its
|
||||
own Rebuild**, which is `/build` scoped to it. That used to be
|
||||
deliberately absent, on the grounds that Update already builds a
|
||||
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**
|
||||
(`Component::dir` in `config.rs`), because a second one very nearly
|
||||
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
|
||||
* 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.
|
||||
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)
|
||||
// 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) {
|
||||
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,9 +1724,19 @@ 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.
|
||||
// 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,
|
||||
@@ -1676,6 +1746,14 @@ private fun AppCard(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-2
@@ -283,6 +283,15 @@ struct ManifestComponent {
|
||||
/// succeeded, which is what makes the runtime log the ordinary
|
||||
/// default.
|
||||
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.
|
||||
///
|
||||
/// Sent for every component, including the unknown case, because
|
||||
@@ -338,6 +347,10 @@ impl ManifestComponent {
|
||||
key: &str,
|
||||
entry: &AppEntry,
|
||||
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> {
|
||||
let name = component.name().to_string();
|
||||
let is_server = matches!(component, crate::config::Component::Server { .. });
|
||||
@@ -376,6 +389,7 @@ impl ManifestComponent {
|
||||
.build
|
||||
.as_ref()
|
||||
.is_some_and(|build| build.build_failed(&name)),
|
||||
has_build: may_build && !component.build().is_empty(),
|
||||
freshness: entry
|
||||
.build
|
||||
.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`,
|
||||
// and they are described in declaration order -- which is build order,
|
||||
// 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());
|
||||
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);
|
||||
@@ -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
|
||||
// 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 pending = entry.pending_declaration();
|
||||
Ok(ManifestApp {
|
||||
// Read from the cache a refresh populated, rather than asking here.
|
||||
new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path),
|
||||
|
||||
+84
-8
@@ -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,
|
||||
/// 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> {
|
||||
let path = home_dir().join(".android/debug.keystore");
|
||||
if !path.is_file() {
|
||||
bail!(
|
||||
"debug keystore not found at {} -- build any Android app once to have \
|
||||
Gradle create it",
|
||||
path.display(),
|
||||
);
|
||||
let candidates: Vec<PathBuf> = android_prefs_dirs()
|
||||
.iter()
|
||||
.map(|dir| dir.join("debug.keystore"))
|
||||
.collect();
|
||||
let (wanted, elsewhere) = candidates
|
||||
.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
|
||||
|
||||
Reference in new issue
Block a user