f000a9d979514c3937f161cba2bd9773e4e34b07
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
390dd11202 |
feat(android): close the audit's remaining parity gaps
Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.
CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.
Two real defects surfaced while building it:
renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
but /device/:id/renew returns only {cert, caChain, notAfter}; only
/device/enroll echoes deviceId. Every silent renewal would have thrown
MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
path segment we addressed.
And because no renew 201 carries renewAfter, a client that persisted it would
rotate exactly once and then report not-due until the cert died. That is why
renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
control plane's own formula. This is a latent iOS defect that Android now
avoids rather than inherits.
/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.
Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.
FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.
UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.
COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.
HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.
/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.
Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
|
||
|
|
538c8ebf34 |
fix(android): move emulator mutation to the render thread
The instrumented suite was run on a device for the first time and the app died
on the first test:
ArrayIndexOutOfBoundsException: length=31; index=31
at com.termux.terminal.TerminalRow.getStyle
at com.termux.view.TerminalRenderer.render
at com.termux.view.TerminalView.onDraw
PROGRESS_ANDROID.md had classified exactly this as "Accepted (not a defect):
steady-state append runs concurrently with the UI-thread onDraw ... torn read
self-corrects next frame". Both halves of that were wrong.
It is not cosmetic. TerminalRenderer.render caches its column bound once from
TerminalEmulator.mColumns and then indexes TerminalRow.getStyle(column) into a
raw long[] sized when that row was allocated. TerminalEmulator.resize publishes
the new mColumns BEFORE resizeScreen() reallocates the rows, so a draw landing
in that window reads the new bound against old-width rows. The first
out-of-range column is exactly oldColumns, which is why the exception was
always length == index — the consistency was a deterministic signature of a
grow, not a rare interleaving. A shrink is harmless, which is probably why
"self-corrects" looked plausible. TerminalBuffer.resize is non-atomic in
several more ways, and TerminalRow.setChar swapping mText for a larger array is
the same hazard on the append path.
Nor does it match upstream. Upstream's background reader only fills a
ByteQueue; TerminalSession$MainThreadHandler is what calls append, and
TerminalView.updateSize resizes — both on the main thread. Upstream has no
concurrent reader at all. There is also no lock to lean on: monitorenter
appears nowhere in TerminalEmulator, TerminalBuffer, TerminalRow or
TerminalRenderer.
The race was dormant until this session. updateSize had no call sites, so
mColumns never changed after bind and there was never a width mismatch to tear
on. Making resize work is what made this reachable — the same lesson as the
rest of this pass: the green suite could not see any of it because no JVM test
instantiates a View.
Fix: every emulator mutation now runs on the render thread, from inside the
same single confined consumer. §6.2 is intact — still one writer draining one
ordered channel, suspending across the main hop, so a resize and an append
still cannot interleave and submission order still holds. They are now also
serialised against onDraw, because one Handler work item cannot run inside
another. Resize still reaches the wire and the §6.4 forced re-assert still
bypasses the dedup.
Chunking becomes load-bearing rather than incidental: each 4 KiB slice is its
own main-thread work item, so a multi-MB ring replay interleaves with frames
and input instead of blocking behind one long call — which is what §6.2's
no-ANR requirement actually needs, rather than "runs off the main thread".
Gating the child's dispatchDraw was considered and rejected: it would have to
cover appends too, leaving a choice between blanking the terminal and blocking
the UI thread on a full reflow. A read/write lock was rejected because append
calls back out through TerminalOutput (title, bell, DA/DSR reply), so holding a
write lock across it would bury a deadlock invariant in app-level callbacks.
The KDoc that made the false claims now states the mechanism with offsets, and
RemoteTerminalHostView records why dispatchDraw is deliberately not gated.
Verified: the device crash is reproduced as a JVM test first (red), so this
regression is now guarded without a device. ./gradlew test :app:assembleDebug
koverVerify -> 903 tests, 0 failures. On the emulator the previously-crashing
test now passes and 5 of 6 alternate-screen scroll tests pass.
|
||
|
|
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.
|
||
|
|
e254918b1c |
feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated): :wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec), :session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client, :client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework: :app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless), :host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink). Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver, Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10); config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers. Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35, S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md. |
||
|
|
4ea8f7862a |
feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)
Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.
Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol — frozen wire contract (sealed Client/ServerMessage, enums,
HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
KeyByteMap (byte-matches public/keybar.ts).
- :api-client — all REST routes, tolerant decode, Origin-iff-guarded, prefs
unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls — pure half: PKCS#12 parse, X509KeyManager alias logic,
CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support — FakeTransport / FakeHttpTransport / virtual-clock fakes.
Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).
Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.
Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
|