Files
DodoSSH/audit-2026-10-02/ANDROID-DESIGN.md
T
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

19 KiB
Raw Blame History

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/, 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): five named steps, the current one lit, with close and logs available while it's in progress.
  • Host-key sheet (13): "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):
    • 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) and the SSH password field (10) 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) 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) 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 TextBlocks 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: SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD FILES;
  • z-glow-fab: 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, 16, 19).

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). 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). 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).
  • Checkboxes: the default CheckBox, with its label sitting below the box's centre (relay, remember password, press-Enter; 10).
  • 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 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). 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).
  • 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). 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). 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). The snippet editor is an inline card with SAVE/CANCEL, and the header "+" stays live while it's open (18).

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).
  • 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).

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, 10).
  • 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).

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);
  • "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);
    • the port field gets the full letter keyboard (z-port-keyboard);
    • 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).

  • 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), "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);
    • 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).

  • 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), 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.