fix(qa): R10 fix phase — 5 P2 bugs fixed (C-HOME-001, C-NAV-002, C-NAV-003, C-PW-001, C-SEC-001)

This commit is contained in:
null 2026-06-27 10:34:26 -05:00
parent 32b5b560a2
commit 9c84c36443
11 changed files with 102 additions and 55 deletions

View File

@ -11,6 +11,13 @@ the existing artwork (`docs/brand/visual-identity.md` + `docs/brand/asset-system
---
## R10 brand walk (2026-06-26) — existing art integration clean, 0 defects
R10 visual sweep doubled as the Pass-H existing-art integration check: Today (paired-books daily-question art),
Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render
on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md`
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
+ PlayArrow glyph and read as intentional action banners — no illustration warranted. No new art to add this round.
## ✅ WIRED INTO ANDROID (2026-06-26 art drop) — all 11 generated illustrations live
The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to
`app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState`

View File

@ -1,7 +1,7 @@
# Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> Build `23dd6a7` (+ `ab29f6b` outcomes fix). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: AJ covered, R9 clean confirmation round (0 new). Open: J-OBS (P3) only. I-001/I-002 fixed+confirmed.**
> Build `32b5b56` + R10 working-tree fixes (5×P2). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: R10 FULL run AJ + fix phase COMPLETE. 0 open P0P2; 1 P3 (J-OBS). 5 P2 found+fixed+verified-live this round (pending 1 confirm). E-GAME-002 confirmed+pruned. Security D1D7 clean.**
> Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is
> confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.)
@ -10,14 +10,14 @@
|---|---|---|
| A — Couple-shared premium | all gated features × neither/partner/self | ✅ pass |
| B — Games lifecycle | all 7 games played full, 2-device, real user-nav | ✅ pass |
| C — Visual (light+dark) | ~14 core screen-types both themes | ✅ pass · deep/list screens **deferred** |
| D — Security & encryption | D1 at-rest · D2 rules · D3 live raw-API · D4D6 | ✅ clean |
| E — Notifications | chat + game start/finish/results live, both-client + suppression | ✅ pass · full fg/bg/killed matrix **partial** |
| F — Resilience | concurrency · offline · lifecycle · process-death · time | ✅ pass |
| G — Account creation / fake-account | sign-up · validation · duplicate · invite-abuse | ✅ pass |
| H — Branding & artwork | consumer brand walk → prompts | see `ClaudeBrandingReview.md` |
| I — Performance & route efficiency | cold-start, jank (core/conversation/hub), leak proxy, caching | ✅ done · **I-001 (P1)** outcomes read denied |
| J — Accessibility | font scale 2.0, semantics, targets, reduce-motion | ✅ done · J-OBS (P3) ~4245dp targets |
| C — Visual (light+dark) | R10 re-sweep: Messages/conv/thread, Today, Paywall, Wheel History, Activity, Home, wheel back-stack | ⚠️ 5 P2 open (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dbl header · C-PW-001 dark paywall · C-SEC-001 recovery) |
| D — Security & encryption | R10: D1 at-rest · D2 rules (incl secure-subdoc gate) · D3 live raw-API · D4 recovery · D5/D6/D7 | ✅ clean (C-SEC-001 = UI wrong-store, not crypto) |
| E — Notifications | R10 live: E-GAME-002 confirmed (start push+banner+Join), finish/answer/reveal pushes | ✅ pass · E-GAME-002 pruned · full fg/bg/killed matrix **partial** |
| F — Resilience | R10: concurrency double-start→1 session · process-death→clean+FCM re-register · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred |
| G — Account creation / fake-account | R10: abuse live via D3 (non-member denied, no self-grant) + invite rules; happy/validation R5-clean (unchanged) | ✅ pass |
| H — Branding & artwork | R10: existing-art integration clean (0 defects), new game surfaces on-brand | ✅ see `ClaudeBrandingReview.md` |
| I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression |
| J — Accessibility | R10: font scale 2.0 reflows (new hero ok), reduce-motion×7 | ✅ done · J-OBS (P3) ~4245dp targets |
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS. Pending one confirm: **F-RACE-001**.
@ -53,6 +53,7 @@ Note: exit each game via "Back to Play" between games so the session closes (B-0
## Pass C — Visual (light + dark), all ~50 routes
~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back).
- **R10 re-sweep (both themes where relevant):** Messages inbox ✅ (dark+light: conversations, avatars, unread dot, previews decrypted, no `enc:` leak), Conversation ✅ (image/voice/text/reaction/read-receipt/date-sep, E2E lock glyphs, correct attribution both dirs), per-question Discussion thread ✅, Today/daily-question ✅ (dark+light, paired-books art on-brand), Activity/Together ✅ (dark). **5 P2 found:** C-HOME-001 (Home shows top pending action twice — `primaryAction` hero + `buildPendingActions` row), C-NAV-002 (wheel results→BACK re-enters finished play screen, no popUpTo), C-NAV-003 (Wheel History/Past Games + PartnerHome double app-bar — route in `shellBackRoutes` while screen owns a TopAppBar; C-CC-001 class), C-PW-001 (dark paywall "What's included" pills light-on-light, `BenefitPill` onSurface text), C-SEC-001 (accepter recovery copy). Premium-locked Wheel History state renders. Date Builder · Question Packs(gated→paywall) · Answer Reveal sealed = token-consistent, R9-clean.
- **R9 deferred sweep — 0 new issues:** Answer History, Together/Activity, Bucket List (empty state + FAB), Date Match deck, Date Matches all render cleanly in **dark** (good contrast, no clipping, no FATAL); Privacy & Terms + Home confirm **light** parity (shared Material3 tokens). Remaining standard list/detail (Wheel History · Date Builder · Past Games · Answer Reveal sealed · Question Packs[gated→paywall]) are token-consistent with the above; fresh-account auth/onboarding visual covered R3/R5. No C findings.
## Pass D — Security & encryption (D1D6) — clean, no P0/P1
@ -111,6 +112,7 @@ Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` →
---
## Round history (one line each)
- **R10** — FULL run AJ + fix phase: 5 P2 found+fixed+verified-live (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dup app bar · C-PW-001 dark paywall · C-SEC-001 recovery wrong-store); E-GAME-002 confirmed live (start push+banner+Join) & pruned; concurrency double-start→1 session; security D1D7 clean; perf/a11y no regression. 0 open P0P2 (5×P2 pending 1 confirm).
- **R7** — security/concurrency deep dive (multi-angle): cornerstone clean; F-RACE-001 found+fixed+verified. 0 new open.
- **R6** — branding drop + Future.md backlog regression: 0 new open.
- **R5** — Cloud Functions deployed (E-OBS/E-003) + new Pass G clean: 0 open.

View File

@ -1,12 +1,12 @@
# Claude QA Report — Full-App QA (living report)
> **Verdict (2026-06-26): 0 open P0P2 (1 P3 J-OBS). Daily-question couple-key reveal QA'd live this session — all PASS, no new bugs. (Reveal feature now committed: HEAD `e6a8dee`.)**
> **Verdict (2026-06-26): R10 FULL ClaudeQAPlan run COMPLETE (AJ + fix phase). 0 open P0P2; 1 P3 (J-OBS). Found 5 P2 this round (Home dup card, wheel back-stack, duplicate app bar, dark paywall contrast, recovery-phrase wrong store) — ALL fixed + verified live + regression-clean (0 FATAL, content still `enc:v1:`). E-GAME-002 confirmed live + pruned. Security cornerstone clean (D1D7). Fixes in working tree — user commits.**
>
> This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current)
`R10 (2026-06-26) full ClaudeQAPlan run | Pass A ✅ Pass B ✅ | Pass C in progress | Fixed **E-GAME-002 (P1, user-reported)** game-start push + foreground deep-link | 1 open P2 (C-SEC-001) + 1 P3 J-OBS | Sam premium = ON | NEXT ACTION: continue Pass C families (Messages, Today/reveal, Date/BucketList, Wheel history, AnswerHistory, YourProgress, Paywall, auth/onboarding, light parity); confirm E-GAME-002 next round then prune. Then DJ.`
`R10 (2026-06-26) full ClaudeQAPlan run COMPLETE | A ✅ B ✅ C ✅ D ✅ E ✅ F ✅ G ✅ H ✅ I ✅ J ✅ | Fix phase ✅ — 5×P2 fixed+verified live (C-HOME-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001) | E-GAME-002 confirmed+pruned | 0 open P0P2; 1 P3 (J-OBS) | Baseline: both FREE, 0 active sessions; build installed both emulators | NEXT (R11 = next session): brief confirmation round → re-verify the 5 P2 fixes hold + regression sweep → prune the 5 Fixed rows → flawless. Optional: J-OBS (P3) touch targets. App fixes in working tree (user commits).`
- **Uncommitted (user commits):** `functions/src/games/onGameSessionUpdate.ts` (DEPLOYED live), `app/.../MainActivity.kt` + `app/.../notifications/PartnerNotificationManager.kt` (E-GAME-002). **+ Foreground game-alert feature (this session, app rebuilt+installed both):** NEW `notifications/GamePromptController.kt`, `ui/components/GamePromptBanner.kt`; edited `core/navigation/AppNavigation.kt`, `core/notifications/AppMessagingService.kt`, `ui/home/HomeScreen.kt`, `ui/home/HomeViewModel.kt`, `ui/wheel/WheelSessionViewModel.kt`. Note: reinstalling the debug APK can leave a stale FCM token until re-register on next launch.
- **Foreground "partner started a game" alert + bold Game Waiting card (R10, user-requested) — DONE+VERIFIED.** When the app is OPEN and a partner starts a game, a prominent **in-app top banner** ("<partner> started <Game>" + Join) slides in (mirrors chat's in-app surface) instead of the easy-to-miss system notification; **Join → joins the game**. Verified live for all 4 session games: banner name correct (Spin the Wheel / This or That / How Well Do You Know Me / Desire Sync); Join → joins wheel (ToT shows graceful "QA is playing a Wheel game" when types mismatch); **suppressed** when already on that game's screen (added `ActiveGameSessionMonitor.enter/leave` to `WheelSessionViewModel` — the others already had it). Home **"Game waiting"** card redesigned as a **bold purple-gradient hero** (glyph + game name + "Join the game"), promoted to top of "Waiting for you", verified **both themes** → tap **joins the specific game** (not the Play-hub fallback). FCM transport on the emulators is flaky (FcmRetry); the banner was exercised via a data-only high-priority send to the partner token (faithful to the deployed payload).
- **Pass C progress (R10):** **Settings family ✅** (dark + Security on light): Settings list, Subscription, Security, Delete account, Notifications all render clean both-relevant-themes; **4 illustrations confirmed in-context** (Security padlock, Delete-account doorway, Quiet-hours moon, + Subscription); back-stack OK (Security/Delete/Notif → BACK → Settings). **Found C-SEC-001 (P2)** — accepter's Recovery phrase disabled + wrong "invite your partner" copy (see Open issues). **Wheel back-stack RE-CHECKED = not a trap:** live wheel session → BACK → spin/setup → BACK → Play hub (2 backs, no dead-end); earlier "stuck" was automation cycling. Leaving mid-wheel leaves a resumable abandoned active session (normal; cleaned). Home ✅ both themes (stale game card gone).
@ -28,19 +28,29 @@
| Severity | Open | Fixed (pending 1 confirm) |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | **1** |
| P2 | **1** | 0 |
| P1 | 0 | 0 |
| P2 | **0** | **5** |
| P3 | **1** | 0 |
## Open issues
## Issues — R10 (5×P2 fixed + verified live this round, pending 1 confirm; J-OBS P3 open)
> Each P2 below was found in R10's report-only passes, then fixed + verified live in the R10 fix phase (build
> succeeded, both emulators reinstalled, 0 FATAL, content still `enc:v1:` at rest). Per hygiene they survive **one**
> confirmation round, then prune. **Fix commits are in the working tree (user commits).** Quick fix summary:
> C-HOME-001 `buildPendingActions().filterNot{it.target==primary?.target}` · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` on session→complete ·
> C-NAV-003 removed WHEEL_HISTORY/GAME_HISTORY/PARTNER_HOME from `shellBackRoutes` · C-PW-001 `BenefitPill` text→`PurpleDeep` ·
> C-SEC-001 `SecurityViewModel` reads `encryptionManager.recoveryPhrase(coupleId)` (CoupleKeyStore) + corrected copy.
| ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---|
| E-GAME-002 | P1 | Notifications / games (user-reported, R10) | **Two bugs in the game-start notification flow** (started Spin-the-Wheel; partner on Settings got nothing). **(a) Push not sent (intermittent):** the atomic session-start (F-RACE-001, `runTransaction`) writes the session doc **and** the `sessions/_active` pointer in one transaction → `onGameSessionUpdate` (onWrite) fires twice and transactional writes deliver `change.before == change.after` ("Snapshot has no readTime") → the `inactive→active` edge was missed → no `partner_started_game`. **(b) Foreground tap dead:** foreground-posted notifications use a **Uri** PendingIntent, but the NavHost has **no navDeepLink for game/challenge/date/capsule routes** → the tap fell through to Home (`deepLinkRouteFromIntent` bails on any Uri). | QA start wheel; Sam on Settings → no push (logs: 20:24 fired, no notifyPartner). Foreground tap → stayed/Home. | **(a)** detect start/finish by current `status` + idempotent `startNotifiedAt`/`finishNotifiedAt` flag claimed in a tx (exactly-once, robust to before/after); skip `sessionId==='_active'`. **(b)** carry the resolved route as an `app_route` intent extra; `deepLinkRouteFromIntent` prefers it (works for all routes, no per-route navDeepLink needed). | **Fixed+verified (deployed funcs + app rebuilt/installed). Start push fires for ALL 4 session games — `startNotifiedAt` SET (set only inside the claim tx right before notifyPartner): this_or_that ✓, how_well ✓, desire_sync ✓, wheel ✓ (+ wheel device delivery "Your partner started a game…"). **Tap-opens-destination verified via the real system-tray tap path (extras→deepLinkRouteFromIntent→navigate):** start push tap **opens the game** — wheel (joins 1/10) + this_or_that (joins 1/10); finish push tap **opens the results** — wheel → real "Here's how you each answered" replay (wheel_complete/{id}) for a fully-played session. Routing is gameType-data-driven (gameRouteForType/gameResultsRouteFor), so how_well/desire_sync follow the same pattern (valid replay routes). Also: foreground/cold app_route tap joins wheel. Finish branch hardened identically (partner_finished_game fired on completion). N/A for non-session games (Connection Challenges/Memory Lane/Date Match use challenge_day_ready/capsule_unlocked/date_match, not partner_started_game). Functions deployed live; app + funcs source uncommitted (user commits).** |
| C-SEC-001 | P2 | Security / recovery (Pass C, R10) | The **accepter** partner's Security screen shows the Recovery phrase **disabled** with copy *"Recovery phrase becomes available after you invite your partner"* — but they're **already paired** (they accepted an invite, never "invite"). The **inviter** (Sam) sees an active Recovery phrase; the **accepter** (QA) cannot access one. Misleading copy for a paired user + surfaces a recovery asymmetry (only the inviter holds the phrase). | 5554 (QA, accepter): Settings → Security → Recovery phrase row greyed + that copy. 5556 (Sam, inviter): same screen → Recovery phrase active. | Fix the accepter copy (e.g. "Your partner holds your couple's recovery phrase" / explain shared-recovery); confirm in **D4** whether the accepter can recover the couple key at all (if not, that's a deeper gap). | **Open (P2)** |
| C-NAV-003 | P2 | Nav / duplicate header (Pass C, R10) | **Two stacked app bars (double back arrow) on Wheel History / Past Games** — regression of the C-CC-001 class. `WHEEL_HISTORY` + `GAME_HISTORY` are in `shellBackRoutes` (AppNavigation.kt:615-616) so the shell draws a "Wheel History" back bar, but `GameHistoryScreen` (`WheelHistoryScreen.kt:69-72`) renders its **own** `Scaffold`+`TopAppBar("Past Games")` → two title bars + two back arrows stacked. A grep of every `shellBackRoutes` screen shows exactly two that own a `TopAppBar`: GameHistory (verified live) and **PartnerHome** (`PartnerHomeScreen.kt:228`, `PARTNER_HOME` in shellBackRoutes:594, reachable via Home StreakCard `onPartner`) — same defect, code-confirmed. | 5554: Play → Spin the Wheel → History → screen shows "← Wheel History" bar over "← Past Games" bar over "Past games" content. | Remove `WHEEL_HISTORY`, `GAME_HISTORY`, `PARTNER_HOME` from `shellBackRoutes` (the screens own their headers) — exactly the fix applied to CONNECTION_CHALLENGES for C-CC-001 (see comment at AppNavigation.kt:609). | **Fixed + verified live R10 (working tree; user commits)** |
| C-PW-001 | P2 | Paywall / dark-mode contrast (Pass C, R10) | **Paywall "What's included" benefit pills are near-invisible in dark theme.** `BenefitPill` (`PaywallScreen.kt:246`) draws a hardcoded **light** background `CloserPalette.PurpleMist` but sets text `color = MaterialTheme.colorScheme.onSurface`, which is near-white on dark → light-on-light, text barely legible (the `PurpleDeep` checkmark stays visible). Light theme is fine. Same class as the fixed C-DS-001. | 5554 (dark): Play → Desire Sync (both free) → Paywall → "What's included" list rows show white pills with unreadable labels (confirmed via crop; text present in hierarchy). 5556 (light): same rows legible. | Give `BenefitPill` text a fixed dark brand color (e.g. `CloserPalette.PurpleDeep` / `0xFF56306F`, matching the checkmark) since the pill background is always light, OR make the pill background theme-adaptive. | **Fixed + verified live R10 (working tree; user commits)** |
| C-NAV-002 | P2 | Nav / back-stack (Pass C, R10) | **Finishing Spin-the-Wheel → results → system BACK re-enters the completed Wheel Session play screen** (shows "10/10, Finish/Skip/End session"), then BACK again → Wheel hub. The session→complete nav uses plain `navigateRoute` (`else → navController.navigate`, no `popUpTo`), so the finished play screen stays on the back stack. This is the "WATCH — wheel back-stack" item flagged in B; now deliberately reproduced on 5556 (not an automation artifact). | 5556: play a wheel to completion → auto-lands on Complete/results → BACK → lands inside the finished Wheel Session screen. | When navigating WHEEL_SESSION→WHEEL_COMPLETE, `popUpTo(WHEEL_SESSION){inclusive=true}` (or pop in WheelSessionScreen on navigateTo) so BACK from results returns to the wheel hub/Play, not the finished play screen. | **Fixed + verified live R10 (working tree; user commits)** |
| C-HOME-001 | P2 | Home / UI redundancy (Pass C, R10) | **Home shows the top pending action twice.** `HomePriorityEngine` picks a `primaryAction` (rendered as the big `PrimaryHomeActionCard` hero) while `buildPendingActions()` independently re-adds the same item to the "Waiting for you" list — no exclusion of the primary. Seen live: "Challenge waiting" appears both as a compact "Waiting for you" row AND as the prominent hero below it. Applies to every pending type (reveal/partner-answered/game/challenge/date/capsule) whenever the engine's primary overlaps the pending list. | 5554 Home (paired, challenge in progress): two "Challenge waiting" cards stacked (list row + hero). | In `buildPendingActions()` (or at the call site) drop the card whose target matches `primaryAction.target` so each waiting item surfaces once. | **Fixed + verified live R10 (working tree; user commits)** |
| C-SEC-001 | P2 | Security / recovery — wrong store (Pass C+D4, R10) | **SecurityScreen reads the wrong recovery-phrase store, so the accepter can't view/copy their phrase there.** Two stores exist: `RecoveryPhraseStore` (global `closer_recovery/recovery_phrase`) is written **only** by the inviter's `InviteRepositoryImpl.createInvite` (line 43); the accepter's `CoupleEncryptionManager.unwrapAndStore` persists the phrase in `CoupleKeyStore` (`recovery_phrase_$coupleId`, line 71). `SecurityScreen` (`SecurityScreen.kt:82,92`) reads `recoveryPhraseStore.load()` (global) → null for the accepter → row greyed + misleading copy *"becomes available after you invite your partner"*. `EditProfileViewModel` (reads `encryptionManager.recoveryPhrase(coupleId)` → CoupleKeyStore) shows it correctly for both. **D4 verified the accepter DOES hold the phrase and CAN recover the couple key — E2EE recovery is sound (not data-loss); this is a UI wrong-source bug.** | 5554 (QA, accepter): Settings → Security → Recovery phrase greyed + that copy. 5556 (Sam, inviter): active. EditProfile shows it for QA. | Make `SecurityScreenViewModel` read the phrase via `encryptionManager.recoveryPhrase(coupleId)` (CoupleKeyStore, the same source EditProfile uses) instead of the global `RecoveryPhraseStore`; fix the unavailable-state copy. | **Fixed + verified live R10 (working tree; user commits)** |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open (P3, non-blocking)** |
## Resolved & confirmed (archived — full detail in git history)
A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** — all fixed and re-verified (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · **E-GAME-002** · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** — all fixed and re-verified (E-GAME-002 confirmed live R10: `startNotifiedAt` set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
## Security cornerstone — clean (Pass D, deep dive, Round 7)
- **D1 at-rest:** chat text + `lastMessagePreview` + all 4 game-answer collections (ToT / How Well / Desire Sync / Wheel, both users) + Memory Lane capsules + date-swipe actions = `enc:v1:`. No plaintext content; only metadata in clear.
@ -49,6 +59,7 @@ A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-
- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
## Round history (one line each)
- **R10 (2026-06-26) — FULL ClaudeQAPlan run AJ + fix phase.** Found 5 P2 in report-only passes, fixed + verified all live: C-HOME-001 (Home dup pending card), C-NAV-002 (wheel results→BACK re-entered finished session), C-NAV-003 (duplicate app bar on Wheel History/PartnerHome), C-PW-001 (dark paywall pills light-on-light), C-SEC-001 (Security read wrong recovery-phrase store → accepter couldn't view phrase; E2EE recovery itself sound). E-GAME-002 confirmed live (startNotifiedAt set + partner_started_game→right partner + foreground banner + Join→joined active ToT) → pruned. D1D7 security clean (non-member denied all raw-API reads/writes, no self-grant, secure-subdoc gate correct, argon2id+AAD=coupleId). Concurrency double-start→1 session. Perf jank 5.53% / a11y font-2.0 reflows — no regression. Build OK, both emulators reinstalled, 0 FATAL, content still `enc:v1:`. App fixes in working tree (user commits).
- **Notif→game fix + dark art + QA sweep (2026-06-26, uncommitted).** **E-GAME-001 (P1, FIXED+VERIFIED):** game notifications "led nowhere" — backgrounded/warm taps landed on Home (MainActivity was standard launch mode → `onNewIntent` never delivered the tap's extras → `pendingDeepLink` unset), and even when routed, the game screen showed *setup* instead of joining (one-shot `getActiveSessionForCouple` raced the post-push Firestore sync → returned stale-empty). Fixes: `AndroidManifest` `MainActivity launchMode=singleTop` + `QuestionSessionRepositoryImpl.getActiveSessionForCouple` now SERVER-first (cache fallback). **Verified live:** Sam backgrounded → taps partner_started_game → lands IN the active This-or-That (1/10), joined, no duplicate session; back-stack sane (game→back→Home→back→exit, C-NAV-001 holds). Generic across game types (shared routing + getActiveSession). **Dark-theme art:** 12 `_dark` variants → `drawable-night-nodpi/` (light names) so dark mode auto-swaps; verified live (Security shows the aubergine variant on dark; light unchanged). **QA sweep:** tabs both themes, deep-link back-stack, all 12 illustrations both themes — **0 FATAL**, baseline intact.
- **Brand art drop (2026-06-26) — wired + QA-swept, 0 issues.** All 11 generated illustrations (A1A12, source gitignored) wired into their screens via shared `EmptyState` + new `BrandIllustration` helper (commits `077a408`→`5868d06`). **Complete both-theme sweep:** in-context dark **and** light verified for Bucket List (A6), Quiet hours (A9), Security (A11), Delete account (A12) — all render as crisp rounded tiles, on-brand, no clipping/contrast issue; A1 (transparent), A3 (banner) + the empty-only states (A2/A4/A5/A8/A10, unreachable on the baseline couple) verified via the debug Art-preview gallery on both themes + the proven shared tile. **0 FATAL/ANR** both devices; baseline intact (0 sessions/outcomes). Process catch: 5556 was on a stale build mid-sweep → reinstalled current, both now on `768f511`. Details in `ClaudeBrandingReview.md`.
- **R9** — clean confirmation round (**0 new findings**): confirmed + pruned I-001/I-002 (0 outcomes denials/CCE on the fixed build); swept deferred Pass C deep/list screens (Answer History, Activity, Bucket List, Date Match/Matches — both themes) + Pass F network (offline cache render + clean reconnect). 0 open P0P2.

View File

@ -416,7 +416,18 @@ fun AppNavigation(
) {
WheelSessionScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navigateRoute
// When the session finishes it navigates to WHEEL_COMPLETE — pop the finished
// play screen off the back stack so BACK from results returns to the wheel
// hub/Play, not back INTO the completed session (C-NAV-002).
onNavigate = { route ->
if (route.startsWith("wheel_complete/")) {
navController.navigate(route) {
popUpTo(AppRoute.WHEEL_SESSION) { inclusive = true }
}
} else {
navigateRoute(route)
}
}
)
}
composable(
@ -591,7 +602,9 @@ private val topLevelRoutes = listOf(
)
private val shellBackRoutes = setOf(
AppRoute.PARTNER_HOME,
// NB: PARTNER_HOME, WHEEL_HISTORY, GAME_HISTORY are intentionally NOT here — those screens
// render their OWN Scaffold+TopAppBar (with back). Adding them drew a SECOND app bar + back
// arrow on top of the screen's own (C-NAV-003 duplicate header, same class as C-CC-001).
AppRoute.QUESTION_CATEGORY,
AppRoute.QUESTION_COMPOSER,
AppRoute.QUESTION_THREAD,
@ -612,8 +625,6 @@ private val shellBackRoutes = setOf(
// on top of the screen's own (C-CC-001 duplicate header / double back).
AppRoute.WAITING_FOR_PARTNER,
AppRoute.SUBSCRIPTION,
AppRoute.WHEEL_HISTORY,
AppRoute.GAME_HISTORY,
AppRoute.THIS_OR_THAT_REPLAY,
AppRoute.DESIRE_SYNC_REPLAY,
AppRoute.HOW_WELL_REPLAY,

View File

@ -624,13 +624,9 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon:
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// No "End this game" here: you've already answered and are only waiting for your partner to
// finish. "Back to Play" leaves safely (results push arrives when they finish); abandoning
// would silently discard the reveal you're waiting for.
}
}

View File

@ -573,7 +573,9 @@ class HomeViewModel @Inject constructor(
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
val pending = buildPendingActions()
// The primary action already gets the prominent hero card; drop it from the "Waiting for
// you" list so the same item isn't surfaced twice (C-HOME-001).
val pending = buildPendingActions().filterNot { it.target == primary?.target }
return copy(
primaryAction = primary,

View File

@ -686,13 +686,9 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// No "End this game" here: you've already done your part and are only waiting for your partner.
// "Back to Play" leaves safely (results push arrives when they finish); abandoning would
// silently discard the reveal you're waiting for.
}
}

View File

@ -261,7 +261,10 @@ private fun BenefitPill(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
// The pill background is always the light PurpleMist (not theme-adaptive), so the
// text needs a fixed dark brand color — onSurface is near-white in dark mode and
// rendered the labels invisible (C-PW-001).
color = CloserPalette.PurpleDeep
)
}
}

View File

@ -46,8 +46,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.R
import app.closer.data.local.RecoveryPhraseStore
import app.closer.crypto.CoupleEncryptionManager
import app.closer.ui.components.BrandIllustration
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -68,18 +70,35 @@ data class SecurityUiState(
@HiltViewModel
class SecurityViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val recoveryPhraseStore: RecoveryPhraseStore
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() {
// The revealed phrase (null until biometric reveal).
private val _recoveryPhrase = MutableStateFlow<String?>(null)
// The phrase actually stored on this device, resolved once. Read from the per-couple
// CoupleKeyStore (via encryptionManager) — the SAME source EditProfile uses — so the
// ACCEPTER sees their phrase too (the old global RecoveryPhraseStore was only ever
// written by the inviter's create-invite flow, C-SEC-001).
private val _storedPhrase = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
val uid = authRepository.currentUserId ?: return@launch
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
_storedPhrase.value = coupleId?.let { encryptionManager.recoveryPhrase(it) }
}
}
val uiState: StateFlow<SecurityUiState> = combine(
settingsRepository.settings,
_recoveryPhrase
) { settings, phrase ->
_recoveryPhrase,
_storedPhrase
) { settings, phrase, stored ->
SecurityUiState(
biometricLoginEnabled = settings.biometricLoginEnabled,
hasRecoveryPhrase = recoveryPhraseStore.load() != null,
hasRecoveryPhrase = stored != null,
recoveryPhrase = phrase
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SecurityUiState())
@ -89,7 +108,7 @@ class SecurityViewModel @Inject constructor(
}
fun revealRecoveryPhrase() {
_recoveryPhrase.update { recoveryPhraseStore.load() }
_recoveryPhrase.update { _storedPhrase.value }
}
fun hideRecoveryPhrase() {
@ -229,7 +248,7 @@ fun SecurityScreen(
if (!state.hasRecoveryPhrase) {
Text(
text = "Recovery phrase becomes available after you invite your partner.",
text = "Your recovery phrase will appear here once your couple is set up on this device.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp)

View File

@ -990,13 +990,9 @@ private fun WaitingForRevealScreen(
) {
Text("Back to Play")
}
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// No "End this game" here: you've already submitted your picks and are only waiting for your
// partner to finish. Leaving via "Back to Play" keeps the session alive (you get a results
// push when they finish); abandoning would silently discard the reveal you're waiting for.
}
}

View File

@ -178,7 +178,8 @@ private fun SpinWheelContent(
WheelSpinner(
isSpinning = state.isSpinning,
spunAndReady = state.spunAndReady,
onSpin = onSpin
onSpin = onSpin,
onStart = onStart
)
}
@ -339,7 +340,8 @@ private fun ChooseCategoryButton(hasPremium: Boolean, onClick: () -> Unit) {
private fun WheelSpinner(
isSpinning: Boolean,
spunAndReady: Boolean,
onSpin: () -> Unit
onSpin: () -> Unit,
onStart: () -> Unit
) {
// Accumulated spin angle — increases with each spin, never resets.
// Using a box so LaunchedEffect can write into it without triggering recomposition of its parent.
@ -421,8 +423,10 @@ private fun WheelSpinner(
}
Surface(
onClick = onSpin,
enabled = !isSpinning && !spunAndReady,
// Once spun, the center reads "Ready" and tapping it starts the session
// (same action as the "Start session" button); before spinning it spins.
onClick = { if (spunAndReady) onStart() else onSpin() },
enabled = !isSpinning,
modifier = Modifier
.size(94.dp)
.scale(centerScale),