# 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.