Public Access
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.
329 lines
19 KiB
Markdown
329 lines
19 KiB
Markdown
# 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.
|