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
+48 -19
View File
@@ -1,7 +1,9 @@
# An Android client: what it would take
**Status: started. Steps 3, 4 and 6 are done; the interface is next.** `src/DodoSSH.Client.Android` is a
real head that builds and packages. The four decisions that shape the work are recorded in
**Status: the decided scope is built. Steps 38 are done.** `src/DodoSSH.Client.Android` is a phone-first
head that builds and packages: the keychain and a terminal, which is exactly the scope
[decided](#scope-the-keychain-and-a-terminal), plus the sign-in and enrollment states needed to reach them
from a phone that has never been used before. The four decisions that shape the work are recorded in
[Decisions](#decisions-taken); everything below them is the audit they were taken against, and it has held
up — with three corrections and one addition, all marked **✅ settled** or **⚠️ corrected** where they
belong.
@@ -437,30 +439,57 @@ go at 360dp:
`setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an
unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key,
which the load path treats as ordinary rather than exceptional.
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
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,
the token exchange and the key binding are one implementation, which is the point. A second OIDC client
would have been a second place for a security bug to live.
**Not started, and it is now the gap that matters most.** The head composes a sign-in delegate that
throws, and `NeedsServer` draws a screen saying so in the user's words. A phone enrolled from the desktop
client unlocks here perfectly well, because unlocking needs no network — so this blocks first-run, not
the product. It is the next piece of work.
The system browser by `ACTION_VIEW` rather than a Custom Tab: a Custom Tab is the nicer surface and
shares the same cookie jar, but it needs `androidx.browser` and this needs nothing. Worth revisiting when
something else pulls AndroidX in.
**A private-use scheme, not an App Link**, and the difference is worth writing down rather than
discovering. The redirect is `dev.dodotech.dodossh:/callback` — the reversed package name, which RFC 8252
§7.1 recommends. Another application *can* also declare that scheme, and Android will show a chooser
rather than refuse; what stops that being a compromise is PKCE, since the code is useless without a
verifier that never leaves the process. An App Link would close it properly, at the cost of an
`assetlinks.json` on the DodoSSH server's own domain — a server change, and the natural upgrade.
6. ~~**The foreground service.**~~ **Done**`SessionForegroundService`, with `SessionKeepAlive` starting and
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
dishonesty the unlock screen's count exists to prevent.
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate. **Started:**
the shell switches on `ShellState` and the unlock screen is built from the design — including both of its
◆ disclosures, the shell count and the paragraph saying *locked* describes the keychain and not this
phone's access to the hosts. Every other state draws a `PendingScreen` naming what is missing, in the
desktop head's `NotBuiltScreen` tradition and for the same reason.
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
Tab or arrows, and every Android SSH client ships one for exactly this reason.
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
choosing a passphrase).
Steps 14 and 6 are done. Step 5 is ordinary work behind seams that already exist, and is what first-run
waits on. Step 7 still dominates everything else put together — the unlock screen is one of nine
destinations and the least structural of them — and step 8 is small only because step 7 comes first.
All five of the ◆ states this document worried about are built and none of them softened:
- the changed-key refusal has no continue button, and is a full-screen panel rather than a bottom sheet
precisely because a sheet is swipe-to-dismiss by convention;
- the recovery code cannot be skipped, and `FLAG_SECURE` is raised for that state alone so the screen's
claim about screenshots is true rather than decorative;
- the delete confirmations keep their counts and replace the row in place;
- the unknown-key prompt shows the fingerprint in full, wrapping rather than clipping;
- the conflict log is a banner on the host list rather than a screen nobody opens.
The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
import has no meaning under scoped storage, and the other four are list screens whose view models already
exist — they are additive rather than structural.
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
reflows a terminal while output is arriving.
The accessory key row needed one addition to the shared layer: `TerminalWorkspace.SendInputAsync`, since
ordinary typing goes from the renderer straight down the socket and there was no way in for input that
has no key to produce it. Ctrl latches rather than being held — one thumb cannot chord — and the latch is
drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
believing they typed an `l`.
All eight are done for the decided scope. What is left is not on this list: the four destinations the
phone does not draw, file transfer (deliberately deferred, and see the decision above for the shape it
takes), the App Link upgrade, and — the one that is not optional — running any of it on a device.
---