Files
DodoSSH/docs/android-port.md
T
jaap-janandClaude Opus 5 a43286ece8 Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who
controls one. Both were written down as refusals rather than omissions:
ADR 0009 listed ownership transfer under "deliberately not built", and
design-import-gaps said an invitation needed "a token with a lifetime and an
outbound mail path". One of those reasons had expired and the other never
applied — an invitation does not need a token if it is not a thing anybody
presents.

Handing a team over is one write. The member you name becomes owner and you
become an admin, in a single transaction, because ownership is sole: promoting
first leaves the team owned twice, demoting first leaves it owned by nobody,
and there is nobody left with the authority to finish a transfer that stopped
in the middle. That is also why it is not two calls to the role endpoint, which
refuses Owner outright. The outgoing owner is demoted rather than removed —
removing them would revoke their vault key grants and flag every team vault for
rekey, which is a far larger act than the one asked for, and somebody handing
over a team is usually staying in it. It unblocks the thing that was impossible
before: an owner can now leave, by handing the team on first.

An invitation is a standing instruction rather than a message. This server has
no outbound mail path, so nothing is sent and there is nothing for the invitee
to present. The row says the next account signing in with that address joins
this team at this role, and telling them to sign in is the caller's job over a
channel this server does not carry. A link nobody can deliver would be worse
than none. It lives in its own table rather than becoming a membership with
MembershipStatus.Invited, and that member stays unwritten for the reason it
always was: team_membership.user_id is not nullable and carries a foreign key,
so somebody who has never signed in has nothing for that row to point at.
Widening it would make the unique index on (team, user) meaningless, because
PostgreSQL counts every NULL as distinct.

Verification is the security boundary, and nothing in this server read it
before. A claim requires the access token to assert email_verified. An
invitation decides what the server will serve, so one claimable by anybody able
to obtain a token carrying somebody else's address is a way into a team — which
is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it
would have been reintroduced by the back door. There is deliberately no setting
that relaxes it: a flag that exists is one somebody turns on for the afternoon
their provider is misconfigured. Absence is refused rather than trusted, and
logged, because a provider that never sends the claim otherwise leaves every
invitation pending with nothing anywhere saying why.

Claiming happens at just-in-time provisioning and again on an hourly sweep. The
sweep is what makes it recoverable rather than one-shot — an invitation issued
between an account being created and that person next signing in would
otherwise be stranded for ever — and it shares its rate with the last-seen
write because both are housekeeping nobody is waiting on.

Archiving is refused while a team owns a vault, and that refusal is the end of
the road rather than a step on it. A team vault is readable because of
membership, so archiving one that still owned vaults would take them away from
everybody holding a key, including the caller, quietly and all at once. Nothing
in this product deletes a vault, so no order of operations gets past it today —
which is stated with a count of what is in the way, for the reason the SFTP
layer refuses a recursive delete: a refusal is visible and a quiet removal is
not. It is owner-only, as handing over is; renaming is not, because a rename is
visible to everybody and reversible by anybody who can do it. The slug is not
renameable at all: it is unique only among live teams, so a rename could take
one an archived team is still holding, and that team could then never be
restored.

LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is
refreshed on ordinary authenticated requests, at most once per account per
hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency
token, so a read-then-write on the hot path would start losing races between
one user's own overlapping requests. An hour is the granularity the question is
actually asked at, and the interface draws it to the day rather than the minute
so it does not read as a precision that is not there. The remarks in Contracts
and in the view model that argued at length for the column's absence are
rewritten rather than extended; both had become false.

Two endpoints already existed and nothing called them. ChangeTeamMemberRole and
ListVaultGrants have been reachable since M3. The role picker refuses Owner
itself rather than letting the server do it, since the interface already knew
the rule; the key-holder list sits under the vault rather than beside the
member, because a grant is per vault and a count on a member row would imply
per-item sharing, which is M5. It lists withdrawn and stale grants and says
which they are — a list that dropped them would show a departed colleague as
merely absent rather than as somebody whose key was taken away — and staleness
is decided by comparing generations, since a grant can be Active and still open
nothing.

ADD MEMBER stopped being a dead end. An address the directory did not know used
to end at a sentence telling the user their colleague had to sign in first. It
invites them instead, from the same button, because which of the two applies is
a fact about the server's account table rather than about what the user is
doing; which one happened is reported afterwards, because that decides what
they do next. An address that merely has an account is invited rather than
refused: refusing would have made the endpoint an oracle for which addresses
have accounts here, answerable by anybody willing to create a team first.

The phone has a TEAMS screen, behind MORE, and it is the reverse of every other
row in design-import-gaps: a shipped screen the design had no slot for. It is
there because an invitation is claimed by signing in, so somebody told they are
now in a team is at least as likely to be holding a phone — and a membership
visible only on a head they never installed is one they cannot see. It draws
SHARE KEY and nothing that takes something away: wrapping a key is the one act
on that screen a server cannot perform at all, and the desktop guards its
revocations with a tooltip, which is a control a touch screen cannot show.

Two defects were found by an adversarial pass and both were green against the
whole suite at the time. The owner-only check on archiving and handing over had
been weakened to the admin check while their messages and comments still said
owner — and since nothing behind the archive endpoint re-checks it, an admin
the owner had promoted could have archived the team out from under them. And
the rename endpoint built its response with a hardcoded Owner role, so an admin
who renamed a team was handed a summary claiming they owned it, and a client
trusting that instead of re-listing would have offered them the two owner-only
buttons the server then refuses.

The new table gets its constraints tested rather than merely migrated: live
uniqueness per (team, address), the citext proof that an address typed by a
person matches one cased by a provider, and reissue after both revocation and
acceptance. The teams screen gets its first entries in the layout suite, at the
minimum window with every list populated and with each of the two states that
cover half of it — it had none, and it just grew four sections and a second
line in the member row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:31:43 +02:00

567 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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.
**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](#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.
**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](#decisions-taken).
---
## 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](#phone-first) — 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.
**Decided: one remote pane and the document picker**, and out of the first scope. See
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
### 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](#sessions-survive-backgrounding-via-a-foreground-service).
### 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.
### 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.
- `NativeKeyboardFocus``user32.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.
---
## 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.
### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer 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.
### 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 code** — *the 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.
- **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
**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.
- *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 into**
`TYPE 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](#decisions-taken).
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done**`DodoSSH.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.**~~ **Done**`AndroidDeviceKeyStore` 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.
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.**~~ **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.~~ **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 MORE hub that holds the five 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`](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.
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, 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 document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
source, which is the decision recorded above and the obvious next piece of work.
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
- **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 blocks the spike, and both want answering before there is anything to release.
- ~~**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.** ADR 0001 says plainly that an
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
but it is a change to a documented security property of this product, and it should be reasoned about in
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
costs reach.
## 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.