Docs: the 2026-10-02 audit reports, the full sweep and the Android design audit, with their screenshots
ci / build and test (push) Successful in 4m49s
ci / android head (push) Successful in 5m26s
ci / api image (push) Successful in 9s
ci / desktop nightly (push) Failing after 56s

REPORT.md is the full-app sweep and what became of each finding. ANDROID-DESIGN.md is a walk through
every phone screen on the emulator, from a cleared install to SFTP, with findings ranked by severity
and a screenshot for each. The home-folder listing in the sweep's transfers screenshot is blurred.
This commit is contained in:
jaap-jan committed 2026-10-03 11:43:03 +02:00
1 parent 858e36279d
commit c21de4ade4
34 files changed
+475

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
# Android design audit — 2026-10-02
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
connecting, host-key trust, terminal, and every Settings sub-page.
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
so treat any performance note as "confirm on Release".
---
## What works well
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
with *close* and *logs* available while it's in progress.
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
both full width.
- **Recovery-code screen**:
- no back and no skip;
- **CONTINUE** stays disabled until the tick;
- a full-width **COPY CODE**;
- capture is blocked.
- **Terminal** ([14](android/14-terminal.png)):
- resizes above the keyboard;
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
- the open-sessions strip and the tab strip are clear.
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
moment").
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
and the Vaults explanation.
---
## Critical / high
### H1 · Password fields expose their plaintext to accessibility services
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
attribute is the secret itself, in clear.
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
the passphrase aloud character by character.
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
ignores it.
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
the desktop's UIA peer for the same leak.
### H2 · The passphrase survives enrolment and pre-fills the unlock box
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
readable from the a11y tree on that screen. It also skips the one moment that proves the user
remembers the passphrase.
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
cleared.
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
key event). Both connected cleanly.
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
the Enter key-up is still in flight.
- **Repro:** host row → type password → hardware Enter.
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
device with a keyboard.
### H4 · Screen-reader names are type names across the app
The a11y tree announces these:
| Control | Announced as |
|---|---|
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
| FAB / header add | `+` (no name) |
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
There are two further problems:
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
screen-reader user can wander into a screen they can't see.
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
covers it.
### H5 · Copying the recovery code isn't marked sensitive
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
not observed, because the screen can't be captured.)
---
## Medium — visual system
### M1 · The accent glow is clipped to a square on every primary button and the FAB
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
FILES;
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
### M2 · Body copy is monospace, against the shared-typeface decision
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
[16](android/16-settings.png), [19](android/19-sftp.png)).
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
`.body`, `.title` and `.detail` to the default sans.
### M3 · Fluent's selection fill leaks into the host list
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
### M4 · Disabled secondary buttons are heavier than enabled ones
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
### M5 · Unstyled Fluent controls among styled ones
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
password, press-Enter; [10](android/10-connect-sheet.png)).
- **Focused fields:** the fill switches from `Field` to pure `#000`.
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
bars.
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
`:pointerover` styles need a touch-safe variant.
### M6 · The terminal's colour and font have drifted from the palette
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
monospace faces on one screen.
### M7 · Iconography
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
hairline `◳`; [16](android/16-settings.png)).
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
"Lock the keychain".
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
the gradient.
---
## Medium — information architecture and flows
### M8 · "Settings" holds the product
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
settings. Options:
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
- or promote SFTP to the bottom nav. Its icon is already ⇅.
### M9 · Keychain can't add anything
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
desktop-only, the empty state should say so.
### M10 · Three ways to add, two ways to edit
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
means "new session".
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
([18](android/18-snippet-editor.png)).
Pick one pattern for each.
### M11 · SFTP
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
writes "Choose something on the host to rename." into the status line at the bottom.
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
- **Password again:** it's asked for again although a shell to the same host is already open.
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
listing given the screen.
### M12 · Bottom sheets
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
[10](android/10-connect-sheet.png)).
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
the confirming action on the right.
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
([13 before trust](android/13-host-key.png)).
### M13 · Sign-in and enrolment
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
user leaves the app. Chrome's own first-run prompts then land on top.
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
confirm your identity once more" on the passphrase screen.
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
### M14 · The global status line leaks across screens
A single `StatusMessage` follows the user around:
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
- "audit-box asks for a password." repeated under the sheet that asks for it.
Scope messages to the screen that raised them, or use transient snackbars.
---
## Low — input, copy, polish
- **Keyboards:**
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
([02](android/02-server-keyboard.png));
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
- hostname and username get autocorrect suggestions.
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
and the snippet fields have no labels, so each field's meaning disappears once typed in.
- **Terminology:**
- "CREATE THE VAULT", then "Creating your keychain";
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
([13](android/13-host-key.png)).
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
- **Notification permission:** Android's prompt appears without explanation at the first connect.
Explain the keep-alive notification first.
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
- **Empty and loading states:**
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
- Vaults first rendered an empty list with no loading indicator;
- the keychain shows both "0 items" and an empty-state sentence.
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
([11](android/11-connecting.png)).
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
it belongs to.
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
([04](android/04-enrol-busy.png)), and the keyboard stays up.
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
as a radio button, not a switch.
## Not verified here
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
to confirm.
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
## Environment notes for a re-run
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
`adb reverse tcp:18180`.
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
shows OFFLINE.
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
user `dodo`).
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
`packages.lock.json`. Restore those files afterwards.