Files
DodoSSH/docs/android-port.md
jaap-jan e936ab4646
ci / android head (push) Successful in 3m17s
ci / desktop nightly (push) Successful in 41s
ci / build and test (push) Successful in 2m27s
ci / api image (push) Successful in 28s
Announce a session's end when it is actually over, and for closes too
The phone's notification kept saying '1 shell connected' after the
shell was gone, and both close routes were at fault. A shell exiting on
its own raised SessionEnded from inside its run's finally block — where
the run task is by definition not yet complete, so the LiveSessionCount
the keep-alive reads still counted the dead shell, and nothing fired
later to correct it. A tab closed by hand announced nothing at all, by
a recorded decision that assumed every subscriber was the closer; the
keep-alive is not, and a close it never heard about left the
notification claiming a shell over nothing.

The end is now announced from a continuation after the run completes,
and CloseSessionAsync announces after its own drain — every subscriber
was already a reconcile-to-reality handler, so the echo the old remark
feared costs nothing. Shutdown stays silent: it is dismantling the
subscribers along with the sessions.
2026-08-09 13:01:14 +02:00

55 KiB
Raw Permalink Blame History

An Android client: what it would take

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, 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; 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.

What the spike came back with, since everything downstream was gated on it:

  • The cryptography works. libsodium.so and libe_sqlite3.so are both in the arm64 APK. NSec resolving its native half despite shipping no Android-specific build was the one dependency this document said was worth proving with a build, and it proves out.
  • Avalonia.Controls.WebView forces targetSdk 36, which settles the open question about Android versions — see Still open. This head is minSdk 28 / targetSdk 36.
  • Cleartext to loopback has to be allowed explicitly. Not in the original audit and easy to lose a day to: Android has blocked plaintext HTTP by default since API 28, so the terminal renderer needs a network security config scoped to 127.0.0.1 or the WebView loads nothing and the failure looks like a renderer that never attached. See Resources/xml/network_security_config.xml.

Still unverified, and it is the honest limit of all this: nothing has run on a device or an emulator. No system image is installed here, so every statement below about runtime behaviour — Argon2id actually completing at the 256 MiB profile on a phone, the WebView actually loading the renderer, the biometric prompt actually releasing the key — remains reasoning from the code and the platform's rules. What is verified is that it compiles, links, packages, and carries the right natives.

The shape agreed: a phone-first client that is the keychain plus a terminal, with sessions and transfers protected by a foreground service. File transfer is not in the first scope; when it arrives it is one remote pane with Android's document picker for moving files in and out. It has since arrived, both ways: the pane, the queue, ACTION_OPEN_DOCUMENT going in and ACTION_CREATE_DOCUMENT coming out, with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected Files session as well, which a transfer count alone was blind to. Corrected the same round: the service's other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for as long as the app stayed foreground; see Sessions survive backgrounding.

What was actually checked, so the rest can be read with the right amount of trust:

  • Every project's target framework, read from Directory.Build.props and the .csproj files.
  • The target frameworks each pinned package ships, read out of the local NuGet cache — so these are the assemblies this solution would actually resolve, not what a package's README claims.
  • Every site in src/ that names a Windows API, a Windows path convention, or a desktop lifetime.

What was not: nothing was compiled for Android, nothing was run on a device or emulator, and no Android SDK is installed here. Every statement below about runtime behaviour is reasoning from the code and the platform's documented rules, and is marked where it matters.


The headline

The port is smaller than it looks in one dimension and much larger in another.

The core is already portable. Every project targets plain net10.0, with no net10.0-windows anywhere and no conditional compilation. All the Windows-specific code now sits in one project — DodoSSH.Client.App, the desktop head. The cryptography, the sync engine, the local cache, the SSH layer, the item kinds and every view model are platform-neutral today, and that is not luck: it is what the project structure has been enforcing all along.

(One file was out of place when this was written — WindowsDeviceKeyStore, in DodoSSH.Client.Session. It has since been moved, which is the only code change this audit produced; see §4.)

The product is not. DodoSSH is a two-pane file browser, a tab strip, a nav rail and a terminal, laid out at a minimum of 880×560, driven by hover, right-click, middle-click and drag-and-drop. A phone is about 360dp wide and has none of those inputs. Roughly none of the interface ports; the question an Android client really asks is not "will this compile" but "what is the Android product".

Two platform rules make that sharper, and they are the things most likely to be underestimated:

  • Scoped storage. Android has no arbitrary local filesystem for an app to browse. The left-hand pane of the Files screen — this machine's drives and directories — has no Android equivalent at all.
  • Background execution. Android stops a backgrounded process. A terminal client whose whole premise is that a shell survives locking the vault, and a transfer queue that runs for minutes, both assume a process that keeps running. On Android that needs a foreground service with a persistent notification, or the feature changes shape.

Neither is a porting problem; both were product decisions, and both have now been taken — a foreground service, and a single remote pane. See Decisions.


What ports as it stands

Verified from the resolved package assemblies.

Dependency Ships for Android Note
libsodium 1.0.22 android-arm64, android-arm, android-x64, android-x86 The native half of all the cryptography
NSec.Cryptography 26.4.0 ⚠️ no Android-specific build Ships net9.0 plus iOS/tvOS/MacCatalyst. The plain net9.0 assembly should load, since the platform-specific part is libsodium — but this is the one dependency worth proving with a build before anything else
SSH.NET 2025.1.0 netstandard2.0, net8.0 Sockets only; needs the INTERNET permission
SQLitePCLRaw.bundle_e_sqlite3 2.1.12 net6.0-android31.0 The local cache
AWSSDK.S3 4.0 netstandard2.0, net8.0
CommunityToolkit.Mvvm 8.4.2 netstandard2.0 Every view model
Avalonia.Controls.WebView 12.0.1 net10.0-android36.0 The surprise — see below
Avalonia.Desktop 12.1.1 net10.0 only Replaced by Avalonia.Android, not ported

So: DodoSSH.Client.Domain, .Storage, .Sync, .Api, .Auth, .Ssh, .Terminal, .Transfer, .ObjectStore, .Import and .Crypto should all target net10.0-android unchanged. That is the great majority of the code, including all of the cryptography and all of the sync protocol.

DodoSSH.Client.Session needed one file moved and no longer does — see §4. DodoSSH.Client.App is the desktop head and does not port; an Android head would be a sibling project sharing its view models.


What does not port, in order of how much it costs

1. The interface — the largest item by far, and it is not a port

880×560 minimum, a 54-pixel nav rail, a 268-pixel host sidebar, a two-pane file browser with six columns per pane, a tab strip, and a layout suite (DodoSSH.Client.App.Layout.Tests, 64 tests) whose entire premise is that everything fits at that minimum. (Those are the numbers this audit was taken against. The desktop's own v2 has since widened the rail to 190 and the minimum to 1016×574, and the suite is 69 cases — which makes the point below larger rather than smaller.)

None of it survives a phone. What an Android client would be is a different product with the same core: probably a host list, a terminal, and a single-pane file browser, with the keychain, snippets, logs and pins as screens rather than as a rail. The desktop screens are not a starting point for that — they are a different answer to a different question.

This is where the real effort is, and it is design effort before it is engineering effort. Everything else on this list is a week or two of work; this is the product.

Decided: phone first. See Decisions — which means a redesign rather than a reflow, and rules out the cheaper tablet route deliberately.

2. The Files screen's left pane has no Android equivalent

Scoped storage means an app sees its own directory and whatever the user hands it through the system picker. There is no browsable C:\ or /home. So the two-pane layout — the thing the whole screen is built around — does not exist on Android.

The honest shapes are: a one-pane remote browser with download to and upload from going through the system document picker, or a remote-to-remote transfer tool with no local side at all. Both are fine; both mean the transfer queue's local half (LocalDirectory, the drive list, the breadcrumb trail) is desktop-only code.

Note what does carry: FileTransferQueue itself, and IRemoteFileStore — Phase 6 already proved that seam holds two very different remotes, and a Uri-backed Android document would be a third. (⚠️ That last clause is wrong, and the build is what corrected it: a document URI cannot resume, so what shipped stages a copy and hands over a path. See the note under the decision below.)

Decided: one remote pane and the document picker, and out of the first scope. See Decisions.

3. Background execution

TerminalWorkspace keeps shells running across a vault lock, deliberately and documented. FileTransferQueue runs one transfer at a time for as long as it takes. VaultViewModel runs an auto-sync pass every minute. All three assume a process Android will stop.

The options are a foreground service with a notification for as long as a session or a transfer is live (which is what every serious SSH client on Android does), or accepting that backgrounding the app drops the connection. The first is not hard; it is a decision about what the app is allowed to do to the user's battery and notification shade, and it wants taking deliberately.

Decided: a foreground service while a shell or a transfer is live. See Decisions.

4. The device key store — the cheap one, because the seam exists

WindowsDeviceKeyStore is DPAPI over a TPM-held key. IDeviceKeyStore is already the interface everything else uses, with three methods and an IsAvailableAsync that exists precisely so a platform without a keystore can say no.

Android's equivalent is the Android Keystore, with StrongBox where the hardware has it, and it is a closer match than the Windows one: it can require biometric or device-credential authentication to release the key, which is what the unlock screen would want anyway. This is a straightforward implementation of an existing interface, and it is the piece of Android integration most clearly worth doing well.

Done, ahead of any decision: WindowsDeviceKeyStore used to sit in DodoSSH.Client.Session, which was the one thing keeping that project from being portable. It now lives in DodoSSH.Client.App/Platform/, and its factory is DesktopDeviceKeyStores — named for the head it belongs to. IDeviceKeyStore and UnavailableDeviceKeyStore stayed behind, because they are the seam rather than an implementation.

The move cost nothing but a namespace, which is the useful part of the finding: the session layer takes a store and has never known which one, so an Android implementation drops into the same hole. Verified by the build and the suite, with the two Windows-only tests moving to DodoSSH.Client.App.Tests alongside it.

5. Sign-in

BrowserLauncher uses Process.Start(UseShellExecute: true); LoopbackCallbackListener implements RFC 8252 §7.3 loopback redirect with a raw TcpListener.

Neither is right on Android. Process.Start does not exist; the platform way is an Intent, and the platform way to receive the redirect is a Custom Tab plus an app link or a custom scheme. Loopback redirect might work, and should not be used: on a shared device any other app can bind a loopback port, which is exactly the attack RFC 8252 §8.3 warns about and the reason app links exist.

So this is a second implementation of an existing shape rather than a port. The PKCE flow, the discovery, the key binding and the token handling above it are all unchanged.

6. ClientPaths

Branches Windows / macOS / XDG, with an explicit comment about wanting a local, non-roaming directory because two machines sharing one cache file corrupts the outbox. On Android the right answer is the app's own filesDir, which is per-app, non-roaming and not user-visible — it satisfies the requirement more cleanly than any desktop platform does. One more branch, or better, the value injected by the head. ClientPaths already takes an explicit directory for exactly this reason.

7. Environment.MachineName

Used as the device name on connection and activity log entries, and when registering a device. On Android it returns something like localhost, which would make every log entry from a phone indistinguishable. Needs a real device name from the head.

DonePhoneEnvironment.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.

  • NativeKeyboardFocususer32.dll SetFocus, and the whole documented asymmetry about focus not returning from the WebView. Android's focus model is different and this problem may simply not exist there.
  • Program.Main[STAThread] (required by WebView2 specifically) and StartWithClassicDesktopLifetime. An Android head is an AvaloniaMainActivity instead.
  • Middle-click tab close, right-click, hover states, drag-and-drop between panes.

9. The terminal — better news than expected, with one unknown

Avalonia.Controls.WebView ships a net10.0-android36.0 target, which was the single fact most likely to sink this. And the transport underneath is more portable than it looks: TerminalDataPlane serves the page and the binary protocol over a loopback WebSocket, and an Android WebView can load http://127.0.0.1:port just as WebView2 does. The xterm.js bundles are embedded resources and are platform-neutral.

Unverified, and it is the thing to check first if this goes ahead: whether Avalonia's Android WebView composites the same way — a native view above everything Avalonia draws. If it does, the occlusion rule in docs/platform-flags.md applies unchanged and IsTerminalShowing keeps doing its job. If it does not, the rule is unnecessary rather than wrong, which is the harmless direction.

The parts that are definitely different are the on-screen keyboard, and the fact that a terminal on a phone needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an accessory key row for this. That is UI work, not porting.

⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and that assumption is WebView2's truth, not Android's. Desktop's WebView2 process starts with the window and dies with it; TerminalDataPlane was written to that reality — one socket, attached once, Interlocked.Exchange-guarded against a second attach ever happening at all. On a phone the WebView's own renderer process is a separate thing from the app process the foreground service above is keeping alive, and Android kills that independently — under memory pressure, or simply for being backgrounded — with no foreground service able to save it. The page then reloads with a fresh socket, and three things broke on that reload before this was found: the second attach was refused outright (409 Conflict), because a second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a send into the dead first socket threw, and that exception unwound TerminalSessionPump's flush loop, freezing the still-live shell behind it — LiveSessionCount kept counting a session nothing would ever drain again; and every byte sent while no page was attached had already spent flow-control credit that no acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit unaware for the whole 30-second keepalive.

Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all checked exactly as before — now displaces whatever socket was attached instead of being refused, since only this app's own page ever knows the token, so a second valid attach is that page, back again. TerminalDataPlane.SendAsync no longer lets a dead-socket send escape as a fault; it reads as "nobody listening," same as no socket being attached at all. TerminalWorkspace resets each live session's credit window on every attach and resends its SessionOpened frame, flagged as a replay, so the fresh page rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And terminal.js's socket now retries itself, forever, with backoff, instead of reporting the connection failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong call. What is not recovered, and says so rather than pretending otherwise: scrollback across a page reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — "the view reconnected; earlier output stayed on the host" — is that honesty put where the person looking at the terminal will actually read it, not buried in a log.


Decisions taken

Four, each recorded with the reasoning that was actually weighed rather than only the outcome.

Scope: the keychain and a terminal

Not a companion, and not the file browser. Everything that is already a list or a form — hosts, groups, keys, passwords, snippets, pins, logs — plus opening a shell.

The terminal is the expensive half and it is the half that makes it an SSH client rather than a viewer. It depends on the WebView spike coming back clean; if it does not, the companion subset is what is left and is still worth shipping, so the work is ordered to find that out early.

File transfer, when it comes: one pane and the document picker

Out of the first scope, decided now so the seams are not built the wrong way. A single remote pane, with Android's document picker for moving files in and out.

This is the shape scoped storage allows, and the interesting part is how little it costs: FileTransferQueue and IRemoteFileStore both carry over unchanged. Phase 6 already put a bucket behind that interface beside an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice. What is desktop-only is the left pane — LocalDirectory, the drive list, the breadcrumb trail.

⚠️ Corrected by the build. Both directions shipped, and neither as a third IRemoteFileStore. A document URI cannot sit behind that interface honestly: the queue seeks, because an upload resumes from the byte the last attempt reached, and a content:// stream promises no seek, no stable length, and no grant that survives the document being edited underneath it. So DocumentStaging copies the chosen document into the application's cache and hands the queue an ordinary path — which cost one class in the head and nothing in the shared layers, rather than a third implementation of a seam and every resume rule rewritten to cope with a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped so RESUME can read it, and swept at the next launch.

Outbound is the mirror image, with one decision the inbound half did not have to take: when to ask. The save picker is raised before the transfer, so QueueDeliveredDownload runs into the same staging directory and hands the finished bytes to a callback the head supplied. Asking afterwards would put the picker minutes away from the button that caused it and, on a phone, frequently in the background — where Android will not show one. The cost is that ACTION_CREATE_DOCUMENT creates its file when it is dismissed, so a download that then fails leaves an empty one; that is said on the screen and in the README rather than left to be found. A delivery that fails keeps the staged bytes for the sweep instead of deleting the one copy of something that was just fetched over somebody's network.

Sessions survive backgrounding, via a foreground service

A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is live.

It costs the user a notification and some battery. It buys the behaviour the desktop client already promises and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was to make TerminalWorkspace's guarantee desktop-only, which is a worse thing to have to write down than a notification is to look at.

Three corrections found after the first cut shipped, all in the wiring rather than the design:

  • A shell opening never started the service. SessionKeepAlive heard TerminalWorkspace.SessionEnded and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and backgrounded the app immediately had no foreground service at all, and Android was free to kill the process holding it. MainWindowViewModel.TerminalSessionOpened is now wired the same way in App.axaml.cs's ComposeKeepAlive.
  • A connected-but-idle Files session counted as nothing. A host open on the Files screen with no transfer moving is a live SFTP connection a dying process would sever, and the old two-argument Reconcile(liveSessions, activeTransfers) had no way to hear about it. TransfersViewModel.HasLiveFileSessionIsConnected with a real ConnectedCipher, which a bucket never has — is the third fact Reconcile now takes.
  • Refreshing the notification restarted the service, which throws when backgrounded. Reconcile called StartForegroundService on every refresh, including the common case of a service that was already running. On API 31+ that throws ForegroundServiceStartNotAllowedException the instant the app is backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and took every session with it. SessionForegroundService now tracks whether it is already running and, when it is, posts the updated notification through NotificationManager.Notify instead of asking Android to start anything.

The notification permission is requested, not just declared. API 33+ requires POST_NOTIFICATIONS at runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so. SessionForegroundService.Reconcile asks for it the first time in this process there is actually something to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is what the manifest's own comment on the permission says.

And a fourth correction, found by the notification refusing to come down. "1 shell connected" outlived the shell, both ways a shell can close. A shell exiting on its own announced SessionEnded from inside its run's own finally block — where the run task is by definition not yet complete, so the LiveSessionCount the keep-alive reads from that event still counted the session that had just ended, and nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded decision that assumed every subscriber was the closer. Both reversed in TerminalWorkspace: the end is now announced from a continuation after the run has actually completed, and CloseSessionAsync announces too, after its own drain — the event's remark carries the reversal, and SessionEnded's subscribers were all already "reconcile to reality" handlers for which a second announcement is harmless.

Phone first

About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum and much of the current layout could have been reused — and the phone is the device people have with them, which for an SSH client is most of the point.

So the interface is a redesign rather than a reflow, and that is the largest single item of work here. The nav rail, the 268-pixel sidebar and the two-pane browser do not survive. What does survive is everything behind them: every view model, every command, every piece of state.


What the interface actually has to carry

Written for designing the phone client. It is an inventory of what exists today and what each part is for — not a layout, and not a claim that any of it should look the same.

Read it as a checklist of things that need somewhere to go. The desktop has room to put a warning, a confirmation and a form on screen at once; a phone does not, and the states most easily lost are the ones that appear rarely and matter most. Those are marked .

Getting in: six states before the app is usable

ShellState, and every one of them is a screen.

  1. Starting — reading the local cache to find out whether this machine is enrolled.
  2. Needs a server — nothing cached: name a server, sign in through the browser. The only state that requires a network.
  3. Needs enrollment — signed in, but the account has no vault key yet. Choose a passphrase.
  4. ◆ Showing the recovery codethe user must not be able to click past this. It is the only moment the code exists; losing it along with the passphrase means the vault is unrecoverable, and there is no server-side reset by design. On desktop it is a whole screen with a confirmation. It needs to stay one.
  5. Locked — the unlock screen. Passphrase box, optional device unlock (biometric on Android), a status line, and the line saying this works with no network. Also carries two disclosures: how many shells are still connected behind the lock screen, and the paragraph explaining that locked describes the keychain and not this machine's access to the hosts. Plus reset this machine for a forgotten passphrase.
  6. Unlocked — everything below.

Chrome that is present on every screen

  • Titlebar — vault name, account name, a search affordance (Ctrl+K on desktop), and a sync dot with a label: synced, pending count, offline, unreachable.
  • Status bar — the selected terminal's live dot and address, the vault's last status sentence, the sync label again, and the search hint.
  • Nav rail — eight destinations: HOSTS FILES KEYS PINS SNIPS LOGS TEAM PREFS. Five characters is a desktop constraint, not a product one; the phone can use words. ⚠️ Corrected: the desktop's v3 moved FILES and the S3 buckets out of the rail and onto fixed tabs beside the terminals, leaving seven — they are the two destinations you stay in while something runs, and a rail entry is for somewhere you go and come back from. The phone reached the same count by a different route: three bottom-bar destinations with the rest behind Settings.
  • Terminal strip — always visible, above every screen. Tabs with a close cross inside each tab, a +, and a sentence when there are none. This is what makes a terminal a surface the window switches to rather than a screen you navigate away from, and it is the single most desktop-shaped idea in the product.
  • Quick connect — a search palette over hosts that connects on Enter.

The nine destinations

⚠️ This is the desktop as it stood when the audit was taken, and it is left that way on purpose — it is the checklist the scope was decided against, so rewriting it would erase what was actually weighed. Two things have moved since, and both are noted where they belong below: the rail lost FILES and S3 to fixed tabs in the desktop's v3, and the hosts sidebar became a grid of cards. Neither changed what a phone needs somewhere to put, which is what this list is for.

1. Hosts — the list of machines, and what is known about the selected one.

  • Sidebar: filter box; group headings with a chevron and a count, shown only when groups exist; host rows carrying a connected dot, name, sync badge, address and one word for how it authenticates. ⚠️ Corrected: the desktop replaced this column with a grid of cards and a right-hand drawer, and its grid holds one level of the groups at a time — so the headings, their chevrons and UNGROUPED are now the phone's alone, drawn from SidebarRows. That is what shipped here, and it is why this head flattens the whole tree rather than navigating into it: there is no room for a row of group cards at 360dp and nothing to open one into. See docs/design-import-gaps.md on the desktop's v3.
  • Editor (doubles as "add"): name, hostname, port, username, notes, one authentication picker covering typed password / key / stored credential, a group picker, a relay checkbox with the sentence explaining that relay puts the address on the server in plain text, and ◆ forget host key — the only way back from a legitimately rebuilt server.
  • Actions: new / edit / delete, replaced in place by the delete confirmation rather than stacked under it.
  • Right column: the connect banner — a password box only for a host that asks for one, a sentence in its place when it does not, and CONNECT.
  • ◆ Unknown host key prompt — fingerprint shown in full, TRUST AND CONNECT / CANCEL. Appears on first contact with any host.
  • ◆ Changed host key refusal — deliberately has no continue button. Presenting this as dismissible is the one design mistake that matters here.
  • ◆ Conflict log — what a merge overrode and what it discarded, scrollable, with DISMISS ALL. The merge is only allowed to pick a winner because this exists.
  • Groups panel: the groups as chips with host counts, a name box that both adds and renames, delete with its own confirmation counting the affected hosts. Since groups gained a parent and the four defaults their hosts inherit, the panel is a small form rather than a box: a parent picker that excludes the group's own descendants, and a default port, username and binding beside the name.

2. Files — two panes and a queue.

  • Bar: a HOST / BUCKET toggle, the matching picker, a password box for hosts that need one, CONNECT (or OPEN for a bucket), DISCONNECT, a connected chip, a status line.
  • Panes: breadcrumb trail, drive roots on the local side, and a listing with name, size, modified and — remote only — POSIX permissions. Each pane has an empty state and a drop highlight in two flavours, accepting and refusing.
  • Queue: direction arrow, name, the remote path, progress with bytes and rate, state, and RESUME / RETRY / stop per row.
  • ◆ The host key prompts appear here too. File transfer is a second, separate connection that makes its own trust decision.
  • On Android this becomes one remote pane plus the document picker — see the decision above — but the queue, its states and the prompts are unchanged.

3. Keychain — everything that is not a host.

  • Categories: ALL / SSH KEYS / PASSWORDS / BUCKETS, each with a count.
  • Table: name, type, one line of detail, sync badge. The detail is what is known about an item and never the secret.
  • Four editors, which are four different shapes: an SSH key (label, private key armour shown unmasked so a truncated paste is visible, passphrase, public key, notes); generate a key (algorithm choice, comment, and it fills the editor rather than saving); a password (label, username, password masked, notes); a bucket (label, bucket, access key id, secret access key masked, region, endpoint, ◆ a path-style checkbox with the sentence explaining why, notes).
  • ◆ Delete confirmations that count — "three hosts authenticate with this key and will refuse to connect". The number is the difference between a sentence somebody reads and one they click past.

4. Pins — the host keys this keychain has approved.

  • Filter that matches fingerprints as well as names, because the workflow is "the operator published SHA256:… — do I have that one?".
  • Table: host, port, algorithm, ◆ fingerprint never truncated, approved date.
  • Detail pane with the fingerprint in full again, a "no host uses this" chip, the note that the date is derived from the item id and means first approval rather than last use, and FORGET THIS HOST KEY.

5. Snippets — saved commands.

  • Filter matching the command text as well as the name.
  • Rows: name, ◆ a "runs immediately" chip, sync badge, and a one-line preview with newlines shown as .
  • Editor: name, a multi-line command box, notes, and ◆ a "press Enter after inserting this" checkbox with the paragraph explaining that leaving it off is the whole safety property.
  • Detail pane with the command in full and two buttons that name the terminal they will type intoTYPE INTO prod-db and, only for a snippet marked as running, RUN IN prod-db. Plus the "no terminal open" state, and the sentence saying whatever is in the terminal receives this.

6. Logs — two logs behind one screen.

  • A CONNECTIONS / KEYCHAIN toggle and a refresh.
  • Connections: live dot, host, address, ◆ "still open" rather than a dash for a session in progress, kind (terminal or files), started, device, and a chip for a refused host key.
  • Keychain: item, type, what happened, the names of the fields that changed, when.

7. Preferences — import ~/.ssh/config, register or forget this device, sign out.

8. Team — nothing behind it, and the screen says so rather than being hidden from the rail.

9. Import — a preview before anything is written: tick per row, alias, resolved address, how it authenticates, a warnings chip, an "already in the keychain" badge, TICK ALL / NONE, and IMPORT N HOSTS. Nothing is stored until the button.

States that cut across every screen

  • Empty states, with copy written per screen — each says what the thing is for, not "no items".
  • A read-only item, written by a newer client: shown, refused for editing, with a message saying to update. Re-encoding would silently drop a colleague's field.
  • Sync badges per row: not synced yet, refused and waiting on a person, read-only.
  • Unreadable items — a count of things that would not decrypt, which is the signal that new key grants are needed after a rekey.
  • Busy, and offline / unreachable, which are different from each other and both different from "everything is synced".

The five most likely to be lost on a phone

In the order I would worry about them, and all of them are full-width blocks today with nowhere obvious to go at 360dp:

  1. The changed-host-key refusal, which must not become dismissible.
  2. The recovery code screen, which must not become skippable.
  3. The counted delete confirmations, which are the difference between a decision and a reflex.
  4. The unknown-host-key prompt, which is the one interruption that is genuinely load-bearing.
  5. The conflict log, which is what makes the merge honest rather than last-writer-wins.

The order of work

  1. Decide the product questions. Done — see Decisions.

  2. Move WindowsDeviceKeyStore into the desktop head. DoneDodoSSH.Client.Session is now free of Windows APIs entirely.

  3. The spike. Done. It came back clean — see the summary at the top. The throwaway screen has been deleted; what it proved is now load-bearing in the head itself.

    It also produced the one structural change the audit did not anticipate. The interface is a redesign, but what survives it — every view model, command and piece of state — had to be reachable from two heads, and it was sitting inside the desktop one. So DodoSSH.Client.Shell is new: the view models, the terminal renderer's files and the palette moved there, and both heads reference it. It is the one place the "everything except App is free of Avalonia" rule bends, and it bends narrowly — what it uses is Dispatcher, the asset loader and a resource dictionary, none of which imply a window.

  4. Android device key store. DoneAndroidDeviceKeyStore plus BiometricGate. A StrongBox-backed AES-GCM key, generated with setUserAuthenticationRequired, wraps the X25519 scalar into a file beside the cache; the keystore will not perform the unwrap unless the prompt actually happened, because the cipher is bound to the prompt rather than merely following it.

    Two things worth knowing. StrongBox is asked for and fell back from, because refusing a TEE-backed key would cost a mid-range phone the whole feature to buy a distinction the threat model does not draw. And 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.

    The prompt is raised on arrival at the lock screen rather than waiting for the button, which is still there and still says what it does. Two rules shape it, both in PhoneShell.TryOfferDeviceUnlock. It happens only at launch: a lock the user asked for is not answered with a request to unlock, because that makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And it happens once: a declined gesture leaves the passphrase box exactly where it was, which is the whole 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, 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.

    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. DoneSessionForegroundService, 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. Corrected since: the opened half of a shell's lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates the notification in place instead of restarting the service, which the API throws on; and POST_NOTIFICATIONS is now actually requested rather than merely declared. See Sessions survive backgrounding for all four.

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

    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. (Snippets, logs and teams have since been built, behind MORE. Pins and import have not, and import still cannot be.)

    Superseded by v2. A second design — DodoSSH Android v2 — is what this head now draws, and it took the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the view models that were already shared, behind a hub that holds the destinations the bottom bar has no room for. ShellScreen gained More and Buckets. The palette went blue and the two heads moved together, because it is shared. What v2 asked for and did not get is in docs/design-import-gaps.md — the short version being that port forwarding is named as absent rather than drawn, because nothing in the SSH layer forwards anything.

    The bar has since gone from four entries to three. The keychain joined the hub, which is now called SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and managing keys is not one of them. IsMoreSurface gained ShellScreen.Vault, which is the whole of it — the membership test, the tab that lights and the back gesture's first case all read that one property. The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange deliberately differently, after the hub itself.

  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.

    The surface has since taken the whole screen. PhoneShell collapses the header, the session strip and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back, the session pills, and a + raising a sheet with the three connections there are.

    A shell rather than the surface, and the two parted company once that surface gained a connect page. With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a page in everything but which enum it is in — so RefreshChrome keeps the bar (and, wide, the rail) under it and the Connections entry lights for the first time. It is the one screen reachable by closing your last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's WebView actually composites above Avalonia content is still the unverified question recorded below, and collapsing is correct under either answer.

    The + also puts the software keyboard away, and that needed the platform. TopLevel.InputPane reports the keyboard and cannot close one — Avalonia's model is that it belongs to whatever has focus, so the supported dismissal is to move focus — and the keyboard over a terminal was raised by the WebView's own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus does nothing to it. Platform/SoftKeyboard.cs asks InputMethodManager directly, off the decor view's window token, and every step of it is allowed to be absent. Without it the sheet arrives under a keyboard covering the half of the screen the sheet is on — and worse, laid out into what is left, since the keyboard's inset shortens everything this head draws.

    With nothing open the surface is a connect screen rather than an empty state, which is why the bar calls it Connections. It offers a user@host[:port] box with a password and the machines most recently connected to, out of the vault's connection log. The connect path in VaultViewModel was shaped like HostRowViewModel all the way down; it now hangs off a four-field ConnectionTarget, so the ladder of refusals, the host-key question, the log entry and the tab's lifecycle are shared rather than copied. The host-key retry replays the attempt that raised the question instead of re-running whichever host was selected — which was correct while a selected host was the only way to connect, and would now dial the wrong machine.

All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.

What is left, in the order it matters:

  • Running any of it on a device. Still the one that is not optional, and still true: nothing here has ever been launched on hardware or an emulator. Everything below is reasoning from the code.

  • The save picker — the other half of file transfer. Built, along with the half before it: both pickers go through Avalonia's storage provider and both stage through the cache. See the correction under the decision above for the shape and for the one thing about it a person will notice — an empty file where a failed download was pointed. What is not built is a folder picker for several downloads at once: the save picker names one destination, so SAVE FILE takes the selected row.

  • Editors. Half built. The v3 hosts screen has both a host editor and a group editor — cards in the list's own row, swapped for the list rather than stacked over it — reached from the design's floating + through a sheet asking which of the two is being added. The keychain still has none, so keys, passwords and buckets remain create-on-desktop-and-sync; its one action is DELETE.

    And everything can now be taken away as well as made. DELETE under a host, and a group menu on the heading carrying Edit, Move to another vault and Delete — the desktop's card menu, as a bottom sheet, because this head draws no group cards and has no right-click. Both questions replace the controls that asked them rather than stacking over them, which is the rule this document's step 7 already recorded for the keychain's. A screen that could fill a keychain and never empty one was the shape before it.

    The one entry the phone's menu does not carry is Open. The desktop's grid holds one level of the group tree at a time and this list holds all of it flattened, so there is nothing here to open a group into — see the correction under Sidebar above, which is the same difference stated from the other end.

  • Pins and import, which v2 does not draw either. Teams is drawn, behind MORE — it was the one of the three whose view model needed nothing new on the phone, because none of that screen is vault content.

  • The App Link upgrade, unchanged from step 5.


Still open

Neither of these blocked the spike, and both wanted answering before there was anything to release. Both now have answers, kept here under the questions that produced them — the heading stays because what is worth reading is which way each went and why, not that the list is empty.

  • Which Android versions. Settled: minSdk 28, targetSdk 36, and the reasoning divided in two.

    The ceiling is not a choice at all, which the audit had slightly wrong. Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly — there is no lower Android target in the package — so a project below targetSdk 36 cannot resolve it and therefore cannot have a terminal. That is a hard constraint rather than a preference, and it also means the API 36 platform must be installed to build this head at all.

    The floor was a real choice, and 28 is where the three platform APIs the device key store depends on all exist in the framework rather than behind an AndroidX shim: BiometricPrompt, StrongBox-backed keys, and setInvalidatedByBiometricEnrollment. Going lower would mean carrying androidx.biometric to reach devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for. API 28 and 29 still cost one branch each in BiometricGate, because allowed-authenticator lists arrived in 30.

  • How it is distributed, and what that does to the supply-chain story. Settled in ADR 0011: the project holds the release key, the deployment never serves the client, and Play is deferred. The question was whether Play App Signing — Google generating and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play App Signing, an installed app can only be updated by a package signed with the same key, so the first release picks one identity for good.

    Two things follow for this head now, before there is anything to release. CI keeps signing with the debug key and must never gain a keystore secret. And no download link for the APK may ever be served by a DodoSSH deployment — that hands the client binary to the party the threat model is about, which is a worse arrangement than either of the two the question was originally between.

Smaller things, decided by default

Recorded so they are choices rather than accidents. Any of them is cheap to revisit.

  • ClientPaths gets the app's own filesDir, injected by the head rather than branched for inside the record — which already takes an explicit directory for exactly this reason. It satisfies the local-and-non-roaming requirement more cleanly than any desktop platform does.
  • The device name on log entries comes from the head, not Environment.MachineName, which returns something like localhost on Android and would make every entry from a phone indistinguishable.
  • NativeKeyboardFocus is not ported. It exists for a documented Win32 asymmetry — focus crosses into WebView2 but does not come back — and Android's focus model is different enough that the problem should be confirmed to exist before anything is written to solve it.
  • The software keyboard is kept off the interface in one place, and by two mechanisms. PhoneShell owns it rather than each screen, because everything the phone draws is inside that one control and a screen added later would otherwise have to remember. The two mechanisms are not a belt and braces: before Android 15 the activity's AdjustResize has the platform shorten the window and the reported keyboard inset arrives already consumed, and from Android 15 edge-to-edge is enforced, the window is no longer resized for the keyboard, and the inset is what there is. Each is dead where the other applies, which is why the margin is taken from the inset alone — the two added together would strand the interface an entire keyboard too high. See docs/manual-checks.md phase 10, which is the only way either is verified.
  • A box that takes a secret says so twice. PasswordChar is what the screen draws and TextInputOptions.ContentType is what the software keyboard is told, and only the second one turns off the suggestion strip and keeps the passphrase out of the IME's learning dictionary. The desktop head needs only the first, which is why the phone's TextBox.secret class carries both rather than the two being set per box.