Skip to content

release: 0.2.0 — Android app (development preview) + new app icon - #102

Merged
thangnm93 merged 112 commits into
masterfrom
release/0.2.0
Aug 23, 2026
Merged

thangnm93 merged 112 commits into
masterfrom
release/0.2.0

Conversation

@thangnm93

Copy link
Copy Markdown
Collaborator

Summary

Ships everything on develop since 0.1.40: the Android target as a development preview, and a redrawn app icon across every platform. Minor bump rather than the usual patch because Android is a new target, not another feature on the existing ones.

Changes

  • Version → 0.2.0 in app/pubspec.yaml; CHANGELOG's Unreleased block folded into the release; roadmap + wiki version headers updated.
  • Android is now released, with a caveat stated everywhere (CHANGELOG, README, roadmap, workflow comment): the APK is signed with the debug key that build.gradle.kts falls back to when key.properties is absent. It installs and runs, but it cannot go to Play, and a later properly-signed build cannot upgrade it in place. A real release keystore has to land before that changes.
  • release.yml gains a build-android job — no Rust step, since the mobile UI is SSH-only and nothing pulls the RDP/VNC native libraries into the Android build. Emits YourSSH-0.2.0-Android.apk plus its debug symbols, and the release job now waits on it and attaches the APK.
  • README — Android in the platform badge and a row in the download table; the roadmap checkbox flipped to shipped.

What is in 0.2.0

Added — Android app (development preview): hosts list with search / folder chips / tag grouping and live reachability probes; terminal with the accessory key bar (sticky Ctrl/Alt, ^C/^D, arrows, special characters), pinch-zoom and long-press cursor drag; single-panel SFTP; snippets quick-run; key generation and import; port-forwarding rules; host import over cloud Supabase pull or P2P QR camera scan; biometric app-lock and the TOFU dialog. RDP/VNC, plugins, recording and the audit log stay desktop-only.

Changed — new app icon: vector masters in assets/branding/, rendered per platform by tool/gen_app_icons.py (macOS iconset inset to Apple's 824/1024 grid, 16–256 px Windows ICO, Android adaptive layers incl. an Android 13+ monochrome layer, in-app/README logos). Glyph holds 6.4–12:1 contrast on the amber plate; icon reads at 6.3–7.3:1 against every dark app surface.

Also fixed on the way in (all Android/CI-side, none of it ever shipped to users): the Android terminal never opening on tap (#98), the release manifest missing INTERNET (#99), the macOS build broken by a supabase_flutter lockfile drift into passkeys (#96), and CI breaking whenever flutter_rust_bridge publishes (#100).

Type of change

  • release — version release to `master`

How was this tested?

Before wiring the new job: `flutter build apk --release` locally (85 MB) and `aapt2 dump permissions` on the resulting APK confirming `android.permission.INTERNET` is present in a release build — that permission only reached the main manifest this cycle, so it was worth checking rather than assuming.

On `develop`: `flutter analyze` clean, 1721 tests green, `flutter build macos` green, and the parallel CI suite (`app`, `bridge (yourssh_rdp)`, `bridge (yourssh_vnc)`, `test`) green at 5.9 min.

Mobile screens captured on an Android 16 emulator against a real SSH server are in `screenshots/11-mobile/`.

Note for the merge

Merging this to `master` triggers `release.yml`, which builds and publishes tag `v0.2.0` with the macOS DMG/ZIP, Windows x64+arm64, Linux amd64+arm64, and now the Android APK.

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.
The launcher artwork was a thin-stroke terminal window exported by hand to
one bitmap per platform. Below ~32 px the strokes blurred into an amber
blob, and with no shared source the macOS iconset, Windows .ico, Android
bitmaps and in-app logos drifted apart.

Replace all of it with vector masters in assets/branding/ plus a generator:

- Composite artwork: amber squircle (superellipse corners, so it matches the
  macOS/iOS shape) with a solid `>_` prompt glyph in #141416, weighted to
  survive a 16 px raster.
- tool/gen_app_icons.py renders every target straight from the vectors via
  sips, so no output is an upscale of a smaller PNG, and re-running it is
  idempotent. It verifies each PNG's real dimensions before accepting it.
- Android gets proper adaptive layers: the foreground glyph is scaled to
  0.95 to stay inside the 66dp-of-108dp safe circle under any OEM mask, and
  a monochrome layer enables Android 13+ themed icons. The background is a
  gradient drawable rather than five PNG buckets — sharp at any density and
  ~380 KB smaller in the APK — with its colours read out of the master SVG
  so there is still one source of truth. values/colors.xml went away with
  the bitmap background it existed for; the manifest now also declares
  roundIcon.
- macOS rasters are inset to 824/1024 per Apple's icon grid so the glyph
  matches the optical size of system icons in the Dock. Windows ships a
  16–256 px multi-size ICO.

Verified: APK resource compile (icon payload 511 KB -> 131 KB), the macOS
.appiconset compiling to AppIcon.icns, and all four Android mask shapes plus
the themed layer rendered down to 36 px.

Refs #95
`flutter build macos` failed on this branch with "'PasskeysPlugin' is only
available in macOS 13.5 or newer". Adding the mobile dependencies
(mobile_scanner, local_auth, google_fonts) re-resolved the lockfile and
carried supabase_flutter from 2.12.4 up to 2.15.0; release 2.13 added a
passkeys dependency whose passkeys_darwin plugin is annotated
@available(macOS 13.5), while the Runner targets MACOSX_DEPLOYMENT_TARGET
10.15 — so the generated macOS plugin registrant stopped compiling. Android
was unaffected, which is why the break went unnoticed.

Resolve the lockfile against develop instead of raising the deployment
target, which would drop macOS 10.15-13.4 for every desktop user. The SDK
itself is genuinely used (supabase_service, share_session_service), so it
cannot be dropped either. pubspec.yaml records why the version is held.

Regenerated plugin registrants follow: passkeys leaves the macOS and Windows
registrants, and device_info_plus / ua_client_hints go with it — they were
only there as supabase_flutter 2.15.0 transitives. macOS Podfile.lock now
carries the two mobile pods that do support it (local_auth_darwin,
mobile_scanner).

Verified: flutter build macos, flutter build apk, flutter analyze clean,
1720 tests green.

Fixes #96
The workflow only triggered on pull_request into master, but master is a
release-only branch — every feature PR targets develop. So feature PRs
reported no checks at all (`gh pr checks` says "no checks reported") and the
suite first ran when a release PR reached master, long after the work had
been reviewed and merged into develop.

Add develop to the trigger list so analyze, the app tests and both Rust
crates' tests gate the branch where work actually lands.
…e old icon

The generator config was still in pubspec.yaml pointing at the previous
artwork — `adaptive_icon_background: "#000000"` and a 388 KB
`assets/android_adaptive_fg.png`. Anyone running `dart run
flutter_launcher_icons` would have silently overwritten the new adaptive
layers with the old black-background icon, and the dev dependency invited
exactly that. It also could not have produced the current icons anyway: it
covers Android/iOS only, takes a raster input, and has no notion of the
per-platform insets the macOS icon grid needs.

Remove the config, the dev dependency and the orphaned foreground asset;
pubspec.yaml now points at tool/gen_app_icons.py instead.

Also stroke yourssh_mark.svg with currentColor: its ink #141416 scores
1.00:1 against the desktop sidebar #141414 and 1.02:1 against the mobile
surface, so a bare mark dropped onto app chrome would have been invisible.
The consumer picks the colour now.
Two follow-ups on the develop trigger:

- actions/checkout@v4 runs on Node 20, which the runners now force onto
  Node 24 with a deprecation annotation. v7 is current (v5 moved to Node 24,
  v6 writes credentials to a separate file, v7 blocks fork checkouts for
  pull_request_target / workflow_run and moves to ESM) — none of which
  affects a plain checkout on a pull_request event.
- Add a concurrency group keyed on the PR so pushing again cancels the run
  still in flight instead of compiling the IronRDP and VNC trees twice side
  by side. A full run is ~9 minutes, most of it Rust.
Tapping a host on Android connected fine but never navigated: the card sat in
its connecting state for the whole session and the terminal only appeared once
the shell had closed.

_openSession awaited SessionProvider.connectAny before pushing the terminal
route, and that future does not resolve when the connection is ready —
_doConnect awaits SshService.openShell, which completes only when the shell
closes. Start the connection and navigate straight away instead: the session is
registered synchronously, so the terminal can pick it up and render its own
connecting state, which is exactly what the desktop call sites do with
unawaited(connectAny(...)).

The existing nav test passed because its fake connectAny returned immediately.
Added a fake that behaves like the real one — session registered synchronously,
future left pending — which fails against the old code and passes now.

Fixes #98
The main manifest declared only CAMERA. Flutter injects INTERNET into the debug
and profile manifests for its own tooling, so every development build had
network access and nothing looked wrong — but a release APK/AAB would have
installed with no network permission at all, failing every SSH connection,
SFTP transfer and port forward on the shipped app.

Fixes #99
Captures all 14 mobile screens off a real device: hosts list, per-host action
sheet, new-host form, snippets, keys, settings, sync/QR pairing, and — against a
live SSH server — the connecting state, a terminal running real commands, the
accessory key bar, the ⋮ menu, the SFTP browser and port forwarding.

Runs under `flutter drive` rather than `flutter test`: the latter uninstalls the
app at the end of the run, taking anything written inside the app's directories
with it, so frames come back over the driver connection and
test_driver/integration_test.dart writes them on the host. The Android surface is
converted with convertFlutterSurfaceToImage first, since takeScreenshot cannot
read a SurfaceView.

The terminal and SFTP shots need a real server: a linuxserver/openssh-server
container on 2222, which the emulator reaches as 10.0.2.2:2222. The test seeds
demo hosts / keys / snippets, puts the demo password in secure storage, disables
the biometric lock (no enrolled fingerprint on an emulator), and restores every
touched pref in a finally block. Without the container those shots degrade to the
failure state instead of failing the run.

Screenshots land in screenshots/11-mobile/. The skill doc records the invocation
plus the emulator gotchas (full /data means INSTALL_FAILED_INSUFFICIENT_STORAGE
for a ~185 MB debug APK).
The RDP and VNC Dart tests started failing with

  Bad state: yourssh_rdp's codegen version (2.12.0) should be the same as
  runtime version (2.13.0)

as soon as flutter_rust_bridge 2.13.0 hit pub.dev. rust/Cargo.toml already pins
the crate exactly (=2.12.0) and lib/src/generated/ was produced by codegen
2.12.0, but the Dart dependency allowed a range, so a fresh resolve — which is
what CI does, since package lockfiles are not committed — picked up a runtime
the generated code refuses to work with.

Pin the Dart side exactly, so all three move together. Bumping the bridge now
means regenerating lib/src/generated/ and updating Cargo.toml in the same
change, which is how flutter_rust_bridge expects to be upgraded.

Fixes #100
feat(mobile): Android mobile app (v1)
One sequential job took ~9m45s, and the two heaviest steps do not depend on
each other at all: the yourssh_rdp cargo tests (165s) and the app suite (176s).
Nothing under app/test loads the native bridge — verified by hiding
assets/native and running the RDP/VNC model and workspace tests, which still
pass — so the app work never needed to queue behind Rust.

Split into an `app` job and a `bridge` matrix job per crate. Each crate keeps
its cargo tests, build.sh and Dart tests together: both cargo invocations use
--release, so the library build reuses the test build's compilation, and
splitting them would compile the IronRDP / vnc-rs tree twice. fail-fast is off
so a broken RDP crate does not hide a broken VNC crate, and rust-cache is keyed
per crate workspace.

Wall clock becomes the longest job (~5 min, the RDP crate) instead of the sum.
It costs two extra Flutter/toolchain setups in compute — cheaper waiting, more
machine time.

master's branch protection requires a status check literally named "test", so
the split keeps a job with that name: an aggregate gate that needs both others
and fails unless both succeeded. It runs with `always()` — otherwise a failure
upstream would skip it and leave the required check pending forever rather than
red.
ci: split the suite into parallel jobs
Minor bump rather than the usual patch: Android is a new target, not another
feature on the existing ones.

- Version to 0.2.0 in app/pubspec.yaml; CHANGELOG's Unreleased block folded
  into the release; roadmap and wiki version headers updated; the roadmap's
  "in progress" Android entry and the README's unchecked Android box now read
  as shipped, both stating the debug-signing caveat.
- README: Android in the platform badge and a row in the download table.
- release.yml gains a build-android job. No Rust step — the mobile UI is
  SSH-only, so nothing pulls the RDP/VNC native libraries into the Android
  build. The APK is signed with the debug key that build.gradle.kts falls back
  to when key.properties is absent, which is called out in the workflow, the
  CHANGELOG, the README and the roadmap: it installs and runs, but it cannot
  go to Play and a later properly-signed build cannot upgrade it in place.

Verified locally before wiring the job: `flutter build apk --release` succeeds
(85 MB) and the release APK really does carry android.permission.INTERNET,
which only landed in the main manifest this cycle.
@thangnm93
thangnm93 merged commit 832c44b into master Aug 23, 2026
4 checks passed
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