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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 08:27:03 -04:00
1 parent 3aa6f2f290
commit f983db154d
14 files changed
+895 -111

No files matched your search

+99 -12
View File
@@ -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 <branch>`), 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