docs(android-plan): lock user decisions R1 (accept best-effort FCM) + R7 (use google-auth-library)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled

R1: accept best-effort FCM background delivery as a documented limitation.
R7: server FCM sender uses google-auth-library (not hand-rolled RS256).
All three open questions (incl. R2 license) now decided; nothing blocks AW3/AW5.
This commit is contained in:
Yaojia Wang
2026-07-08 11:38:38 +02:00
parent 34e4a88059
commit cf88e7c588

View File

@@ -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≈12d
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≈12d
### 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`?
### 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.