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()). --> + +