From c7b3af48612a555e7e2b83d36c83fd1ff2849aa0 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 1 Sep 2026 11:31:59 -0400 Subject: [PATCH] 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 --- AGENTS.md | 43 ++- .../com/example/devupdater/BuildStatus.kt | 18 +- .../com/example/devupdater/UpdateManifest.kt | 7 + .../com/example/devupdater/UpdaterScreen.kt | 247 ++++++++++++++---- server/src/routes.rs | 23 +- server/src/sdk.rs | 92 ++++++- 6 files changed, 358 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1dcae1a..a420130 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/BuildStatus.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/BuildStatus.kt index 2fc2e78..5f7d4fc 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/BuildStatus.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/BuildStatus.kt @@ -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") diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdateManifest.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdateManifest.kt index 66adeb0..d54848a 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdateManifest.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdateManifest.kt @@ -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), diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt index 18d69fc..83efd04 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt @@ -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, 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, + ) } } diff --git a/server/src/routes.rs b/server/src/routes.rs index 8621873..0478023 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -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 { 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, entry: &AppEntry) -> Result, entry: &AppEntry) -> Result Result { } } +/// 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 { + 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 { - 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 = 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 = 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