Commit Graph

7 Commits

Author SHA1 Message Date
Yaojia Wang
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.
2026-07-30 15:37:09 +02:00
Yaojia Wang
35d32f4670 feat(android): decode the v0.6 git-panel surface
The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd,
all after the last Android commit, so the client silently ignored every new
field (they are additive and optional, so nothing crashed — it just rendered
the pre-w6 shape: one bare dirty dot and no sync information at all).

Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount,
ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus
GET /projects/worktree/state (read) and POST /projects/git/fetch (write).

Two rules from the server's own commit message are encoded as behaviour
rather than left to each call site to remember:

  `SyncState.isFullySynced(now)` is the only sanctioned way to ask whether
  the green all-clear may be rendered, and it answers false for anything
  unknown. ahead/behind compare against @{u}, a locally cached ref that only
  a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most
  importantly NO UPSTREAM leaves both counts undefined — which is not zero —
  and that is the normal state of a fresh worktree branch. The server calls
  that fall-through the easiest bug to ship here; there is a test per way it
  could regress, including a fetch timestamp in the future, which is clock
  skew rather than evidence that anything was checked.

  `GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather
  than assuming the unpushed ones come first. git log is date-ordered, so
  merging an older branch interleaves unpushed commits below pushed ones, and
  the row-count shortcut fails in the dangerous direction — it would label an
  unpushed commit as pushed. It returns null, not -1, when no boundary may be
  drawn, so "no upstream" cannot be mistaken for "boundary before everything".

Origin policy is asserted in both directions: worktree/state carries no
Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch
body contains no remote or refspec — the remote is derived server-side
precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its
own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed
fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of
pretending it refreshed.

Verified: :api-client:test + koverVerify green; 22 new tests.
2026-07-30 09:45:59 +02:00
Yaojia Wang
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.
2026-07-19 08:31:29 +02:00
Yaojia Wang
5e427dcf98 feat(android): device enrollment library + rotation (B4)
Hardware-backed (StrongBox/TEE) key + PKCS#10 CSR + /device/enroll client in
:api-client, presented via the existing X509KeyManager; renew body {csr}-only;
DeviceKeyProvider seam makes the orchestration JVM-testable. api-client tests +
koverVerify 80% gate pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
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.
2026-07-13 05:58:28 +02:00
Yaojia Wang
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.
2026-07-10 16:41:21 +02:00
Yaojia Wang
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.
2026-07-08 13:45:07 +02:00