Public Access
Two conflicts, and the second is worth recording. main's M4 bullet gained the Android signing decision while this branch rewrote the M5 line either side of it; both are kept. The other is an ADR number collision: two sessions each took 0010, one for vault key rotation and one for Android distribution, and both are now on main. ADR numbers are identifiers — "see ADR 0010" appears in code comments as well as in prose — so leaving two would make every such reference ambiguous. The rotation ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009 and the sync code; the Android one is referenced from README and android-port.md. So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011, with its title and both references updated. Nothing about either decision changes.
658 lines
45 KiB
Markdown
658 lines
45 KiB
Markdown
# An Android client: what it would take
|
||
|
||
**Status: the decided scope is built. Steps 3–8 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. *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.
|
||
|
||
**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. (**⚠️ 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](#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.
|
||
|
||
**Done** — `PhoneEnvironment.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.
|
||
|
||
- `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.
|
||
|
||
> **⚠️ 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 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.
|
||
|
||
**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.**~~ **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 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`](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 terminal is showing — one binding on `IsShowingPages` each — 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. 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.** 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 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](adr/0011-android-distribution.md): 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.
|