Give the phone a way to enrol the fingerprint it already unlocks with
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 35s
ci / build and test (push) Successful in 1m36s

The Android device key store, the biometric gate and the lock screen's UNLOCK
WITH FINGERPRINT button have all shipped since this head was written, and none
of them could ever run: that button appears only when a device key exists, and
nothing on the phone could create one. `CanUnlockWithDevice` was false on every
launch of every phone. This is the missing half.

**The offer is on PREFERENCES**, which held a PendingScreen until it had a
setting on it. It is there rather than beside the button it turns on because
registering needs an unlocked keychain and a reachable server — the vault has to
be open to seal the bundle, and the wrap has to reach the account or a phone
somebody has lost could never be revoked. Neither is true on the lock screen.

One card, and exactly one of its three blocks is ever drawn: the offer, the
withdrawal, or the sentence saying this phone has nowhere to keep a key. That is
`CanRegisterDevice` / `CanForgetDevice` / `HasNoDeviceKeyOption`, which are two
flags and not one and its negation for the reason written where they are set —
a phone with no screen lock and a phone already registered are both "cannot
register", and only the second has anything to take back. The withdrawal has no
confirmation, deliberately, and the sentence above it carries what the desktop
puts in a tooltip this head has no room for. `StatusMessage` is on the screen
because it is the only feedback this head has once the system's own dialogue has
gone.

**Two things would have been wrong in the feature the moment it worked.**

`Environment.MachineName` answers `localhost` on Android, and registering names
the device — so every phone would have arrived in the account's device list as
another identical row, on the very screen a lost handset is revoked from.
`PhoneEnvironment.DeviceName` was already written and never called; the shell
now takes it as an optional constructor argument that the desktop does not pass,
and it reaches enrollment, registration and every connection log entry. That was
gap §7 of docs/android-port.md, and it is now closed.

And the status line said "Waiting for Windows…" over an Android biometric
prompt. `GestureWait` picks the sentence from the platform rather than from a
head, unlike the device name beside it: a device name is a fact about one
handset only the head can read, and which dialogue appears is a fact about the
operating system this assembly is running on.

Two tests cover the seam — the injected name reaching the account, and the
default still being this machine's own name — and `FakeVaultServer` records what
each device called itself, because the name is the only part of a registration a
person ever reads. The gesture itself is unreachable from any test process, so
Phase 13 of docs/manual-checks.md carries five checks, including that enrolling
a new fingerprint in Android's own Settings destroys the key. That one is the
property that makes this a fast path rather than a weakening of the passphrase.
This commit is contained in:
2026-08-03 16:12:50 +02:00
parent fd8497bb76
commit 38d8706784
10 changed files with 327 additions and 20 deletions
+14
View File
@@ -198,6 +198,13 @@ Used as the device name on connection and activity log entries, and when registe
returns something like `localhost`, which would make every log entry from a phone indistinguishable. Needs a
real device name from the head.
**Done**`PhoneEnvironment.DeviceName` reads what the user typed into Android's own Settings, falling back
to the marketing model, and `MainWindowViewModel` takes it as an optional constructor argument that the
desktop head does not pass. It reaches all three places the machine name was: enrollment, device
registration, and every connection log entry. The registration is the one that had to be fixed before the
device key could be offered on a phone at all — the account's device list is what a lost handset is revoked
from, and a list of identical `localhost` rows is a revocation nobody dares press.
### 8. The Windows-only bits of the desktop head
Listed for completeness; none of these is ported, they are simply absent from an Android head.
@@ -452,6 +459,13 @@ go at 360dp:
fallback, and a prompt that came back after being dismissed would be a modal you cannot get out of to
type into it. It watches three properties rather than one because startup sets the state to `Locked`
before it has asked the keystore whether there is a key to offer, and does both inside the busy wrapper.
**Enrolling one is on PREFERENCES**, and until it was, none of the above could ever happen on a phone:
the store, the gate and the lock screen's button all shipped, and nothing in this head could create the
key they are about — so `CanUnlockWithDevice` was false on every launch of every phone. Registering needs
an unlocked keychain and a reachable server (the vault has to be open to seal the bundle, and the wrap has
to reach the account or a lost phone could never be revoked), which is why the offer is on a screen behind
SETTINGS rather than beside the button it turns on.
5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be worth more than the
implementation. `IAuthorizationCallback` now sits between `OidcClient` and the loopback listener, so the
two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery,