Closer/ClaudeReport.md

101 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Claude QA Report — Full-App QA (living report)
> **RUN-STATE: Round 2 | Pass B RESTARTED from game #1 (R2-B2, play-as-the-user) | Build reinstalled both devices == HEAD source. NEXT ACTION: redo all 7 games via the real user nav path, in order — 1.This or That → 2.How Well → 3.Desire Sync → 4.Connection Challenges → 5.Memory Lane → 6.Spin the Wheel → 7.Date Match. Couple currently HAS premium (Sam premium ON) so premium games are unlocked; revert Sam to free after the premium games. Exit each via Back to Play so the session closes. Then Pass C deep/stateful + nav/back; live notification matrix; D3 non-member; Pass F (incl. F-OBS).**
> Pass-B note: a finished game keeps its session active until a player exits the results (Back to Play); leaving both on results blocks the next game until "End their game". Exit cleanly between games.
> **Pass-B MINDSET (user, 2026-06-24): PLAY AS THE USER** — navigate only via the real in-app path a person would tap (no deep-links/admin pokes/shortcuts); expect what a user expects. When the natural path fails, **REPORT FIRST** (log issue + severity + the user action that failed & what was expected), **THEN** a minimal workaround to proceed — never silently engineer around breakage; a flow needing a workaround is broken and must be filed.
> R2-1 DONE: A-001 couple-shared re-verified live (Desire Sync/Memory Lane/Wheel enter when partner premium; free→paywall). **D-001 (P1) FIXED+DEPLOYED** (capsules/challenges rules; Memory Lane + Connection Challenges now load). Sam reverted to free (baseline).
> Round 1 complete (all 5 passes run report-only; P0P2 found were fixed in-line). Fixes: A-001 (e8892a9), E-001 (ce12abb). Open P3: A-003, B-001, E-002.
> **EXECUTION MODE: autonomous run-to-completion — do NOT stop; fix blockers inline; keep cycling fix→re-QA until flawless. Do NOT hand back when context fills — the harness auto-compacts and you continue from THIS run-state (re-read it + coverage after any summary). Commit before interruptible work; recover stuck sessions via the session-start ritual.**
> **STANDING AUTHORIZATION (user, 2026-06-24): may `firebase deploy --only firestore:rules` + has admin access (Firestore reads/writes/seeds + entitlement toggles) — run these without pausing. Only the macOS requirement for iOS (Parts 2/3) remains a hard stop.**
> Playbook: `ClaudeQAPlan.md`. Coverage matrix: `ClaudeQACoverage.md`. Report-only during passes (no fixes until the fix phase).
> Devices: emulator-5554 (QA=`Y05AKO`) + emulator-5556 (Sam=`imDjjO`), paired (coupleId `Xal3Kw3gjSdn0niERYKJ`). Build == HEAD `64f0a7e`.
_(Prior games/notifications QA from 2026-06-24 was completed + verified; superseded by this full-app effort.)_
---
## Severity summary (current — R1 fixed + R2 findings)
| Severity | Open | Fixed |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 2 | 2 |
| P2 | 3 | 1 |
| P3 | 4 | 0 |
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2).
**Round 2 (Pass B play-as-user restart) new/changed:** **B-001** escalated **P3→P1** (a finished game never closes its
session via any normal path — proven: both tapped "Back to Play", session stayed `active` 12s later — so every game
blocks the next, recoverable only via the destructive "End their game"; breaks the core loop); **B-002 (P2, new)** Home
"Play now / your partner is waiting to play" lands on the generic Play hub instead of resuming/indicating the waiting
game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informational notif routing), F-OBS
(load-fail handling). Deferred for "flawless": exhaustive deep/stateful screens (Pass C), full live notif matrix + D3.
---
## Pass A — Couple-shared premium ✅ pass complete
**Target:** if either partner is premium, all premium features unlock for both.
**Result:** only chat is couple-shared. Every other feature gate is per-user → a free user whose partner paid stays locked.
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| A-001 | Premium gating | PlayHubViewModel, DesireSyncScreen, MemoryLaneScreen, ConnectionChallengesScreen, QuestionPackLibraryViewModel, wheel CategoryPicker/SpinWheel/WheelHistory(VMs) | **P1** | These gated on per-user `EntitlementChecker.isPremium()` instead of couple-shared. A free partner of a premium user stayed locked. | Set Sam premium, QA free → QA Play hub still showed 🔒 on Desire Sync + Memory Lane. | **FIXED** `e8892a9` — routed all gates through `CouplePremiumChecker` (now exposes isPremium/hasPremium resolving partner internally). Verified: Sam premium → QA enters Desire Sync; both free → QA → paywall. |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | Open (deferred — thread hasPremium into the card composables) |
| A-002 | Premium (control) | ConversationViewModel (chat) | — | **Working correctly** (couple-shared) — kept as the reference pattern for the A-001 fix. | Verified prior round: partner-premium unlocks chat media/reactions for the free partner. | OK |
**Note (by-design, not a bug):** `SubscriptionScreen` uses per-user `isPremium()` — correct, it reflects the user's *own* subscription/account state, not a feature gate.
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial)
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| B-001 | Games / sessions | couples/{id}/sessions | **P1** (was P3→P2→P1) | **A finished game NEVER closes its session — there is no normal-user path to complete it — so every game leaves a dangling `status=active` session that blocks ALL other games.** Definitively proven on the R2-B2 restart: played This or That fully through on BOTH devices → both reached the results screen → **BOTH tapped the intended "Back to Play" button** → both navigated back to the Play hub, **but the session stayed `active`** (re-checked at +0s and +12s; no cloud-function cleanup; `completedAt` never set). So neither "Back to Play" nor leaving to Home completes a finished session — the ONLY thing that does is the **destructive "End their game"** (which the next game offers as "Sam is playing a … game", misleading copy since nobody is actually playing). Net: a couple **cannot cleanly play two games in a row** — after every game, the next one is blocked until one partner kills the (already-finished) session. This breaks the core game loop for every session → **P1**. **Fix:** mark the session `completed` when both players reach results (or on "Back to Play"); don't let a completed/stale session block new games; fix the misleading "is playing" / "End their game" copy. | Play This or That to results on both → both tap "Back to Play" → Firestore `couples/{id}/sessions` still has that this_or_that doc `status=active` (12s later) → open any other game → "Waiting for Sam — Sam is playing a … game", only "End their game" unblocks. | Open |
| B-002 | Home → Play nav (play-as-user) | HomeScreen "Your partner is waiting to play" card → "Play now" | **P2** | The Home card explicitly promises resuming the specific waiting game — "**Your partner is waiting to play. A game is ready for the two of you. Jump back in and keep the ritual going.**" → **"Play now"** — but tapping it just lands on the **generic Play hub** (the game list). It does NOT open/resume the waiting game, and the Play hub shows **no indication of which game is waiting** nor any "resume" affordance. A user told to "jump back in" cannot tell what to tap or how to rejoin. (Also: BOTH partners' Home cards say "**your** partner is waiting to play" for the same session, so each thinks the other is mid-game.) **Fix:** "Play now" should deep-link into the active session (its play/results screen), or the Play hub should surface a "Resume — How Well" entry; the Home copy should reflect whose turn it actually is. | Cold start → Home → tap "Play now" → lands on Play hub, no waiting-game indicator. | Open |
| B-003 | Desire Sync results (copy/clarity) | DesireSyncScreen results | **P3** | The results stats are internally **inconsistent/confusing**. Header: "**3 shared desires — 2 answers stayed private.**" Per-person row: "**You 5 private / Sam 5 private**". Progress bar caption: "**3 shared, 2 kept private.**" So the same screen says both "2 kept private" (total) AND "5 private" (each person) — a user can't tell whether 3 are shared or all 5 stayed private. (Mechanically "5 private" likely means "all 5 of each person's raw answers stay private, 3 happened to overlap", but that framing isn't clear and contradicts the "2 kept private" line.) **Fix:** make the three counters consistent (e.g., drop or relabel the per-person "5 private", or clarify "your individual answers are always private"). | Play Desire Sync to results → read the three differing private/shared counts. | Open |
**Launch/crash sweep (QA, free):** This or That ✅ (mood/length select), How Well Do You Know Me ✅ (intro), Connection Challenges ✅, Spin the Wheel ✅ — all render, **no FATAL**. Desire Sync + Memory Lane are premium-gated (covered in Pass A; gameplay needs premium toggle). Date Match: todo. Full two-device start→finish + results not exhaustively re-run this round (the prior round verified `onGameSessionUpdate` start/finish end-to-end).
**R2-B2 Desire Sync playthrough (couple-shared premium):** QA (free) entered with NO paywall (A-001 holds live). Both played full 5 Yes/No; QA T,T,T,T,F + Sam T,T,F,T,F → results show **exactly 3 shared desires** (the mutual-yes Q1/Q2/Q4) with Q3 (mismatch) and Q5 (both no) correctly **hidden** — reveal/privacy logic CORRECT; results match on both devices; no crash. Findings: B-003 (P3 copy), C-DS-001 (P2 dark contrast on revealed list).
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending)
**Method:** 5554=Dark, 5556=Light; readable dark|light pair montages + a code scan for non-adapting colors.
**Verified clean (both themes, readable, no clipping):** Home, Today, Play, Messages inbox, Settings. `closerBackgroundBrush()` is theme-aware (adapts). No FATAL on these.
| ID | Area | Screen | Severity | Description | Status |
|---|---|---|---|---|---|
| C-OBS | Theming | ~20 screens (AnswerRevealScreen 15, WheelSessionScreen 14, DateMatchScreen 10, PaywallScreen 9, BucketListScreen 9, SettingsScreen 7, HomeScreen 5, …) | observe | Use hardcoded `Color(0x…)` literals (195 total) that don't adapt to theme — a dark-mode contrast risk to verify per-screen. Main screens checked look fine; deep/stateful screens (reveal, wheel session, dates, bucket list) still need visual verification in both themes. | Open (verify in continuation) |
| C-CC-001 | Nav / layout — duplicate header + double back | ConnectionChallengesScreen (series list) | **P2** | The screen shows **TWO stacked "Connection Challenges" titles, each with its own back arrow** — a nav-scaffold app bar (title "Connection Challenges" + back) AND an in-content header ("Connection Challenges / Pick a series to build a habit together." + a second back arrow right below it). Verified it's a **redundant duplicate header, not a double-pushed route**: tapping the inner back arrow pops straight to the Play hub (same as the app-bar back). No dead-end, but two identical titles + two back buttons is confusing ("which back do I press?") and looks broken — exactly the "double back" case to flag. **Fix:** drop the in-content TopAppBar/back (let the nav scaffold own the title+back), or remove the scaffold bar for this route. | Play hub → Connection Challenges → two "Connection Challenges" headers + two back arrows stacked at top. | Open |
| C-DS-001 | Theming / readability (dark) | DesireSyncScreen results — "You both said yes to" list | **P2** | In **dark mode**, the revealed shared-desire list items render as **dim, low-contrast muted-pink text on a dark pink-tinted card** — legible but well below the crisp high-contrast black text the same items show in **light mode** (verified side-by-side: QA dark vs Sam light). Given the user's "text must be readable" bar — and that this is the intimate payoff content the user most wants to read — the dark-mode contrast is too low. (May be intentional "muted" styling; if so it needs a dark-mode-specific brighter token.) | 5554=Dark: play Desire Sync to results → the 3 shared-desire rows are dim/hard to read vs the same rows on 5556=Light. | Open |
_Deep/stateful screens (answer reveal, wheel session/complete, date match/builder/matches, bucket list, memory capsule, history, paywall, auth/onboarding/pairing) need their states set up — pending next chunk._
| C-NAV-001 | Nav / back-stack — onboarding+auth not popped after login | MainActivity AppNavigation (start/auth/onboarding graph) | **P1** | **The auth + onboarding destinations are never popped from the nav back stack after login, so pressing system Back from Home walks BACKWARD into onboarding → the welcome/login screen instead of exiting the app.** Confirmed with a CLEAN reproduction (no scripted pollution): cold start → land on **Home** (authenticated, "Connected with Sam") → press system **Back once** → lands on the **"Answer honestly" onboarding carousel** (still inside `closer.app/app.closer.MainActivity`, so it's in-app nav, not a separate task). Tapping the carousel's **Skip** then reaches **"Closer — Create account / I already have an account"** (the pre-auth welcome) — i.e., a logged-in user pressing Back appears to be logged out. Not data loss (cold start returns to Home; Firebase auth persists), but it's a core, every-user nav defect and very alarming UX. **Fix:** on successful auth/onboarding completion, navigate to Home with `popUpTo(<auth/onboarding graph or start route>) { inclusive = true }` (and `launchSingleTop`) so Home is the back-stack root and Back from Home exits the app. | Cold start (logged in) → Home → press system Back → onboarding carousel appears instead of the app closing. | Open |
**Pass B requirement (updated):** each game must be **played one complete time through on both devices** (start → every step → finish/reveal/results), not just launched. Round 1 did launch-only → **full playthroughs owed in Round 2** for all 7 (premium games need a premium toggle). A launch-only result = `partial`, not `pass`.
**Pass C requirement (added):** **navigation from every entry point** (each screen reached from all its links — e.g. conversation from inbox/Discuss/notification; game from Play/notification; paywall from each gate) + **back-stack / "double-back"** (system back AND in-app back return to the right place from each entry; no dead-ends, no exit-app surprise, **no screen needing two backs**/duplicate stack entries; deep-link/notification entries land with a sane back stack). Owed in Round 2. Wrong/double back or dead-end = P2 (P1 if it traps the user).
## Pass D — Security & Encryption ✅ clean (no P0/P1 found)
- **D1 at-rest:** all private content is ciphertext — message `text` + `lastMessagePreview` + thread messages = `enc:v1:`; daily answers `encryptedPayload` = `sealed:v1:`; **Memory Lane capsules `title` + `content` = `enc:v1:`** (live-verified R2-B2: admin read the just-created capsule → both fields ciphertext, `status:sealed`, `unlockAt` set, only metadata plaintext). Metadata (dates, types, commitmentHash, ids) plaintext as expected. Chat media bytes = Tink ciphertext (verified prior round + unchanged code path). **No plaintext content leak.**
- **D2 rules:** no catch-all `match /{document=**}`, no blanket `if true`; **`hasPremium` server-only** (client create/update blocked, rules L172/174); entitlements `write:false`; conversations/messages/typing/reactions + entitlement partner-read scoped to members.
- **D4 key exchange:** pairing uses a **wrapped couple key** (`wrappedCoupleKey` + `kdfSalt`/`kdfParams` + `encryptedRecoveryPhrase`); invite code is the KDF seed, never stored raw; strict E2EE (invites without a wrapped key rejected) — confirmed in `acceptInviteCallable`.
- **D5 App Check/secrets:** App Check enforced (`SecurityModule`, `PlayIntegrityChecker`, `FirebaseInitializer`); both service-account JSONs gitignored **and untracked**; `allowBackup=false`.
- **D6 leak vectors:** analytics events carry only metadata (no message/answer content); `allowBackup=false`.
_Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh 3rd account (rule logic verified member-scoped); a fresh Storage-bytes spot-check of chat media._
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| D-001 | Rules — missing subcollection rules | **P1** | `couples/{id}/capsules` and `couples/{id}/challenges` had **no `match` block** → default-deny → **Memory Lane hung on its loading heart** and **Connection Challenges** couldn't load (live `PERMISSION_DENIED` confirmed). Two premium features broken. | Sam premium, QA opens Memory Lane → stuck loading heart; logcat `Listen for Query(.../capsules) failed: PERMISSION_DENIED`. | **FIXED + DEPLOYED** — added member-read + ciphertext-enforcing `capsules` rule (title/content/promptUsed must be `enc:v1:`) and a `challenges` rule (catalog-referenced, progress-only). Re-verified live: Memory Lane shows empty state, Connection Challenges shows the series list, **0 permission errors**. |
| F-OBS | Resilience (UI) | **P3** | MemoryLaneScreen (and likely others) **hangs on the loading indicator forever** when a Firestore query fails, instead of showing an error/empty state. Masked the D-001 root cause. Add load-failure handling. | Was visible before D-001 fix (stuck heart). | Open (Pass F) |
| (outcomes) | Rules | — | The Round-1 `outcomes` list `PERMISSION_DENIED` is **by-design** — the rule restricts reads to specific dayKeys (`day_0/30/60/90`); a bare list query is correctly denied. Not a bug. | — | Closed (by-design) |
## Pass E — Notifications
- **Copy carries no private content:** all function notification bodies are generic ("Tap to read and reply", "Answer together before it expires", etc.); `${title}` refers to public question/game titles, not user answers. ✓ (ties to D6)
- **Routing:** centralized in `PartnerNotificationType` (`fromRemoteType` → `routeFor`); chat opens the exact conversation, reveal→answerReveal(questionId), games→Play, capsule→Memory Lane, etc.
- **Foundations** (prior round, code present): FCM token registration on sign-in, POST_NOTIFICATIONS, channels.
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| E-001 | Notification routing | **P2** | Type-string mismatch: functions send `daily_question` + `challenge_day_ready`, but client mapped only `daily_question_reminder` + `challenge_waiting` → tapping those did NOT deep-link to Today / Connection Challenges. | **FIXED** `<pending-commit>` — added `daily_question`/`challenge_day_ready` to `fromRemoteType` (build green; live tap-verify deferred). |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | Open |
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._