19 KiB
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; P0–P2 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): mayfirebase 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 (coupleIdXal3Kw3gjSdn0niERYKJ). Build == HEAD64f0a7e.
(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 P0–P2 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. |
| 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. |
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 answersencryptedPayload=sealed:v1:. 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 blanketif true;hasPremiumserver-only (client create/update blocked, rules L172/174); entitlementswrite: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 inacceptInviteCallable. - 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. |
| 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). |
| (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. |
— |
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.