Compare commits

...

19 Commits

Author SHA1 Message Date
null f924af9445 qa(brand): complete both-theme sweep of the art drop — 0 issues, 0 FATAL
In-context dark+light verified (Bucket List, Quiet hours, Security, Delete account);
A1/A3 + empty-only states via the debug gallery both themes. Caught + fixed a stale
build on 5556. Baseline intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:11:42 -05:00
null 768f511870 docs(brand): mark all 11 generated illustrations wired into Android (A1-A12)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:01:40 -05:00
null 63699c09da docs(qa): note the brand art drop landed + Pass C re-verify owed on touched screens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:01:17 -05:00
null 5868d06421 brand(art): wire Delete account calm-goodbye illustration (A12)
DeleteAccountScreen gains illustration_account_deletion_goodbye centered above the copy —
a soft box releasing hearts (no alarm imagery), making the goodbye respectful and on-brand.
Verified live on dark; 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:59:53 -05:00
null 9b1e946ed8 brand(art): pairing-success hero -> A1 celebration; Security header -> A11 privacy-lock
PairingSuccessScreen replaces the white-keyhole app-icon chip joining the two partner
avatars with the illustration_pairing_success celebration (transparent, tile=false,
keeps the spring + pulse) so the "you're connected" beat shows the mark resolving with a
burst of hearts. SecurityScreen gains the illustration_privacy_recovery scene at the top.
Verified live: Security on dark (warm privacy-lock, not cold vault); A1 confirmed in the
debug gallery (transparent floats cleanly). 0 FATAL. Pairing-success needs a fresh pairing
to see in situ; A1 render proven via gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:57:26 -05:00
null 86679752b0 brand(art): wire Connection Challenges header (A3 banner) + Quiet hours (A9)
ConnectionChallenges series-list gains the illustration_connection_challenges_header
banner (16:9, BrandIllustration) under the title. Notification settings Quiet-hours
section gains the illustration_quiet_hours scene centered above the toggle. Verified live:
Quiet hours on dark (night-window scene reads beautifully); A3 banner + A1 (transparent,
tile=false) + A2 confirmed in the debug gallery — all crisp + on-brand. 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:53:28 -05:00
null fb4620559b brand(art): wire Date Match A5 (empty + it's-a-match) + Memory Lane A4; add all new art to debug gallery
DateMatches empty -> illustration_date_match_empty; the "It is a match!" modal replaces
the heart-icon circle with illustration_date_match_success (celebration). Memory Lane
empty replaces the 📦 emoji with illustration_memory_lane_capsule. ArtPreviewScreen
(debug) now shows all 12 new illustrations via BrandIllustration so they're verifiable on
both themes without needing empty/match data. Verified live (gallery, dark): A10/A11/A12
tiles render crisp + on-brand; 0 FATAL. (Empty/match states need data not present on the
baseline couple; render path proven via the shared tile.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:48:13 -05:00
null 5d74858679 brand(art): wire Answer History (A2) + Past Games (A10) empty illustrations
AnswerHistory primary empty swapped from the generic illustration_couple_history to the
purpose-made illustration_answer_history_empty (A2). WheelHistory (the "Past Games"
history screen) empty gains illustration_past_games_empty (A10). Both via the shared
EmptyState (rounded-tile, both-theme verified in Run 2). Empty states need empty data so
not reachable live on the baseline couple; render path proven via the shared component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:40:08 -05:00
null 4aec224f0d brand(art): wire Messages-empty (A8) + Bucket List-empty (A6); add BrandIllustration helper
EmptyState already supports illustrationResId (rounded-tile clip), so Bucket List just
passes illustration_bucket_list_empty. Messages inbox gained a proper empty state
("Your private conversation starts here") with illustration_messages_empty. Added
BrandIllustration() helper (theme-safe rounded tile / tile=false for transparent art)
for the upcoming header/hero placements. Verified live both themes: rounded illustration
tile reads cleanly on dark (card) and light (white card on blush); 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:36:53 -05:00
null 077a408785 brand(art): add 12 generated illustrations to drawable-nodpi; gitignore brand source art
Phase 0 of wiring the generated brand art (full-res per request). Adds A1 pairing,
A2 answer-history, A3 challenges header, A4 memory-lane capsule, A5 date-match
empty+success, A6 bucket-list, A8 messages, A9 quiet-hours, A10 past-games,
A11 privacy-recovery, A12 account-deletion to res/drawable-nodpi. Source working art
(docs/brand/{generated-art,sources,exports}) gitignored — only app copies committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:29:42 -05:00
null 5ba5b4a8ec qa(R9): clean confirmation round — deferred Pass C + Pass F network swept, 0 new findings
Confirmed + pruned I-001/I-002 (0 outcomes denials/CCE on fixed build). Deferred Pass C
deep/list screens swept (Answer History, Together/Activity, Bucket List, Date Match,
Date Matches in dark; Home/Privacy&Terms light parity) — clean, no clipping/contrast/
FATAL. Pass F network: airplane-mode -> cache render no crash; reconnect -> recovers.
Baseline restored (0 sessions, 0 outcomes). FLAWLESS bar: 0 open P0-P2 (1 P3 J-OBS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:42:03 -05:00
null 9505defd29 qa(R9): confirm + prune I-001/I-002 (0 outcomes denials/CCE on fixed build)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:33:46 -05:00
null dbf8a6f18e qa(R8): wrap — Pass E new-type status (not implemented), couple-premium-unlock idea to Future.md
Code check: the speculative Pass E types added by the merged playbook (join_game,
partner_joined_game, game_ended, date_plan_update, subscription_entitlement_changed,
...) are not implemented (0 files) -> marked not implemented -> Future.md. Logged the
worthwhile ones to Future.md ## QA (notify free partner on couple premium unlock; join/
end pushes). Round 8 at the flawless bar: 0 open P0-P2 (1 P3 J-OBS); I-001/I-002 fixed+
verified pending Round 9 confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:00:31 -05:00
null ab29f6b12f fix(outcomes): restore Your Progress read — scope query to allowed dayKeys + coerce Long scores (I-001, I-002)
I-001: getOutcomes() did a bare collection list .get() on couples/{cid}/outcomes,
which firestore.rules denies (reads allowed only for dayKey in day_0/30/60/90) ->
always PERMISSION_DENIED, swallowed to emptyList(). Now scopes the query with
whereIn(FieldPath.documentId(), OUTCOME_DAY_KEYS) so it satisfies the rule.

I-002 (found while fixing I-001): toOutcomeScores() cast values to Map<String,Int>,
but Firestore returns integer fields as Long on Android -> ClassCastException ->
scores dropped (same shape submitOutcomeCallable writes, so the real path was broken
too). Now coerces (value as? Number)?.toInt().

Verified live: 0 outcomes PERMISSION_DENIED after relaunch; seeded a day_0 baseline
(int64) -> "Your Progress" shows "Baseline recorded" (was "No baseline yet"). Seed
removed, couple baseline restored (0 outcomes, 0 active sessions). Both pending one
re-QA confirmation round before pruning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:58:37 -05:00
null 35d36e6851 qa(R8): Pass J accessibility — font scale 2.0 usable, semantics clean, reduce-motion honored
font_scale 2.0: Home/Paywall/Settings reflow+scroll, no clipped/hidden buttons (only
ellipsis + nav-label wrap). Semantics: 0 unlabeled Icon(), 111 decorative null'd,
meaningful labels on key controls, loader has "Loading…". Reduce-motion honored in 7
surfaces, no hang/crash. Touch targets mostly 48dp. Finding J-OBS (P3): a few
conversation icon-buttons ~42-45dp wide. No P0/P1/P2 a11y blockers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:47:27 -05:00
null f740b1d9a1 qa(R8): Pass I performance — route smoke checklist + I-001 (P1) outcomes read denied
Cold start 1253ms; core tabs 6.3% janky; conversation/Play-hub scroll ~36ms 90th;
no window/Activity/listener leak (meminfo stable over open/close x6); lazy-load (17),
Coil (11), Room caching all in place. Found I-001 (P1): FirestoreOutcomeDataSource
.getOutcomes() does a bare collection list .get() that firestore.rules:658 denies
(reads allowed only for dayKey in day_0/30/60/90) -> always PERMISSION_DENIED, swallowed
to emptyList() -> "Your Progress" never shows recorded outcomes + re-prompts done days.
Fix (fix phase): whereIn(documentId, [day_0..90]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:41:08 -05:00
null 11208c6fb5 qa(R8): re-confirm F-RACE-001 live (race -> 1 session, loser joins same set) + prune
Round 8 chunk 1. Simultaneous mood-tap on both emulators -> exactly 1 active session
(was 2 pre-fix); race-loser hit WaitingForPartner -> "Join the game" -> joined the
winner's session at the same Q1 (shared reveal preserved). Regression smoke clean:
no FATAL, game opens both devices, inbox loads, messages + date_swipes ciphertext at
rest. F-RACE-001 pruned to the archived-ID line per report hygiene; 0 open P0-P3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:32:31 -05:00
null 96987bf29a docs(qa): merge notification-suite playbook, add report hygiene + finding-routing, clean report/coverage
- ClaudeQAPlan.md: fold the deep notification + join-game suite into Pass E (both-client
  matrix, 6 assertions, expanded inventory, game/join-game suites, payload security,
  malformed/stale tests); add Pass B join-paths + Pass C routes-into-games; add missing
  batch rows G/H; add Report-hygiene (one-confirmation-round prune) + coverage-matrix
  hygiene + easy-to-read mandate; add "Where every finding goes" routing table.
- ClaudeReport.md: collapse stacked R1-R7 run-states + fixed tables to current-state
  (0 open P0-P3; F-RACE-001 pending one confirm; older fixed IDs archived).
- ClaudeQACoverage.md: current-status matrix (flip stale fail->A-001 to pass, drop
  contradictory Pass B footer, add status-at-a-glance, surface todo/deferred).
- removed stray seed/questions/Claude_QA_Playbook_Full_App_QA_Notifications_Merged.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:23:30 -05:00
null 23dd6a75e8 fix(games): atomic session start to prevent duplicate sessions on concurrent start (F-RACE-001)
Simultaneous game start by both partners created two divergent active sessions (TOCTOU: a
non-transactional check-then-create in GameSessionManager.startGameWithCouple). Each partner
ended up in a separate session with different questions → no shared reveal.

Fix: QuestionSessionRepository.startSessionAtomically runs a Firestore transaction on a
per-couple pointer doc (couples/{cid}/sessions/_active). It reads the pointer (+ the pointed
session) and either returns AlreadyActive (caller joins the existing session) or atomically
creates the new session and re-points the lock. Concurrent starts contend on the one pointer,
so the loser's transaction retries, sees the now-set pointer, and joins instead of duplicating.
The pointer self-heals (checks the pointed session's status) so no clear-on-finish is needed,
and it carries no status/completedAt so it's invisible to the active/history queries.
GameSessionManager routes all 7 games through it. firestore.rules adds member-write for
sessions/_active (deployed).

Verified live on both emulators: atomic create → 1 session + pointer; sequential 2nd start →
joins (1 session); literal parallel-tap race → 1 session (was 2); 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:43:06 -05:00
37 changed files with 716 additions and 383 deletions

6
.gitignore vendored
View File

@ -77,3 +77,9 @@ docs/brand/asset-system.md
docs/brand/visual-identity.md docs/brand/visual-identity.md
docs/brand/asset-system.md docs/brand/asset-system.md
ClaudeBrandingReview.md ClaudeBrandingReview.md
ClaudeQAPlan.md
# Brand source/working art (heavy; the wired copies live in app/src/main/res)
docs/brand/generated-art/
docs/brand/sources/
docs/brand/exports/

View File

@ -11,6 +11,32 @@ the existing artwork (`docs/brand/visual-identity.md` + `docs/brand/asset-system
--- ---
## ✅ 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`
(rounded-tile, theme-safe) and a new `ui/components/BrandIllustration.kt` helper:
| Art | Screen wired | Verified |
|---|---|---|
| A1 `pairing_success` (transparent) | PairingSuccessScreen hero (replaced the keyhole chip; pulse/spring kept) | gallery (transparent floats) |
| A2 `answer_history_empty` | AnswerHistoryScreen empty (replaced generic couple_history) | shared EmptyState (Run-2 both-theme) |
| A3 `connection_challenges_header` (banner) | ConnectionChallengesScreen series-list header | gallery (16:9 banner) |
| A4 `memory_lane_capsule` | MemoryLaneScreen empty (replaced 📦 emoji) | shared tile |
| A5 `date_match_empty` / `date_match_success` | DateMatchesScreen empty / "It is a match!" modal | shared tile / gallery |
| A6 `bucket_list_empty` | BucketListScreen empty | **live dark + light** |
| A8 `messages_empty` | MessagesInboxScreen (new empty state added) | shared EmptyState |
| A9 `quiet_hours` | NotificationSettings quiet-hours section | **live dark** |
| A10 `past_games_empty` | WheelHistoryScreen ("Past Games") empty | shared tile |
| A11 `privacy_recovery` | SecurityScreen header | **live dark** |
| A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** |
All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification.
**Not done:** A7 pack art = **N/A** (all 10 packs already have `pack_art_*`). **G-set glyphs = still backlog**
(not generated; tracked in `Future.md` `## QA`). Empty/match/pairing states that need empty-or-new data weren't
reachable on the baseline couple — render path proven via the shared tile + gallery. Commits `077a408`→`5868d06` on `dev`.
---
## House Style (paste this at the top of EVERY image prompt) ## House Style (paste this at the top of EVERY image prompt)
> Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**, > Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**,

View File

@ -1,111 +1,116 @@
# Claude QA Coverage Matrix # Claude QA Coverage Matrix
> Resume anchor. Status: `todo | pass | fail(→id) | n/a`. See `ClaudeReport.md` run-state header for current position. > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> **Round 6 (branding + Future.md regression) COMPLETE 2026-06-25, client `f47c8e2`:** new surfaces from `95cad84` (white-keyhole icons, animated chip+fill loader, splash, pairing hero) + `f47c8e2` (inclusive gender, turn copy, push-budget split, results-suppression `ActiveGameSessionMonitor`, paywall retry/offline/hide-Continue, auth rotator). **0 new issues; still 0 open P0P3.** Live: loader (both themes), splash→handoff, launcher icon, ToT+How Well open (no crash → #4 VM injection sound), paywall purchase screen (friendly error + Try again + Continue hidden, online→generic msg), onboarding illustration. Unit tests green. Gender step / rotator / turn-copy / results-timing / weekly-cap = code+unit-verified (live deferred: fragile multi-text-field & 2-device timing; low risk over proven patterns). Baseline restored (QA re-signed-in via admin token; couple intact). > 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.**
> **Round 5 (functions deploy + expanded re-QA) COMPLETE 2026-06-25, client `765916a` + functions DEPLOYED:** E-OBS FIXED+DEPLOYED (12 senders set channelId; chat push → `partner_activity` live) + E-003 results-ready FIXED+DEPLOYED (finished-game → per-session results). **0 open P0P3.** New Pass G (account creation + fake-account) clean. Varied gameplay (Standard/Deep, 0-match) + nav fuzzing — no new bugs. Baseline restored (couple intact, throwaway deleted, Sam re-paired). > Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is
> _Round 4: E-003 + B-004 (P2) + A-OBS (P3) FIXED + verified live._ > confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.)
## Pass G — Account creation, validation & fake-account abuse ## Status at a glance
**R5 live:** sign-up flow end-to-end (email/pw/confirm → profile 3 steps → unpaired home) ✓; weak-password → friendly | Pass | Coverage | Status |
"at least 8 characters" error ✓; fresh-account **isolation** (unpaired "Invite my partner", zero couple data) ✓; |---|---|---|
**duplicate-email → `auth/email-already-exists`** rejected ✓; invite code **single-use + 24h expiry** shown, **bogus | A — Couple-shared premium | all gated features × neither/partner/self | ✅ pass |
code "ZZZ-ZZZ" → "Invite not found."** rejected (friendly, not paired) ✓; **recovery phrase** client-generated ✓; | B — Games lifecycle | all 7 games played full, 2-device, real user-nav | ✅ pass |
sign-out → onboarding carousel → debug-token restore ✓. **No security findings.** (Rules-level non-member READ denial: | C — Visual (light+dark) | ~14 core screen-types both themes | ✅ pass · deep/list screens **deferred** |
covered by app-level isolation + static member-scoped rules audit; live crafted-request blocked by App Check.) | 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 |
## Pass A — Couple-shared premium (states: neither / partner-only / self) **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**.
---
## Pass A — Couple-shared premium (neither / partner-only / self)
| Feature | neither→locked | partner→both unlock | self→unlock | Status | | Feature | neither→locked | partner→both unlock | self→unlock | Status |
|---|---|---|---|---| |---|---|---|---|---|
| Chat media + reactions | pass | pass | pass | pass (couple-shared) | | Chat media + reactions | pass | pass | pass | ✅ pass (reference pattern) |
| Play: Desire Sync | pass | **fail→A-001** | pass | fail→A-001 | | Play: Desire Sync | pass | pass | pass | ✅ pass |
| Play: Memory Lane | pass | **fail→A-001** | pass | fail→A-001 | | Play: Memory Lane | pass | pass | pass | ✅ pass |
| Play: Connection Challenges | pass | fail→A-001 | pass | fail→A-001 | | Play: Connection Challenges | pass | pass | pass | ✅ pass |
| Question Packs (premium) | pass | fail→A-001 | pass | fail→A-001 | | Question Packs (premium) | pass | pass | pass | ✅ pass |
| Wheel: Category Picker / Spin / History | pass | fail→A-001 | pass | fail→A-001 | | Wheel: Category Picker / Spin / History | pass | pass | pass | ✅ pass |
| Date Match / Plan Date | pass | fail→A-001 | pass | fail→A-001 | | Date Match / Plan Date | pass | pass | pass | ✅ pass |
| Subscription screen (own status) | n/a | n/a | n/a | pass (by-design per-user) | | Subscription screen (own status) | n/a | n/a | n/a | pass (by-design per-user) |
Pass A: **complete** (1 systemic P1). **A-001 FIXED** (e8892a9) — couple-shared everywhere; re-verify each feature in re-QA. New cosmetic A-003 (P3, badge). Subscription screen by-design. Verified live: neither→paywall ("Go deeper together"); partner→couple-shared unlock (Sam free entered Desire Sync + Memory Lane); self→unlock; premium badges hidden under premium / shown when free. (A-001 couple-shared gap + A-003 badge fixed & confirmed.)
**R3 re-verified LIVE (2026-06-25):** neither→paywall ("Go deeper together"), partner→couple-shared unlock (Sam free entered Desire Sync + Memory Lane), self→unlock; A-003 badges hidden under premium / shown when free (count 0↔2). New A-OBS (P3): paywall plan-load shows raw "credentials issue" error (env: no RevenueCat sandbox).
## Pass B — Games lifecycle (start / play / finish + results) ## Pass B — Games lifecycle (start / play / finish + results, 2-device, real user-nav)
**RESTARTED 2026-06-24 (R2-B2): full re-run from game #1 with the PLAY-AS-THE-USER mindset** (navigate only via the All 7 played one complete time through on both devices via the real in-app path; gameplay all PASS.
real in-app path; report-first-then-workaround on any broken flow). Prior R2 This or That / How Well passes are | Game | starts | plays | finishes/results | no crash | Evidence |
superseded — redo every game cleanly. (Prior result for reference: This or That 5/5 ✅, How Well 5/5 ✅.)
**✅ R2-B2 COMPLETE — all 7 games played one full time through on both devices via real user nav; gameplay all PASS.**
Findings surfaced by playing-as-user: **B-001 (P1)** finished session never closes → blocks next game; **C-NAV-001 (P1)**
back from Home resurfaces onboarding/auth; **B-002 (P2)** Home "Play now" → generic hub; **C-CC-001 (P2)** Connection
Challenges dup header/double-back; **C-DS-001 (P2)** Desire Sync dark-mode low contrast; **B-003 (P3)** confusing Desire
Sync counts. Sam reverted to free (baseline). `date_match` push verified live (Pass E bonus).
| Game | starts | plays | finishes/results | no crash | Status |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1. This or That | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: 5/5 via Play hub, answers synced, results match both (4/5 "Two peas in a pod", Q2 Differ correct), no crash ✅. Session-lifecycle bug B-001 (P1) hit on exit.** | | 1. This or That | pass | pass | pass | pass | 5/5 via Play hub, answers synced, results match both ("Two peas in a pod"). |
| 2. How Well Do You Know Me | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA answered 5 (incl. a 1-5 scale Q5); Sam predicted via Play hub — 3 correct + 1 deliberate miss (Kind tone vs Specific examples) + scale match → results show 4/5 "You really know each other" with the wrong one marked ✗ on BOTH devices, scoring accurate, no crash ✅** | | 2. How Well Do You Know Me | pass | pass | pass | pass | QA answered 5 (incl. 15 scale); Sam predicted via hub → 4/5, wrong one marked ✗ on both, scoring accurate. |
| 3. Desire Sync | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA(free) entered w/o paywall (A-001 live ✅); both answered 5 Yes/No → exactly 3 mutual desires revealed, mismatches hidden (privacy correct), results match both, no crash ✅. Findings: B-003 (P3 confusing counts), C-DS-001 (P2 dark-mode low contrast on revealed list).** | | 3. Desire Sync | pass | pass | pass | pass | QA(free) entered w/o paywall; both answered 5 Yes/No → exactly 3 mutual desires, mismatches hidden, match on both. |
| 4. Connection Challenges | pass | **pass (day-cycle, user-nav)** | **pass** | pass | **R2-B2: opened (D-001 rules hold ✅); started Gratitude Week → both completed Day 1 → day ✓, 🔥1 streak, advanced to Day 2 "Both of you showed up today", synced on both, no crash ✅. (7-day series is time-gated; full per-day cycle verified.) Finding: C-CC-001 (P2 duplicate header + double back). Minor: first partner's view shows next-day content + "waiting for partner" before the day is mutually done (self-resolves).** | | 4. Connection Challenges | pass | pass | pass | pass | Gratitude Week → both did Day 1 → 🔥1, advanced to Day 2 synced. (7-day series time-gated; per-day cycle verified.) |
| 5. Memory Lane | pass | **pass (create+seal, user-nav)** | **pass (sealed)** | pass | **R2-B2: loads clean (D-001 ✅, no hung heart); QA wrote a capsule (title+body), picked "1 month" → sealed "Opens in 29 days"; **encrypted at rest** (title+content `enc:v1:`, `unlockAt`=+30d, status=sealed); Sam sees the same sealed capsule cross-device; no crash / no PERMISSION_DENIED ✅. Unlock/reveal is future-dated (can't test w/o time-travel). Single header (no C-CC-001 here).** | | 5. Memory Lane | pass | pass (create+seal) | pass (sealed) | pass | Capsule sealed "Opens in 29 days", encrypted at rest (title+content `enc:v1:`), cross-device. Unlock future-dated. |
| 6. Spin the Wheel | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA(free) entered (A-001 ✅); spun → "Date Night" category → both answered all 10 prompts (multi-select) → reveal "Here's how you each answered" with per-Q You/partner breakdown matching on BOTH devices, no crash ✅. Wheel session synced (Sam joined QA's active session). Dark answer text a bit dim (C-OBS pattern, readable).** | | 6. Spin the Wheel | pass | pass | pass | pass | Spun → category → both answered all 10, per-Q You/partner breakdown matches both, session synced. |
| 7. Date Match | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA(free) entered (A-001 ✅, in Play hub below Question Packs); both swiped date-idea deck (❌/⭐/💗); QA + Sam both liked the same 3 → 3 `date_matches` created (sunrise_hike/kayak/rock_climbing); Sam got "It is a match!" modal + LIVE "It's a match!" push notification; "Your Matches" shows all 3 "Mutual love"; no crash / no PERMISSION_DENIED ✅. (Premium-badged ideas accessible via couple premium.)** | | 7. Date Match | pass | pass | pass | pass | Both swiped deck, 3 mutual likes → 3 `date_matches`, "It's a match!" modal + live push, "Your Matches" shows all 3. |
_Note: stale active session blocked games (B-001); cleared via in-app "End their game" (recovery verified). Exit each game via Back to Play between games so the session closes._ Note: exit each game via "Back to Play" between games so the session closes (B-001 auto-completion fix verified). F-RACE-001 (simultaneous start) fixed — see Pass F.
**REQUIREMENT (updated): each game must be played ONE COMPLETE time through on both devices (every step → finish/
reveal/results), not just launched.** All rows above are currently `launch ok / partial` only → **full playthrough
still owed for every game** in Round 2 (premium games need a premium toggle). A launch-only row counts as `partial`, not `pass`.
## Pass C — Visual (light + dark), all ~50 routes ## Pass C — Visual (light + dark), all ~50 routes
**R3 (2026-06-25):** ~14 screen-types swept in Dark (5554), several in Light (5556 during A/B) — all render clean, ~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).
readable, no FATAL, no new dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play - **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.
hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question
(+answer detail), Messages inbox, Conversation (image+voice+text+reaction). C-DS-001 dark-contrast fix holds.
**Back-stack ✅** deep→hub→Home→launcher clean (no double-back; C-NAV-001 holds). C-OBS resolved (debug menu gated).
_Deferred (nav-drift; standard list/detail, lower-risk): Question Packs detail, Bucket List, Past Games, Wheel History,
Answer Reveal (sealed), Date Builder/Plan Date, fresh-account auth/onboarding/pairing._
## Pass D — Security & Encryption (D1D6) ## Pass D — Security & encryption (D1D6) — clean, no P0/P1
**R7 DEEP DIVE (multi-angle, 2026-06-25):** **D1 at-rest — CLEAN (admin ground-truth read):** messages `text` + - **D1 at-rest (admin ground-truth):** messages `text` + `lastMessagePreview`, all 4 game-answer collections (this_or_that/how_well/desire_sync/wheel, both users), capsule title+content, `date_swipes.actions` = `enc:v1:`; `wrappedCoupleKey` ciphertext (recovery-phrase-wrapped, **argon2id**); `encryptedRecoveryPhrase` server-blind + **wiped on acceptance**; plaintext `inviteCode` **not exploitable** (no code-encrypted secret persists; `/invites/{code}` readable only by inviter).
`lastMessagePreview`, all 4 game-answer collections (`this_or_that`/`how_well`/`desire_sync`/`wheel`, both users), - **D2 rules:** no catch-all, no blanket `if true`; sessions update allowlist + immutable `startedByUserId` + monotonic status; `hasPremium` + entitlements server-only; ciphertext enforced on private fields; capsules/challenges member-scoped.
capsule title+content, `date_swipes.actions` = `enc:v1:`; `wrappedCoupleKey` = ciphertext (recovery-phrase-wrapped, - **D3 raw-API negative (LIVE):** non-member ID token → Firestore REST on couple doc/conversation/messages/answers/session/capsules/partner-profile = **all 403**; non-member writes incl. real `users/{uid}/entitlements/premium` = **all 403 → no self-grant**. Member token reads 200 → **App Check not enforced on Firestore; rules are the sole gate and hold**.
**argon2id**); `encryptedRecoveryPhrase` server-blind + **wiped on acceptance** (confirmed absent on accepted invite); - **D4/D5/D6:** wrapped couple key + KDF; App Check (client), gitignored SA JSONs, `allowBackup=false`; analytics metadata-only. Unchanged, hold.
plaintext `inviteCode` **not exploitable** (no code-encrypted secret persists; `/invites/{code}` readable only by - Two hardening notes → `Future.md` (App Check off on Firestore; `users/{uid}` update rule allows arbitrary non-`hasPremium` fields).
inviter). **D3 raw-API negative (LIVE, executed — no longer deferred):** non-member ID token (Identity Toolkit
`signInWithCustomToken`) → Firestore REST on couple doc/conversation/messages/answers/session/capsules/partner-profile
= **all `403 PERMISSION_DENIED`**; non-member writes (couple doc, partner entitlement, **real path
`users/{uid}/entitlements/premium`**) = **all `403` → no self-grant**. Member token reads `200` (characterizes layer:
**App Check not enforced on Firestore — rules are the sole gate, and they hold**). Only writable = cosmetic own-doc
fields (`plan`) that **no gate reads**. **No P0/P1 security findings.** Two hardening notes → `Future.md`.
## Pass E — Notifications (type × {foreground / background / killed} + tap-to-open, both clients)
**R3:** D2 deployed rules re-audited ✅ (B-001 sessions + D-001 capsules/challenges fixes present; hasPremium + Full live two-device run (games + messages):
entitlements server-only; ciphertext enforced; no catch-all). D1 at-rest ✅ (chat text + lastMessagePreview = - **chat_message** ✅ end-to-end — channel `partner_activity`, title "Sam sent a message" (name, not private), body content-free, **text NOT in payload**; tap → exact conversation with content; white monochrome small icon.
`enc:v1:`; how_well answers + capsules = `enc:v1:`). D4/D5/D6 unchanged since R1 (code identical) → hold. - **partner_started_game** ✅ — channel `game_activity`, "QA is playing… Tap to join!" (content-free); tap → joins the active session.
**D3 live non-member: deferred** (needs a 3rd fresh account; only 2 emulators, both couple members; rule logic - **partner_finished_game / results** ✅ — results push delivered to backgrounded partner, channel `game_activity`, content-free; tap → per-session results (per E-003 fix).
statically member-scoped). No P0/P1 security findings. - **results-suppression** ✅ — partner foregrounded on the session received 0 pushes (ActiveGameSessionMonitor), while backgrounded partner got the results push. Delivery + suppression both confirmed.
- **New speculative types — `not implemented → Future.md` (R8 code check, 0 files each):** `join_game`/`game_invite`, `partner_joined_game`, `game_abandoned`/`game_ended`, `date_plan_update`, `memory_capsule_created`, `challenge_day_completed`, `subscription_entitlement_changed`. Worthwhile ones (couple-premium-unlock push; join/end pushes) logged to `Future.md` `## QA`. Not counted as pass.
## Pass E — Notifications (17 types × {foreground, background, killed} + tap-to-open) - **Deferred (Round 9):** the full implemented-type × {fg/bg/killed} matrix isn't exhaustively re-run live — implemented types are routing-code-verified + centralized in `PartnerNotificationType`; chat/game start/finish/results + date_match verified live (R3/R5/R6).
**R6 live (games + messages, 2026-06-25, build `f47c8e2`):** full live two-device run.
- **chat_message** ✅ end-to-end: Sam→QA (QA bg) posts on **channel=partner_activity**, title "Sam sent a message"
(partner name, not private), body "Tap to read and reply." — **message text NOT in payload** (privacy holds);
small icon = white monochrome mark; tap→**main conversation with content** (verified via the exact intent —
shade-tap is flaky in the adb harness, lands on launcher, but the contentIntent routing is sound).
- **partner_started_game** ✅: QA started This or That → Sam (bg) posts on **channel=game_activity**, "QA is playing /
QA has started a game. Tap to join!" (content-free); tap→**joins the active session** (same 1/5 prompt).
- **partner_finished_game / results** ✅: both finished → **results push DELIVERED to backgrounded QA** (Round 5
couldn't confirm this live) on **channel=game_activity**, "Sam finished the game / Sam finished — tap to see your
results!" (content-free); tap→**per-session This or That results** (5/5), per E-003.
- **#4 results-suppression** ✅: Sam stayed foreground on the session throughout → received **0** notifications
(the partner_completed_part + partner_finished_game pushes to Sam were suppressed by ActiveGameSessionMonitor),
while backgrounded QA got the results push. Clean confirmation of both delivery + suppression.
- No FATAL either device; baseline tidy (0 active sessions, couple intact). **No issues found.**
**R3 live:** FCM tokens valid for both. **chat_message ✅ full chain** (bg deliver + content-free + tap→exact
conversation w/ content). **partner_started_game**: bg deliver + content-free ✅; tap→Play hub (not the game) =
**E-003 (P2)**. **E-OBS (P3)**: bg pushes use fcm_fallback channel. date_match live-verified R2-B2. E-001/E-002 fixes
present in code. Full 17×{fg/bg/killed} matrix not exhaustively run; routing centralized + code-verified for the rest.
## Pass F — Resilience / lifecycle / concurrency / time ## Pass F — Resilience / lifecycle / concurrency / time
**R3:** offline (airplane mode) → Today renders from cache, no crash ✅; rotation/config-change → landscape renders, - **Concurrency race:** F-RACE-001 (P1) fixed + **re-confirmed live (R8):** simultaneous mood-tap on both devices → **1 session** (was 2); race-loser landed on WaitingForPartner → **"Join the game"** → joined the winner's session at the **same Q1** (shared reveal preserved). Archived. *(Minor pre-existing note: loser can alternatively land on Play hub; not seen this run.)*
state preserved, no crash ✅; process-death/restore → ~6 cold restarts all clean to Home (auth persists) ✅; - **Offline:** airplane mode → Today renders from cache, no crash.
concurrency → both devices played games simultaneously, sessions synced + B-001 auto-complete on concurrent finish ✅. - **Lifecycle:** rotation/config-change → state preserved; ~6 cold restarts → clean to Home (auth persists).
Time-gated content (capsule "Opens in 29 days", challenge day-gating) can't be time-traveled — noted. - **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
- **R9 network resilience:** airplane-mode on → Date Match + Messages render from cache, **no crash, no error dead-end**; reconnect → inbox refreshes, no stuck state, 0 FATAL (extends R3 offline-Today-from-cache).
- **Deferred (Round 10, low-risk):** time-travel-gated content (capsule unlock, challenge day-gating — needs clock manipulation); account-lifecycle deletion-cascade deep run (disruptive on the baseline couple). Minor note: race-loser can land on Play hub vs WaitingForPartner (no dup/crash; pre-existing routing).
## Pass G — Account creation, validation & fake-account abuse
Sign-up end-to-end (email/pw/confirm → 3-step profile → unpaired home) ✅; weak-password → friendly "at least 8 characters" ✅; fresh-account isolation (zero couple data) ✅; **duplicate-email → `auth/email-already-exists`** rejected ✅; invite single-use + 24h expiry, **bogus code → "Invite not found."** ✅; recovery phrase client-generated ✅; sign-out → onboarding → debug-token restore ✅. **No security findings.** (Non-member READ denial = live D3 above + app-level isolation.)
## Pass I — Performance & route efficiency (R8, build `23dd6a7`, emulator-5554, debug build)
Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` → drive route → read `gfxinfo`):
| Route / list | Jank / latency | Notes |
|---|---|---|
| Cold start → Home | 1253ms to first frame | acceptable (debug; release AOT-faster); no skipped frames, no ANR |
| Core tabs (Home/Today/Play/Messages/Settings) | 6.3% janky frames | smooth; no Choreographer-skip spam |
| Conversation (realtime listener) scroll | 90th 36ms / 95th 53ms | minor debug hitching; **no leak** |
| Play hub scroll | 90th 36ms / 95th 38ms | smooth |
- **Caching / lazy-load:** LazyColumn/Row/Grid in 17 files; Coil (AsyncImage) in 11; Room DAOs cache static question/category data locally — all in place, no load-all anti-patterns seen.
- **Leak check:** conversation open/close ×6 → ViewRootImpl=1, Activities=1, Views +2, PSS bounded after trim → no window/Activity/listener leak.
- **Redundant reads:** precise per-read counts need an instrumented/Perfetto build (Firestore success reads aren't in adb logcat); no failing-read spam **except I-001**; no leaked listeners.
- **Finding: I-001 (P1) — FIXED+VERIFIED** `getOutcomes()` bare-list query was rules-denied → fixed with `whereIn(documentId, dayKeys)`; 0 PERMISSION_DENIED after. **I-002 (P1) — FIXED+VERIFIED** (found fixing I-001): scores stored as int64 → read as Long → `Map<String,Int>` cast CCE → swallowed; fixed via `Number.toInt()`. Live: seeded day_0 → "Your Progress" shows "Baseline recorded". Both pending Round-9 confirm.
## Pass J — Accessibility (R8, emulator-5554)
- **Font scaling (font_scale 2.0, worst case):** Home, Paywall, Settings all **reflow + scroll, no clipped/hidden buttons** — meets the acceptance bar. Minor: long subtitles/email ellipsize, bottom-nav labels wrap ("Mess ages"). Restored to 1.0. ✅
- **TalkBack / semantics:** 0 `Icon()` calls without `contentDescription`; 111 explicit `null` (decorative silenced); meaningful labels on all key controls (Back ×26, Send, Close, Dismiss, photo actions, date-swipe Love/Maybe, capsule, edit/delete); loader uses `clearAndSetSemantics` + "Loading…" message. ✅
- **Touch targets:** most controls 48dp; **J-OBS (P3):** a few conversation icon-buttons measure ~4245dp wide (48dp tall) — single-axis marginal, fully operable; bump to 48dp.
- **Reduce-motion (animator_duration_scale 0):** nav sweep + screens work, no hang/unreachable content, 0 FATAL; honored in code across 7 surfaces (LoadingState, CelebrationOverlay, AnswerReveal, DesireSync, ThisOrThat, BrandMessageRotator, LocalQuestionContent). Restored to 1. ✅
- **Contrast:** covered by Pass C both themes (C-DS-001 dark-contrast fixed); precise WCAG ratios need a measurement tool — spot-checks clean, no new dim areas.
- **Keyboard/IME:** text fields validated functionally in Pass G (sign-up/profile); full hardware-keyboard tab-order **deferred** (emulator HW-keyboard harness).
- **Findings:** J-OBS (P3) only; no P0/P1/P2 a11y blockers.
## Pass H
- **H Branding** — deliverable in `ClaudeBrandingReview.md` (consumer brand walk → ready-to-paste art prompts).
---
## Round history (one line each)
- **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.
- **R2R4** — play-as-user game restart + fix phase; all P0P2 fixed + verified (archived IDs above).

View File

@ -8,28 +8,45 @@
> parity → **Part 3** = run these same passes on iOS + a cross-platform (Android↔iOS) pass. **Parts 2 & 3 live in > parity → **Part 3** = run these same passes on iOS + a cross-platform (Android↔iOS) pass. **Parts 2 & 3 live in
> `ClaudeiOSPlan.md`** (note: iOS build/run/QA requires macOS — not possible from this Linux box). > `ClaudeiOSPlan.md`** (note: iOS build/run/QA requires macOS — not possible from this Linux box).
## Where every finding goes (route it here — exactly one home each)
| What you found | Where it goes | Form |
|---|---|---|
| **A bug** — broken / incorrect / crashing / insecure, premium bypass, wrong-or-missing notification, dead-end nav | **`ClaudeReport.md`** | Table row: stable ID (`A-001`, `E-003`…) + severity (P0P3) + repro + status |
| **An idea / improvement** — works but could be better, confusing copy, missing affordance, rough-but-not-broken flow, "it'd be great if…", feature idea | **`Future.md`** `## QA` | Short title + what prompted it + suggested improvement |
| **New artwork to create** — illustrations, glyphs, image-gen prompts | **`ClaudeBrandingReview.md`** | House-style prompt + placement |
| **What got tested + its status** (pass / fail / todo / deferred) | **`ClaudeQACoverage.md`** | Coverage cell (the resume anchor) |
- A branding **defect** (mis-colored, clipped, off-brand, low-contrast art) is a **bug → `ClaudeReport.md`**, not a brand
idea — only *new art to create* goes to `ClaudeBrandingReview.md`.
- Logging an idea in `Future.md` is **never** a substitute for filing a real defect: if it's broken, it gets an ID in
`ClaudeReport.md` too.
- Bug lifecycle: filed in `ClaudeReport.md` → fixed → kept **one** confirmation round → pruned to the archived-ID line
(detail lives in git). `Future.md` ideas sit in the backlog until built. (See **Report hygiene** under Reporting.)
## Context ## Context
Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Five QA dimensions: Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Five QA dimensions:
1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**. 1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**.
2. **Games** — each starts, plays, finishes correctly on both devices. 2. **Games** — each starts, plays, **joins, resumes**, finishes, **and reopens results** correctly on both devices.
3. **Full visual pass, light + dark** — every screen, text readable, nothing clipped/invisible. 3. **Full visual pass, light + dark** — every screen, text readable, nothing clipped/invisible.
4. **Security & encryption (cornerstone)** — every private field is ciphertext at rest, rules hold against 4. **Security & encryption (cornerstone)** — every private field is ciphertext at rest, rules hold against
non-members, keys/recovery are sound. Findings here default to P0. non-members, keys/recovery are sound. Findings here default to P0.
5. **Notifications** — all 17 types deliver to the right partner (foreground/background/killed), deep-link 5. **Notifications** — the **full suite**: every type delivers to the right partner (foreground/background/killed),
correctly, and leak no private content. deep-links correctly, opens the right destination on **both clients**, covers all **game/join-game** flows, handles
stale notifications, and leaks no private content.
Scope decisions: **exhaustive** visual pass (all ~50 screens, both modes); **full scope incl. pre-pairing** flows Scope decisions: **exhaustive** visual pass (all ~50 screens, both modes); **full scope incl. pre-pairing** flows
(fresh throwaway account); **couple-shared everywhere** — per-user gates are bugs, fixed by routing through (fresh throwaway account); **couple-shared everywhere** — per-user gates are bugs, fixed by routing through
`core/billing/CouplePremiumChecker.kt`. `core/billing/CouplePremiumChecker.kt`; **full notification suite** — every type, game + join-game pushes, deep-links,
stale-notification handling, and all in-app paths into joining/resuming/results, verified on **both clients**.
**Early known signal:** only chat uses `CouplePremiumChecker`; games/packs/dates/wheel gate on the user's own **Early known signal:** only chat uses `CouplePremiumChecker`; games/packs/dates/wheel gate on the user's own
`EntitlementChecker.isPremium()` — so premium almost certainly does NOT unlock for the free partner there. Pass A `EntitlementChecker.isPremium()` — so premium almost certainly does NOT unlock for the free partner there. Pass A
confirms + enumerates this; the fix phase applies couple-shared everywhere. confirms + enumerates this; the fix phase applies couple-shared everywhere.
## Execution mode — run to completion (autonomous; do NOT stop) ## Execution mode — run to completion (autonomous; do NOT stop)
- **Do not stop to check in or ask for approval.** Run all five passes → the fix phase → re-QA rounds **continuously - **Do not stop to check in or ask for approval.** Run all passes (AJ) → the fix phase → re-QA rounds **continuously
until a flawless round** (zero open P0P2, Passes D + E clean, every game fully played through, navigation/back-stack until a flawless round** (zero open P0P2, Passes D + E clean, every game fully played through, all notification
verified). Don't hand control back early. routes verified, navigation/back-stack verified). Don't hand control back early.
- **Unblock yourself:** if anything **blocks progress** (a stale/blocking session, a crash, a build break, a missing - **Unblock yourself:** if anything **blocks progress** (a stale/blocking session, a crash, a build break, a missing
prerequisite state, a broken nav path that prevents reaching a screen), **fix it immediately and continue** — even prerequisite state, a broken nav path that prevents reaching a screen), **fix it immediately and continue** — even
though passes are otherwise report-only. Blocking issues are fixed inline so the run can proceed; non-blocking though passes are otherwise report-only. Blocking issues are fixed inline so the run can proceed; non-blocking
@ -110,7 +127,10 @@ State lives in **files**, not memory:
- **`ClaudeReport.md`** = the issue log (committed). Each issue row is **self-contained in text** (repro + expected - **`ClaudeReport.md`** = the issue log (committed). Each issue row is **self-contained in text** (repro + expected
+ actual) — screenshots are session-only and won't survive a compaction; never rely on a screenshot path alone. + actual) — screenshots are session-only and won't survive a compaction; never rely on a screenshot path alone.
- **`ClaudeQACoverage.md`** = the coverage matrix: every screen×mode, feature×premium-state, game×lifecycle, - **`ClaudeQACoverage.md`** = the coverage matrix: every screen×mode, feature×premium-state, game×lifecycle,
notification×{foreground,background,killed}, each `todo | pass | fail(→issue id)`. The resume anchor. notification×{foreground,background,killed}, each `todo | pass | fail→id | not implemented→Future.md | blocked→id`.
The resume anchor.
- **`Future.md`** (`## QA`) = the non-bug improvement/idea backlog; **`ClaudeBrandingReview.md`** = the branding/artwork
review + image-prompt backlog. Both committed alongside the report/coverage.
- **Persistent memory** (`memory/`): QA methodology + exact commands; emulator↔account↔coupleId mapping; - **Persistent memory** (`memory/`): QA methodology + exact commands; emulator↔account↔coupleId mapping;
`scratchpad/set_premium.js` + admin tooling; the couple-shared-premium-everywhere goal + the per-user-gate gap. `scratchpad/set_premium.js` + admin tooling; the couple-shared-premium-everywhere goal + the per-user-gate gap.
- **Run-state header** pinned at the TOP of `ClaudeReport.md`, always current: `Round N | Pass X | Chunk Y | - **Run-state header** pinned at the TOP of `ClaudeReport.md`, always current: `Round N | Pass X | Chunk Y |
@ -123,7 +143,8 @@ State lives in **files**, not memory:
- **Chunking**: run small chunks (Pass C one screen-group; Pass A one feature), checkpoint after each. - **Chunking**: run small chunks (Pass C one screen-group; Pass A one feature), checkpoint after each.
- **Session-start ritual**: (1) read run-state header + both MD files; (2) `adb devices` shows **both** emulators - **Session-start ritual**: (1) read run-state header + both MD files; (2) `adb devices` shows **both** emulators
online; (3) **installed build == current HEAD** (rebuild+reinstall if unsure — never QA a stale APK); (4) continue online; (3) **installed build == current HEAD** (rebuild+reinstall if unsure — never QA a stale APK); (4) continue
at the first `todo` / unverified-fix. at the first `todo` / unverified-fix; (5) if a prior chunk left an active/stuck game session, recover it via in-app
"End their game" (log if needed), then redo that chunk.
## Batch sizing — sub-batch each pass to ONE context window (Round-1 calibration) ## Batch sizing — sub-batch each pass to ONE context window (Round-1 calibration)
A pass is a **category**, not a unit of work. Execute each pass as **sub-batches (chunks)**, where a chunk = the A pass is a **category**, not a unit of work. Execute each pass as **sub-batches (chunks)**, where a chunk = the
@ -138,8 +159,12 @@ prevents half-done/lost work and gives cleaner per-chunk verification + revertab
| B Games | **one game per chunk** — full two-device playthrough + edges + commit | 7 | | B Games | **one game per chunk** — full two-device playthrough + edges + commit | 7 |
| C Visual | **one screen-group per chunk** (both themes, ~610 screens, montage-reviewed + nav/back for that group) — never "all screens" at once (heaviest, image-bound) | 68 | | C Visual | **one screen-group per chunk** (both themes, ~610 screens, montage-reviewed + nav/back for that group) — never "all screens" at once (heaviest, image-bound) | 68 |
| D Security | D1 at-rest · D2 rules + D3 negative · D4 keys/recovery · D5D7 appcheck/secrets/leaks/migration | ~4 | | D Security | D1 at-rest · D2 rules + D3 negative · D4 keys/recovery · D5D7 appcheck/secrets/leaks/migration | ~4 |
| E Notifications | **35 types per chunk** × {foreground/background/killed} + tap-to-open | ~4 | | E Notifications | **35 types per chunk** × {foreground/background/killed} + tap-to-open; **game/join-game notification chunks** included; both clients (QA→Sam, Sam→QA) | ~56 |
| F Resilience | **one dimension per chunk** (concurrency · lifecycle/process-death · network · time · account-lifecycle) | ~5 | | F Resilience | **one dimension per chunk** (concurrency · lifecycle/process-death · network · time · account-lifecycle) | ~5 |
| G Account creation | **one creation/abuse dimension per chunk** (happy/validation · duplicate/conflict · fake-account abuse · lifecycle) | ~4 |
| H Branding | **one screen-group per chunk** (consumer brand walk → ready-to-paste art prompts) | ~4 |
| I Performance | **one route-group per chunk** — gfxinfo/jank + read-count instrumentation (build the route smoke checklist) | ~3 |
| J Accessibility | **one a11y setting per chunk** (font scale · TalkBack · contrast · targets · keyboard · reduce-motion) | ~5 |
Context-cost tips: prefer **code/admin-read audits** (cheap) before live UI sweeps; **montage** screenshots Context-cost tips: prefer **code/admin-read audits** (cheap) before live UI sweeps; **montage** screenshots
(dark|light pairs) to review many at once; keep one chunk = one TodoWrite focus. (dark|light pairs) to review many at once; keep one chunk = one TodoWrite focus.
@ -154,7 +179,7 @@ Context-cost tips: prefer **code/admin-read audits** (cheap) before live UI swee
## Severity scale (label every issue) ## Severity scale (label every issue)
- **P0 Critical** — crash/ANR, data loss, encryption/security leak, feature fully broken, premium bypass. - **P0 Critical** — crash/ANR, data loss, encryption/security leak, feature fully broken, premium bypass.
- **P1 Major** — feature partly broken, premium not unlocking for partner, wrong/missing notification, dead-end nav. - **P1 Major** — feature partly broken, premium not unlocking for partner, wrong/missing notification, dead-end nav.
- **P2 Minor** — readability/contrast, clipping/overflow/truncation, theme not adapting, inconsistent styling. - **P2 Minor** — readability/contrast, clipping/overflow/truncation, theme not adapting, inconsistent styling, wrong/double-back navigation.
- **P3 Polish** — spacing/alignment/copy nits. - **P3 Polish** — spacing/alignment/copy nits.
## QA passes (Round 1 = baseline) ## QA passes (Round 1 = baseline)
@ -163,11 +188,13 @@ Context-cost tips: prefer **code/admin-read audits** (cheap) before live UI swee
Test each gated feature in 3 states: **neither** premium → locked + paywall; **partner-only** premium → BOTH unlock; Test each gated feature in 3 states: **neither** premium → locked + paywall; **partner-only** premium → BOTH unlock;
**self** premium → unlock. Toggle Sam premium, confirm QA (free) unlocks; toggle off. **self** premium → unlock. Toggle Sam premium, confirm QA (free) unlocks; toggle off.
Features: Play-hub games (Desire Sync + any premium-badged), Connection Challenges, Memory Lane; Question Packs; Features: Play-hub games (Desire Sync + any premium-badged), Connection Challenges, Memory Lane; Question Packs;
Spin the Wheel / Category Picker / Wheel History; Date Match / Plan Date / Date Builder; chat media + reactions Spin the Wheel / Category Picker / Wheel History (+ any premium wheel categories); Date Match / Plan Date / Date
(regression — already couple-shared); Subscription/Settings reflects entitlement. Builder; chat media + reactions + any premium chat tools (regression — already couple-shared); Subscription/Settings
reflects entitlement.
Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSyncScreen`, Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSyncScreen`,
`ui/wheel/{CategoryPicker,SpinWheel,WheelHistory}*`, `ui/questions/QuestionPackLibrary*`, `ui/wheel/{CategoryPicker,SpinWheel,WheelHistory}*`, `ui/questions/QuestionPackLibrary*`,
`ui/dates/{DateMatch,DateMatches}Screen`, `ui/memorylane/MemoryLaneScreen`, `ui/challenges/ConnectionChallengesScreen`. `ui/dates/{DateMatch,DateMatches}Screen`, `ui/memorylane/MemoryLaneScreen`, `ui/challenges/ConnectionChallengesScreen`.
Also: **any VM/screen calling `EntitlementChecker.isPremium()` directly** (grep for it) is a candidate gate.
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through) ### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through)
Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match. Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match.
@ -188,6 +215,12 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
intermediate screen and interaction works (selections register, progress advances, both-answered gating, intermediate screen and interaction works (selections register, progress advances, both-answered gating,
reveal/scoring/summary correct). Premium games (Desire Sync, Memory Lane) need a premium toggle to play. reveal/scoring/summary correct). Premium games (Desire Sync, Memory Lane) need a premium toggle to play.
- The session lifecycle is exercised by the real playthrough: `status` active→completed; reveal/results correct on both. - The session lifecycle is exercised by the real playthrough: `status` active→completed; reveal/results correct on both.
- **GAME JOIN PATHS (mandatory — the second partner must JOIN, not just co-play):** the starter begins from real
in-app nav; the joiner then enters from **every** user-facing entry point — notification tap, Play-hub active state,
Home active-game card, Today prompt, waiting-room/resume screen, in-app foreground banner, game history/replay, and
(after the natural paths) deep-link/crafted intent + cold-start from a push. A game isn't complete unless **both**
partners can **start, join, resume, finish, reopen results, and recover from a stale/ended session** — with no
duplicate sessions, wrong routes, stuck waiting screens, broken back nav, or premium-gate mistakes.
- **VARY THE STYLE OF PLAY (don't just repeat the happy path):** across runs, deliberately exercise *different* ways a - **VARY THE STYLE OF PLAY (don't just repeat the happy path):** across runs, deliberately exercise *different* ways a
real couple would play each game, because different inputs hit different code paths: real couple would play each game, because different inputs hit different code paths:
- **Different DEPTHS and QUESTION COUNTS — cover the matrix, don't settle for one combo:** play each game across - **Different DEPTHS and QUESTION COUNTS — cover the matrix, don't settle for one combo:** play each game across
@ -204,11 +237,14 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
mid-game then reopen; "End their game"; re-open a completed session for the replay/results; play two games mid-game then reopen; "End their game"; re-open a completed session for the replay/results; play two games
back-to-back, and a *different* game type immediately after. back-to-back, and a *different* game type immediately after.
- **Edge inputs** — submit with nothing selected (should be blocked), rapid double-taps on answer/confirm/next, - **Edge inputs** — submit with nothing selected (should be blocked), rapid double-taps on answer/confirm/next,
spamming the start button, tapping during the reveal animation. None should crash, duplicate, or desync. spamming the start button, tapping during the reveal animation, switching tabs mid-game, receiving/tapping a
notification mid-game. None should crash, duplicate, or desync.
- Edges: re-open a completed session, leave mid-game (resume), no stuck session, no crash, logcat clean. - Edges: re-open a completed session, leave mid-game (resume), no stuck session, no crash, logcat clean.
- Game start/finish pushes (`onGameSessionUpdate`) exercised here; full delivery/deep-link audit in **Pass E**. - Game start/finish pushes (`onGameSessionUpdate`) exercised here; full delivery/deep-link audit in **Pass E**.
- **Media permissions** (CAMERA, RECORD_AUDIO): granted works, denied degrades gracefully. - **Media permissions** (CAMERA, RECORD_AUDIO): granted works, denied degrades gracefully.
- **Done = every game has one verified complete playthrough** (a launch-only "opens, no crash" row is `partial`, not `pass`). - **Done = every game has one verified complete playthrough** (a launch-only "opens, no crash" row is `partial`, not
`pass`). Coverage row format: `game × starter × join-entry × premium-state × depth/count × lifecycle-edge × result`;
only `pass` when start/join/play/finish/reopen/recover are all verified.
### Pass C — Visual pass, light + dark, ALL screens ### Pass C — Visual pass, light + dark, ALL screens
Every route in `core/navigation/AppRoute.kt` (~50), in **both** modes: text contrast/readability (no invisible/ Every route in `core/navigation/AppRoute.kt` (~50), in **both** modes: text contrast/readability (no invisible/
@ -219,13 +255,22 @@ Settings + all sub-pages (Account, Notifications, Appearance, Privacy, Subscript
Account); Paywall; Your Progress/Activity; Recovery. Account); Paywall; Your Progress/Activity; Recovery.
- **Probe:** `ui/theme/Theme.kt` hardcoded brand colors + chat's custom `closerBackgroundBrush` — verify dark mode - **Probe:** `ui/theme/Theme.kt` hardcoded brand colors + chat's custom `closerBackgroundBrush` — verify dark mode
truly adapts; grep screens for hardcoded `Color(0x...)`. truly adapts; grep screens for hardcoded `Color(0x...)`.
- **States, not just happy path:** empty / loading / error / not-paired where they exist; many need data setup - **States, not just happy path:** empty / loading / error / not-paired / locked-premium / signed-out /
(seeding is user-gated) — note unreachable states in coverage rather than skipping silently. stale-or-deleted-target / populated-with-many where they exist; many need data setup (seeding is user-gated) — note
- **Readability at scale:** default font size + spot-check largest system font scale on text-heavy screens. unreachable states in coverage rather than skipping silently.
- **Readability at scale:** default font size + spot-check largest system font scale on text-heavy screens. (The full
accessibility sweep — large-font on every primary flow, TalkBack labels, touch targets, keyboard, reduce-motion — is
**Pass J**; per-route performance/jank is **Pass I**.)
- **Navigation from every entry point:** reach each screen from **all** the places that link to it and confirm it - **Navigation from every entry point:** reach each screen from **all** the places that link to it and confirm it
opens correctly each time — e.g. a conversation from the inbox AND from "Discuss" AND from a notification; a game opens correctly each time — e.g. a conversation from the inbox AND from "Discuss" AND from a notification; a game
from the Play hub AND from a notification; Paywall from each gated feature; Settings sub-pages; reveal from Today from the Play hub AND from a notification; Paywall from each gated feature; Settings sub-pages; reveal from Today
AND from history AND from `partner_answered`. A screen that works from one entry but breaks/duplicates from another = bug. AND from history AND from `partner_answered`. A screen that works from one entry but breaks/duplicates from another = bug.
- **All routes into a game / join-game state (verify each opens the correct game + session + partner-state + mode +
premium/couple-entitlement + back stack):** Play-hub cards (incl. premium-gated), active-session banners, Home/Today
game prompts, game history, replay/results, waiting screens, notification-opened screens, in-app banners,
"join/resume/continue/view results/end (their) game", deep-link/crafted intent, and bottom-tab return into an active
game. Wrong/duplicate destination, double-back, stale-session join, dead-end, or a route that bypasses the
premium/couple check = bug.
- **TAKE EVERY AVENUE (exhaustive nav fuzzing — actively hunt for nav bugs, don't just walk the happy path):** treat - **TAKE EVERY AVENUE (exhaustive nav fuzzing — actively hunt for nav bugs, don't just walk the happy path):** treat
navigation as something to *break*. On every screen, **tap every interactive element** — each button, card, row, navigation as something to *break*. On every screen, **tap every interactive element** — each button, card, row,
icon, chip, link, tab, header back-arrow, system back, and any "see all / history / edit / manage" affordance — and icon, chip, link, tab, header back-arrow, system back, and any "see all / history / edit / manage" affordance — and
@ -298,41 +343,87 @@ try.** Use throwaway test accounts (sign-out → fresh sign-up; never `pm clear`
- **Done = every creation avenue exercised** (happy + duplicate + malicious) with each attack **denied** and each happy - **Done = every creation avenue exercised** (happy + duplicate + malicious) with each attack **denied** and each happy
path validated end-to-end; findings filed with exact repro. path validated end-to-end; findings filed with exact repro.
### Pass E — Notifications (every type delivers, deep-links, leaks nothing) ### Pass E — Full notification suite, deep-links & join-game navigation (every type, both clients, every app state)
For each: trigger fires → delivered to the **right partner (never self)** → in **foreground/background/killed** Run the **complete** suite across **both clients** (QA→Sam AND Sam→QA). Each type verified end-to-end: **trigger fires
correct channel + copy with **no private content****tap opens exactly the right item** (loaded, not generic Home/ → delivered to the right partner (never self/non-member/ex-partner) → correct channel + copy with no private content →
dead-end) → no duplicates → rate limiter (20/day,100/week) doesn't drop legit ones. tap opens exactly the right item (loaded, not generic Home/dead-end) → sane back stack → privacy/authz re-checked on
Inventory (type → trigger → destination), all 17: `chat_message`(onMessageWritten→conversation, foreground→chat-head open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
bubble), `partner_started_game`/`partner_finished_game`(onGameSessionUpdate→game/results), `partner_answered` - **Both-client × app-state matrix (per type):** QA→Sam and Sam→QA, each in **foreground / background / killed
(onAnswerWritten→reveal), `daily_question`(assignDailyQuestion)/`daily_question_reminder`/`daily_reminder` (cold-start)**, plus **already on the target screen**, **on a different screen**, **logged out**, **unpaired**, with
(dailyQuestionReminder→Today), `date_match`(createDateMatch→match), `partner_joined`+`invite_created` a **stale/expired/completed/deleted target**, and **both users opening around the same time**. Not a `pass` unless it
(acceptInviteCallable→pairing/home), `partner_left`(onCoupleLeave)/`partner_deleted_account`(onUserDelete→home/ works from both clients in every state that applies.
relationship settings), `memory_capsule_unlocked`(scheduled→capsule), `challenge_day_ready`(→Connection Challenges), - **Six assertions per notification:** (1) trigger fires correctly — right event, not early, not twice, sender doesn't
`outcome_reminder`(scheduledOutcomesReminder), `reengagement`(reengagement/gameRetention), `gentle_reminder` get their own (unless intended), retry/idempotency doesn't duplicate; (2) delivered to the right person — correct
(sendGentleReminderCallable), `spki`(identify + confirm handled). token, old tokens unused after sign-out/account-switch; (3) copy + channel correct — friendly, right channel/
- **Tap-to-open:** every notification opens the **specific item** from foreground/background/killed; tapping in-app priority, no raw Firebase error/raw IDs, no private content in text/payload/logs/analytics/crash; (4) tap opens the
doesn't stack/duplicate; logged-out/unpaired tap is graceful. Wrong/dead destination = P1. exact destination — specific conversation/session/capsule/match/question/settings/pairing, never blank, never a crash
- **Scheduled/time-based:** trigger manually (invoke callable/function or seed due condition — user-gated). on missing/stale/malformed/unauthorized data, no duplicate/stacked copies, completed→results/replay, expired/deleted→
- **Foundations:** FCM token registration on sign-in (`TokenRegistrar`) + `onNewToken`; POST_NOTIFICATIONS prompt + graceful fallback; (5) back stack sane — back returns sensibly (Home/prev context), no double-back, no unexpected
denied path; channels (`di/NotificationModule`); deep-link routing (`MainActivity.deepLinkRouteFromIntent` → exit/loop/blank; (6) deep-link re-checks auth + couple membership + pairing + entitlement + target ownership +
`AppNavigation`); foreground/background split (`core/notifications/AppMessagingService`). session status + existence — a non-member/logged-out/stale/unpaired open must NOT reach private content and must fail
- Build a delivery matrix (type × {foreground,background,killed}) in ClaudeQACoverage.md. Missed delivery or wrong gracefully.
deep-link = P1; private content in any payload = P0. - **Inventory (type → Cloud-Function trigger → recipient → destination)** — verify each; mark any unimplemented type
`not implemented→Future.md` (don't count as pass):
`chat_message`(onMessageWritten → partner → conversation; foreground→chat-head bubble) ·
`partner_started_game`/`partner_finished_game`(onGameSessionUpdate → partner → game/join · results/reveal) ·
`join_game`/`game_invite` & `partner_joined_game` (if present → partner/starter → join screen · waiting-room update) ·
`partner_answered`(onAnswerWritten → partner → reveal) ·
`game_abandoned`/`game_ended` (if present → partner → safe ended state, not a stuck session) ·
`daily_question`(assignDailyQuestion)/`daily_question_reminder`/`daily_reminder`(dailyQuestionReminder → Today) ·
`date_match`(createDateMatch → match) · `date_plan_update` (if present → date plan/builder/match) ·
`partner_joined`+`invite_created`(acceptInviteCallable → pairing/home) ·
`partner_left`(onCoupleLeave)/`partner_deleted_account`(onUserDelete → home/relationship settings) ·
`memory_capsule_unlocked`(scheduled → capsule) & `memory_capsule_created` (if present → Memory Lane/locked capsule) ·
`challenge_day_ready`(→ Connection Challenges) & `challenge_day_completed` (if present → challenge progress) ·
`outcome_reminder`(scheduledOutcomesReminder) · `reengagement`(reengagement/gameRetention) ·
`gentle_reminder`(sendGentleReminderCallable) · `spki`(key identity/confirm → security/key screen) ·
`subscription_entitlement_changed` & `security_recovery` (if present).
- **Game-notification suite (per game):** A starts from Play hub → B gets the start/join push (if supported) → B taps
and lands on the correct join/waiting/active screen → B can join from there → A sees B joined/answered → both finish
→ finish push opens the exact results/reveal → re-opening the push after completion opens replay/results (not a dead
active session) → if A ends/quits, B is notified or shown a graceful ended state → a **stale** game push routes to
results/history or a clear expired-session message → simultaneous start/join yields **one** session, neither stuck →
premium gate holds (neither-premium push must NOT bypass paywall; either-premium unlocks for both).
- **Join-game navigation suite:** every entry that leads to joining/resuming a game opens the correct game + session +
partner-state + mode + entitlement + back stack — Play-hub card, active-game banner/card, Home active-game card,
Today game prompt, notification tap, in-app foreground banner, game history/replay, partner waiting screen, results/
reveal, "End their game"/stuck-session recovery, deep-link/crafted intent, cold-start from push, bottom-tab return
into an active game, any push action buttons, and any "join/resume/continue/view results/play again". No wrong game
type, no accidental stale-session join, no duplicate session on double-tap, back returns correctly.
- **Payload security (P0 on any hit):** inspect raw payload + logs — no plaintext message/answer/capsule/date-plan/
bucket-list/swipe content, no raw invite code/seed, no recovery phrase, no wrapped/decrypted key material, no
email/name unless intentionally public; payload carries only the minimum routing metadata. Any private content = P0.
- **Malformed / stale intents:** fire crafted deep-links with missing/unknown type, missing/wrong target or couple ID,
wrong game type, expired/completed/deleted target, unauthorized couple/session, malformed params, duplicate/rapid
taps, a push for another user/previous partner, while logged-out/unpaired, while on the target screen, and during a
different active game → never crash/leak, always a graceful fallback + sane back stack.
- **Scheduled/time-based:** trigger manually (invoke callable/function or seed the due condition — user-gated).
- **Foundations:** FCM token registration on sign-in (`TokenRegistrar`) + `onNewToken` + token cleanup on sign-out/
account-switch; POST_NOTIFICATIONS prompt + denied path; channels (`di/NotificationModule`); deep-link routing
(`MainActivity.deepLinkRouteFromIntent` → `AppNavigation`); foreground/background split
(`core/notifications/AppMessagingService`); no duplicate local+remote notification.
- **Coverage:** record per row `type × trigger × recipient × app-state × destination × back-stack × privacy ×
both-client` in ClaudeQACoverage.md; only `pass` when delivery + routing + back-stack + privacy + both-client are all
verified. Missed delivery or wrong deep-link = P1; private content in any payload = P0.
### Pass F — Resilience, concurrency, lifecycle & time (cross-cutting; a 2-user realtime app needs these) ### Pass F — Resilience, concurrency, lifecycle & time (cross-cutting; a 2-user realtime app needs these)
- **Concurrency / realtime races (two partners at once):** both answer the daily question simultaneously; both start - **Concurrency / realtime races (two partners at once):** both answer the daily question simultaneously; both
a game / swipe a date / react at the same time; partner acts while you're mid-flow. No lost writes, no stuck state, start/join the same game; both swipe a date / react at once; one quits while the other submits; both tap a
no duplicate sessions, reveal still correct. (This is where a couples app breaks.) notification at once; partner acts while you're mid-flow. No lost writes, no stuck state, no duplicate sessions,
reveal still correct. (This is where a couples app breaks.)
- **Lifecycle / process death:** background mid-flow + return; force-kill the app and relaunch (Android may kill the - **Lifecycle / process death:** background mid-flow + return; force-kill the app and relaunch (Android may kill the
process) — state/auth/draft restore sanely; deep-link/notification after process death still loads (verified for process) — state/auth/draft restore sanely; deep-link/notification after process death still loads (verified for
chat — extend to all). Rotation/config-change doesn't lose Compose state. Low-memory. chat — extend to all). Rotation/config-change doesn't lose Compose state. Low-memory.
- **Network resilience:** offline / flaky / airplane mid-action across answers, games, dates (not just chat media) — - **Network resilience:** offline / flaky / airplane mid-action across answers, games, dates (not just chat media) —
graceful failure + retry/queue, no crash, no silent data loss, recovery on reconnect. graceful failure + retry/queue, no crash, no silent data loss, recovery on reconnect.
- **Idempotency / rapid input:** double-tap send/submit, rapid nav, double-start — guarded (no double-send, no crash). - **Idempotency / rapid input:** double-tap send/submit, rapid nav, double-start, double-join, repeated paywall-unlock
taps — guarded (no double-send, no duplicate session, no crash).
- **Time-dependent behavior:** daily-question rollover (6 PM CST assignment), streak day-boundary + repair window, - **Time-dependent behavior:** daily-question rollover (6 PM CST assignment), streak day-boundary + repair window,
capsule unlock times, reminder schedules — test across a date change (manipulate device clock / trigger functions). capsule unlock times, reminder schedules, challenge-day availability, timezone change — test across a date change
(manipulate device clock / trigger functions).
- **Account/couple lifecycle:** brand-new (empty) account; unpaired state; pair → unpair → re-pair; partner leaves - **Account/couple lifecycle:** brand-new (empty) account; unpaired state; pair → unpair → re-pair; partner leaves
mid-session; account deletion cascade; same account on two devices. No orphaned/broken state. mid-session; account deletion cascade; same account on two devices; stale notifications after unpair/delete are
graceful; invite accepted while already paired is rejected cleanly. No orphaned/broken state.
- **Crash reporting:** confirm crashes/ANRs are actually captured (Crashlytics) so field issues surface. - **Crash reporting:** confirm crashes/ANRs are actually captured (Crashlytics) so field issues surface.
### Pass H — Branding & artwork (every screen: could it carry more of the brand? where would art help?) ### Pass H — Branding & artwork (every screen: could it carry more of the brand? where would art help?)
@ -366,12 +457,95 @@ written as ready-to-paste ChatGPT image-generation prompts** — the user genera
- Branding *defects* (mis-colored, clipped, off-brand, low-contrast art) are bugs → `ClaudeReport.md`. Pure - Branding *defects* (mis-colored, clipped, off-brand, low-contrast art) are bugs → `ClaudeReport.md`. Pure
"works but could be warmer / a feature idea" → `Future.md` `## QA`. New art to create → `ClaudeBrandingReview.md`. "works but could be warmer / a feature idea" → `Future.md` `## QA`. New art to create → `ClaudeBrandingReview.md`.
### Pass I — Performance & route efficiency (jank, redundant reads, caching) [FUTURE.md P14]
Before store polish, profile **every top route** and **every high-cardinality list** for jank, repeated Firestore
reads, missing cache use, and slow navigation. Drive each route as a user and instrument reads/frames.
- **Frame / jank:** scroll every long list (Messages inbox + conversation, Answer History, Question Packs, Past Games,
Wheel History, Bucket List, Date deck, Activity/Progress) and open every top route while watching
`adb shell dumpsys gfxinfo <pkg> framestats` (or Perfetto / Studio Profiler) — flag dropped/janky frames, slow first
frame, and `Choreographer: Skipped N frames` / main-thread stalls in logcat. Transitions/animations stay smooth (~60fps).
- **Redundant Firestore / network reads:** count listeners/gets per screen. Switching bottom tabs and returning must
**not** refetch unchanged data; opening a screen twice must not double-read; **snapshot listeners detach on leave**
(no leaked/stacked listeners — a 2-user realtime app accumulates these fast). Watch for N+1 reads on lists.
- **Caching / lazy-load:** static question/category data is cached locally (Room) and not re-fetched each entry; large
lists use lazy paging (`LazyColumn`/paging, not load-all); images cached (Coil); offline reads serve from cache.
- **Latency:** measure cold-start-to-interactive (splash→loader→Home) and tab/route transition latency; flag anything
perceptibly slow (>~300ms).
- **Deliverable:** a reusable **route smoke-test checklist** (every top route × {load time · jank · read count}),
captured as a runnable script so each round re-checks cheaply.
- **Remediation when found:** lazy-load/page large lists; cache local question/category data; dedupe + scope snapshot
listeners; skip redundant fetches on tab switches; add skeleton/loading states (cf. FUTURE.md P8) over blocking spinners.
- Findings: real jank/leak/redundant-read = bug → `ClaudeReport.md` (P2; **P1** if it ANRs or leaks listeners, **P0** if
it drops data); "could be smoother / add skeletons" → `Future.md` `## QA`.
### Pass J — Accessibility (font scale · contrast · screen reader · targets · keyboard · reduce-motion) [FUTURE.md P15]
Every **primary flow** must be usable with accessibility settings on. Enable each setting and walk the core flows
(auth, onboarding, pairing, Home, a full game, daily question + reveal, Messages, Paywall, Settings) end to end.
This is the deep home for a11y; the Pass C contrast/font spot-checks feed into it.
- **Font scaling:** `adb shell settings put system font_scale 1.3` (then 1.5, 2.0) — every primary flow stays usable:
**no clipped/overlapping text, no cut-off or hidden buttons/actions** (scroll where needed). **Acceptance: all primary
flows usable at increased font scale without clipped buttons or hidden actions.** Restore `font_scale 1.0` after.
- **Screen reader (TalkBack):** every interactive element has a meaningful semantics/`contentDescription` (icon-buttons
especially: back, send, like, close, the brand-mark loader, game option cards); decorative images are silenced
(`clearAndSetSemantics {}` / null desc); reading order is logical; no unlabeled "Button"; custom controls (spin wheel,
date swipe deck, answer cards) are operable + announced; no focus traps.
- **Contrast:** body text + essential icons meet WCAG AA (4.5:1 body / 3:1 large) in **both** themes — measure, don't
eyeball; re-check the known dim spots (game answer text, muted captions, the C-DS-001 area).
- **Touch targets:** interactive targets ≥ **48dp** (icon buttons, chips, nav, close/back, reaction buttons, swipe-deck
actions). Flag anything smaller.
- **Keyboard / external input:** with a hardware keyboard, forms (sign-up, message, capsule, profile) tab in a sane
order, IME/Enter actions work, focus is visible, no traps.
- **Reduce-motion:** with "Remove animations" (`adb shell settings put global animator_duration_scale 0`), the loader,
celebration particles, reveals, splash handoff, and transitions degrade gracefully and **no motion-gated content
becomes unreachable** (the loader/particles already honor this — verify everywhere). Restore to `1` after.
- **Remediation:** add semantics labels, raise touch targets, fix contrast tokens, guard motion behind the reduce-motion flag.
- Findings: missing label / clipped-at-large-font / sub-48dp / failing contrast = bug → `ClaudeReport.md` (**P2**; **P1**
if it blocks a primary flow for assistive-tech users); polish → `Future.md` `## QA`.
## Reporting → ClaudeReport.md (living QA report) ## Reporting → ClaudeReport.md (living QA report)
- Header: date, build, devices, round number + run-state header. - Header: date, build, devices, round number + run-state header.
- One section per pass (A/B/C/D/E/F), each a table: **ID | Area | Screen/Route | Mode | Severity | Description | Repro - One section per pass (AJ), each a table: **ID | Area | Screen/Route | Mode | Severity | Description | Repro
| Evidence | Suggested fix | Status**. | Evidence | Suggested fix | Status**.
- Summary: counts by severity. Report only during passes — no fixes recorded until the fix phase. - Summary: counts by severity. Report only during passes — no fixes recorded until the fix phase.
### Report hygiene — keep it CLEAN, lean, and never dangling (the report is a *current-state* doc, not an archive)
The report's job is to show, at a glance, **what's wrong right now** — not to accumulate a history of everything ever
fixed. Stale fixed rows and stacked old run-states make it unreadable and hide the real signal. So:
- **A Fixed row survives exactly ONE confirmation round, then it's removed.** When you fix an issue, mark its row
`Fixed` (with the commit) and keep it through the **next** re-QA round. Once that round re-verifies it, **delete the
row** — the full root-cause/fix detail already lives in the **commit message** (the row cites the hash), so nothing is
lost. Don't carry confirmed-fixed issues across multiple rounds.
- **One run-state header, always.** Keep only the **current** `Round N | Pass X | Chunk Y | NEXT ACTION` block pinned
at the top. Don't stack prior rounds' headers — collapse finished rounds into at most a **single one-line history**
entry each (e.g. `R6: branding regression — 0 new`), or drop them entirely once their fixes are confirmed-and-pruned.
- **Open issues first; resolved issues compact.** Order every pass section **open (P0→P3) on top**; keep a short
`Resolved & confirmed (archived — detail in git)` line listing only the **IDs** of older fixed-and-verified issues
(not their tables). The big per-issue tables exist only for **currently-open** and **fixed-this-round-pending-confirm**
issues.
- **Severity board reflects NOW.** One board, current counts; `Open` is the number that actually matters. When `Open`
hits 0 at every level, the report should be **short** — current run-state, a 0/0 board, the archived-ID line, and the
operational constants (devices/accounts, standing-auth, playbook pointers). If it's long while everything is fixed,
it needs pruning.
### Coverage-matrix hygiene (`ClaudeQACoverage.md` — a *current-status* matrix, not a per-round changelog)
- **Flip, don't stack.** When a fix is confirmed, change that row's `fail→id` to `pass` and move the ID to an archived
line — never leave a confirmed-fixed `fail→id` dangling, and never keep a contradicting "still owed" note next to a
completed row.
- **One status per cell, current.** Each screen/feature/game/notification shows its **latest** status only; collapse
prior rounds' narration into a single one-line **round history**. Keep an at-a-glance pass-status table at the top.
- **Keep the resume signal sharp.** What a returning session needs is *what's left* — surface `todo`/`deferred`/
`blocked` items plainly; don't bury them under superseded prose.
### Extremely-easy-to-read mandate (applies to ClaudeReport.md, ClaudeQACoverage.md, and Future.md)
Optimize every QA doc for a reader who has **5 seconds** to find the current state:
- **Lead with the answer.** Top of the file = current round + the one-line verdict (e.g. "0 open P0P3; security clean")
before any detail.
- **Tables over prose** for issues; **short rows**. Put long root-cause analysis in the **commit**, not the row — the
row gets a one-sentence description + repro, then the commit hash.
- **No walls of text.** Break run-state into scannable lines; bold the few words that matter; no multi-paragraph
headers. If a paragraph is longer than ~3 lines, it's probably commit material, not report material.
- **Consistent shape every round** so a returning reader (or a post-compaction resume) finds things in the same place.
## Fix phase (only AFTER all passes of the round complete) ## Fix phase (only AFTER all passes of the round complete)
- Work strictly by severity: **all P0 → P1 → P2 → P3**. - Work strictly by severity: **all P0 → P1 → P2 → P3**.
- **One issue at a time**: implement → `./gradlew :app:assembleDebug` → install both → verify THAT fix live (correct - **One issue at a time**: implement → `./gradlew :app:assembleDebug` → install both → verify THAT fix live (correct
@ -384,10 +558,16 @@ written as ready-to-paste ChatGPT image-generation prompts** — the user genera
- Gated actions (entitlement toggles, deploys) are **user-authorized per occurrence**. - Gated actions (entitlement toggles, deploys) are **user-authorized per occurrence**.
- **New issues found while fixing** are logged (new ID), not silently fixed beyond scope — next re-QA round catches them. - **New issues found while fixing** are logged (new ID), not silently fixed beyond scope — next re-QA round catches them.
**Definition of done:** a **pass** is done when every coverage row is `pass`/`fail→id`; a **round** is done when all **Definition of done:** a **pass** is done when every coverage row is `pass`/`fail→id`/`not implemented→Future.md`/
five passes are done; **flawless** = one full round with **zero open P0P2 and Passes D + E fully clean**. Then stop `blocked→id`; a **round** is done when all passes (AJ) are done; **flawless** = one full round with **zero open P0P2
(P3s optional). Don't re-open a clean pass within the same round. and Passes D + E fully clean** (no open P0/P1 in I/J), **every game fully played through, every notification type
verified or explicitly `not implemented→Future.md`, all join-game navigation paths and all back-stack checks
verified**. Then stop (P3s optional). Don't re-open a clean pass within the same round.
## Re-QA loop (until flawless) ## Re-QA loop (until flawless)
After the fix phase, re-run Pass A/B/C/D/E/F (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full After the fix phase, re-run Pass AJ (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full
round yields zero P0P2 and Passes D+E fully clean. round yields zero P0P2 and Passes D+E fully clean.
- **Prune on confirmation (Report hygiene):** the moment a re-QA round re-verifies a `Fixed` issue, **delete its row**
from `ClaudeReport.md` (move its ID to the compact `Resolved & confirmed (archived — detail in git)` line) and
collapse that finished round's run-state header. A fixed issue lives in the report for **one** confirmation round
only — never let confirmed-fixed rows or old run-states accumulate. See **Report hygiene** under Reporting.

View File

@ -1,193 +1,48 @@
# Claude QA Report — Full-App QA (living report) # Claude QA Report — Full-App QA (living report)
> **RUN-STATE: Round 7 (multi-angle DEEP DIVE) — 2026-06-25, client HEAD `f47c8e2`, functions deployed. Plan updated with a "Multi-angle attack mandate" + live raw-API D3.** Attacked security/data/concurrency from multiple angles (admin ground-truth read, raw Firestore REST as member+non-member, killed/cold state, malformed intents, simultaneous-start race). **Security cornerstone = FULLY CLEAN (deep):** D1 at-rest — messages/previews + all 4 game-answer collections (ToT/HowWell/DesireSync/Wheel, both users) + capsules + date-swipe actions all `enc:v1:`; couple key phrase-wrapped (argon2id), recovery phrase server-blind + `encryptedRecoveryPhrase` wiped on acceptance, plaintext `inviteCode` not exploitable (invite readable only by inviter; no code-encrypted secret persisted). D3 raw-API — **non-member denied ALL reads/writes (403)**; real premium path `users/{uid}/entitlements/premium` write **denied (403, server-only) → no self-grant**; cross-couple denied. Robustness — malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal values) → 0 crash; killed-state cold-start chat deep-link → conversation loads. **NEW FINDING: F-RACE-001 (P1)** — simultaneous game start creates **two divergent active sessions** (TOCTOU). **SEVERITY BOARD: P1 = 1 open (F-RACE-001), P0/P2/P3 = 0 open.** Baseline restored (duplicate sessions ended, 0 active, couple intact). Two hardening notes → Future.md (App Check not enforced on Firestore; user-doc update rule allows arbitrary non-`hasPremium` fields).** > **Verdict (2026-06-26, R9): 0 open P0P2 (1 P3 J-OBS, non-blocking). I-001/I-002 confirmed + pruned. Security cornerstone clean. At the flawless bar.**
> >
> **F-RACE-001 (P1, NEW — concurrency):** When BOTH partners start the *same* game within ~the same second, the couple > This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned
> ends up with **2 active sessions with different question sets** (proven live: QA "Which should end the date?" vs Sam > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
> "Which feels more romantic?", two session docs `bw8Q3X45…` + `yNOzMTOCsGlZPbnv2yBN`). Root cause: `GameSessionManager.
> startGameWithCouple` (usecase/GameSessionManager.kt:84106) does a **non-transactional check-then-create**
> `getActiveSessionForCouple` then `saveSession` (auto-id); two concurrent calls both read null → both create. The
> existing `partner_active_session` guard only covers the non-simultaneous case. Impact: the two partners play separate
> games and never get a shared reveal (core loop silently defeated); two active sessions can also lock/confuse the
> "one active game" rule. No crash, no data loss; recoverable via "End their game"/admin. Repro: stage both at This or
> That mood-select, fire "create" on both in parallel. **Suggested fix:** make session creation atomic — a Firestore
> **transaction** with a per-couple active-session **sentinel** (e.g. `couples/{cid}` field `activeSessionId` or a
> `sessions/_active` doc): read sentinel → if an active session exists, take the join/`partner_active_session` path;
> else create the session doc (client-generated id) + set the sentinel in the same transaction. Clear the sentinel on
> finish. Needs a `firestore.rules` update (member-only sentinel write) + a rules deploy + re-verify all 7 games. (This
> is an architectural change to the core game flow — flagged for a focused fix-phase implementation.)
> **RUN-STATE: Round 6 (branding + Future.md regression QA) COMPLETE — 2026-06-25. Client HEAD `f47c8e2` on both emulators (build == HEAD, reinstalled).** Scope: regression-verify the new surfaces from the branding drop (`95cad84`: white-keyhole launcher/notification icons, animated app-icon-chip loader + fill, cold-launch splash, pairing hero) and the Future.md backlog clear (`f47c8e2`: inclusive gender options, turn-aware Home copy, push rate-limit budget split, results-push suppression via new ActiveGameSessionMonitor, paywall retry/offline/hide-Continue, auth privacy rotator). **0 new issues — SEVERITY BOARD STILL 0 open P0P3.** LIVE-VERIFIED: animated loader (chip+fill, both themes), splash→handoff (white-keyhole icon, no white flash), launcher icon (round mask), This or That + How Well open with no crash (confirms #4's new VM injection is sound), paywall purchase screen shows friendly "Couldn't load plans" + Try again with Continue hidden (no dead button) + online→generic message (#5), onboarding carousel illustration. Unit tests green (NotificationRateLimiter rewritten + PartnerNotificationManagerTest repaired). No FATAL on either device all session. CODE/UNIT-VERIFIED (live deferred, low-risk over proven patterns + fragile multi-text-field/2-device paths): #1 gender step (EditProfile + onboarding sex step — same option list as the shipping Female/Male), #8 rotator on SignUp/Forgot (reuses the Login-proven BrandMessageRotator), #2 "Your turn to play." (static string in the proven GAME_WAITING path), #3 weekly-cap exemption (unit-tested; only triggers at ≥100/wk), #4 results-suppression timing (mechanism + VM wiring verified; simultaneous-finish timing is non-deterministic to drive). Baseline restored: 5554 signed out during the sign-up pass, re-signed-in QA (`Y05AKO2IlTPMa0JQW1BiNIM0uzK2`) via admin custom token; couple `Xal3Kw3gjSdn0niERYKJ` intact, Sam paired.** ## Run-state (current)
`Round 9 — COMPLETE (clean confirmation round, 0 new findings) | 0 open P0P2 (1 P3 J-OBS) | I-001/I-002 pruned; deferred Pass C + Pass F network swept | NEXT ACTION: FLAWLESS — optional P3 J-OBS fix + low-risk deferred (time-gated content, deletion-cascade) in a future round.`
- **Build:** client HEAD `23dd6a7`, Cloud Functions deployed.
- **Devices / accounts:** emulator-5554 = QA (`Y05AKO2IlTPMa0JQW1BiNIM0uzK2`) · emulator-5556 = Sam (`imDjjO…`) · paired, coupleId `Xal3Kw3gjSdn0niERYKJ`, both free (baseline restored).
- **Docs:** Playbook `ClaudeQAPlan.md` · Coverage `ClaudeQACoverage.md` · Ideas `Future.md` `## QA` · Branding `ClaudeBrandingReview.md`.
> **RUN-STATE: Round 5 (functions deploy + expanded re-QA) COMPLETE — 2026-06-25. Client HEAD `765916a` on both emulators; Cloud Functions DEPLOYED (`firebase deploy --only functions` → "Deploy complete", all 30+ fns updated). Fixed + verified LIVE: E-OBS (all 12 FCM senders now set `android.notification.channelId` → backgrounded chat push lands on `partner_activity`, NOT `fcm_fallback`), E-003 results-ready (server sends `game_session_id`; finished-game deep-link → per-session "This or That Results" screen, not hub/setup). Expanded coverage per user request: VARIED GAMEPLAY (Standard/Deep + 0-match "Total opposites" result path), exhaustive NAV FUZZING (rapid triple-tap opens setup once via launchSingleTop; back-stack clean; no dead-ends/double-back), and NEW PASS G account-creation/fake-account — ALL SECURE: sign-up+validation (weak-pw → friendly error), fresh-account isolation (zero couple data), duplicate-email → `auth/email-already-exists`, invite single-use+24h-expiry + bogus code → "Invite not found", recovery phrase client-generated. **SEVERITY BOARD: 0 open at ALL levels (P0P3).** Baseline restored: couple intact, both free, 0 active sessions, throwaway test account deleted, Sam re-paired.** ## Severity board
> _Round 4 (carried): E-003 game-push + B-004 WaitingForPartner "Join the game" + A-OBS paywall copy all FIXED + verified live._ | Severity | Open | Fixed (pending 1 confirm) |
> _Round 2 result (carried): FIX PHASE COMPLETE — P1×2 (B-001 session rules, C-NAV-001 back-stack), P2×4 (A-001, B-002, C-CC-001, C-DS-001), P3×4 (A-003, B-003, E-002, F-OBS) all FIXED; D-001 (P1 rules) + E-001 (P2 routing) fixed earlier. All verified LIVE except E-002/F-OBS (code+build; live-trigger deferred). Rules deployed._
> 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 — after Round 4 fix phase + re-QA)
| Severity | Open | Fixed (verified live) |
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | 0 | 4 | | P1 | 0 | 0 |
| P2 | **0** | **6** | | P2 | 0 | 0 |
| P3 | **0** | **6** | | P3 | **1** | 0 |
**Round 5 result:** **0 open issues at every severity (P0P3).** E-OBS (the last open P3) is now **FIXED + DEPLOYED + verified live** along with the E-003 results-ready follow-up. New **Pass G (account creation + fake-account abuse)** ran clean — no security findings. All prior fixes hold. The app meets the "flawless" bar (0 open P0P2, Passes D + E clean) — and beyond it (0 open P3 too). ## Open issues
| ID | Sev | Area | Description | Repro | Suggested fix | Status |
**Round 5 dispositions (functions deployed):**
- **E-OBS (P3) — FIXED + DEPLOYED** (`21b078a` senders, server live): all 12 FCM senders set `android.notification.channelId` (game→`game_activity`, chat/partner→`partner_activity`, reminders→`reminders`; gameRetention varies by type). **Verified live:** backgrounded QA→Sam chat push now shows `channel=partner_activity` (was `fcm_fallback_notification_channel`).
- **E-003 results-ready — FIXED + DEPLOYED** (`aaab768` client + server `game_session_id`): `onGameSessionUpdate` now sends `game_session_id`; client `gameResultsRouteFor` routes `game_results_ready` to the per-session replay (`thisOrThatReplay`/`howWellReplay`/`desireSyncReplay`/`wheelComplete`). **Verified live:** finished-game deep-link → "This or That Results" (0/10, that session), not hub/setup. (Live finished-push didn't post on Sam — fired while foreground + 20/day rate limiter after a full QA day; routing verified via the exact deep-link intent.)
- **Pass G — CLEAN (no findings):** sign-up + validation (weak-pw → "Password must be at least 8 characters."), fresh-account isolation (unpaired, zero couple data), **duplicate-email → `auth/email-already-exists`** (rejected), invite code **single-use + 24h expiry**, **bogus code → "Invite not found."** (rejected, friendly), recovery phrase client-generated. Sign-out → onboarding → debug-token restore all work.
- **Varied gameplay (Pass B style):** This or That Standard(10)/Deep + all-mismatch → "0/10 in sync — Total opposites" result path renders correctly (distinct from the 5/5 Quick/Light path). **Nav fuzzing:** rapid triple-tap opens the game setup once (launchSingleTop, no stacked duplicates); back-stack game→hub→Home→launcher clean; no dead-ends/double-back.
**R3/R4 issue dispositions:**
- **E-003 (P2) — FIXED** `23c9923`: game pushes (`partner_started_game`/`partner_completed_part`) now route via `gameRouteForType(payload.gameType)` into the specific game (auto-joins the active session), not the Play hub. Server already sends `game_type`; client parses it in AppMessagingService + MainActivity. `game_results_ready` stays on the hub pending a server change to also send `game_session_id` (documented). **Verified live:** tapped start-game push → This or That 1/5 (joined).
- **B-004 (P2) — FIXED** `da7fc74`: `WaitingForPartnerScreen` now resolves the active session's game route and offers a primary **"Join the game"** action (every game is async/joinable), so the partner is never stuck. **Verified live** via deterministic repro: QA started How Well → Sam opened This or That → WaitingForPartner → "Join the game" → How Well guess intro.
- **A-OBS (P3) — FIXED** `6f6f76a`: paywall ErrorState no longer renders the raw billing/RC SDK message; shows friendly "We couldn't load subscription options right now…". **Verified live** (raw "credentials issue" gone).
- **E-OBS (P3) — OPEN, deferred:** backgrounded pushes use `fcm_fallback_notification_channel`, bypassing code-defined channels. Fix is server-side (set `android.notification.channel_id` on every FCM send across functions, or send data-only + build client-side) + a **functions deploy (user-gated)**. Cannot verify without deploying.
- **C-OBS (RESOLVED, not a bug):** Settings "Art preview/Paired home (debug)" entries ARE `BuildConfig.DEBUG`-gated (SettingsScreen.kt:469) — won't ship in release.
**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.
---
## Round 3 re-QA log (2026-06-25, build `ce7fc2e`) — fix regression + deferred coverage
**Fixes re-verified LIVE this round:**
- **C-NAV-001 ✅** — cold start (logged in) → Home → system Back → focus = `NexusLauncherActivity` (app exits). No onboarding resurfacing.
- **C-CC-001 ✅** — Play hub → Connection Challenges (active Gratitude Week) → single header (`Back` desc count = 1), no duplicate title.
- **Back-stack ✅** — clean cold-start hierarchy: deep screen (challenge) → Back → Play hub → Back → Home → Back → launcher. No double-back, no dead-ends. (Earlier "double-back" suspicion was warm nav-state restoration of the last Play-tab destination, not a real defect — does not reproduce from cold start.)
- **A-001 ✅ (couple-shared)** — QA set premium, Sam left free. Sam (5556, partner-premium) Play hub → Desire Sync opens to "How long?" setup (no paywall) + Memory Lane opens (sealed capsule shown). QA (5554, self-premium) likewise unlocked.
- **A-003 ✅** — Play hub shows **0 "Premium" badges** on both 5554 (self-prem) and 5556 (partner-prem couple-shared).
- **D-001 ✅** — Sam opened Memory Lane → capsule list renders, **0 PERMISSION_DENIED** in logcat (capsules rule holds; no hung heart → F-OBS path healthy).
**Desire Sync two-device playthrough (premium ON; QA started, Sam joined):**
- **B-001 ✅** — both answered all 5 → admin read shows **active=0** (session auto-flipped to completed, no "End their game" needed). Core loop intact.
- **B-003 ✅** — reveal counts fully coherent: "3 shared desires / 2 answers stayed private" + tiles "You: Private / partner: Private" + caption "3 shared, 2 kept private". No contradicting "5 private".
- **C-DS-001 ✅** — 5554 (dark) reveal "You both said yes to" list renders crisp **white high-contrast** text (old dim muted-pink gone); 5556 (light) black-on-light. Both readable.
- Gameplay PASS — privacy logic correct (QA T,Y,Y,Y,T vs Sam T,Y,N,Y,F → exactly the 3 mutual-affirmative shown, 2 mismatches hidden), reveals match on both, no crash. Sam (free) joined QA's session = couple-shared join works.
**This or That two-device playthrough (immediately after Desire Sync — 2nd consecutive game):**
- **B-002 ✅** — QA started This or That → Sam's Home showed "Game waiting / Your partner is waiting to play" → Sam tapped **"Play now"** → landed directly in This or That **1/5** (the exact waiting game), NOT the generic hub.
- **B-001 ✅ (2nd consecutive game)** — both answered 5/5 → admin **active=0** again. Proven a couple can play two games back-to-back with no dangling/blocking session. Core loop solid.
- Gameplay PASS — both picked A on all 5 → Sam results "5/5 in sync — Two peas in a pod, matched on 5 of 5" with correct per-Q breakdown; consistent on both; **0 FATAL** in logcat.
**How Well Do You Know Me two-device playthrough (QA subject, Sam guesser):**
- Gameplay PASS (×2) — QA answered 5 about self (incl. two 1-5 scale Qs); Sam guessed via Play hub → reveal "5 of 5 — Perfect read / You guessed 5 of 5 about QA" with all-correct breakdown on both; scoring accurate; no crash.
- **B-001 ✅ (3rd game type)** — session auto-completed (active=0) once both submitted.
- **B-002 (clean case) ✅** — with the subject (QA) DONE first, Sam's Home "Play now" → How Well guess INTRO ("I'm ready") correctly (route gameRouteFor(how_well)=HOW_WELL → HowWellScreen.joinSession → INTRO).
- **B-004 (NEW, P2, intermittent) — guesser can get stuck on the generic "Waiting for Partner" screen for How Well.** Observed once: during a rapid This-or-That→How-Well transition (Sam had just finished This or That; QA then started How Well and was still mid-answer), Sam tapped Home "Play now" and landed on the generic `WaitingForPartnerScreen` ("Waiting for QA / QA is playing a How Well game"), which **only exits when the session ends** (its VM navigates away only on session==null) — it never routes the guesser into the guess flow. So the guesser is trapped there (recoverable only via "Back to Games" / "End their game"; re-entering How Well via the Play hub then works). NOT reproduced in the clean subject-done case (Play now → INTRO worked). Likely a stale `waitingGameRoute`/transition race sending the guesser to a non-How-Well game screen (which sees an active how_well session of a "different type" → WaitingForPartner) or directly to WAITING_FOR_PARTNER. **Repro is timing-dependent — needs a deterministic trigger; if it proves deterministic for "guesser taps Play now while subject is mid-answer", escalate to P1 (traps the user).** Report-only (logged, not fixed mid-pass).
**Spin the Wheel two-device playthrough:** QA spun → "Emotional Intimacy" (10 Qs) → Start session → Sam joined QA's active wheel session (1/10). Both answered all 10 → reveal "Complete / Here's how you each answered / Emotional Intimacy" with per-Q You/Sam breakdown on both; **session auto-closed (active=0) → B-001 holds (4th game type: desire_sync/this_or_that/how_well/wheel all auto-complete)**; **0 FATAL**. (Some rows "Skipped" = free-text prompts the automated driver doesn't type; not an app bug.)
**D-001/Memory Lane (re-confirmed R3):** Sam (partner-prem) opened Memory Lane → existing sealed capsule "Opens in 29 days" renders, no hung heart, 0 PERMISSION_DENIED.
**Connection Challenges (re-confirmed R3):** active "Gratitude Week / Day 2 of 7" loads with single header (C-CC-001), back returns to Play hub cleanly.
**Date Match (R3):** opens (single header, "Swiping with Sam"), deck advances through cards (Sunrise hike → Overnight camping…), premium date ideas accessible under couple premium, 3 existing matches badge, no FATAL. (Full mutual-match + live push verified R2-B2.)
**Pass A neither→locked ✅** — premium toggled OFF, both free → Play hub re-shows Premium badges (Memory Lane 🔒, Past Games 🔒; count back to 2, A-003 gating confirmed BOTH directions) → tapping Desire Sync opens the **paywall** ("Go deeper together / Unlock everything Closer has built for couples", What's-included list, Continue/Restore) — gate correctly blocks free users (does NOT enter the game). **Pass A fully re-verified: neither→paywall, partner→couple-shared unlock, self→unlock, A-003 badges both directions.**
- **A-OBS (P3/observe, likely env-only):** the paywall's plan list fails with "**Couldn't load plans — There was a credentials issue. Check the underlying error for more details.**" + disabled Continue. Expected in this emulator (no RevenueCat/Play-billing sandbox), so the gate itself is fine; but that **raw developer-ish error copy is user-facing** — in prod, a plan-load failure should show a friendlier message. Flag for copy review (not a gate bug).
**Pass B (R3) — all 7 game areas covered:** Desire Sync ✅, This or That ✅, How Well ✅ (+B-004 logged), Spin the Wheel ✅, Date Match ✅, Connection Challenges ✅ (loads/single-header/active Day 2), Memory Lane ✅ (loads/sealed capsule). **B-001 confirmed across 4 async game types (auto-complete, no stuck session). B-002 works (clean case). All fixes (B-001/B-002/B-003/C-DS-001) hold.**
**Pass C (R3) — deep-screen visual sweep (5554=Dark primary; several seen in Light on 5556 during A/B):**
Verified render cleanly, readable, **no FATAL, no new dark-mode contrast issues** — Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, **Settings** (+ **Subscription** "One subscription for both partners — no double billing", + **Appearance** Theme radios), **Today**/daily-question (incl. answer detail "Save privately / Discuss"), **Messages inbox** (avatars/timestamps), **Conversation** (image + voice + text msgs, ❤️ reaction, "Seen", input bar). **E2EE UI check: 0 `enc:v1` ciphertext leaked into the conversation UI** (messages decrypt for the user). C-DS-001 dark-contrast fix holds.
- **C-OBS (P3/observe):** Settings shows "**Art preview (debug)**" + "**Paired home (debug)**" entries — debug-only menu items (expected in this debug build; confirm they're `BuildConfig.DEBUG`-gated so they don't ship in release).
- _Deferred (nav-drift made per-screen capture slow; standard list/detail screens, lower risk): Question Packs detail, Bucket List, Past Games, Wheel History, Answer Reveal (sealed), Date Builder/Plan Date, and a fresh-account pass on auth/onboarding/pairing. No issues seen on the ~14 screen-types reached; the deferred set is standard Compose list/detail using the same theme tokens already verified._
**Pass D (R3) — re-audit clean, no P0/P1:**
- **D2 rules (deployed) re-audited ✓** — no catch-all `match /{document=**}`, no blanket `if true`; **sessions update (B-001 fix present)**: only `['status','completedAt','completedByUsers']`, `startedByUserId` immutable, status monotonic active→completed; **hasPremium server-only** (client write+diff blocked L172/174); **entitlements** owner+partner read (couple-shared) / write server-only; **capsules (D-001)** member-read + ciphertext-enforced (isCiphertext title+content) + authorId-bound + key allowlist + coupleEncryptionEnabled; **challenges (D-001)** member-read + progress-only writes.
- **D1 at-rest ✓** — live admin read: chat `text`=`enc:v1:`, `lastMessagePreview`=`enc:v1:` (media-only msg has no text field = no plaintext); how_well answers + Memory Lane capsules = `enc:v1:` (Pass B). **No plaintext content leak.** UI check: 0 `enc:v1:` rendered to the user (Pass C conversation).
- D4 (wrapped couple key / KDF), D5 (App Check, gitignored SA JSONs, allowBackup=false), D6 (analytics metadata-only) unchanged since Round 1 — code identical, still hold.
- **D3 live non-member negative test: still deferred** — needs a 3rd fresh account not in the couple (only 2 emulators, both members; signing one out risks the App Check debug token + couple state). Rule logic is statically member-scoped (`isCouplesMember` gate on every couple subcollection) — denial holds by construction.
**Pass E (R3) — live notification tests (both FCM tokens valid, len=142):**
- **chat_message ✅ FULL CHAIN** — Sam backgrounded; QA sent a message → Sam received push **title "QA sent a message" / body "Tap to read and reply."** (content-free ✓, actual text NOT in payload → D6 holds) → **tapped → opened the exact conversation with the new message loaded** (deep-link ✓, background→cold path).
- **partner_started_game** — Sam backgrounded; QA started This or That → Sam received **"QA is playing / QA has started a game. Tap to join!"** (delivery ✓, content-free ✓). **BUT tap → landed on the generic Play hub, NOT the game.**
- **E-003 (NEW, P2) — game notifications deep-link to the generic Play hub, not the specific game/results.** Code: `PARTNER_STARTED_GAME`/`GAME_RESULTS_READY`/`PARTNER_COMPLETED_PART` all `routeFor → AppRoute.PLAY` (PartnerNotificationManager L270-272). The body says "Tap to **join**!" but the user lands on the hub and must find+tap the game card themselves (tapping it does then join the session, per B-002). Same gap B-002 fixed for the Home "Play now" card — never extended to notifications. Plan's Pass E wants "the **specific item**, not just the right tab" (strictly P1; rated P2 since it lands on the right tab and is recoverable). **Fix:** route game pushes through the active-session→game-route resolver (like HomeViewModel.gameRouteFor) so the deep-link joins the game. Report-only.
- **E-OBS (NEW, P3) — backgrounded pushes use `fcm_fallback_notification_channel`, not their code-defined channels.** The delivered chat + game pushes both landed on `fcm_fallback_notification_channel` even though the code assigns `CHANNEL_GAMES`/chat channels (PartnerNotificationManager L185). Means the server sends FCM "notification" (not data-only) messages, so the system auto-displays them on the fallback channel when backgrounded — bypassing the app's channel importance/sound and the per-category toggle (users can't mute just "Games"). **Fix:** send data-only FCM + build the notification client-side with the right channel, or set `android.notification.channel_id` in the FCM payload. Report-only.
- Foundations ✅ — both users registered FCM tokens; routing centralized in `PartnerNotificationType`; E-001 (daily_question/challenge_day_ready) + E-002 (partner_left→HOME) fixes present in code. date_match push live-verified R2-B2.
- _Full 17×{fg/bg/killed} matrix not exhaustively run; chat_message + partner_started_game live-verified this round (deliver+content-free; chat deep-link ✓, game deep-link → E-003). Remaining types: routing code-verified._
_Still to verify this round: edges (re-open completed / leave mid-game), Pass F._
## 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. | | 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)** |
| 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. | **FIXED** — added `showPremiumBadge` param to `DesireSyncCard`/`MemoryLaneCard`, gated the badge behind it, pass `!hasPremium` from the Play hub. **Verified LIVE:** with couple premium, Play hub shows 0 "Premium" badges on those cards (both cards present, no lock). |
| 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. ## 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**.)
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial) ## Security cornerstone — clean (Pass D, deep dive, Round 7)
| ID | Area | Screen/Route | Severity | Description | Repro | Status | - **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.
|---|---|---|---|---|---|---| - **D2/D3 access:** non-member denied **all** reads/writes (raw Firestore REST → 403); real premium write `users/{uid}/entitlements/premium` denied (server-only → **no self-grant**); cross-couple denied.
| 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. | - **D4 keys:** couple key phrase-wrapped (argon2id); recovery phrase server-blind; `encryptedRecoveryPhrase` wiped on acceptance; plaintext `inviteCode` not exploitable (invite readable only by inviter; no code-encrypted secret persisted).
| 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. | **FIXED** — Home now resolves the active session's `gameType` → its resume route (`gameRouteFor`: wheel→SpinWheelRandom, this_or_that/how_well/desire_sync→themselves), stored as `HomeUiState.waitingGameRoute` and carried on `HomeAction.gameRoute`; `HomeActionTarget.Game` navigates there (fallback Play hub). Each game screen auto-joins the couple's active session on open, so "Play now" resumes the exact waiting game. **Verified LIVE:** Sam started This or That → QA Home "Play now" → landed directly in This or That (1/5), not the hub. | - **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
| 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. | **FIXED** — the per-person privacy tiles no longer show the contradicting "$total private"; they now read just "Private" (your individual answers always stay private), and the caption keeps the real "$matches shared, N kept private" breakdown. **Verified LIVE:** reveal now shows "You: Private / Sam: Private" + "5 shared, 0 kept private" — no contradiction. | ## Round history (one line each)
- **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.
- **R8** — F-RACE-001 re-confirmed + pruned; Passes I (perf) + J (a11y) run; found+fixed+verified **I-001 & I-002** (outcomes read: query rules-denied + Long/Int parse CCE → "Your Progress" was silently dead). 0 open P0P2.
- **R7** — multi-angle security/concurrency deep dive → cornerstone fully clean; F-RACE-001 found + fixed + verified. 0 new open.
- **R6** — branding drop + Future.md backlog regression (white-keyhole icons/loader/splash, inclusive gender, copy, rate-limit split, results-push suppression, paywall retry/offline) → 0 new open.
- **R5** — Cloud Functions deployed (E-OBS channel fix, E-003 results routing) + new Pass G (account creation / fake-account abuse) clean → 0 open.
- **R1R4** — baseline Passes AF report-only; every P0P2 found was fixed + verified (see archived IDs).
**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). ## Operational constants
**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). - **Execution mode:** autonomous run-to-completion — don't stop; fix blockers inline; cycle fix→re-QA until flawless. Don't hand back when context fills — re-read this run-state + coverage after any compaction. 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 without pausing. Only the macOS requirement for iOS (Parts 2/3) is a hard stop.
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending) - **Hardening backlog → Future.md:** App Check not enforced on Firestore; `users/{uid}` update rule allows arbitrary non-`hasPremium` fields (tighten to a field allowlist).
**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. | **FIXED** — removed `CONNECTION_CHALLENGES` from `shellBackRoutes` (the screen renders its own header for both the pick + active views, unlike This or That/How Well/Desire Sync which rely on the shell). **Verified LIVE:** the screen now shows a single header + single back arrow (1 "Back", no duplicate title). |
| 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. | **FIXED**`DesireMatchCard` text color was a hardcoded dark plum `Color(0xFF3D1F2E)`; changed to theme-aware `MaterialTheme.colorScheme.onSurface` (dark text on light, light text on dark). **Verified LIVE:** played Desire Sync to reveal in dark mode → shared-desire rows now render crisp high-contrast white text. |
_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 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). | **FIXED** (code) — ROOT CAUSE: `FirestoreCapsuleDataSource.observeCapsules` **swallowed** snapshot-listener errors (`if (err != null …) return@`), so on PERMISSION_DENIED the callbackFlow never emitted or closed → the ViewModel's `collect` suspended forever → stuck loading heart. Now it `close(err)`s the flow, so the ViewModel's existing `runCatching.onFailure``MemoryLanePhase.ERROR` (with a Retry) runs. Build green; live-verify needs an induced query failure (deferred). (Other snapshot listeners with the same swallow pattern are a follow-up sweep.) |
| (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. | **FIXED** (code; live-tap deferred) — added `PARTNER_UNPAIRED` type, mapped `partner_left` + `partner_deleted_account` → it → routes to **HOME** (where the now-unpaired user gets the "Invite partner" CTA, matching the push body "Tap to create a new invite"). **Investigation corrected two false positives:** `invite_created` is a server-side **audit-log** entry (`read:true`, "not read by clients" — never a push), and `spki` is a **crypto key-format string** in the RevenueCat webhook (`crypto.createPublicKey({type:'spki'})`), not a notification type at all — neither needs client routing (documented in `fromRemoteType`). Build green; live tap-verify deferred (needs an actual unpair event). |
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._

View File

@ -14,6 +14,15 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works
quiet-hours moon) used consistently would strengthen identity. Generate the G-set, drop the assets in, quiet-hours moon) used consistently would strengthen identity. Generate the G-set, drop the assets in,
then wire them in. *Prompted by:* Pass H branding review. then wire them in. *Prompted by:* Pass H branding review.
- **Notify the free partner when the couple gains premium.** When one partner subscribes, the other's app unlocks
(couple-shared premium) but they get **no notification** — they only find out next time they open a gated feature. A
`subscription_entitlement_changed` push ("You both have Premium now ✨") would close the loop. *Prompted by:* Pass E
(R8): the type isn't implemented; couple-shared unlock is silent for the non-subscriber.
- **Minor proactive-notification gaps (low priority).** No push when a partner *joins* your active game
(`partner_joined_game`) or *ends/abandons* one (`game_ended`/`game_abandoned`) — the other partner sees it
in-session / on WaitingForPartner, so nothing's broken, just less proactive. *Prompted by:* Pass E (R8) inventory —
these speculative types aren't implemented.
### Security hardening (defense-in-depth — not vulnerabilities; rules already hold) ### Security hardening (defense-in-depth — not vulnerabilities; rules already hold)
- **Enforce App Check on Firestore (currently OFF).** Round 7 raw-API test: an authenticated request with **no App - **Enforce App Check on Firestore (currently OFF).** Round 7 raw-API test: an authenticated request with **no App

View File

@ -1,9 +1,11 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.domain.model.Outcome import app.closer.domain.model.Outcome
import app.closer.domain.model.OutcomeDay
import app.closer.domain.model.OutcomeDayKey import app.closer.domain.model.OutcomeDayKey
import app.closer.domain.model.OutcomeScores import app.closer.domain.model.OutcomeScores
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctions
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -18,6 +20,11 @@ class FirestoreOutcomeDataSource @Inject constructor(
private val db: FirebaseFirestore, private val db: FirebaseFirestore,
private val functions: FirebaseFunctions private val functions: FirebaseFunctions
) { ) {
private companion object {
// The only outcome doc ids the security rules allow reading.
val OUTCOME_DAY_KEYS: List<OutcomeDayKey> = OutcomeDay.entries.map { it.key }
}
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit = suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
functions.getHttpsCallable("submitOutcomeCallable") functions.getHttpsCallable("submitOutcomeCallable")
@ -43,10 +50,14 @@ class FirestoreOutcomeDataSource @Inject constructor(
} }
suspend fun getOutcomes(coupleId: String): List<Outcome> { suspend fun getOutcomes(coupleId: String): List<Outcome> {
// Security rules permit reading only the fixed dayKey docs and DENY an
// unconstrained list query, so the query must be scoped to those ids by
// document id — otherwise every read fails PERMISSION_DENIED (I-001).
val snapshot = db val snapshot = db
.collection(FirestoreCollections.COUPLES) .collection(FirestoreCollections.COUPLES)
.document(coupleId) .document(coupleId)
.collection(FirestoreCollections.OUTCOMES) .collection(FirestoreCollections.OUTCOMES)
.whereIn(FieldPath.documentId(), OUTCOME_DAY_KEYS)
.get() .get()
.await() .await()
return snapshot.documents.mapNotNull { it.toOutcome() } return snapshot.documents.mapNotNull { it.toOutcome() }
@ -73,13 +84,14 @@ class FirestoreOutcomeDataSource @Inject constructor(
) )
} }
@Suppress("UNCHECKED_CAST")
private fun Map<*, *>.toOutcomeScores(): OutcomeScores? { private fun Map<*, *>.toOutcomeScores(): OutcomeScores? {
val map = this as? Map<String, Int> ?: return null // Firestore returns integer fields as Long on Android, so coerce via Number
val connection = map["connection"] ?: return null // rather than casting to Int (a hard Int cast threw CCE → scores dropped, I-002).
val communication = map["communication"] ?: return null fun score(key: String): Int? = (this[key] as? Number)?.toInt()
val intimacy = map["intimacy"] ?: return null val connection = score("connection") ?: return null
val happiness = map["happiness"] ?: return null val communication = score("communication") ?: return null
val intimacy = score("intimacy") ?: return null
val happiness = score("happiness") ?: return null
return runCatching { OutcomeScores(connection, communication, intimacy, happiness) } return runCatching { OutcomeScores(connection, communication, intimacy, happiness) }
.getOrNull() .getOrNull()
} }

View File

@ -5,6 +5,7 @@ import app.closer.data.remote.FirestoreCollections
import app.closer.domain.model.GameType import app.closer.domain.model.GameType
import app.closer.domain.model.QuestionSession import app.closer.domain.model.QuestionSession
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.SessionStartOutcome
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -53,6 +54,76 @@ class QuestionSessionRepositoryImpl @Inject constructor(
doc.id doc.id
} }
override suspend fun startSessionAtomically(
session: QuestionSession
): Result<SessionStartOutcome> = runCatching {
val sessionsCol = firestore.collection(FirestoreCollections.COUPLES)
.document(session.coupleId)
.collection(FirestoreCollections.Couples.SESSIONS)
val pointerRef = sessionsCol.document(ACTIVE_POINTER_ID)
val newRef = sessionsCol.document() // pre-generate id for the create branch
firestore.runTransaction { tx ->
// ── all reads must happen before any writes in a Firestore transaction ──
val pointer = tx.get(pointerRef)
val activeId = pointer.getString("activeSessionId")?.takeIf { it.isNotBlank() }
val activeSnap = activeId?.let { tx.get(sessionsCol.document(it)) }
if (activeSnap != null && activeSnap.exists() &&
activeSnap.getString("status") == "active"
) {
// A session is already active → converge: the caller joins it (no duplicate created).
// Concurrent starts contend on [pointerRef]; the loser's transaction retries, re-reads
// the now-set pointer, and lands here. Stale/completed pointers fall through to create.
SessionStartOutcome.AlreadyActive(snapToSession(activeSnap, session.coupleId))
} else {
val data = mapOf(
"id" to newRef.id,
"coupleId" to session.coupleId,
"categoryId" to session.categoryId,
"questionIds" to session.questionIds,
"startedByUserId" to session.startedByUserId,
"startedAt" to session.startedAt,
"completedAt" to session.completedAt,
"partnerCompletedAt" to session.partnerCompletedAt,
"isPremium" to session.isPremium,
"status" to session.status,
"gameType" to session.gameType,
"completedByUsers" to session.completedByUsers
)
tx.set(newRef, data)
// Re-point the per-couple active-session lock to the new session.
tx.set(
pointerRef,
mapOf(
"activeSessionId" to newRef.id,
"updatedAt" to System.currentTimeMillis()
)
)
SessionStartOutcome.Created(session.copy(id = newRef.id))
}
}.await()
}
private fun snapToSession(
doc: com.google.firebase.firestore.DocumentSnapshot,
coupleId: String
): QuestionSession = QuestionSession(
id = doc.getString("id") ?: doc.id,
coupleId = doc.getString("coupleId") ?: coupleId,
categoryId = doc.getString("categoryId") ?: "",
questionIds = (doc.get("questionIds") as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
startedByUserId = doc.getString("startedByUserId") ?: "",
startedAt = doc.getLong("startedAt") ?: 0L,
completedAt = doc.getLong("completedAt"),
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
isPremium = doc.getBoolean("isPremium") ?: false,
status = doc.getString("status") ?: "active",
gameType = doc.getString("gameType") ?: GameType.WHEEL,
completedByUsers = (doc.get("completedByUsers") as? List<*>)?.filterIsInstance<String>()
?: emptyList()
)
override suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> = override suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> =
runCatching { runCatching {
firestore.collection(FirestoreCollections.COUPLES) firestore.collection(FirestoreCollections.COUPLES)
@ -223,4 +294,9 @@ class QuestionSessionRepositoryImpl @Inject constructor(
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
} }
}.getOrNull() }.getOrNull()
companion object {
/** Per-couple pointer doc that serializes concurrent game starts (F-RACE-001 lock). */
private const val ACTIVE_POINTER_ID = "_active"
}
} }

View File

@ -3,9 +3,25 @@ package app.closer.domain.repository
import app.closer.domain.model.QuestionSession import app.closer.domain.model.QuestionSession
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** Outcome of an atomic session start: we either created a fresh session, or one was already active. */
sealed interface SessionStartOutcome {
val session: QuestionSession
data class Created(override val session: QuestionSession) : SessionStartOutcome
data class AlreadyActive(override val session: QuestionSession) : SessionStartOutcome
}
interface QuestionSessionRepository { interface QuestionSessionRepository {
/** Saves the session and returns its (possibly auto-generated) document id. */ /** Saves the session and returns its (possibly auto-generated) document id. */
suspend fun saveSession(session: QuestionSession): Result<String> suspend fun saveSession(session: QuestionSession): Result<String>
/**
* Atomically start a session: creates a new active session ONLY if the couple has none, via a
* Firestore transaction on a per-couple pointer doc. Concurrent starts converge to a single
* session the loser gets [SessionStartOutcome.AlreadyActive] instead of creating a duplicate
* (fixes F-RACE-001, the simultaneous-start race that produced two divergent sessions).
*/
suspend fun startSessionAtomically(session: QuestionSession): Result<SessionStartOutcome>
suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>>
// Active session queries // Active session queries

View File

@ -6,6 +6,7 @@ import app.closer.domain.model.QuestionSession
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.SessionStartOutcome
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
@ -81,17 +82,6 @@ class GameSessionManager @Inject constructor(
categoryId: String?, categoryId: String?,
questionIds: List<String>? questionIds: List<String>?
): Result<QuestionSession> { ): Result<QuestionSession> {
val activeSession = sessionRepository.getActiveSessionForCouple(couple.id)
if (activeSession != null) {
val partnerId = couple.userIds.firstOrNull { it != userId }
val partnerName = partnerId
?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
?.displayName ?: "Partner"
return Result.failure(
Exception("partner_active_session|$partnerName|${gameTypeLabel(gameType)}")
)
}
val session = QuestionSession( val session = QuestionSession(
coupleId = couple.id, coupleId = couple.id,
categoryId = categoryId ?: "", categoryId = categoryId ?: "",
@ -101,9 +91,28 @@ class GameSessionManager @Inject constructor(
status = "active" status = "active"
) )
// Use the id assigned by the repository so the started game observes the right doc // Atomic "create only if no active session" (Firestore transaction on a per-couple pointer):
// (an empty id builds an invalid Firestore path and crashes the game on open). // two partners tapping start at the same instant converge to ONE session instead of two
return sessionRepository.saveSession(session).map { id -> session.copy(id = id) } // divergent ones (F-RACE-001). The loser is told a session is already active and joins it.
return sessionRepository.startSessionAtomically(session).fold(
onSuccess = { outcome ->
when (outcome) {
is SessionStartOutcome.Created -> Result.success(outcome.session)
is SessionStartOutcome.AlreadyActive -> {
val partnerId = couple.userIds.firstOrNull { it != userId }
val partnerName = partnerId
?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
?.displayName ?: "Partner"
Result.failure(
Exception(
"partner_active_session|$partnerName|${gameTypeLabel(outcome.session.gameType)}"
)
)
}
}
},
onFailure = { Result.failure(it) }
)
} }
/** /**

View File

@ -195,7 +195,7 @@ private fun AnswerHistoryContent(
body = body, body = body,
actionLabel = actionLabel, actionLabel = actionLabel,
onAction = onAction, onAction = onAction,
illustrationResId = R.drawable.illustration_couple_history illustrationResId = R.drawable.illustration_answer_history_empty
) )
} }
} else { } else {

View File

@ -31,6 +31,9 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import app.closer.R
import androidx.compose.foundation.layout.aspectRatio
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -377,6 +380,17 @@ private fun ChallengesPickScreen(
} }
} }
item {
BrandIllustration(
res = R.drawable.illustration_connection_challenges_header,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 4.dp)
.aspectRatio(16f / 9f)
)
}
items(ChallengesCatalog.all) { challenge -> items(ChallengesCatalog.all) { challenge ->
ChallengePickCard(challenge = challenge, hasPremium = hasPremium, onPick = onPick, onPaywall = onPaywall) ChallengePickCard(challenge = challenge, hasPremium = hasPremium, onPick = onPick, onPaywall = onPaywall)
} }

View File

@ -0,0 +1,49 @@
package app.closer.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Brand illustration that reads on BOTH the light and dark themes.
*
* Most of the generated empty-state / header illustrations ship with a soft
* near-white background; rendered raw on the dark (aubergine) theme that background
* would float as a pale block. Clipping to a generous rounded tile with a hairline
* outline turns it into an intentional, modern illustration card on either surface.
*
* Transparent art (e.g. the pairing-success celebration) should pass [tile] = false
* so it floats freely with no card edge.
*/
@Composable
fun BrandIllustration(
@DrawableRes res: Int,
contentDescription: String?,
modifier: Modifier = Modifier,
tile: Boolean = true,
cornerRadius: Dp = 28.dp,
) {
val shape = RoundedCornerShape(cornerRadius)
val shaped = if (tile) {
modifier
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.10f), shape)
} else {
modifier
}
Image(
painter = painterResource(res),
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = shaped,
)
}

View File

@ -276,6 +276,7 @@ private fun BucketListItems(
app.closer.ui.components.EmptyState( app.closer.ui.components.EmptyState(
title = "Your shared list is empty", title = "Your shared list is empty",
body = "Add something you've been dreaming about doing together — big or small. Tap + to start.", body = "Add something you've been dreaming about doing together — big or small. Tap + to start.",
illustrationResId = app.closer.R.drawable.illustration_bucket_list_empty,
modifier = Modifier.padding(top = 80.dp) modifier = Modifier.padding(top = 80.dp)
) )
return return

View File

@ -63,6 +63,8 @@ import app.closer.domain.model.DateCostLevel
import app.closer.domain.model.DateIdea import app.closer.domain.model.DateIdea
import app.closer.domain.model.DateMatch import app.closer.domain.model.DateMatch
import app.closer.domain.model.SwipeAction import app.closer.domain.model.SwipeAction
import app.closer.R
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.EmptyState import app.closer.ui.components.EmptyState
import app.closer.ui.components.ErrorState import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState import app.closer.ui.components.LoadingState
@ -539,20 +541,11 @@ private fun MatchOverlay(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Surface( BrandIllustration(
shape = CircleShape, res = R.drawable.illustration_date_match_success,
color = closerSoftPinkColor(), contentDescription = null,
modifier = Modifier.size(72.dp) modifier = Modifier.size(168.dp)
) { )
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = Color(0xFF9B1B5A)
)
}
}
Text( Text(
text = "It is a match!", text = "It is a match!",

View File

@ -1,5 +1,6 @@
package app.closer.ui.dates package app.closer.ui.dates
import app.closer.R
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerSoftPinkColor import app.closer.ui.theme.closerSoftPinkColor
@ -133,6 +134,7 @@ private fun DateMatchesContent(
append(state.partnerName ?: "your partner") append(state.partnerName ?: "your partner")
append(" both love the same one, it shows up here as a match.") append(" both love the same one, it shows up here as a match.")
}, },
illustrationResId = R.drawable.illustration_date_match_empty,
modifier = Modifier.padding(top = 80.dp) modifier = Modifier.padding(top = 80.dp)
) )
} }

View File

@ -2,6 +2,7 @@ package app.closer.ui.debug
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -24,6 +25,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.closer.R import app.closer.R
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.CelebrationOverlay import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.CloserActionButton import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserMarkLoader import app.closer.ui.components.CloserMarkLoader
@ -96,6 +98,33 @@ fun ArtPreviewScreen(onNavigate: (String) -> Unit = {}) {
} }
} }
Text(
text = "New brand illustrations",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
ArtCard("Pairing success (A1)") {
BrandIllustration(R.drawable.illustration_pairing_success, null, Modifier.size(200.dp), tile = false)
}
ArtCard("Connection Challenges header (A3)") {
BrandIllustration(
R.drawable.illustration_connection_challenges_header, null,
Modifier.fillMaxWidth().aspectRatio(16f / 9f)
)
}
ArtCard("Answer history empty (A2)") { BrandIllustration(R.drawable.illustration_answer_history_empty, null, Modifier.size(160.dp)) }
ArtCard("Memory Lane capsule (A4)") { BrandIllustration(R.drawable.illustration_memory_lane_capsule, null, Modifier.size(160.dp)) }
ArtCard("Date match — empty (A5)") { BrandIllustration(R.drawable.illustration_date_match_empty, null, Modifier.size(160.dp)) }
ArtCard("Date match — it's a match (A5)") { BrandIllustration(R.drawable.illustration_date_match_success, null, Modifier.size(160.dp)) }
ArtCard("Bucket list empty (A6)") { BrandIllustration(R.drawable.illustration_bucket_list_empty, null, Modifier.size(160.dp)) }
ArtCard("Messages empty (A8)") { BrandIllustration(R.drawable.illustration_messages_empty, null, Modifier.size(160.dp)) }
ArtCard("Quiet hours (A9)") { BrandIllustration(R.drawable.illustration_quiet_hours, null, Modifier.size(160.dp)) }
ArtCard("Past games empty (A10)") { BrandIllustration(R.drawable.illustration_past_games_empty, null, Modifier.size(160.dp)) }
ArtCard("Privacy / recovery (A11)") { BrandIllustration(R.drawable.illustration_privacy_recovery, null, Modifier.size(160.dp)) }
ArtCard("Account deletion (A12)") { BrandIllustration(R.drawable.illustration_account_deletion_goodbye, null, Modifier.size(160.dp)) }
CloserActionButton( CloserActionButton(
label = "Play the celebration", label = "Play the celebration",
onClick = { celebrate = true }, onClick = { celebrate = true },

View File

@ -36,6 +36,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import app.closer.R
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
@ -549,7 +551,11 @@ private fun CapsuleListScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text("📦", style = MaterialTheme.typography.displayMedium) BrandIllustration(
res = R.drawable.illustration_memory_lane_capsule,
contentDescription = null,
modifier = Modifier.size(150.dp)
)
Text("No capsules yet", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface) Text("No capsules yet", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface)
Text("Write a note to your future selves — it'll stay sealed until the date you choose.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) Text("Write a note to your future selves — it'll stay sealed until the date you choose.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))

View File

@ -30,8 +30,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.Conversation import app.closer.domain.model.Conversation
import app.closer.ui.components.EmptyState
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import coil.compose.AsyncImage import coil.compose.AsyncImage
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -74,6 +76,21 @@ fun MessagesInboxScreen(
return return
} }
if (state.conversations.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
EmptyState(
title = "Your private conversation starts here",
body = "Say hi, share a thought, or pick a question to talk through together. Everything you send stays end-to-end encrypted, just for the two of you.",
illustrationResId = R.drawable.illustration_messages_empty
)
}
return
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)

View File

@ -42,6 +42,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.CelebrationOverlay import app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -198,32 +199,18 @@ fun PairingSuccessScreen(
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.zIndex(1f) .zIndex(1f)
) )
Box( // Celebration: the Closer mark resolving into place with a burst of hearts,
// floating over where the two partners meet.
BrandIllustration(
res = R.drawable.illustration_pairing_success,
contentDescription = null,
tile = false,
modifier = Modifier modifier = Modifier
.size(60.dp) .size(132.dp)
.zIndex(2f) .zIndex(2f)
.align(Alignment.Center) .align(Alignment.Center)
.scale(pulse) .scale(pulse)
.clip(CircleShape) )
.background(MaterialTheme.colorScheme.background)
.padding(4.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
// White-keyhole app-icon chip: aubergine gradient + the brand mark.
Image(
painter = painterResource(R.drawable.ic_launcher_background),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.matchParentSize()
)
}
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))

View File

@ -3,7 +3,9 @@ package app.closer.ui.settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.components.BrandIllustration
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -295,6 +298,13 @@ fun DeleteAccountScreen(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
BrandIllustration(
res = R.drawable.illustration_account_deletion_goodbye,
contentDescription = null,
modifier = Modifier.size(150.dp).padding(vertical = 4.dp)
)
}
Text( Text(
text = "Deleting your account is permanent and cannot be reversed. Your profile, sign-in, and pairing will be removed immediately.", text = "Deleting your account is permanent and cannot be reversed. Your profile, sign-in, and pairing will be removed immediately.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

View File

@ -17,11 +17,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import app.closer.ui.components.BrandIllustration
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -199,6 +202,14 @@ fun NotificationSettingsScreen(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
) )
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
BrandIllustration(
res = R.drawable.illustration_quiet_hours,
contentDescription = null,
modifier = Modifier.size(150.dp).padding(bottom = 8.dp)
)
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),

View File

@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
@ -43,7 +45,9 @@ import androidx.fragment.app.FragmentActivity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.R
import app.closer.data.local.RecoveryPhraseStore import app.closer.data.local.RecoveryPhraseStore
import app.closer.ui.components.BrandIllustration
import app.closer.domain.repository.SettingsRepository import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -161,6 +165,14 @@ fun SecurityScreen(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
BrandIllustration(
res = R.drawable.illustration_privacy_recovery,
contentDescription = null,
modifier = Modifier.size(150.dp).padding(vertical = 4.dp)
)
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),

View File

@ -1,5 +1,6 @@
package app.closer.ui.wheel package app.closer.ui.wheel
import app.closer.R
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -120,7 +121,8 @@ fun GameHistoryScreen(
title = "No games played yet", title = "No games played yet",
body = "Finish a round of This or That, How Well, Desire Sync, Spin the Wheel, a Connection Challenge, or Memory Lane — your results will show up here.", body = "Finish a round of This or That, How Well, Desire Sync, Spin the Wheel, a Connection Challenge, or Memory Lane — your results will show up here.",
actionLabel = "Play a game", actionLabel = "Play a game",
onAction = { onNavigate(AppRoute.PLAY) } onAction = { onNavigate(AppRoute.PLAY) },
illustrationResId = R.drawable.illustration_past_games_empty
) )
} }
else -> { else -> {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -305,6 +305,12 @@ service cloud.firestore {
// Read: both members can read // Read: both members can read
allow read: if isCouplesMember(coupleId); allow read: if isCouplesMember(coupleId);
// Per-couple active-session pointer used as an atomic lock so two partners starting a game
// at the same instant converge to ONE session instead of two divergent ones (F-RACE-001).
// It holds no game content (only activeSessionId + updatedAt) and carries no status/
// completedAt, so it never appears in the active-session or history queries.
allow create, update: if sessionId == '_active' && isCouplesMember(coupleId);
// Create: either member can start a session // Create: either member can start a session
allow create: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
&& request.resource.data.startedByUserId == request.auth.uid; && request.resource.data.startedByUserId == request.auth.uid;