A thin, full-screen WebView wrapper around an sshx.io
session that makes it usable on a phone. It loads the session URL, injects the
validated in-page overlay (../sshx-mobile.js, shipped as an
asset), and — the reason this is a native app and not a bookmarklet — drives a
trusted resize-handle drag via WebView.dispatchTouchEvent(MotionEvent) so
the sshx PTY reflows crisply to the device viewport. It can also scan a
session QR with CameraX + ML Kit.
- Package / applicationId:
dev.moukrea.sshxmobile - App label:
sshx mobile - Min / target / compile SDK: 26 / 35 / 35
- The authoritative build contract is
SPEC.md.
You need the Android SDK to build this. This repo machine has the JDK but no Gradle and no Android SDK, so the app is built in Android Studio, not here. See Limitations below.
-
Install Android Studio (Ladybug / 2024.2.1 or newer) with:
- Android SDK Platform 35,
- Android SDK Build-Tools 35.x,
- a device or emulator running Android 8.0 (API 26) or newer.
The pinned toolchain (AGP 8.7.3, Gradle 8.11.1, Kotlin 2.0.21, Compose BOM
2024.12.01, CameraX 1.4.1, ML Kit barcode 17.3.0) is declared in the Gradle
files; Android Studio downloads it automatically. Build with JDK 17 —
jvmToolchain(17)is set, so a host JDK 21 is fine.
-
Open the project: File → Open → select the
android/directory (the one containingsettings.gradle.kts). Let Gradle sync finish (first sync downloads the SDK/AGP/Compose artifacts). -
Run / install:
- Plug in a device with USB debugging on, or start an emulator.
- Press Run ▶ (the
appconfiguration), or from a terminal that has the Android SDK on itsPATH:cd android ./gradlew installDebug # installs the debug APK on the connected device # or build an APK to sideload: ./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
-
Grant permissions on first launch: Camera (only when you open the QR scanner) and Internet (granted at install).
On launch you get the entry screen:
-
Enter a room id / URL. Paste any of these into the text field and tap Connect:
- a full URL:
https://sshx.io/s/UuWX4sOSrm#dmGcdlAyGfkrMf - a tail:
s/UuWX4sOSrm#dmGcdlAyGfkrMf - a slashed tail:
/s/UuWX4sOSrm#dmGcdlAyGfkrMf
The input is run through
UrlNormalizer→ canonicalhttps://sshx.io/s/<id>#<fragment>. The#fragmentis the end-to-end decryption key and is passed verbatim to the WebView — never stripped, re-encoded, or sent to the server. The last successful URL is remembered and pre-filled next launch. - a full URL:
-
Scan QR. Opens the camera scanner. Point it at a QR printed by the host tool
../host/sshx-qr. The first decoded value (the full URL) is normalized and the session opens automatically. -
Resolver URL (optional). A stable address (e.g. a Tailscale
http://<host>.<tailnet>.ts.net:8765/current-url) served by the host's optional resolver. When set, the app fetches the current session URL from it instead of you re-scanning after a host restart. See Persistence below.
Once the session loads, the injected overlay hides sshx's desktop chrome and gives
you a phone layout: a tab bar (close / new-tab / zoom) on top, the active
terminal filling the height, and a keyboard-aware ← ↑ ↓ → bar at the bottom
that lifts above the on-screen keyboard.
sshx ignores synthetic JS pointer/wheel events, so injected JavaScript cannot
resize/reflow the PTY. But a real/trusted drag of a terminal's bottom-right
resize handle does reflow the PTY crisply (more rows/cols, native font). In an
Android WebView, JS injected via evaluateJavascript is still synthetic — but
native WebView.dispatchTouchEvent(MotionEvent) delivers trusted touch.
So ReflowController performs, on the main thread, for each terminal tab:
prepReflow(i)(overlay JS) → switch to tabi, setmode = "native"+ zoom1so the handle sits at a real, unscaled, draggable coordinate; it returns the handle position and the bottom-righttarget, both in CSS px.performTrustedDrag(...)→ converts CSS px to device px (devicePx = cssPx * displayMetrics.density) and dispatches a fixed-downTimeACTION_DOWN→ interpolatedACTION_MOVEs →ACTION_UPgesture, dragging the handle to the target. This is the trusted touch that makes sshx reflow.- After all tabs,
finishReflow("fillHeight")returns to the height-filling display mode (now near 1:1, so text stays crisp) and restores the previously active tab.
The sweep re-runs (debounced, coalesced — only one at a time, latest viewport
wins) on: overlay-ready, layout changes (rotation / multi-window / foldable /
keyboard via addOnLayoutChangeListener), the keyboard inset-animation end fast
path, onConfigurationChanged, and the overlay's own visualViewport change
callback. A fallback poll covers a missed bridge call.
sshx's reconnect loop survives network changes without changing the URL, so
a phone holding the URL just waits through outages. But a new URL is minted on
every host-process restart (there is no session-id pinning flag).
To avoid re-scanning after a restart, the host can run a tiny resolver that always serves the current URL at a stable address. Set that address in the app's Resolver URL field; the app fetches it (running the response through the same normalizer) on connect, and re-fetches on a WebView connection error or a manual refresh.
Because the resolver returns the URL — including the #key — in plaintext, the
app's network_security_config.xml permits cleartext only to RFC-1918 (LAN)
and *.ts.net (Tailscale) hosts; sshx itself stays HTTPS-only. See
../host/README.md for the privacy trade-off and setup.
- Requires the Android SDK to build here. This repo machine has no Gradle and no Android SDK; build in Android Studio (or any machine with the SDK + JDK 17).
- The overlay couples to sshx's current DOM/classnames (
.term-container, thebg-red-500close dot, the Create new terminal button, the.cursor-nwse-resizehandle). An sshx front-end change can break the overlay or the reflow handle math. - Zoom is a CSS scale (crisp at 100%, soft when zoomed): xterm's
fontSizelives in unreachable Svelte closures. Crisp magnification needs a real resize, which is what the trusted-drag reflow does for fill, not arbitrary zoom. - The resolver fetch is cleartext over your overlay net only; do not point it
at a public address (it would leak the E2E
#key). - Pinned versions are all-or-nothing (Compose-compiler ↔ Kotlin coupling is
exact). See
SPEC.md§0.
- Host tools (QR + persistence + resolver):
../host/README.md - The in-page overlay:
../sshx-mobile.js - Build contract:
SPEC.md