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.
94 lines
5.4 KiB
XML
94 lines
5.4 KiB
XML
<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
|
|
<!-- Talk to the WebTerm server over WS/HTTP. No NEARBY_WIFI_DEVICES: Android
|
|
has no local-network permission prompt; plain WS to a LAN IP needs none
|
|
(plan §8 / R12). -->
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<!-- Push (A30/A31) — requested with rationale at runtime on API 33+. -->
|
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
<!-- QR pairing (A19) — CameraX preview; requested with rationale at runtime, denial
|
|
degrades to manual URL entry. android:required=false so non-camera devices install. -->
|
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
|
|
|
<!-- Cleartext: @xml/network_security_config is the AUTHORITATIVE policy (minSdk 29, so the
|
|
platform always reads it and IGNORES android:usesCleartextTraffic). The attribute is set
|
|
to true only so the manifest does not state the opposite of what actually ships: bare-LAN
|
|
`ws://` must work (plan §6.9). The config is where the real posture lives — cleartext ON
|
|
for user-typed LAN addresses (no CIDR syntax exists to scope it), system-only trust
|
|
anchors, and cleartext still BLOCKED for *.terminal.yaojia.wang. Read its header before
|
|
changing either value; they must stay in agreement. -->
|
|
<!-- Backup: OFF, and deliberately not an exclusion list (plan §8 "app-private, uninstall-wiped,
|
|
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
|
|
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
|
|
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
|
|
files. `dataExtractionRules`/`fullBackupContent` were rejected because they are allow-by-
|
|
default: every store added later is backed up until someone remembers to exclude it, and a
|
|
silent miss here is an off-device credential leak. Nothing in this app has restore value that
|
|
justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway.
|
|
NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed
|
|
by a `dataExtractionRules` <device-transfer> block, which needs an @xml resource (tracked
|
|
separately — see the orchestrator note). Do not set this back to true. -->
|
|
<application
|
|
android:name=".WebTermApp"
|
|
android:allowBackup="false"
|
|
android:label="@string/app_name"
|
|
android:supportsRtl="true"
|
|
android:theme="@style/Theme.WebTerm"
|
|
android:networkSecurityConfig="@xml/network_security_config"
|
|
android:usesCleartextTraffic="true">
|
|
|
|
<activity
|
|
android:name=".MainActivity"
|
|
android:exported="true"
|
|
android:label="@string/app_name"
|
|
android:theme="@style/Theme.WebTerm">
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN" />
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
</intent-filter>
|
|
<!-- A32 deep links → DeepLinkRouter (v4-UUID whitelist; the manifest never validates). -->
|
|
<!-- Custom scheme: webterminal://open?host=<uuid>&join=<uuid> -->
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data android:scheme="webterminal" android:host="open" />
|
|
</intent-filter>
|
|
<!-- Verified App Link: https://terminal.yaojia.wang/open?host=&join= .
|
|
autoVerify needs assetlinks.json (release SHA-256) served at
|
|
/.well-known/assetlinks.json — a deploy artifact (§8), not built here. -->
|
|
<intent-filter android:autoVerify="true">
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data android:scheme="https" android:host="terminal.yaojia.wang" android:pathPrefix="/open" />
|
|
</intent-filter>
|
|
</activity>
|
|
|
|
<!-- Push (A30) — FCM data-only entry point (server sends no notification block). -->
|
|
<service
|
|
android:name=".push.FcmService"
|
|
android:exported="false">
|
|
<intent-filter>
|
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
</intent-filter>
|
|
</service>
|
|
<!-- Deny action: auth-free, no-UI, expedited POST (goAsync). -->
|
|
<receiver
|
|
android:name=".push.DenyBroadcastReceiver"
|
|
android:exported="false" />
|
|
<!-- Allow action: translucent, excluded-from-recents trampoline hosting BiometricPrompt
|
|
(a receiver/service cannot present it — R1). -->
|
|
<activity
|
|
android:name=".push.AllowTrampolineActivity"
|
|
android:theme="@style/Theme.WebTerm.Translucent"
|
|
android:excludeFromRecents="true"
|
|
android:taskAffinity=""
|
|
android:exported="false" />
|
|
|
|
</application>
|
|
|
|
</manifest>
|