From f983db154db18e72f950f44e0f6aaaaafc7ce723 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Wed, 2 Sep 2026 08:27:03 -0400 Subject: [PATCH] Say a build is signed with the wrong key before the installer does Three things. A download whose signing certificate does not match the installed copy is stopped with a dialog naming both digests and offering the one thing that gets past it: removing the old app. Android's own answer is "App not installed" with no cause, which reads as the download having failed. Where either certificate cannot be read the answer is "don't know" and the install goes ahead as before. The download is carried on the component's state so that the removal is followed by the install it was for rather than by a second download. Pressing that revealed that ACTION_DELETE now needs REQUEST_DELETE_PACKAGES, and fails invisibly without it -- so the "Remove the old app" offer for a renamed package had presumably never worked either. The build mode and the installed variant are one dropdown, not two. They answer to the same words, so two pickers offering debug and release read as one choice asked twice. Where a component declares modes the mode is the whole answer, and the server serves the build named after it rather than the newest. Every dropdown now hangs from one outlined pill with a chevron. And a checkout parked on a chosen commit is no longer called out of date, with HEAD in the commit picker as the way back to following the branch. The commit list comes from that branch rather than from HEAD, so parking no longer hides the commits after it -- the same one-way door the tracked-only dirty check closed, in a place that check did not reach. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 111 ++++- app/androidApp/src/main/AndroidManifest.xml | 10 + .../com/example/devupdater/ApkInstaller.kt | 14 +- .../kotlin/com/example/devupdater/AppsApi.kt | 15 +- .../com/example/devupdater/InstalledBuilds.kt | 11 + .../com/example/devupdater/NerdIcons.kt | 9 +- .../com/example/devupdater/SigningKeys.kt | 108 +++++ .../com/example/devupdater/UpdaterScreen.kt | 431 +++++++++++++++--- .../src/main/res/font/nerd_icons.ttf | Bin 2340 -> 2404 bytes app/build-icon-font.sh | 1 + server/src/build_state.rs | 79 +++- server/src/git.rs | 164 ++++++- server/src/registry.rs | 17 +- server/src/routes.rs | 36 +- 14 files changed, 895 insertions(+), 111 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/devupdater/SigningKeys.kt diff --git a/AGENTS.md b/AGENTS.md index 45c5c39..500b471 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -392,6 +392,37 @@ mutable at runtime from the phone. somebody named HEAD -- worth doing now that the sheet can produce the state, where before it was only reachable on the build machine. +- **A checkout parked on a chosen commit is not one to call out of + date, and the way back is `HEAD`.** Two halves of the same fact. + The commit list is read from the **branch** (`git::head_branch`, then + `git log `), not from HEAD: listed from HEAD, the commits + *after* the parked one are not in the list, so the picker that moved + the checkout back could only move it further back -- the same one-way + door the tracked-only dirty check was written to close, in a place + that check does not reach. `head_branch` answers the branch a + checkout is on, or, when it is parked, the single local branch + containing that commit; `None` when several contain it or none does, + because there is then no such thing as *the* branch and choosing one + would be this server deciding which history somebody meant. It rides + on `/refs` as `head`, and the picker's `HEAD` entry moves there -- + shown as the *current* value while the checkout is following that + branch, which is what "not pinned to anything" looks like. A checkout + parked further back than the window gets its own commit appended, so + the picker never has nothing to show for where it is. + And `freshness` takes a `parked` flag, reporting a differing commit as + **unknown** rather than `Behind` while the checkout is detached: every + way it reads as behind while parked is a build that failed, a + declaration waiting to be accepted, or a component with no build step, + and each of those already says so on the same card beside the button + for it -- so "out of date" there is either redundant or a nag about a + decision somebody made. Unknown rather than current, because nothing + measured the output to be what was wanted. `component_is_stale` is + deliberately *not* changed: Update and Rebuild still rebuild a parked + checkout, which is the whole point of parking one. The flag is passed + down from `describe` rather than read per component, since git has + already been asked for that project's status once and `/manifest` is + fetched on every open, resume and Refresh. + - **Unrelated histories are the one pull failure the phone may override.** A checkout sharing no commit with its upstream has no fast-forward and never will, so with nothing offered the card is one that can never be @@ -745,18 +776,41 @@ mutable at runtime from the phone. release one. It stays quiet until this server has built the component once, so a project built by hand is not told it is out of date. -- **Each component card has a settings sheet, and the variant picker - lives in it.** The gear sits at the row's right-hand end beside the log +- **Each component card has a settings sheet, and there is one picker in + it, not two.** The gear sits at the row's right-hand end beside the log button, drawn unconditionally so its presence is never the signal. The - sheet holds the build mode, which of the discovered builds this phone - installs, whether to strip, and Enrol; a section with nothing to offer - says so rather than vanishing, since "this project declares one way of - building" and "we could not tell" must not look alike. The variant - picker moved here from the foot of the card because on the card the two - read as one choice -- both say `debug` and `release` -- and they are - not: one decides what the build machine *builds*, the other which - finished build this phone takes. Everything applies on Save except - Enrol, which is an action and happens on the press. + sheet holds one **Build** choice, whether to strip, and Enrol; a + section with nothing to offer says so rather than vanishing, since + "this project declares one way of building" and "we could not tell" + must not look alike. Everything applies on Save except Enrol, which is + an action and happens on the press. + The build mode and the installed variant were two dropdowns, and Iris + asked for one (2026-09-02). They are genuinely different questions -- + what the build machine *builds*, and which finished build this phone + *takes* -- but they answer to the same words, so two pickers both + offering `debug` and `release` read as one choice asked twice; moving + the variant off the card into the sheet had not fixed that, it had only + moved it. So **where a component declares modes, the mode is the whole + answer**: `AppEntry::resolve_apk` serves the build named after the + component's effective mode when the phone asks for no particular one, + which is what lets the phone stop having a second opinion. Saving in + that state clears any stored variant outright rather than leaving it, + since a pin set before the modes existed would otherwise go on quietly + overriding a control that is no longer shown. A component with no + declared modes still gets the variant list, because otherwise there + would be nothing to choose at all -- and the note says which of the two + it is offering, so "the machine decides" and "this phone decides" are + never confused. + Serving the mode's build rather than the newest is the right default + for its own sake: a debug build made by hand after the mode was set to + release is *newer*, and serving it would put a debug app on the phone + from a card saying release. + Every dropdown in the app hangs from one `PickerButton` -- an outlined + pill with a chevron, Iris's ask in the same message -- so "there is a + choice here" looks the same for a mode, a branch and a commit. The + chevron is `md-chevron_down`, which like every other glyph had to be + added to **both** `NerdIcons.kt` and `build-icon-font.sh` and the font + regenerated. **The row's text is inside one weighted child so it can never push the controls off the edge**, and every reading in it truncates with an ellipsis. A control that leaves because the text grew is one the reader @@ -781,7 +835,40 @@ mutable at runtime from the phone. on disk to be served. Storing it server-side meant one enrolled device silently changing what another was offered. The stored choice is keyed by project *and* component on the device, so pinning one client to a - release build says nothing about the other. + release build says nothing about the other. Naming none is answered by + the component's own mode before it is answered by the mtime; see the + settings sheet below. + +- **A build that cannot be installed over what is on the phone says so + before the installer does.** Android refuses a package signed by a + different key than the installed copy and reports it as "App not + installed" with no cause, which reads as the download having failed. + So `SigningKeys.kt` compares the downloaded APK's certificates against + the installed package's, after the download and before the install + intent, and the card puts up a dialog naming both digests with the one + thing that gets past it: remove the old app. Iris asked for this on + 2026-09-02. + It is **not** a guarantee and must not become one: where either side + cannot be read the answer is "don't know", the install goes ahead, and + Android decides exactly as before -- blocking on a guess would be worse + than the sentence it replaces. Certificate *sets* are compared first, + because a multiply-signed package is only replaceable by one signed by + all the same keys and `hasSigningCertificate` reports false for it + however the certificate is presented; that call is then asked as well, + because it is the only thing that knows a rotated key's lineage. + The download is carried on the state (`ComponentState.WrongKey`) so + that removing the old app is followed by the install it was for rather + than by a second download. The removal happens in the system's own + dialog with this app off screen, so `continuePendingInstalls` is called + from **both** things that can learn it finished -- the package + broadcast and the resume -- since which arrives first depends on how + long somebody spends in that dialog. + **`ACTION_DELETE` needs `REQUEST_DELETE_PACKAGES` now**, and without it + the failure is invisible from this side: the uninstaller starts, logs + that this uid lacks the permission, and finishes without drawing, so + the button reads as dead. It needed no permission when this app's first + uninstall offer (the renamed-package one) was written, so that button + had presumably never worked and nothing said so. Found by pressing it. - **A project can produce more than one APK, and each component's builds are found under its own `cwd`.** `APK_PATTERNS` is anchored at diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 1a3a086..543eccf 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -6,6 +6,16 @@ an ACTION_VIEW intent; without it the intent silently fails on Android 8+ (see ApkInstaller.kt's canRequestInstall()). --> + +