Public Access
docs/android-port.md was an audit of work not started; it now says what is built. Three of its statements needed correcting rather than extending, and they are marked where they sit: the Android version question is settled and was never as open as it looked, because Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed entirely; and the spike produced a structural change it did not anticipate, in DodoSSH.Client.Shell. A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project outside the solution is a project nobody notices breaking. It packages as well as builds: a native library with no Android ABI and an assembly that will not dex are both invisible to a compile, and both are exactly what this head is exposed to. The README says plainly that signing in is not built, that a fingerprint re-enrolment destroys the device key, that a notification appears while a shell is open, and that none of it has run on a device.
505 lines
32 KiB
Markdown
505 lines
32 KiB
Markdown
# An Android client: what it would take
|
||
|
||
**Status: started. Steps 3, 4 and 6 are done; the interface is next.** `src/DodoSSH.Client.Android` is a
|
||
real head that builds and packages. The four decisions that shape the work are recorded in
|
||
[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.
|
||
|
||
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.
|
||
|
||
**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.
|
||
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
|
||
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
|
||
|
||
**Not started, and it is now the gap that matters most.** The head composes a sign-in delegate that
|
||
throws, and `NeedsServer` draws a screen saying so in the user's words. A phone enrolled from the desktop
|
||
client unlocks here perfectly well, because unlocking needs no network — so this blocks first-run, not
|
||
the product. It is the next piece of work.
|
||
6. ~~**The foreground service.**~~ **Done** — `SessionForegroundService`, with `SessionKeepAlive` starting and
|
||
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
||
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
||
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
||
dishonesty the unlock screen's count exists to prevent.
|
||
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate. **Started:**
|
||
the shell switches on `ShellState` and the unlock screen is built from the design — including both of its
|
||
◆ disclosures, the shell count and the paragraph saying *locked* describes the keychain and not this
|
||
phone's access to the hosts. Every other state draws a `PendingScreen` naming what is missing, in the
|
||
desktop head's `NotBuiltScreen` tradition and for the same reason.
|
||
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
|
||
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
|
||
Tab or arrows, and every Android SSH client ships one for exactly this reason.
|
||
|
||
Steps 1–4 and 6 are done. Step 5 is ordinary work behind seams that already exist, and is what first-run
|
||
waits on. Step 7 still dominates everything else put together — the unlock screen is one of nine
|
||
destinations and the least structural of them — and step 8 is small only because step 7 comes first.
|
||
|
||
---
|
||
|
||
## 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.
|