21 KiB
Claude QA Report — Full-App QA (living report)
RUN-STATE: Round 2 | FIX PHASE in progress. ✅ Both P1s FIXED + verified live: B-001 (sessions rules now allow
completedByUsers→ finished games auto-complete, next game not blocked; deployed) and C-NAV-001 (AppNavigation clears entry flow → Back from Home exits app, deployed to both devices). Open: P2 ×3 (B-002 Home "Play now"→hub, C-CC-001 dup header, C-DS-001 dark contrast) + P3 ×4 (A-003 badge, B-003 Desire Sync counts, E-002 notif routing, F-OBS load-fail). NEXT ACTION: fix the 3 P2s by severity (one at a time: implement→build→install→verify→commit), then P3s. Then re-QA round. Build/rules == current HEAD; both emulators on the fixed APK; couple baseline both free, 0 active sessions. 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 | 0 | 4 |
| 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. ROOT CAUSE (found in fix phase): a Firestore RULES bug, not app code. The sessions allow update rule required affectedKeys().hasOnly(['status','completedAt']), but the async-game completion path (markUserComplete) always writes completedByUsers (each player records themselves; the session flips to completed only once both are in). So every "I reached results" write was denied (the failure is swallowed by onFailure), completedByUsers never reached 2, and the session stayed active forever. abandonSession ("End their game") only diffs status/completedAt, so it passed the rule — exactly why that was the only thing that worked. |
Play This or That to results on both → session stayed active; next game blocked. |
FIXED + DEPLOYED — sessions allow update now permits ['status','completedAt','completedByUsers'], lets any couple member record completion progress, keeps startedByUserId immutable + status monotonic (active→completed, never revert). Re-verified LIVE: played This or That fully on both → session auto-flipped to status=completed, completedByUsers=[both], 0 active sessions (no Back-to-Play/End-their-game needed); then opened How Well immediately → its setup screen, NOT "Waiting for Sam". Core loop restored. |
| 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. | FIXED — in AppNavigation.navigateRoute, navigating to HOME from an entry route (ONBOARDING/CREATE_PROFILE/PAIR_PROMPT/LOGIN/SIGN_UP/FORGOT_PASSWORD) now does navigate(HOME){ popUpTo(0){inclusive=true}; launchSingleTop }, wiping the entire pre-app flow so HOME is the back-stack root. Normal tab-switch semantics (selectTab) untouched. Re-verified LIVE: cold start (logged in) → Home → system Back → focused activity is the launcher (NexusLauncherActivity), app exits cleanly — onboarding no longer resurfaces. |
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:; Memory Lane capsulestitle+content=enc:v1:(live-verified R2-B2: admin read the just-created capsule → both fields ciphertext,status:sealed,unlockAtset, 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 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.