Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s

All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is 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, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
This commit is contained in:
2026-07-31 21:43:11 +02:00
parent 81e7e6d939
commit 7a3a521c59
51 changed files with 2144 additions and 134 deletions
+29 -12
View File
@@ -260,10 +260,16 @@ vault is visible to everybody in that team and moving it back means deleting and
### The Android head
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one. It is
**partly built**: what exists is the unlock screen, the platform integration under it, and a shell that
names each state it has not built yet rather than showing an empty screen. What it is for, what was decided
and what is left are in [`docs/android-port.md`](docs/android-port.md).
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
keychain and a terminal, which is the scope [`docs/android-port.md`](docs/android-port.md) decided on and
the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read the keychain; the two
host-key decisions and the counted delete confirmations are there too, and none of them were softened to
fit 360dp.
What it does **not** have is file transfer — deliberately, since scoped storage means there is no local
pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs,
teams), whose view models are already shared and which are additive rather than structural. Importing an
`~/.ssh/config` has no meaning on a phone at all.
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
@@ -284,13 +290,18 @@ API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-an
anything lower cannot resolve it and the head loses its terminal. The floor is API 28, which is where
`BiometricPrompt` and StrongBox-backed keys exist without an AndroidX shim.
Three things about it are worth expecting rather than discovering.
Four things about it are worth expecting rather than discovering.
**Signing in is not built yet, and that is the one real gap.** The desktop client receives the authorization
response on a loopback `TcpListener`; reusing that on a phone would be a security regression rather than a
shortcut, because on a shared device any other app can bind a loopback port — the attack RFC 8252 §8.3 names.
It needs an app link instead. A phone enrolled from the desktop client unlocks here perfectly well, because
**unlocking needs no network at all**, so this blocks first-run rather than the product.
**Signing in does not use the desktop's loopback redirect, and must not.** On a shared device any other
application can bind a loopback port and race for the authorization code — the attack RFC 8252 §8.3 names.
The phone registers a redirect with the system instead and is handed the response as an intent. Everything
above that — PKCE, the state check, discovery, the token exchange, the key binding — is the same code the
desktop runs, because the only thing that varies is where the response arrives.
The redirect is a private-use scheme (`dev.dodotech.dodossh:`) rather than an Android App Link, and the
limit is worth knowing: another app can declare the same scheme, and Android will offer a chooser rather
than refuse. PKCE is what makes an intercepted code useless. An App Link closes it properly and costs an
`assetlinks.json` on your own server's domain.
**A fingerprint releases the device key, and re-enrolling a fingerprint destroys it.** The key is generated
with `setInvalidatedByBiometricEnrollment`, which is what stops somebody who can add their own fingerprint to
@@ -301,9 +312,15 @@ passphrase again and re-registering — which the unlock screen treats as ordina
client's promise that locking the vault does not close your shells is only true here behind a foreground
service. The notification is the price of that promise; it goes when the last shell does.
**The recovery code screen blocks screenshots.** `FLAG_SECURE` is raised for that one state and lowered
again afterwards, so the screen's own claim is true and a shell is still screenshotable. It stops the
accident worth stopping — the only copy of an unrecoverable code landing in a cloud photo library, or in the
recent-apps thumbnail — and stops nothing determined, since a second phone photographs a screen perfectly
well.
**Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries
for arm64 — that is verified. Everything about its runtime behaviour is not, and `docs/android-port.md` says
which claims those are.
for arm64 — that is verified, and CI verifies it on every change. Everything about its runtime behaviour is
not, and `docs/android-port.md` says which claims those are.
### End-to-end verification