diff --git a/docs/ANDROID_CLIENT_PLAN.md b/docs/ANDROID_CLIENT_PLAN.md index a4c58a0..5768ecc 100644 --- a/docs/ANDROID_CLIENT_PLAN.md +++ b/docs/ANDROID_CLIENT_PLAN.md @@ -236,7 +236,7 @@ Client-side frame max message size raised to **16 MiB** (guard with a client-sid APNs routes (`POST/DELETE /push/apns-token`) do not transfer to FCM. Add, mirroring `src/push/apns.ts` behind the existing `combineNotifyServices(...)` seam (zero new event paths — `notify(session, cls, token?)` already carries needs-input/done): -- **NEW `src/push/fcm.ts`** — a `NotifyService` impl: OAuth2 (RS256-signed service-account JWT → `oauth2.googleapis.com/token`, ~1h bearer cached like the APNs JWT) → `POST fcm.googleapis.com/v1/projects/{PID}/messages:send` with `{message:{token, data:{sessionId,cls,token?}, android:{priority:"high"|"normal", ttl}}}`. **Data-only** (no `notification` block). Prune on `UNREGISTERED`/`INVALID_ARGUMENT`; refresh token on 401. **Default: hand-roll RS256 + HTTPS** to honor the repo's zero-new-dep ethos (matching `apns.ts`); adding `google-auth-library` is an option flagged in R7. +- **NEW `src/push/fcm.ts`** — a `NotifyService` impl: OAuth2 bearer via **`google-auth-library`** (`GoogleAuth`/`JWT` with the service-account key + scope `https://www.googleapis.com/auth/firebase.messaging`; the lib handles token mint + ~1h refresh) → `POST fcm.googleapis.com/v1/projects/{PID}/messages:send` with `{message:{token, data:{sessionId,cls,token?}, android:{priority:"high"|"normal", ttl}}}`. **Data-only** (no `notification` block). Prune on `UNREGISTERED`/`INVALID_ARGUMENT`; the lib auto-refreshes on 401. **DECIDED (R7): use `google-auth-library`** (add as a server dep) rather than hand-rolling RS256 — less crypto to own/test than `apns.ts`'s hand-rolled JWT. - **NEW route `POST/DELETE /push/fcm-token`** — mirrors the apns-token route but with an **FCM-token validator that is intentionally loose** (non-empty, bounded max length, `base64url` charset plus `:` — FCM token format/length is undocumented and changes; a strict `{140-170}` regex risks rejecting valid tokens). Same `Origin`-guard + rate limit. - **NEW env group `FCM_*`** (project id + service-account key path), all-or-disabled like `loadApnsConfig`. @@ -483,13 +483,13 @@ Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end | # | Risk / question | Severity | Mitigation / decision | |---|---|---|---| -| **R1** | **FCM has no server-defined action buttons**; data-only high-priority is the only faithful Allow/Deny reproduction, and delivery is best-effort (Doze; OEM battery killers on Xiaomi/Huawei/Samsung; **a force-stopped/swiped-away app may get no FCM until next launch**; normal-priority "done" is Doze-batched). | **Critical** | Gate = `priority:"high"` data-only, notification built on-device; "done" = `normal`. **S2 spike measures real-device delivery early.** **Open question (user):** accept weaker background-delivery guarantees (documented best-effort), given the FGS decision in R9. | +| **R1** | **FCM has no server-defined action buttons**; data-only high-priority is the only faithful Allow/Deny reproduction, and delivery is best-effort (Doze; OEM battery killers on Xiaomi/Huawei/Samsung; **a force-stopped/swiped-away app may get no FCM until next launch**; normal-priority "done" is Doze-batched). | **Accepted (was Critical)** | **DECIDED (user): accept best-effort background delivery** — documented as a known limitation; no background FGS (R9). Gate = `priority:"high"` data-only, notification built on-device; "done" = `normal`. S2 spike still runs to quantify real-device loss and inform user-facing copy ("delivery not guaranteed while backgrounded"). | | **R2** | **Termux `terminal-emulator`/`terminal-view` licensing.** ~~GPL, must vendor~~ **CORRECTED (independently verified 2026):** these two libraries are **Apache-2.0** (per Termux `LICENSE.md` — they descend from jackpal's Apache-2.0 emulator; only `termux-shared` + the app module are GPLv3) and are **consumable via JitPack** (`com.termux:termux-app:terminal-view:0.118.0`), so no copyleft on the app and vendoring is optional. | **Low (was Critical)** | **No longer a blocking legal decision.** Mitigation = **scope the dependency to `terminal-view`(+`terminal-emulator`) only; never pull `termux-shared`/app module** (GPLv3). Add `com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava` to dodge a Guava conflict. When vendoring for version-pinning, spot-check per-file Apache headers. jackpal fork is a needless fallback (Apache-2.0 but archived 2022, VT-100-only). Sources: Termux `LICENSE.md`, Termux-Libraries wiki. | | **R3** | **Byte-exact JSON encode parity** vs `JSON.stringify`. | High | Hand-rolled `StringBuilder` codec + golden-vector tests (A4). Non-negotiable gate. | | **R4** | **OkHttp binds `SSLSocketFactory` at build time** + pooled/resumed connections keep the old identity → breaks "no-relaunch" rotation. | High | Custom re-reading `X509KeyManager` over the AndroidKeyStore identity + `connectionPool.evictAll()` on rotation. Instrumented test proves next-handshake cert + no old-identity reuse (A11). | | **R5** | **cols×rows drift** — Android font metrics ≠ SwiftTerm. | High | Derive metrics from `TerminalRenderer` exactly; unit-test the formula; QA resize parity vs web/iOS. | | **R6** | **Oversized-replay classification** — OkHttp has no default max message size. | Medium | Client-side 16 MiB frame cap → non-retryable `replayTooLarge`; spike a real large-scrollback session. | -| **R7** | **Server OAuth2 for FCM** — RS256 JWT + refresh vs the zero-new-dep rule. | Medium | **Default: hand-roll RS256+HTTPS** (matches `apns.ts`). **Open question (user, low-stakes):** allow `google-auth-library` instead. | +| **R7** | **Server OAuth2 for FCM** — RS256 JWT + refresh vs the zero-new-dep rule. | Resolved | **DECIDED (user): use `google-auth-library`.** Add it as a server dependency (a deliberate exception to the zero-new-dep ethos) so A33 doesn't hand-roll RS256/JWKS. | | **R8** | **Key-bar above IME** — no `inputAccessoryView`. | Medium | Compose overlay via `WindowInsets.ime`/`imePadding()`; QA multiple IMEs; taps never route through `BasicTextField`. | | **R9** | **Background WS vs Android 12+ FGS-background-start block and the Android 14 `dataSync` / Android 15 `mediaProcessing` 6h-per-24h cap** (the draft's "6h FGS cap" was imprecise). No clean `foregroundServiceType` for "hold a WebSocket" (`dataSync` is capped; `specialUse` needs Play review; `connectedDevice` needs a companion). | Medium | **Decision: drop the background foreground-service entirely.** WS is foreground-only (STARTED-scoped); **FCM high-priority = background wake → foreground reconnect + replay**; server keeps the PTY alive. Avoids `specialUse` Play review and any `startForeground()` crash. | | **R10** | **EventBus fan-out** — a single `SharedFlow` with `onBufferOverflow=SUSPEND` back-pressures the engine pump (head-of-line-blocks OUTPUT for every consumer); DROP could lose a gate. | Medium | **Per-consumer buffered `Channel`s** (fan-out coroutine offers to each) — the true analogue of iOS's per-subscriber `AsyncStream`; a slow collector neither drops a gate nor stalls output. Tested in A15. | @@ -519,7 +519,7 @@ Sizes carry over the iOS subsystem estimates, re-scaled for the port (S≈1–2d 1. **AW0 A1→A2→A3** (serial). Simultaneously kick off **A33** (server FCM — independent) and stand up **S1** and **S2** sandboxes. 2. **AW1** in parallel: **A4** (codec golden vectors) and **A7** (transport) are on the critical path; A5, A6, A8, A9, A10, A11, A12, A13 fill the batch. **Run S1 (renderer seam) and S2 (FCM delivery) here.** -3. **Resolve R7 (FCM OAuth2) now** — before AW5 commits. (R2 Termux license is already resolved: Apache-2.0 via JitPack, scope to `terminal-view`/`terminal-emulator` only.) Feed S2 results into the R1 decision. +3. **All three open questions are now DECIDED** — R2 (Termux = Apache-2.0/JitPack), R7 (use `google-auth-library`), R1 (accept best-effort FCM delivery). Nothing blocks AW3 or AW5. S2 still runs to quantify FCM loss and drive user-facing "not guaranteed while backgrounded" copy. 4. **AW2 A14** (engine) then **A15** (wiring/DI freeze) — everything terminal/UI waits on the `SessionEvent` contract and the frozen wiring seam. 5. **AW3 A16** (renderer) — the XL spike; get it green against a live Claude Code session (decide R5 cols×rows fidelity) before A17/A18. Give it explicit schedule buffer. (No R2 license fallback needed — Termux terminal libs are Apache-2.0.) 6. **AW4** — fan out the screens: land **A21** (terminal wiring + reconnect banner) + **A22** (gate) first (full engine→UI path), then discovery/projects/diff/quick-reply/timeline/cold-start/cert, then A26 large-screen last. **Start A19/A23/A24/A25 during AW3** (they depend only on AW1 modules) to reclaim wall-clock lost to the serial A14→A16 spine. @@ -558,7 +558,7 @@ Sizes carry over the iOS subsystem estimates, re-scaled for the port (S≈1–2d ### Post-review correction (independent verification) - **R2 downgraded Critical→Low.** A dedicated web-verification pass (after the two explorer agents for the terminal stack failed their structured-output step) established that Termux's `terminal-view` + `terminal-emulator` are **Apache-2.0** (not GPL) and **available on JitPack** (not vendor-only). The plan's worst-case "choose GPL / fork / open-source" framing was wrong; the renderer decision is de-risked and AW3 is no longer gated on a license call. §2, §9 R2, §5, §10, and open-question #1 updated accordingly. Only guardrail: keep the dependency scoped to those two libs (never `termux-shared`/app module, which are GPLv3). -### Open questions that genuinely need the user's decision -1. ~~**R2 (BLOCKS A16 / AW3):** Termux is GPL and must be vendored.~~ **RESOLVED (verified 2026) — no longer a user decision.** `terminal-view`/`terminal-emulator` are **Apache-2.0** and available on **JitPack**; no copyleft on the app as long as the dependency is scoped to those two libs (not `termux-shared`/app). AW3/S1 are **no longer gated on a license call**. Only residual (technical, not a blocker): JitPack-consume vs vendor-pin — default to JitPack `0.118.0`, vendor only if you need a pinned commit. -2. **R1 (push reliability):** Accept best-effort background delivery for FCM data-only high-priority (Doze / OEM battery killers / force-stop), given the decision to run **no background foreground-service**? The S2 spike will quantify it, but the acceptance call is yours. -3. **R7 (low-stakes):** Server FCM OAuth2 — hand-roll RS256 to honor the repo's zero-new-dep ethos (default), or permit `google-auth-library`? \ No newline at end of file +### Decisions locked (no open questions remain) +1. ~~**R2 (BLOCKS A16 / AW3):** Termux is GPL and must be vendored.~~ **RESOLVED (verified 2026).** `terminal-view`/`terminal-emulator` are **Apache-2.0** on **JitPack**; no copyleft as long as the dependency is scoped to those two libs (not `termux-shared`/app). AW3/S1 not gated on a license call. Residual technical-only choice: JitPack-consume vs vendor-pin → default JitPack `0.118.0`. +2. **R1 (push reliability): ✅ ACCEPTED (user).** Best-effort FCM background delivery is accepted as a documented limitation (no background FGS). S2 spike still runs to quantify loss and drive user-facing "delivery not guaranteed while backgrounded" copy. +3. **R7 (FCM OAuth2): ✅ DECIDED (user) — use `google-auth-library`.** Added as a server dependency (deliberate exception to zero-new-dep); A33 does not hand-roll RS256. \ No newline at end of file