Files
web-terminal/android
Yaojia Wang 3e5cfdc1cf fix(android): make the terminal actually usable on a device
The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.

1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
   but never installed a `TerminalViewClient`, and the stock Termux view
   dereferences `mClient` with no null guard on the first line of the paths
   a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
   offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
   returning false continues into `mTermSession.write()` at offset 150, and
   `mTermSession` is null forever because `TerminalSession` is final and
   forking a process is what this app must not do. So the client consumes
   the key itself and `KeyRouting` has no defer-to-stock branch at all —
   the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
   stays the authority for the key tables, so DECCKM still emits `ESC O A`.

2. The PTY never learned the real size. Nothing called
   `RemoteTerminalSession.updateSize`, and the stock fallback is dead
   (`TerminalView.updateSize` early-returns without a session), so every
   full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
   it from real cell metrics — read through the public `getFontWidth()` /
   `getFontLineSpacing()` accessors rather than reflection, which would
   break silently under R8, or a re-measured Paint, which would drift from
   what the renderer actually draws (plan risk R5).

3. Bare-LAN connections were impossible. `network_security_config.xml` was
   a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
   http and derives ws://. The format has no CIDR syntax, so the allowlist
   its own comment promised cannot be written; cleartext is permitted in
   base-config, the tunnel domain keeps a TLS block, trust anchors are
   pinned to system-only, and the guard moves to the §5.4 pairing tiers.

Adversarial review then found three more, and one it got wrong:

  - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
    path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
    into `handleKeyCode` whenever the alternate buffer is active, and that
    dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
    this was a crash during normal use on the app's main screen.
    `TerminalScrollGesture` claims the vertical drag first and reproduces
    all three `doScroll` branches, closing the fling runnable and
    `onGenericMotionEvent` with it.
  - `autofill()` dereferences `mTermSession` unguarded while the view
    advertises itself autofillable, so any password manager crashed it.
    The subtree is excluded from the autofill structure.
  - The review asked for stock text selection to be restored. It cannot be:
    the ActionMode's own Copy and Paste handlers dereference the absent
    session, so a reachable selection UI has an NPE on its Copy button.
    Long press stays consumed and copy-out is recorded as a feature gap.

Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.

Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
2026-07-30 08:59:01 +02:00
..

WebTerm — Android client

A native Android client for the WebTerm browser-terminal server, targeting functional parity with the shipped iOS client. See the full design in docs/ANDROID_CLIENT_PLAN.md (stack §2, module architecture §3, server contract §4, task waves §5).

This directory is a Gradle multi-module project. The module set mirrors the iOS SPM package set and inherits its rule: dependencies only flow down; nothing points upward (ARCHITECTURE §1).

Build environment (SDK installed — all modules build)

The Android SDK is installed and every module — pure Kotlin/JVM and Android-framework alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build against SDK 35/36.

  • Pure Kotlin/JVM (./gradlew test): :wire-protocol, :session-core, :api-client, :client-tls, :test-support, :transport-okhttp.
  • Android-framework (online in settings.gradle.kts): :app, :terminal-view, :host-registry, :client-tls-android.

Setup: local.propertiessdk.dir=/usr/local/share/android-commandlinetools; google() is in pluginManagement/dependencyResolutionManagement. Green gate: ./gradlew test :app:assembleDebug koverVerify.

Module map (mirror of the iOS SPM packages — plan §3)

iOS SPM package Android module Kind Status
WireProtocol :wire-protocol pure Kotlin/JVM built
SessionCore (reducers) :session-core pure Kotlin/JVM built
APIClient :api-client pure Kotlin/JVM built
ClientTLS (pure half) :client-tls pure Kotlin/JVM built
TestSupport :test-support pure Kotlin/JVM (fakes) built
ClientTLS (fwk half) :client-tls-android Android (AndroidKeyStore/Tink) built
HostRegistry :host-registry Android (DataStore) built
SwiftTerm host view :terminal-view Android (Termux wrap) built
App/WebTerm :app Android app (Compose/Hilt/FCM) built

Not yet scaffolded: :transport-okhttp (OkHttp TermTransport/HttpTransport impls, JVM) is owned by task A7 and will be added then. The iOS URLSession*Transports consolidate into it (plan §3 framing note).

Dependency graph (arrows = "depends on")

                          :app
        ┌───────────────┬───┴────┬──────────────┬───────────────┐
        ▼               ▼        ▼              ▼               ▼
 :terminal-view   :session-core :api-client  :host-registry  :client-tls-android
        │              │          │                               │
        │              │          │                               ▼
        │              │          │                          :client-tls (pure)
        └──────┬───────┴──────────┴──────────────┬────────────────┘
               ▼                                  ▼
         :wire-protocol   ◀────────────  :transport-okhttp
               ▲
               └──────── :test-support  → test source sets only

:wire-protocol is the frozen shared contract (Android analogue of src/types.ts + WireProtocol) — ClientMessage/ServerMessage, MessageCodec, Validation, WireConstants, HostEndpoint (the single Origin/wsURL derivation), and the TermTransport / HttpTransport / PingableTermTransport boundary interfaces. New wire types are added only here (a coordination point).

Toolchain

  • Gradle 9.6.1 (via the committed wrapper — always use ./gradlew).
  • Kotlin 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
  • JVM toolchain 17 (jvmToolchain(17) in every module).
  • Versions are pinned in the version catalog gradle/libs.versions.toml: kotlinx-serialization-json, kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.

Pure modules apply kotlin("jvm") + kotlin("plugin.serialization"), wire the libs.bundles.unit-test bundle into testImplementation, and run tests on the JUnit Platform (tasks.test { useJUnitPlatform() }).

Build & test

# Use the committed wrapper for everything.
./gradlew help          # sanity: the build configures
./gradlew projects      # lists the 5 pure modules
./gradlew build         # compile all pure modules
./gradlew test          # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)

Testing target: ≥80% Kover coverage on the pure modules (:wire-protocol, :session-core, :api-client, :client-tls pure half). TDD, immutable data, small focused files — same discipline as the rest of the repo.

Android SDK setup (proven working)

The pure JVM modules need only a JDK + Gradle. The Android-framework modules (:app, :terminal-view, :host-registry, :client-tls-android — plan AW2+) need the Android SDK. This machine is set up and the toolchain is proven (an AGP library module compiled against SDK 35 and produced an AAR):

  • SDK location: /usr/local/share/android-commandlinetools (installed via brew install --cask android-commandlinetools).
  • Installed packages: platform-tools, platforms;android-35, platforms;android-36, build-tools;35.0.0, build-tools;36.0.0. (:app compiles against SDK 36 — the Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; platforms;android-37 is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.)
  • android/local.properties (gitignored) points Gradle at it: sdk.dir=/usr/local/share/android-commandlinetools.
  • Shell env (for sdkmanager/adb): export ANDROID_HOME=/usr/local/share/android-commandlinetools.

Wiring an Android module (the working recipe)

  • Repos: google() is in both pluginManagement and dependencyResolutionManagement in settings.gradle.kts (needed to resolve AGP + androidx).
  • Plugin: AGP 9.2.1 (libs.plugins.android.library / .android.application), compatible with Gradle 9.6.1.
  • Gotcha: AGP 9 has built-in Kotlin — apply ONLY the android plugin. Adding org.jetbrains.kotlin.android errors with "no longer required since AGP 9.0".
  • Module block: android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }. (Framework modules target compileSdk = 36; targetSdk stays 35 per plan §2.)
  • :app UI-stack version matrix (A13, proven :app:assembleDebug green): AGP 9.2.1 · Kotlin 2.3.21 · Compose-compiler plugin org.jetbrains.kotlin.plugin.compose = 2.3.21 · Compose BOM 2025.11.01 (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0, material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP 2.3.9 · androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins: android.application + kotlin.plugin.compose + ksp + dagger.hilt.android (NEVER kotlin.android). Bump these together with compileSdk 37 once platform 37 is installable.

To add more SDK pieces later (e.g. an emulator image for instrumented tests): sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator".