f000a9d979514c3937f161cba2bd9773e4e34b07
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
97e39949a8 |
Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md |
||
|
|
5cc755b0b6 |
fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped — recorded as a withKnownIssue rather than fixed. Height now derives from the content size category (and tracks live changes via registerForTraitChanges); the known-issue marker is gone, replaced by positive assertions including a 12-category no-clip sweep and a guard that stays red if anyone writes the constant back. Honest tradeoff: the keycap font is clamped at .accessibility2, the same policy the design system already applies to dense content, because an unclamped AX5 bar would eat the terminal. A test pins the clamp so the two cannot drift. - Thumbnails silently 401'd on a token-gated host: the pipeline built its own transport with no token source, and by design never throws, so every preview degraded to a placeholder with no signal. Assembled from AppEnvironment now. - Android had zero CI while the token wave shipped 24 files of secret-handling code. The instrumented leg is workflow_dispatch-only and says why in the file: no one has ever seen it green on a runner, and a required leg nobody trusts just produces a false green. - Android persisted a validated token before the host probe succeeded, stranding a secret for a host that never paired. App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691. |
||
|
|
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.
|
||
|
|
8075d2c671 |
test(android): the instrumented suite A34/A35 never had
The plan marked A34 (instrumented E2E against a real host) and A35
(macrobenchmark) DONE. Neither had a single line of code — :app had no
androidTest source set at all, and `testInstrumentationRunner` named a
HiltTestRunner class that did not exist. They had been converted into checklist
bullets. This adds 67 instrumented tests and 3 macrobenchmarks, and every one
of them has actually been executed on an emulator against this repo's own
server — not merely compiled.
Why this layer had to exist: four crash-on-first-interaction defects shipped
behind a green 900-test suite, for one structural reason — no JVM test
instantiates an android.view.View, creates an InputConnection, dispatches a
MotionEvent or runs a layout pass. So the rig builds the REAL RemoteTerminalView
in a REAL Activity and drives it through the REAL framework entry points,
faking only the wire so assertions can be about BYTES rather than "did not
crash". That distinction matters: a no-crash-only test would have passed against
the silent-black-hole variant of the IME bug, which was arguably the worse half
of it.
The four crash regressions (28 tests):
keys — Enter is \r not \n, Ctrl+C is 0x03, Backspace 0x7f, and arrows in BOTH
DECCKM modes driven off the live emulator bit, so ESC[A vs ESC OA is
asserted rather than assumed; BACK falls through and emits nothing; soft
keyboard commitText/sendKeyEvent/deleteSurroundingText and a CJK
composition pass through verbatim.
alternate-screen scroll — every test feeds ESC[?1049h FIRST and asserts the
buffer is active before touching anything, because that is the branch that
used to crash. Real dispatchTouchEvent drags both directions, the DECCKM
form, the mouse-tracking branch emitting a wheel report and NO arrows
(branch order), the wheel's 3-rows-per-notch, and the fling proven not to
start by 800 ms of silence after ACTION_UP.
autofill — the framework's own autofill-structure gate is closed on both the
frame and the stock child, before and after focus.
resize — including the draw-race counterpart of the AIOOBE fixed in
|
||
|
|
9114630c3a |
feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written Cookie header on every request and on the WS upgrade (no CookieJar, matching the frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401 upgrade as a terminal state with no reconnect loop. |
||
|
|
c1612d0145 |
build(android): make release buildable and instrumented testing possible
Four gaps that made "ship it" and "test it on hardware" impossible. RELEASE SIGNING. assembleRelease was silently producing app-release-unsigned.apk because there was no signingConfigs block at all — the failure mode where you only notice at install time. Credentials now resolve from keystore.properties, then local.properties, then WEBTERM_RELEASE_* env; nothing is created or committed, and keystore.properties.example is the template. The degradation is ENFORCED, not documented. The signingConfig is only created when credentials actually resolve — an empty config is precisely what makes AGP emit an unsigned artifact quietly — and a guard on packageRelease throws with an actionable message, distinguishing "not configured" from "credentials found but the keystore file is missing: <path>". It hooks the PACKAGE task on purpose: R8 has already run and reported by then, so a developer without a keystore still gets the full shrink result rather than an early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails loudly, and no unsigned APK is written. VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01", where the suffix is a factual claim about device verification rather than decoration: alpha means device QA is essentially unstarted, beta means the A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole checklist is ticked. versionCode stays 1 because no artifact has ever left the build machine. MINIFICATION. release had isMinifyEnabled = false and an empty rules file — so nothing had ever exercised R8 on this app, and the first release build would have been the experiment. Now minify + resource shrinking, with a deliberately short rules file: the actual artifacts were checked, and kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship their own consumer rules, so none are re-declared. The termux packages are kept whole because the JitPack artifacts ship no rules and :terminal-view binds to non-API internals — it writes the public TerminalView.mEmulator field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting that wrong yields a build that crashes only in production. lintVitalRelease also passes now, the two pre-existing lint ERRORS having been fixed earlier this session. ANDROID TEST ENABLEMENT. :app had no androidTest source set and no instrumentation runner, which is the mechanical reason A34/A35 have no code at all. The runner (Hilt-aware) and dependencies are wired, and a :macrobenchmark module exists. The tests themselves are a separate change. Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleBenchmark :macrobenchmark:assembleBenchmark :app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0 failures on a forced re-execution with test-results deleted and --no-build-cache. |
||
|
|
b09b90e5bb |
fix(android): the ThumbnailPipeline bugs its skipped review would have caught
This file's cross-review was explicitly skipped when it was written — PROGRESS_ANDROID.md says so — and it had no production call sites until this session, so nothing had ever reviewed OR executed it. Wiring it to the home screen finally made an adversarial concurrency review worthwhile. It confirmed the hard parts are right (the check-then-insert dedup critical section, withPermit's exactly-once release with the fallback outside it, the NonCancellable finally, and rethrowing CancellationException) and then found five real defects. Every premise was re-verified from the termux v0.118.0 bytecode before fixing. 1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows went up to 200 aliased external rows 100..199 onto 0..99 — and left the emulator's scroll state invalid during append(). A pure ThumbnailGrid now derives transcriptRows = max(rows, MIN). 2. A legitimately-sized session was LMK bait. cols/rows are server-supplied and the protocol validates resize only to [1,1000], so a 1000x1000 session forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every poll tick. Rather than clamp the grid — which would CROP a real screen — the cell size is now derived from the target, shrinking the font's own cell by an exact integer fraction with the aspect preserved. Any geometry in 1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from 3.1 MB to 322 KiB. 3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp callTimeout (the per-socket read timeout is reset by every trickled byte) and the render is deliberately uncancellable by any caller, so two slow-trickle previews held both permits until close() and no thumbnail rendered again. A withTimeout now sits inside withPermit, budgeting the work rather than the permit wait. The subtlety that makes this correct: TimeoutCancellationException IS a CancellationException, so it is caught BEFORE that branch and routed to the placeholder — otherwise a slow host would still kill the pipeline instead of degrading. 4. The in-flight entry was leakable, contradicting a comment that claimed it never was. `scope.async` on an already-cancelled scope never runs its body, so the finally never ran: a post-close() request left a dead Deferred that every later request awaited forever, and inFlight grew unbounded. Now guarded inside the mutex, and the comment names the one path that genuinely cannot release (where the map dies with the pipeline anyway). 5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every PTY byte and the list polls every 5s, so an active session mints a new key each tick; the row cancelled the old caller but the old render ran a full GET plus a full raster whose bitmap was cached under a key nothing would request again — occupying both permits ahead of the key actually on screen, and growing without bound once render latency exceeded arrival rate. A newer key now cancels the older in-flight render for that session. Also made two false statements true rather than leaving them: retainOnly now prunes under the same monitor publish() takes, so a render landing after the prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no longer claims to prevent an oversized-body OOM (the body is already buffered and parsed by then) nor to "mirror the server's cap" (the server defaults to 24 KiB, not 256 KiB, and an operator can raise it unbounded). RED was observed for all four behavioural fixes before implementing. Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify -> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent distrusted a FROM-CACHE green, forced a real execution, and confirmed the code reached the packaged APK by scanning its dex. |
||
|
|
980dbaa928 |
feat(android): wire the dead code, render the git panel, make the token gate real
Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.
Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:
- Session-list preview thumbnails. SessionsHome called SessionListScreen
without the `thumbnails` argument, so the `= null` default won and every
row rendered a placeholder forever — on the home session chooser, whose
entire purpose is seeing what each session is doing.
- Quick-reply chips and their palette editor.
- PointerContextMenu (large-screen secondary click).
ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.
The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.
Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.
The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.
And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.
Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.
Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
|
||
|
|
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.
|
||
|
|
0b35dc043f |
feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)
- wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI +
host-menu "自动获取证书"), mirroring iOS — Android previously only had manual
.p12 import; the enroll library was built but unreachable.
- renew is now mTLS-only ({csr}-only body, no Authorization header) matching the
/device/:id/renew contract (the enroll bearer is minutes-lived → silent
rotation would have thrown weeks later).
- enroll refreshes the identity-repository cache so a mid-session-enrolled cert
is presented on the next mTLS handshake without a process restart.
gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify
green. On-device QA (keygen/enroll/present) is the operator's step.
|
||
|
|
bc31de85dd |
feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)
Brings the native Android client to parity with the web/iOS Projects + git surface, consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules" premise was stale; they were already online.) - :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders, safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/ gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body; writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)). - :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push; new WorktreeViewModel (create/remove/prune + client branch validation + isMain block); ProjectDetailViewModel failure-isolated PR-chip + recent-commits. - Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits, worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the pre-existing "no inbound link to the diff screen" gap. Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify` BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held; git status android-only. Compose rendering/interaction deferred to on-device QA (android/DEVICE_QA_CHECKLIST.md), as prior Android waves did. |
||
|
|
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.
|