Files
jaap-jan c21de4ade4
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
Docs: the 2026-10-02 audit reports, the full sweep and the Android design audit, with their screenshots
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.
2026-10-03 11:43:03 +02:00

329 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.