Skip to content

feat(mobile): Android mobile app (v1) - #86

Open
thangnm93 wants to merge 96 commits into
developfrom
feat/android-mobile-app
Open

feat(mobile): Android mobile app (v1)#86
thangnm93 wants to merge 96 commits into
developfrom
feat/android-mobile-app

Conversation

@thangnm93

@thangnm93 thangnm93 commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds Android as a target of the existing app/ Flutter project — a focused mobile SSH client (v1) that reuses the platform-agnostic core and adds a mobile UI layer. Built as 5 milestones (M1–M5). Closes #85.

Changes

  • M1 — Target + platform branch: android/ target; main() branches on isMobilePlatform (new lib/platform/runtime_platform.dart) to mount YourSSHMobileApp and skip the desktop-only bootstrap. compileSdk 36 for transitive plugins; build-artifact gitignores.
  • M2 — Hosts + terminal: mobile-scoped MobileBootstrap (reuses existing providers/services); hosts list + search + connect, minimal add-host form; TerminalView with an accessory key bar (sticky Ctrl/Alt, ^C/^D, arrows, chars) and pinch-to-zoom; multi-session strip.
  • M3 — Host import: cloud Supabase pull + P2P QR camera scan (mobile_scanner, camera permission); transfer-code parser.
  • M4 — SFTP + snippets: single-panel SFTP (browse / download / upload via file_picker); snippets quick-insert sheet.
  • M5 — Security + polish: biometric app-lock (local_auth), TOFU host-key-mismatch dialog, terminal appearance settings, release signing via key.properties (debug fallback).
  • Docs: specs + 5 plans under docs/superpowers/; roadmap + README note Android v1 (in development).

Desktop (macOS/Windows/Linux) code paths are untouched — the entire widget tree branches at the root, and only the shared main() bootstrap is platform-gated.

Type of change

  • feat — new feature

How was this tested?

  • cd app && flutter analyze — no new warnings (2 pre-existing integration_test probe warnings remain, unrelated)
  • cd app && flutter test — all 1543 tests pass (33 new mobile widget/unit tests, TDD)
  • cd app && flutter build apk --debug — builds green
  • Manually verified on: Android device — pending (no device/emulator available this pass)

Screenshots

N/A this pass (no on-device run yet); mobile UI is widget-test-covered.

Checklist

  • PR title follows Conventional Commits
  • Code and comments are written in English
  • New/changed behavior is covered by tests
  • No secrets, credentials, or personal data in the diff

Notes for reviewers

  • New mobile deps mobile_scanner + local_auth reintroduce native plugins into desktop bundles (single shared pubspec) — accepted v1 tradeoff.
  • Command history on mobile is deferred (no input bar to capture commands).
  • Unrelated pre-existing macOS pod drift (passkeys / device_info_plus / ua_client_hints registrant) is not touched by this PR.

Update — Claude Design import redesign

The mobile UI was reworked to match the imported Claude Design "SSH Client" (dark, Apple-inspired, amber accent), on top of the v1 milestones above:

  • Mobile-only amber theme + expanded tokens + bundled Manrope / Inter / Roboto Mono; reskinned primitives and a custom blurred 4-tab bottom bar (Hosts · Snippets · Keys · Settings). Desktop theme untouched (stays green).
  • Redesigned Hosts (tag folder chips + reachability/latency probe), New host form, Terminal (session tabs + reskinned accessory bar), Files (SFTP), and new Keys / Snippets / Port forwarding / Sync-QR screens. PortForwardService wired into the mobile bootstrap.
  • Fixes from a whole-branch review: wired KeyGenService into the bootstrap (Generate no longer crashes), persist generated-key passphrase, real OpenSSH SHA256 fingerprints, removed dead-end controls, balanced filter chips.
  • Android launcher icon (adaptive amber terminal glyph on black).

Spec & plan: docs/superpowers/specs/2026-06-26-mobile-design-import-design.md, docs/superpowers/plans/2026-06-26-mobile-design-import.md.

Verified on the Android emulator (API 36): Hosts, Snippets, a live SSH session, Settings, and the launcher icon. flutter analyze clean (lib/mobile + test/mobile); full flutter test green (168 mobile tests).

thangnm93 added 30 commits June 24, 2026 18:19
thangnm93 added 29 commits June 26, 2026 21:45
_doSave() now returns Future<String> (the saved host's id). For new hosts
the id comes from the Host object built before addHost is called; for edits
it is existing.id. _saveAndConnect uses the returned id to call byId()
instead of the fragile allHosts.last assumption.
- _importKey now shows _ImportPassphraseSheet after file pick; calls
  savePassphrase(entry.id, passphrase) when non-empty, with
  context.mounted guards across every await.
- New app/lib/mobile/util/ssh_fingerprint.dart: sha256Fingerprint()
  parses the OpenSSH public key line, SHA256-hashes the raw blob via
  the crypto package, and returns the canonical 'SHA256:<base64>' form
  (no '=' padding); returns null for unparseable input.
- KeyCard._fingerprintLine replaced truncated-base64 display with the
  real fingerprint; falls back to 'SHA256:(pending)' for null.
- New test/mobile/util/ssh_fingerprint_test.dart: 7 cases covering the
  known vector, no-comment variant, empty/single-token/invalid-base64
  inputs, and extra-whitespace handling.
- keys_screen_test.dart: seed publicKey values updated to realistic
  OpenSSH key lines; comment typo '1 keys' → '1 key' fixed.
Rewrites MobileSettingsScreen with ListGroup/SettingsRow primitives:
TERMINAL (TerminalAppearanceControls embedded), SECURITY (biometric
toggle on kAppLockPrefKey, auto-lock chevron), KEYBOARD & SYNC
(shortcut key bar toggle, Supabase sync sheet, P2P QR pair).
Sync banner card reflects isSupabaseConfigured. Footer shows version
via PackageInfo. Preserves full Supabase config + P2P QR import logic
via bottom sheet. Wires Settings tab in MobileHomeShell.
- Introduce kAccessoryBarPrefKey ('mobile_accessory_bar_enabled') in
  app/lib/mobile/util/mobile_prefs.dart; wire the Shortcut key bar
  toggle to read/write this key via SharedPreferences instead of the
  desktop-only shellIntegrationEnabled flag
- Fix sync-sheet pull spinner: wrap _SupabaseSyncSheet in StatefulBuilder
  so setSheetState drives the in-sheet spinner/disabled state during an
  in-flight pull (parent setState never reached the modal builder context)
- Upgrade test pump: register SettingsProvider, HostProvider, SyncService
  so the screen never throws ProviderNotFoundException; add two pref-key
  persistence tests (biometric → kAppLockPrefKey, shortcut bar →
  kAccessoryBarPrefKey)
Both home-shell test helpers were missing SnippetProvider, KeyProvider,
KeyGenService, SettingsProvider, SyncProvider, SyncService, and SshService.
MobileHomeShell uses an IndexedStack so all 4 tab screens build eagerly;
each screen's provider dependencies must be satisfied at the shell level.

Also switches the 'Settings tab is reachable' assertion from
tester.widget to tester.firstWidget: TerminalAppearanceControls renders
a DropdownButton which Flutter implements via IndexedStack internally,
causing find.byType(IndexedStack) to match multiple widgets once the
Settings screen is fully rendered.
Replace MobileSessionsScreen with MobileTerminalScreen: adds amber header
bar with back/split/⋮ buttons, session-tab strip with active/inactive states
and a "+" tile, accessory-bar pref wiring (kAccessoryBarPrefKey), and stub
⋮ menu items for Files and Port forwarding. Preserves pinch-zoom,
CursorDragMapper long-press drag, and showTerminalSidePanel.
…ive picker list

- pop-on-empty: re-check sshSessions.isEmpty at postFrameCallback fire time and
  reset _popScheduled so a later empty→fill→empty cycle still triggers a pop
- re-entry path: use existing.last.id (matches new-connect path which uses after.last.id)
- host picker: read HostProvider.allHosts inside the sheet builder so the list
  is live while the sheet is open, not a stale snapshot
- tests: add T5 (pop-on-empty) and T6 (no-spurious-pop) to terminal_nav_test.dart;
  fix pre-existing __ lint in probe connector lambda
MobileSftpScreen is now contextual: accepts a required Host param instead of
reading SessionProvider.activeSshSession. Design 04 applied: header with back
+ "Files / <host>" + upload + ⋮; breadcrumb row (home icon, mono segments,
current bold); meta row with item count, total size, and cycling sort control
(Name/Size ↑↓); list rows with typed icons (folder=blue, dotfiles/secrets=lock
yellow, archives/images/code), mono names for dotfiles, file sizes in mono,
chevrons for dirs, hairline dividers inset 47px. SFTP browsing logic
(single reusable SftpClient, load-token, listdir, chunked download,
SftpTransferService upload) preserved unchanged.

Terminal ⋮ "Files" entry now calls onOpenFiles(host) with the active session's
host; MobileHostsScreen passes a callback that pushes MobileSftpScreen(host).
Add PortForwardProvider + PortForwardService to MobileBootstrap,
mirroring the desktop wiring in main.dart: onStatus/onConnections
callbacks feed the provider, autoStartAll fires after provider.ready,
and onHostDeleted tears down tunnels. SnippetProvider and KeyGenService
were already present. Providers list grows from 11 → 13 entries.
Add a testWidgets test that pumps the real provider tree and calls
Provider.of<T> from a child BuildContext, catching type-param or
Provider.value vs ChangeNotifierProvider.value mismatches that
field-access checks cannot detect. Remove the misleading comment that
discouraged the real test in favour of the indirect list-length check.
Introduces MobileSyncScreen (Screen 09): heading, P2P QR export card
(reuses the desktop P2P start-server + base64-key JSON format), E2E
Supabase status card, and "Scan QR code" button.

Settings "Supabase sync" and "Pair new device" rows now push
MobileSyncScreen. Scan header reskinned to MobileColors/mobileHeading.
Reject empty URL, non-http/https URL, and empty anon key in _saveConfig
instead of writing broken state to SyncProvider. Shows an inline error
(keyed sync-config-error) and keeps the form open. Sync code remains
optional (unchanged).

4 new widget tests cover the three rejection paths and the valid
round-trip (including setSyncCode exercised with a Crockford-safe code).
Fix 8 lint issues in mobile test files:
- unnecessary_underscores in 3 files (use named params instead of _/__)
- unused_local_variable noSession in snippets test (use it as provider value)
- prefer_function_declarations_over_variables in probe test (clock fn)

Close tracked minor gaps:
- Replace raw SizedBox(height:4) with MobileTokens.space1 in snippets screen
- Add probeAll debounce unit test (concurrent duplicate ids not double-probed)
- Add widget test asserting _EntryRow shows Icons.lock_outline for .env file

Regression confirmed: AppLockGate wraps app in mobile_app.dart, TofuWatcher
in mobile_home_shell.dart, pinch-zoom + CursorDrag in terminal screen, P2P
QR import reachable via mobile_qr_scan_screen / mobile_sync_screen, no
dangling refs to deleted mobile_sessions_screen.dart.
…assphrase, remove dead-end controls

Finding 1: add KeyGenService to MobileBootstrap providers list — opening
the Generate sheet no longer throws ProviderNotFoundException at runtime.

Finding 2: capture addKeyFromFile return value in _GenerateSheetState._generate
and call keyProv.savePassphrase when passphrase is non-empty — mirrors the
Import flow; generated passphrase-protected keys are now usable after restart.

Finding 3: remove all no-op / misleading interactive controls —
  - port_forward_screen: replace empty PopupMenuButton with SizedBox
  - sync_screen: remove placeholder more_vert IconButton from AppBar
  - terminal_screen: remove dead Split IconButton (mobile has no split)
  - hosts_screen: replace no-op _CircleButton header items with non-interactive
    _DecorativeCircle; remove now-dead _CircleButton class
  - settings_screen: remove no-op tune AppBar action; render Auto-lock row as
    display-only (drop onTap/chevron since the feature is not built)

Finding 4: add MobileColors.fieldFill (0xFF1C1C1E) and MobileColors.tabInactive
(0xFF6D6D72) tokens; replace all hardcoded occurrences in mobile_hosts_screen,
mobile_keys_screen, and mobile_tab_bar with the tokens.

Tests: extend mobile_bootstrap_test to assert KeyGenService resolves from the
provider tree; add two unit tests in keys_screen_test asserting savePassphrase
is called iff passphrase is non-empty.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant