diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index bb805606..17763858 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -1,6 +1,7 @@ # Claude QA Coverage Matrix > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. +> **R20 (2026-06-29) — fresh full ClaudeQAPlan run; found + FIXED 2 escaped bugs.** Build HEAD `62696a6` + R20 fixes (uncommitted: `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`). Cheap gates all green (unit **210** · fn **24** · theme-scan CRIT **0** · painter-xml **0** · wiring 🔴**0** · smoke **6/6 both**). Cornerstones A/B/D/E live-clean, 0 FATAL. **B-ABANDON-001 (P2)** — Quit/abandon on any game silently `PERMISSION_DENIED` (full `saveSession` set drops server-only flags → rule rejects removed `affectedKeys`) → stranded session; **fixed** (targeted `update(status,completedAt)`), verified live (quit→active=0→new game starts). **B-COPY-001 (P3)** — Home GAME_WAITING hero falsely claimed "partner already played their part"; **fixed** (neutral partner-named copy), verified live both devices. Both pending 1 confirm. See `ClaudeReport.md` R20 run-state. > Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **Deploy status:** `functions/` + `firestore.rules` **DEPLOYED by user** (join push live; Tier-2 self-constraint **verified live** — member own-uid add 200, foreign-uid/removal 403). No remaining deploy gates. Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** > > **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 ✓". @@ -15,7 +16,7 @@ | Pass | Coverage | Status | |---|---|---| | A — Couple-shared premium | R18: re-verified live (Sam free) — Desire Sync + premium **Boundaries** pack → Paywall; Mixed pack free prompt opens (gate not over-broad); Free filter graceful empty. R13 A-201 (Date Match) holds. | ✅ pass (multi-surface gate re-confirmed R18) | -| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. | ✅ pass · finish-gate + submit-retry + abandon verified (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | +| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. **R20: B-ABANDON-001 (P2) FIXED** — that abandon (+ ToT/HowWell Quit; all route through `abandonSession`) actually failed `PERMISSION_DENIED` server-side (full `saveSession` `doc.set()` dropped the server-only flags → session-update rule rejected the removed `affectedKeys`) so the session stayed stranded `active`; fix = targeted `update(status,completedAt)`; verified live (quit→active=0→a different game starts immediately). **R20: B-COPY-001 (P3) FIXED** — Home GAME_WAITING hero no longer falsely claims "partner already played their part." | ✅ pass · finish-gate + submit-retry + **abandon now actually works (B-ABANDON-001)** (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | | C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. R18b: **C-ORIENT-001 RESOLVED** — `MainActivity` locked to portrait (no landscape design; verified `requestedOrientation=PORTRAIT` holds under forced rotation). C-DARKART-002 confirmed + pruned. | ✅ theme-scan CRIT 0 · C-DARKART-002 + C-ORIENT-001 resolved · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.md` | | D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds | | E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. **R18b: NEW `partner_joined_game` (joiner avatar) + standardized durable in-app game banner** — all foreground game pushes route through the themed banner (started/joined transient, your-turn/results persistent + tappable; foreground OS dupe suppressed, background OS unchanged); verified live per kind + Pass-E smoke 6/6 both emulators (join push pending functions+rules deploy). | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · banner standardized · Doze/battery = needs-device | diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index d30ad360..bbb4bb91 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -519,6 +519,14 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges - **Different exit/resume styles** — finish normally; quit mid-game; background mid-game then resume; cold-kill mid-game then reopen; "End their game"; re-open a completed session for the replay/results; play two games back-to-back, and a *different* game type immediately after. + - **⛔ VERIFY QUIT/ABANDON ACTUALLY ENDS THE SESSION (server-side, by admin read — RETROSPECTIVE from B-ABANDON-001).** + "Quit" / "End their game" navigating away is **not** proof the session ended — the abandon write is best-effort and + **swallowed** (`runCatching{…}.onFailure{ Log.d }`), so a `PERMISSION_DENIED` looks like success in the UI. After any + quit/abandon, confirm the session is actually `completed` via an admin read (**0 active sessions**) AND that a *new/ + different* game can then be started; watch `logcat` for `PERMISSION_DENIED` on the `sessions/{id}` doc during the quit. + A session that "won't clear" between rounds is a **bug to root-cause, not a test-data nuisance** — B-ABANDON-001 (the + full-`saveSession` `doc.set()` dropping server-only flags → rule rejects the removed `affectedKeys`) hid for several + rounds precisely because it was dismissed as cleanup difficulty. See the manual's [B-ABANDON-001 landmine](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes). - **Edge inputs** — submit with nothing selected (should be blocked), rapid double-taps on answer/confirm/next, spamming the start button, tapping during the reveal animation, switching tabs mid-game, receiving/tapping a notification mid-game. None should crash, duplicate, or desync. diff --git a/ClaudeReport.md b/ClaudeReport.md index 426c51ff..2b972963 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,6 +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`. - **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`. @@ -57,8 +58,8 @@ |---|---|---| | P0 | 0 | 0 | | P1 | 0 | 0 | -| P2 | **1** (O-AGE-001 pre-ship — needs product/legal) | 0 | -| P3 | **1** (BRAND-DARK-COVERAGE — needs dark art assets) | 0 | +| P2 | **1** (O-AGE-001 pre-ship — needs product/legal) | **1** (B-ABANDON-001 — fixed+verified live R20) | +| P3 | **1** (BRAND-DARK-COVERAGE — needs dark art assets) | **1** (B-COPY-001 — fixed+verified live R20) | _R18b cleanup (2026-06-28): **pruned the entire confirmed backlog** (one-confirmation-round rule). Pruned to the archived line: **O-ONBOARD-001** (P0, verified live R18b) · **C-DARKART-002** (verified live R18) · **C-THEME-001/002/ @@ -129,6 +130,8 @@ routed correctly, so that was a test artifact, not a bug.)_ | ID | Sev | Area | Description | Suggested fix | Status | |---|---|---|---|---|---| +| B-ABANDON-001 | P2 | Games lifecycle (Pass B) | **Quit/abandon on any game silently failed `PERMISSION_DENIED`, stranding the session `active`.** `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) round-tripped through `saveSession`, which does a **full `doc.set()`** of a fixed 13-field map — dropping the server-only flags the Cloud Function wrote (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`). The session-update rule's `affectedKeys().hasOnly([...])` counts those *removed* keys, so the write is denied; the session never flips to `completed` → stranded active (blocks starting new games), and the failure is swallowed by `Log.d`. Proven live (R20): `Write failed at couples/.../sessions/MWkzZOWWRLrLNNoSwM0n: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. Affects ToT/HowWell/Wheel (all route through `abandonSession`). Escaped R19 (only the arrayUnion paths were rules-tested). | **FIXED (R20):** targeted `update(mapOf("status" to "completed","completedAt" to now))` in `abandonSession` so `affectedKeys == {status, completedAt}` ⊆ allowlist + monotonic active→completed; `finishGame` now delegates to it. Verified live: Quit → no denial → `active=0`, then a different game started immediately (lockout resolved). | **Fixed — pending 1 confirm** | +| B-COPY-001 | P3 | Content/copy (Pass P) · Home | **Home "GAME_WAITING" hero falsely claimed the partner already played.** Body was hardcoded *"Your partner already played their part — take your turn to reveal how you two line up,"* but the card fires on `getActiveSessionForCouple()?.takeIf { uid !in completedByUsers }` — and for async games `completedByUsers` stays empty until BOTH finish, so the card (and its false claim) shows the instant a game is merely *started*, even when neither partner has answered. Confirmed live (R20): with `completedByUsers=[]` + no answers, QA's Home showed the claim, then QA joined to a fresh Q1/10 (no reveal). Copy-vs-behavior mismatch. | **FIXED (R20):** 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 `GamePromptBanner`. Verified live both devices (starter + joiner). | **Fixed — pending 1 confirm** | | O-AGE-001 | P2 | Release / store readiness (Pass O) | **No age gate / age verification despite adult-intimacy content.** Sign-up collects only email+password+confirm; Create Profile collects name+gender; `domain/model/User.kt` has **no DOB/age field**; the only "birthday" in-app is the *partner's* relationship special-date (`SpecialDatesSection`), not age. Yet the app ships sexual/intimacy content (Desire Sync). Google Play content-rating + sexual-content policy generally require an accurate maturity rating and may require an age gate. _(Static finding — 2026-06-28 QA-plan gap review; confirm against current Play policy + intended content rating.)_ | Add an 18+/age-appropriate gate where required + complete the Play content/maturity questionnaire to match actual content. **Pre-ship gate** (does not block per-round flawless). | **Open (pre-ship)** | | BRAND-DARK-COVERAGE | P3 | Art / theme | Most illustrations are **light-only** — only 12 of ~25 have a `drawable-night-nodpi/` dark variant. All `illustration_couple_*` heroes (paywall/subscription/onboarding/invite/history), `daily_question`, `partner_activation`, `tonight_partner_prompt`, `together_empty`, and **all 10 `pack_art_*` banners** show the **light/pink image on a dark screen** (feathered edges don't change the image colors). | Generate dark/aubergine-palette variants for each light-only asset → `drawable-night-nodpi/` (identical filename); `BrandIllustration` auto-selects per in-app theme. Re-run the decoupled-theme check. List in `ClaudeBrandingReview.md`. | **Open (P3)** | diff --git a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt index 93a1555e..8d24b892 100644 --- a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt @@ -295,12 +295,23 @@ class QuestionSessionRepositoryImpl @Inject constructor( override suspend fun abandonSession(coupleId: String): Result = runCatching { val activeSession = getActiveSessionForCouple(coupleId) ?: return@runCatching - saveSession( - activeSession.copy( - completedAt = System.currentTimeMillis(), - status = "completed" + // Targeted update of ONLY the rule-allowlisted progress fields. A full saveSession() here + // does doc.set() of the whole document, which DROPS the server-only flags the Cloud Function + // wrote (startNotifiedAt / joinNotifiedAt / partFinishNotifiedAt). The session-update rule + // counts those removed keys in affectedKeys(), so the write is PERMISSION_DENIED and the + // session is stranded `active` (blocks new games; the failure is swallowed). Mirror + // markUserComplete's targeted update so affectedKeys == {status, completedAt}. (B-ABANDON-001) + firestore.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.SESSIONS) + .document(activeSession.id) + .update( + mapOf( + "status" to "completed", + "completedAt" to System.currentTimeMillis() + ) ) - ).map { }.getOrThrow() + .await() } override suspend fun getSessionById(coupleId: String, sessionId: String): QuestionSession? = diff --git a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt index 0ee04328..2845781b 100644 --- a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -138,16 +138,13 @@ class GameSessionManager @Inject constructor( throw Exception("Session ID mismatch") } - val completedAt = System.currentTimeMillis() - val updatedSession = currentSession.copy( - completedAt = completedAt, - status = "completed" - ) - - // Propagate a failed write: saveSession returns Result.failure rather than - // throwing, so without getOrThrow() the outer runCatching would report success - // and leave the session stuck "active" (locking the couple out of new games). - sessionRepository.saveSession(updatedSession).map { }.getOrThrow() + // Complete via the targeted allowlisted update (status + completedAt only). Do NOT round-trip + // through saveSession() — its full doc.set() drops the server-only notification flags + // (startNotifiedAt/joinNotifiedAt/partFinishNotifiedAt), which the session-update rule counts + // as disallowed affectedKeys → PERMISSION_DENIED, stranding the session active and locking the + // couple out of new games. abandonSession performs the correct write. (B-ABANDON-001) + // getOrThrow propagates a failed write (otherwise the outer runCatching falsely reports success). + sessionRepository.abandonSession(coupleId).getOrThrow() } /** diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 8328c71f..5b38ed85 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -625,10 +625,17 @@ class HomeViewModel @Inject constructor( cta = "Answer to unlock reveal" ) + // NOTE: GAME_WAITING fires whenever there's an active session this user hasn't finished + // (for async games like This or That, completedByUsers stays empty until BOTH finish, so we + // cannot assume the partner has played their part here). Keep this copy accurate for every + // state — the real-time "X played their part, your turn" nudge is delivered separately by the + // push-driven YOUR_TURN GamePromptBanner, which knows the partner actually finished first. Priority.GAME_WAITING -> HomeAction( - eyebrow = "Your turn", - title = "Your turn to play.", - body = "Your partner already played their part — take your turn to reveal how you two line up.", + eyebrow = "Game in progress", + title = "Pick up your game.", + body = partnerName?.let { + "Jump back in to finish your picks and see how you and $it line up." + } ?: "Jump back in to finish your picks and see how you two line up.", cta = "Play now", target = HomeActionTarget.Game, tone = HomeActionTone.Ritual, diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index a4f3f22f..76efa425 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -1163,6 +1163,12 @@ SCRIPTS.md These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now. +### B-ABANDON-001 — never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys +**Symptom (R20)**: tapping **Quit** on any game (This or That / How Well / Wheel) navigated away but **left the session `active` server-side** — stranded, blocking new games — with no user-visible error. Logcat showed `Write failed at couples/{id}/sessions/{sid}: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. The failure was swallowed by `runCatching{…}.onFailure{ Log.d }`. This had been mistaken for a test-data cleanup nuisance for several rounds ("Quit doesn't cancel server-side") before being root-caused. +**Root cause**: `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) completed the session by calling `saveSession(activeSession.copy(status="completed", completedAt=now))`. `saveSession` does `doc.set(data)` where `data` is a **fixed 13-field map** — so it **overwrites the whole document and drops any field not in that map**, including the server-only notification flags the Cloud Function wrote via Admin SDK (`startNotifiedAt`, `joinNotifiedAt`, `partFinishNotifiedAt`, `finishNotifiedAt`). The session-update rule's `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status','completedAt','completedByUsers','joinedByUsers'])` counts a **removed** key as affected → those dropped flags fail `hasOnly` → the entire write is denied. (The normal both-finished completion path was unaffected because `markUserComplete` uses a **targeted `tx.update(docRef, {completedByUsers, status?, completedAt?})`**, never `saveSession`.) +**Fix (R20)**: `abandonSession` now does a targeted `firestore...document(activeSession.id).update(mapOf("status" to "completed","completedAt" to now))` so `affectedKeys == {status, completedAt}` ⊆ the allowlist (and the active→completed transition satisfies the monotonic-status clause); `finishGame` delegates to it. Verified live: Quit → no denial → session `active=0` → a different game starts immediately (lockout gone). +**Re-introduction risk**: **`saveSession()` is a CREATE primitive (full `doc.set`), not an UPDATE primitive.** Any code that mutates an *existing* session must use a targeted `update(...)` touching ONLY allowlisted keys (`status`/`completedAt`/`completedByUsers`/`joinedByUsers`) — a full `set()` silently strips server-written fields and the rule denies it. The hazard is invisible in unit tests (no rules) and is swallowed by best-effort `Log.d` callers, so **any new session-completion/abandon path must be exercised live against deployed rules** (watch logcat for `PERMISSION_DENIED` on the session doc), not just compiled. When you add a new server-only session field, it is dropped by `saveSession` for the same reason — prefer `update()` everywhere a session already exists. + ### N-001 / N-002 — VMs that wait for the screen to push an id silently no-op if nothing pushes it **Symptom (R15)**: the **Bucket List was entirely non-functional** — add/load/complete/delete all did nothing, no error, no logcat. **Root cause**: `BucketListViewModel` gated every operation on `if (coupleId.isEmpty()) return`, expecting the screen to call `setCoupleId(...)` — but `BucketListScreen` never did (the nav route passes no coupleId and there's no `LaunchedEffect`). So `coupleId` stayed `""` and every op returned early **silently**. Same class hit **Date Builder (N-002)**: `savePreference()` bailed on `dateIdeaId.isEmpty()` while **nothing ever calls `setDateIdeaId`**, the preference had an empty `coupleId`, and it wrote to `date_plan_preferences` — a collection **no screen reads**. So "Create Plan" silently saved nothing. **Fix (R15, N-001)**: `BucketListViewModel` resolves the couple **itself** in `init` via `CoupleRepository.getCoupleForUser(uid)` → `setCoupleId` → `loadItems` (mirrors `MemoryLaneViewModel`/`YourProgressViewModel`, the correct pattern). Bucket items encrypt at rest (`enc:v1:`) once a real coupleId flows. diff --git a/docs/brand/generated-art/closer_app_explainer_feature.png b/docs/brand/generated-art/closer_app_explainer_feature.png new file mode 100644 index 00000000..389d2108 Binary files /dev/null and b/docs/brand/generated-art/closer_app_explainer_feature.png differ diff --git a/docs/brand/generated-art/closer_app_explainer_feature.svg b/docs/brand/generated-art/closer_app_explainer_feature.svg new file mode 100644 index 00000000..bd3fea65 --- /dev/null +++ b/docs/brand/generated-art/closer_app_explainer_feature.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + Closer + Private questions for two. + Answer prompts. Reveal together. + Encrypted before sync. + + + + + + + + Daily prompts + Small check-ins + + + + + + + Mutual reveal + See together + + + + + + + + Private sync + Google can't decrypt + + + + + + + A private space for two + + diff --git a/docs/brand/generated-art/closer_e2ee_privacy_feature.png b/docs/brand/generated-art/closer_e2ee_privacy_feature.png new file mode 100644 index 00000000..276e4b4e Binary files /dev/null and b/docs/brand/generated-art/closer_e2ee_privacy_feature.png differ diff --git a/docs/brand/generated-art/closer_e2ee_privacy_feature.svg b/docs/brand/generated-art/closer_e2ee_privacy_feature.svg new file mode 100644 index 00000000..290497a4 --- /dev/null +++ b/docs/brand/generated-art/closer_e2ee_privacy_feature.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + Closer + End-to-end encrypted. + Answers lock on your device before sync. + Google can store them, not decrypt them. + + + + + + On device + Encrypted first + + + + + + + + + Your keys + Stay with you + + + + + + + + + + Google can't + decrypt them + + + + + + + Only your devices can decrypt + + +