diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index 17763858..0a72b0b2 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -6,7 +6,7 @@ > > **Scope expanded (plan review):** the playbook now has first-class passes **K–O** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **Device/OS matrix = `blocked→needs-device` (pre-ship):** all per-round QA runs on two **identical** emulators (5554/5556, same API + screen) — minSdk/targetSdk · small/large screen · ≥1 physical device are NOT covered; don't claim "device matrix ✓". > -> **First-run / cold-path = `blocked→fixture` (run the fresh-install lane):** the recurring emulators are **paired + signed-in + onboarding-complete**, so the recurring passes **cannot reach onboarding / sign-up / login / auth-logo / pairing / new-device-recovery / day-1 empty states** — this is the fixture blind-spot that hid **O-ONBOARD-001** (P0, every fresh install crashed). Cover it on a **throwaway** device (e.g. `emulator-5558` / fresh AVD — never `pm clear` 5554/5556) on any onboarding/auth/pairing/branding/`res/drawable` change + pre-ship. Don't claim "first-run ✓" off the logged-in fixtures. +> **First-run / cold-path = `blocked→fixture` (run the fresh-install lane):** the recurring emulators are **paired + signed-in + onboarding-complete**, so the recurring passes **cannot reach onboarding / sign-up / login / auth-logo / pairing / new-device-recovery / day-1 empty states** — this is the fixture blind-spot that hid **O-ONBOARD-001** (P0, every fresh install crashed). Cover it on a **throwaway** device (e.g. `emulator-5558` / fresh AVD — never `pm clear` 5554/5556) on any onboarding/auth/pairing/branding/`res/drawable` change + pre-ship. Don't claim "first-run ✓" off the logged-in fixtures. **R20 (2026-06-29) — FULL FRESH-PAIRING LANE RUN ✅ (user-requested), 0 FATAL:** two throwaways uninstalled→fresh-installed (5558=Avery + booted CloserCodexQA/5560=Riley). A: notif-permission → **onboarding all 3 slides** ("Answer honestly / No peeking / Grow Closer" — CtaSlide logo renders, O-ONBOARD-001 stays fixed) → auth landing (AuthLogoMark renders) → **sign-up** → **3-step profile** (name · inclusive gender Female/Male/Non-binary/Prefer-not-to-say · optional photo skip) → unpaired handoff → **invite code 47V-JCZ** + recovery phrase shown. B: **Skip onboarding path** verified (slide 1 → auth) → sign-up → profile → **Accept-instead → enter 47VJCZ → "Pair up"** → "You're connected / Riley & Avery". **New couple `3sRSEvky7HdSUOY9F1z0` (2 userIds, `encryptionVersion=2`) created server-side**; both devices flipped to paired in real-time; B's day-1 check-in modal + first daily Q render. No new bugs. Teardown: throwaways uninstalled, 5560 powered off, QA/Sam (5554/5556) untouched + clean (active=0). (Residual isolated throwaway Firestore couple left as disposable test data.) > > **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — contains architecture, security model, data model, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section that backs every fix-and-pruned ID below. > Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index bbb4bb91..da27272b 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -34,7 +34,9 @@ process rule, make sure it doesn't contradict the Guardrails. entitlement math) — **a red suite is a P0/P1 regression gate, stop and fix before QA'ing a build**; (b) the **scanners** — `qa/entrypoint_smoke.sh` (both serials), `scripts/theme-scan.sh` (Pass C), `scripts/wiring-scan.sh` (Pass N), `scripts/painter-xml-scan.sh` (crash guard — `painterResource()` on a non-`` XML drawable throws on render; - caught O-ONBOARD-001 class — exit≠0 is a P0 gate); (c) optional + caught O-ONBOARD-001 class — exit≠0 is a P0 gate); (c) the **instrumented render smoke** (when an emulator is attached) + — `./gradlew :app:connectedDebugAndroidTest` runs `FirstRunRenderSmokeTest` (first-run composables paint in light+dark; + the on-device net for the "composes fine, crashes on first paint" class — a red run is a P0 gate); (d) optional **monkey fuzz** `adb shell monkey -p app.closer --throttle 300 --pct-touch 90 -v 5000` (any crash = bug). File 🔴/🟠 to `ClaudeReport.md`; record counts in coverage. 4. **Run the passes report-only**, sub-batched to one context window each — recurring set **A–N + P** (K money-path + @@ -219,9 +221,15 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere. logic-level — unit/functions tests, `theme-scan`, `wiring-scan`, `painter-xml-scan` — **none of them actually render a composable.** `painterResource` on a `` compiled fine, passed all 205 unit tests, and only threw on first paint. There is a whole class of "composes fine, crashes on render" bugs (resource resolution, `LocalContext` casts, bad - `painterResource`) with **zero automated coverage**. Until a screenshot/instrumented render test exists (see `Future.md` - — Roborazzi/Paparazzi JVM screenshot test or a Compose/Espresso smoke), the **fresh-install lane above is the only net** - for render crashes on first-run screens — treat it as mandatory, not optional. + `painterResource`). **R20 added the first on-device net for it:** `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt` + renders the first-run crash composables (`CtaSlide` + `AuthLogoMark`, light+dark — the exact O-ONBOARD-001 sites) via a + Compose `createComposeRule()` and asserts they paint; proven to FAIL on the reintroduced bug. Run it with + `./gradlew :app:connectedDebugAndroidTest` (needs a connected emulator; filter a class with + `-Pandroid.testInstrumentationRunnerArguments.class=…`). It currently covers **only the first-run leaf composables** — + most routes still have no render test, so the **fresh-install lane above remains the net for the rest** until the smoke + grows (see `Future.md` — extend toward sign-in→pair→daily-Q→game with a Hilt test runner, and/or a Roborazzi/Paparazzi + screenshot suite). Treat both the fresh-install lane and (when an emulator is attached) `connectedDebugAndroidTest` as + part of the render-crash net. - **Automate the regression smoke:** capture the smoke checklist as a runnable script (adb/Maestro) so every round re-checks it cheaply instead of by hand. **Built:** `qa/entrypoint_smoke.sh ` (+ helper `qa/qa_push.js`) — the cold-start / entry-point launch-integrity smoke. It launches via the launcher AND sends a @@ -237,9 +245,10 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere. `CloserBrandCopyTest`, …) and `cd functions && npm test` (`entitlementLogic.test.ts`). **A failing test is a regression bug (P0/P1) — file it and do not QA a build with a red suite.** A fix that breaks a test isn't "Fixed" (see Fix phase). These guard the exact invariants this QA chases (ciphertext format, rate limiting, quiet-hours suppression, - entitlement math), so a green run is a precondition, not a bonus. (**Coverage gap:** there are **0 instrumented - tests** — no on-device Compose/Espresso smoke; logged in `Future.md`. Until that exists, the live passes + scanners - are the only UI-behavior net.) + entitlement math), so a green run is a precondition, not a bonus. (**Instrumented coverage (R20):** the first on-device + test now exists — `FirstRunRenderSmokeTest` (a Compose render smoke of the first-run screens); run it when an emulator + is attached with `./gradlew :app:connectedDebugAndroidTest`. It's still **first-run-only** — broader UI/nav/DB-DataStore + behavior remains uncovered, so the live passes + scanners are still the main UI-behavior net; grow the suite per `Future.md`.) - **Stress / monkey fuzz (cheap random-crash net the manual nav-fuzz misses):** once per build run `adb shell monkey -p app.closer --throttle 300 --pct-touch 90 -v 5000` on each emulator with `logcat` capturing — any `FATAL EXCEPTION`/ANR it triggers is a bug (file it with the monkey seed). This complements Pass C's *targeted* diff --git a/ClaudeReport.md b/ClaudeReport.md index 2b972963..f6476774 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,7 +18,7 @@ > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. ## Run-state (current) -- **R20 (2026-06-29) — fresh full ClaudeQAPlan run from the start (user: "run the full ClaudeQAPlan") — found + FIXED 2 real escaped bugs (NOT a clean confirmation round).** Baseline: HEAD `62696a6` (R18b/R19 work committed; clean tree), both emulators paired + **free** (admin-confirmed), build reinstalled both. Cleared 1 stale ToT session by playing it through. **Cheap gates ALL GREEN:** unit **210** · functions **24** · `theme-scan` **CRITICAL 0** (9 MAJOR/23 REVIEW = intentional brand gradients) · `painter-xml-scan` **0** · `wiring-scan` **🔴0** · `entrypoint_smoke.sh` **6/6 on BOTH emulators (0 blocked)**. Discovery ritual: no drift (14 notif types + all fn triggers match coverage). **Cornerstones live-clean:** **A ✅** enforcement audit (every `isPremium`/`PremiumBadge` has a real `CouplePremiumChecker` gate — no badge-without-gate; A-201 class stays closed) + live both-free → Desire Sync → **Paywall** "Go deeper together" (graceful K-env "couldn't load plans", no crash). **B ✅** full 2-device This-or-That (QA joined via Home card → answered 10 → **first-finisher** → Sam got live **YOUR_TURN banner** → joined via banner → completion → **symmetric 5/10 "in sync" reveal** both devices). **D ✅** D1 at-rest `enc:v1:` (messages + lastMessagePreview + all 4 game answer-maps' per-uid values) · D2 rules static (Tier-2 self-constraint present, lines 361–374) · D3 non-member couple/messages/capsules/desire_sync reads **403** · D5 self-grant entitlement **403**. **E ✅** cold-start smoke 6/6 both + live YOUR_TURN + persistent RESULTS banners + `partner_completed_part` first-finisher push delivered. **0 FATAL across the whole live session.** **TWO BUGS FOUND + FIXED + VERIFIED LIVE:** **(1) B-ABANDON-001 (P2)** — Quit/abandon on ANY game silently failed `PERMISSION_DENIED`: `abandonSession` round-tripped through `saveSession` (a full `doc.set()`) which **drops the server-only flags** (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`); the session-update rule counts those removed keys in `affectedKeys()` → denied, so the session stayed `active` (stranded → blocks new games), failure swallowed by `Log.d`. Proven via logcat (`Write failed at .../sessions/…: PERMISSION_DENIED` → `quit-abandon no-op`). **Fix:** targeted `update(status, completedAt)` mirroring `markUserComplete` (`affectedKeys == {status, completedAt}` ⊆ allowlist) in `QuestionSessionRepositoryImpl.abandonSession`; routed the latent-twin dead method `GameSessionManager.finishGame` (0 callers) through it too. **Verified live:** Quit → no denial → `active=0`, then **started a different game immediately** (lockout resolved). **(2) B-COPY-001 (P3)** — Home "GAME_WAITING" hero hardcoded *"Your partner already played their part — take your turn to reveal"* but fires on `uid !in completedByUsers` only (for async games `completedByUsers` stays empty until BOTH finish), so it falsely claims the partner finished the instant a game is merely *started*. **Fix:** neutral, partner-named, always-accurate copy ("Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up.") — the accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN banner. **Verified live both devices** (starter + joiner). Build + **210 unit + 24 functions green** after fixes. Remaining passes carry recent-round status (zero functional diff coming in; my fixes are HomeViewModel copy + session-completion writes only, no effect on C/F/G/H/I/J/L/M/N/P): **C** theme-scan CRIT 0; **L** chat at-rest `enc:v1:` (via D1); **K** money-path + **O** release + Doze = `blocked→needs-device`. **Verdict: R20 — cornerstones A/B/D/E live-clean, all cheap gates green, 0 FATAL; found + fixed B-ABANDON-001 (P2) + B-COPY-001 (P3) live. Board: 0 open P0/P1; 1 open P2 (O-AGE-001 pre-ship, user-blocked) + 1 P2 fixed-pending-confirm (B-ABANDON-001); 1 open P3 (BRAND-DARK-COVERAGE, user-blocked) + 1 P3 fixed-pending-confirm (B-COPY-001).** Uncommitted (user commits): `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `docs/Engineering_Reference_Manual.md`. +- **R20 (2026-06-29) — fresh full ClaudeQAPlan run from the start (user: "run the full ClaudeQAPlan") — found + FIXED 2 real escaped bugs (NOT a clean confirmation round).** Baseline: HEAD `62696a6` (R18b/R19 work committed; clean tree), both emulators paired + **free** (admin-confirmed), build reinstalled both. Cleared 1 stale ToT session by playing it through. **Cheap gates ALL GREEN:** unit **210** · functions **24** · `theme-scan` **CRITICAL 0** (9 MAJOR/23 REVIEW = intentional brand gradients) · `painter-xml-scan` **0** · `wiring-scan` **🔴0** · `entrypoint_smoke.sh` **6/6 on BOTH emulators (0 blocked)**. Discovery ritual: no drift (14 notif types + all fn triggers match coverage). **Cornerstones live-clean:** **A ✅** enforcement audit (every `isPremium`/`PremiumBadge` has a real `CouplePremiumChecker` gate — no badge-without-gate; A-201 class stays closed) + live both-free → Desire Sync → **Paywall** "Go deeper together" (graceful K-env "couldn't load plans", no crash). **B ✅** full 2-device This-or-That (QA joined via Home card → answered 10 → **first-finisher** → Sam got live **YOUR_TURN banner** → joined via banner → completion → **symmetric 5/10 "in sync" reveal** both devices). **D ✅** D1 at-rest `enc:v1:` (messages + lastMessagePreview + all 4 game answer-maps' per-uid values) · D2 rules static (Tier-2 self-constraint present, lines 361–374) · D3 non-member couple/messages/capsules/desire_sync reads **403** · D5 self-grant entitlement **403**. **E ✅** cold-start smoke 6/6 both + live YOUR_TURN + persistent RESULTS banners + `partner_completed_part` first-finisher push delivered. **0 FATAL across the whole live session.** **TWO BUGS FOUND + FIXED + VERIFIED LIVE:** **(1) B-ABANDON-001 (P2)** — Quit/abandon on ANY game silently failed `PERMISSION_DENIED`: `abandonSession` round-tripped through `saveSession` (a full `doc.set()`) which **drops the server-only flags** (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`); the session-update rule counts those removed keys in `affectedKeys()` → denied, so the session stayed `active` (stranded → blocks new games), failure swallowed by `Log.d`. Proven via logcat (`Write failed at .../sessions/…: PERMISSION_DENIED` → `quit-abandon no-op`). **Fix:** targeted `update(status, completedAt)` mirroring `markUserComplete` (`affectedKeys == {status, completedAt}` ⊆ allowlist) in `QuestionSessionRepositoryImpl.abandonSession`; routed the latent-twin dead method `GameSessionManager.finishGame` (0 callers) through it too. **Verified live:** Quit → no denial → `active=0`, then **started a different game immediately** (lockout resolved). **(2) B-COPY-001 (P3)** — Home "GAME_WAITING" hero hardcoded *"Your partner already played their part — take your turn to reveal"* but fires on `uid !in completedByUsers` only (for async games `completedByUsers` stays empty until BOTH finish), so it falsely claims the partner finished the instant a game is merely *started*. **Fix:** neutral, partner-named, always-accurate copy ("Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up.") — the accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN banner. **Verified live both devices** (starter + joiner). Build + **210 unit + 24 functions green** after fixes. Remaining passes carry recent-round status (zero functional diff coming in; my fixes are HomeViewModel copy + session-completion writes only, no effect on C/F/G/H/I/J/L/M/N/P): **C** theme-scan CRIT 0; **L** chat at-rest `enc:v1:` (via D1); **K** money-path + **O** release + Doze = `blocked→needs-device`. **Verdict: R20 — cornerstones A/B/D/E live-clean, all cheap gates green, 0 FATAL; found + fixed B-ABANDON-001 (P2) + B-COPY-001 (P3) live. Board: 0 open P0/P1; 1 open P2 (O-AGE-001 pre-ship, user-blocked) + 1 P2 fixed-pending-confirm (B-ABANDON-001); 1 open P3 (BRAND-DARK-COVERAGE, user-blocked) + 1 P3 fixed-pending-confirm (B-COPY-001).** Uncommitted (user commits): `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `docs/Engineering_Reference_Manual.md`. **R20 follow-up (user: "make it so" on the instrumented smoke):** added the project's **first instrumented UI test** — `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`, an on-device Compose render smoke of the first-run crash composables (`CtaSlide` + `AuthLogoMark`, light+dark — the O-ONBOARD-001 `painterResource` sites). Infra: `testInstrumentationRunner` + `ui-test-junit4` in `build.gradle.kts`; exposed `CtaSlide` as `internal`; un-blocked the androidTest source set (the stale `CanonicalVectorCaptureInstrumentTest` couldn't compile against `private RecoveryKeyManager.deriveKey` → `@VisibleForTesting internal`). **Verified on emulator-5558 (API 34): 4/4 pass; PROVEN to catch the class** — reintroducing the `` foreground failed the test with the exact `IllegalArgumentException: Only VectorDrawables…` at `loadVectorResource`, then reverted → green. 210 unit + 24 functions still green. Wired into the QA-plan cheap gates (`./gradlew :app:connectedDebugAndroidTest` when an emulator is attached) + Future.md item marked started. Added files: `build.gradle.kts`, `RecoveryKeyManager.kt`, `OnboardingScreen.kt`, `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`, `ClaudeQAPlan.md`, `Future.md`. - **R19 (2026-06-28) — fresh full ClaudeQAPlan run from the start (user-directed, post-implementation).** Baseline reset clean: both emulators paired, both **free** (admin-confirmed premium=false), **0 active sessions** (cleared the one stale ToT by completing it 10/10). **Cheap gates ALL GREEN:** `./gradlew testDebugUnitTest` **210**, `cd functions && npm test` **24**, `scripts/theme-scan.sh` **CRITICAL 0** (11 MAJOR/25 REVIEW = intentional brand-purple/white in the wheel + banner + bubble colored-surface contexts), `scripts/painter-xml-scan.sh` **0**, `qa/entrypoint_smoke.sh` **6/6 on both emulators**. **Cornerstones live:** **A ✅** premium gate — both-free → Desire Sync → Paywall "Go deeper together" (graceful "couldn't load plans" K-env limit, no crash). **B ✅** This-or-That full 2-device → completed **10/10 "in sync"** reveal (+ this round's finish-gate / submit-retry / Quit-abandon). **D ✅** security cornerstone (raw-API): non-member couple/messages/capsules/desire_sync reads + self-grant entitlement **all DENIED 403**; messages at-rest `enc:v1:`; session/answer docs carry no plaintext. **E ✅** notifications: cold-start smoke 6/6 both + `partner_joined_game` live end-to-end (deployed) + standardized durable banner. **0 FATAL.** Remaining passes **carry their recent-round status** (per `ClaudeQACoverage.md`, code-stable — this round's diff is additive client + the deploy-gated rule): **C** theme-scan CRIT 0 + this-session dark spot-checks; **F** offline-cache (carried) + new Wheel submit-retry (unit-tested); **L** chat at-rest `enc:v1:` confirmed; **G/H/I/J/N/P** carried; **K** money-path + **O** release + Doze/battery = `blocked→needs-device` (pre-ship). **Verdict: R19 — cornerstones (A/B/D/E) live-clean, all cheap gates green, 0 FATAL; board 0 open P0/P1, 1 P2 (O-AGE-001 pre-ship), 1 P3 (BRAND-DARK-COVERAGE) — both user-blocked.** **Tier-2 rules DEPLOYED by user + VERIFIED LIVE (member-token raw-API):** own-uid add to `joinedByUsers` **ALLOWED 200** (legit path intact); foreign-uid add to `joinedByUsers`+`completedByUsers` **DENIED 403**; array removal **DENIED 403** — own-uid-only self-constraint holds without breaking markUserJoined/markUserComplete. (functions + rules all deployed; no remaining deploy gates.) - **R18b (2026-06-28) — polish + error-handling/security/consolidation hardening (user: "look for gaps, error handling, security, what else can we improve, can we consolidate").** Bundled with the modern-feel polish. **Error handling:** **E1 (P2 bug) FIXED** — `WheelSessionViewModel.submitAndFinish` used to swallow a submit failure and navigate to the reveal anyway (silent data loss + stuck session); now it surfaces a **retryable error** (`submitFailed`/`error` + a `SubmitErrorCard` "Retry", no false reveal), matching ToT/DesireSync/HowWell. **E3** — the silently-swallowed join-writes now `.onFailure { Log.w }` in all 4 games. **Consolidation (targeted):** deduped the 3 copies of the save-error string into `ui/games/GameCopy.SAVE_ANSWERS_ERROR` (used by all four); the per-game answer/reveal logic is intentionally NOT unified (premature abstraction). **Modern feel:** `GamePromptBanner` — soft **haptic on arrival** (reuses `CelebrationOverlay` `LocalHapticFeedback`), **spring overshoot** entrance, **live green presence dot** on the avatar for JOINED, **whole-card tap-to-act + swipe-up dismiss**, `liveRegion` + `contentDescription` a11y, and `GamePromptController` now **won't let a transient (started/joined) clobber a persistent (your-turn/results)** banner; **chat bubble** got the same arrival haptic for parity; warmer brand-voice copy (banner `styleFor` = client source of truth, cross-commented with the function). **Predictive back (2026):** `android:enableOnBackInvokedCallback="true"` (safe — 0 `onBackPressed`/`BackHandler` in the app). **Tier 3:** the Wheel active screen got a **"Quit game"** escape hatch (`abandon()`→`abandonSession`) so leaving mid-wheel no longer strands a session (mirrors ToT). **Verified:** build + **210 Android unit (+1 new Wheel submit-retry test) + 24 functions** green; **live (5554):** JOINED banner shows warm copy + green presence dot + real avatar; **Pass-E cold-start smoke 6/6 on BOTH emulators** (notification path regression-clean); 0 FATAL. **Tier 2 security — DEPLOYED + VERIFIED LIVE:** `firestore.rules` now constrains `completedByUsers`/`joinedByUsers` so a member can only add **their own** uid (no spoofing the partner's join/completion, no removals; `get(...,[])` tolerates old docs). Member-token raw-API test: own-uid add **200**, foreign-uid add (both arrays) **403**, removal **403**; markUserJoined/markUserComplete still pass. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `WheelSessionViewModelTest.kt`, `GamePromptBanner.kt`, `GamePromptController.kt`, `MessageBubbleOverlay.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `ui/games/GameCopy.kt`, `AndroidManifest.xml`, `firestore.rules`, `functions/src/games/onGameSessionUpdate.ts` (sender_name), `ClaudeReport.md`, `ClaudeQACoverage.md`. **NEXT: reset + full ClaudeQAPlan A→P fresh run (R19).** - **R18b (2026-06-28) — FEATURE: "partner joined your game" push + standardized in-app game banner (user: "notification that your paired partner joined the game … w/ their icon … make that theme the standard").** Decision (discussed): **"Standardize, keep durable."** **New `partner_joined_game`:** the non-starter opening an active session writes their uid to `joinedByUsers` (new field; client `markUserJoined` mirrors `markUserComplete`; `firestore.rules` session allowlist += `joinedByUsers`, server-only `joinNotifiedAt` deliberately excluded); `onGameSessionUpdate` adds a branch that claims a one-time `joinNotifiedAt` and notifies the **starter** " joined your game" with the **joiner's avatar**. Wired the join-write into all 4 games' non-starter paths (ToT/DesireSync/HowWell `joinSession` + Wheel `load()` resume), best-effort + off the critical path (guarded to never fire for the starter). **Standardized banner:** generalized `GamePromptController`/`GamePromptBanner` with a `kind` (STARTED/JOINED/YOUR_TURN/RESULTS) — per-kind copy/action/avatar; **STARTED/JOINED transient (~9s), YOUR_TURN/RESULTS persistent** (stay until tapped/dismissed). `AppMessagingService` now routes all 4 game types to the banner in the **foreground** (suppressing the foreground OS duplicate; background OS unchanged — already shows the avatar large-icon); `AppNavigation` routes the banner action by kind (RESULTS→per-session results, else→the game). Added `sender_name` to the game push `data` so the banner names the partner. **Build + 209 Android unit + 24 functions tests green** (added a `PartnerNotificationTypeTest` case for the new type's mapping + routing). **Live (5554, foreground, real avatar+name):** STARTED "Sam started a game"+Join (avatar loaded), **JOINED "Sam joined your game"+View**, YOUR_TURN "Sam finished their part / Your turn"+Play, RESULTS "Sam finished / See your results"+View — **RESULTS still showing at 15s (persistent) while STARTED auto-dismissed by 12s (transient)**; **0 QA-SMOKE entries in the shade (no foreground OS dupe); 0 FATAL.** **Pass-E regression smoke 6/6 on BOTH emulators** (shared cold-start path clean). **⚠ DEPLOY REQUIRED (user):** the `partner_joined_game` push only fires once `functions/` + `firestore.rules` are deployed to `closer-app-22014` (I can't deploy; prior prod writes were classifier-denied) — until then the `joinedByUsers` client write is denied by the live rules (best-effort, swallowed, no crash). The banner-standardization for already-deployed types (started/your-turn/results) works immediately. Uncommitted (user commits): `QuestionSession.kt`, `QuestionSessionRepository.kt`(+Impl), `GameSessionManager.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `WheelSessionViewModel.kt`, `GamePromptController.kt`, `GamePromptBanner.kt`, `AppMessagingService.kt`, `PartnerNotificationManager.kt`, `AppNavigation.kt`, `PartnerNotificationTypeTest.kt`, `functions/src/games/onGameSessionUpdate.ts`, `firestore.rules`, `Future.md`, `ClaudeReport.md`. diff --git a/Future.md b/Future.md index 781b2d5c..a5ad2644 100644 --- a/Future.md +++ b/Future.md @@ -22,12 +22,18 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works light and dark, pixel-diffs them, and fails on unexpected white backgrounds or invisible text. When done, run it in CI against every UI PR. -- **Instrumented / on-device test coverage (currently 0 androidTest).** `app/src/test` has 19 solid unit tests - (encryption, rate limiter, quiet hours, streak, entitlement, …) but `app/src/androidTest` is empty — there is no - automated on-device net for UI behavior, navigation, or DB/DataStore integration; the live QA passes + scanners are the - only thing catching that class. Add a small **Compose UI / Espresso smoke** (sign-in → pair → answer daily Q → open a - game → send a message) wired into the per-round gate (alongside `qa/entrypoint_smoke.sh`), then grow it. *Prompted by:* - the R-? QA-plan gap review — the playbook now runs `./gradlew testDebugUnitTest` but has no instrumented suite to run. +- **🟡 STARTED (R20) — Instrumented / on-device test coverage (was 0 androidTest).** First cut shipped: + `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt` — an on-device **Compose render smoke** of the first-run + screens (`CtaSlide` + `AuthLogoMark`, light + dark), the exact `painterResource` logo sites that crashed every fresh + install in **O-ONBOARD-001**. It's the net for the "composes fine, crashes on first paint" class the JVM unit tests + + static scanners structurally can't catch. **Proven (R20):** passes green on-device, and FAILS with the original + `IllegalArgumentException: Only VectorDrawables…` when the bug is reintroduced. Infra added: `testInstrumentationRunner` + + `ui-test-junit4` (build.gradle.kts); also un-blocked the androidTest source set (the stale `CanonicalVectorCaptureInstrumentTest` + couldn't compile against `private deriveKey` → made it `@VisibleForTesting internal`). Run: `./gradlew :app:connectedDebugAndroidTest`. + **Still to grow:** it's Hilt-/Firebase-free leaf-composable rendering only — extend toward a fuller + sign-in → pair → answer daily Q → open a game → send a message flow (needs a Hilt test runner + fakes), and wire + `connectedDebugAndroidTest` into the per-round gate / CI alongside `qa/entrypoint_smoke.sh`. *Prompted by:* the QA-plan + render-coverage gap (O-ONBOARD-001 escaped because nothing rendered a composable). - **✅ DONE — Consistent brand glyphs across game cards + waiting surfaces.** G-set + G2 (17 glyphs) in `res/drawable-nodpi/glyph_*.xml`; **13 wired + verified live:** every Play-hub card (This or That, How Well, Desire diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f521d713..77ff1e62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,6 +19,8 @@ android { versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // RevenueCat API key. Set RC_API_KEY in local.properties (never committed). // Debug builds fall back to a placeholder; release builds abort — see task guard below. buildConfigField( @@ -220,4 +222,10 @@ dependencies { // Canonical-vector capture harness (paired-CI for iOS↔Android E2EE fixture fill) androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test:runner:1.5.2") + + // Instrumented Compose render smoke (first-run screens) — the on-device net for the + // "composes fine, crashes on first paint" class (e.g. O-ONBOARD-001). Needs the BOM so the + // ui-test version matches the app's Compose; ui-test-manifest (debug, above) hosts the ComposeRule. + androidTestImplementation(composeBom) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") } diff --git a/app/src/androidTest/java/app/closer/ui/FirstRunRenderSmokeTest.kt b/app/src/androidTest/java/app/closer/ui/FirstRunRenderSmokeTest.kt new file mode 100644 index 00000000..9324152e --- /dev/null +++ b/app/src/androidTest/java/app/closer/ui/FirstRunRenderSmokeTest.kt @@ -0,0 +1,75 @@ +package app.closer.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.closer.ui.auth.AuthLogoMark +import app.closer.ui.onboarding.CtaSlide +import app.closer.ui.theme.CloserTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented render smoke for the FIRST-RUN screens — the on-device net for the whole + * "composes fine, crashes on first paint" class that our JVM unit tests and static scanners + * cannot catch (they never render a composable). + * + * Both composables exercised here carry the two `painterResource(...)` logo calls that crashed + * EVERY fresh install in O-ONBOARD-001 (a `` XML routed through Compose's VectorDrawable + * loader, which throws). That P0 compiled, passed all unit tests, and only threw on first paint — + * exactly what this test guards. Rendering these on-device executes the `painterResource` calls; a + * regression throws during composition and fails the test. + * + * Deliberately Hilt-/Firebase-/network-free: it composes the leaf first-run composables directly + * (no Activity launch, no auth, no backend), so it stays fast and deterministic. Both light and + * dark are exercised (the logo resolves per-theme drawables). Grow this toward a fuller + * sign-in → pair → daily-Q → game flow once a Hilt test runner + fakes are in place. + * + * Run on a connected emulator/device: + * ./gradlew :app:connectedDebugAndroidTest + */ +@RunWith(AndroidJUnit4::class) +class FirstRunRenderSmokeTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun ctaSlide_rendersLogoAndCtas_light() { + composeRule.setContent { + CloserTheme(darkTheme = false) { CtaSlide(onNavigate = {}) } + } + composeRule.onNodeWithText("Create account").assertIsDisplayed() + composeRule.onNodeWithText("I already have an account").assertIsDisplayed() + } + + @Test + fun ctaSlide_rendersLogoAndCtas_dark() { + composeRule.setContent { + CloserTheme(darkTheme = true) { CtaSlide(onNavigate = {}) } + } + composeRule.onNodeWithText("Create account").assertIsDisplayed() + composeRule.onNodeWithText("I already have an account").assertIsDisplayed() + } + + @Test + fun authLogoMark_renders_light() { + composeRule.setContent { + CloserTheme(darkTheme = false) { AuthLogoMark() } + } + // contentDescription "Closer" lives on the foreground logo Image — its presence proves the + // painterResource(closer_launcher_foreground) call composed without throwing. + composeRule.onNodeWithContentDescription("Closer").assertExists() + } + + @Test + fun authLogoMark_renders_dark() { + composeRule.setContent { + CloserTheme(darkTheme = true) { AuthLogoMark() } + } + composeRule.onNodeWithContentDescription("Closer").assertExists() + } +} diff --git a/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt b/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt index 836dcf9a..ac5e7a3a 100644 --- a/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt +++ b/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt @@ -1,5 +1,6 @@ package app.closer.crypto +import androidx.annotation.VisibleForTesting import java.util.Base64 import com.google.crypto.tink.KeysetHandle import com.google.crypto.tink.aead.AesGcmKeyManager @@ -78,7 +79,12 @@ class RecoveryKeyManager @Inject constructor() { com.google.crypto.tink.JsonKeysetReader.withBytes(bytes) ) - private fun deriveKey(phrase: String, salt: ByteArray): ByteArray { + // internal (not private) ONLY so the iOS↔Android canonical-vector capture harness + // (CanonicalVectorCaptureInstrumentTest) can derive the Argon2id fixture. @VisibleForTesting + // keeps lint flagging any non-test production caller. (Without this the androidTest source set + // fails to compile, which blocked every instrumented test, incl. the first-run render smoke.) + @VisibleForTesting + internal fun deriveKey(phrase: String, salt: ByteArray): ByteArray { val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) .withSalt(salt) .withParallelism(ARGON2_PARALLELISM) diff --git a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt index 9d54aea2..32b170b1 100644 --- a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt @@ -217,8 +217,11 @@ private fun ValueSlide( } } +// `internal` (not private) so the instrumented render smoke (FirstRunRenderSmokeTest) can compose +// this slide directly — it carries the two `painterResource` logo calls that crashed every fresh +// install in O-ONBOARD-001, so rendering it in a test is the cheap guard against that class. @Composable -private fun CtaSlide(onNavigate: (String) -> Unit) { +internal fun CtaSlide(onNavigate: (String) -> Unit) { Column( modifier = Modifier .fillMaxSize() diff --git a/docs/screenshots/Selection_1534.png b/docs/screenshots/Selection_1534.png new file mode 100644 index 00000000..ce7f62ef Binary files /dev/null and b/docs/screenshots/Selection_1534.png differ diff --git a/docs/screenshots/Selection_1535.png b/docs/screenshots/Selection_1535.png new file mode 100644 index 00000000..c2e03e5e Binary files /dev/null and b/docs/screenshots/Selection_1535.png differ