diff --git a/.gitignore b/.gitignore index dbcb44a7..16361a02 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ iphone/.build/ iphone/Package.resolved # Private project docs (agent-only, never commit) +Claude*.md FUTURE.md HISTORY.md PROJECT.md @@ -93,5 +94,6 @@ ClaudeReport.md relationship-app.code-workspace # QA fixtures with test credentials — local only, never commit +qa/README.md qa/fixtures.local.md qa/*.local.md diff --git a/ClaudeBrandingReview.md b/ClaudeBrandingReview.md deleted file mode 100644 index 6b40209a..00000000 --- a/ClaudeBrandingReview.md +++ /dev/null @@ -1,335 +0,0 @@ -# Closer — Branding & Artwork Review (Pass H) - -Living status document for Closer's brand artwork pass. It records which illustrations/glyphs are live, which surfaces -should reuse existing assets, and which brand issues belong in implementation or QA rather than image generation. - -**Asset ownership:** Codex is responsible for making all needed images, dark variants, and custom glyphs. Do not leave -image work as user-generated prompt handoff; backlog items here are Codex-owned assets to generate, add to the repo, and -verify. - -**Current state (2026-06-28 re-audit against the code/resources — supersedes the 2026-06-27 audit):** -- **Backlog 1 (dark illustration variants) — essentially DONE.** `drawable-night-nodpi/` now holds a dark variant for - **every** non-transparent surface: all 22 `illustration_*` (incl. all `illustration_couple_*` heroes, `daily_question`, - `partner_activation`, `tonight_partner_prompt`, `together_empty`) **and all 10 `pack_art_*` banners**. Only **3** - transparent celebration assets have no dark variant — `illustration_premium_unlock`, `illustration_spin_wheel`, - `illustration_streak_milestone` — which rule 1 exempts *if* they read on both themes (verify, don't assume). Decoupled - in-app-Dark + system-Light now renders the dark art correctly app-wide after the **C-DARKART-002** fix (see below), so - the "light/pink art on a dark screen" defect class is closed for these surfaces. -- **Backlog 2 (custom glyphs) — ✅ DONE (2026-06-28).** Every generic Material icon has been replaced with a Closer - glyph: **0 `Icons.*` call sites and 0 `androidx.compose.material.icons` imports remain** (was 187 across ~49 distinct - icons). Wiring goes through a central `ui/components/CloserGlyphs.kt` accessor object (`@Composable` getter vals → - `ImageVector.vectorResource(R.drawable.glyph_*)`), used by 53 files; `CategoryGlyph.kt` resolves category glyphs the - same way. Build + 205 unit tests green; verified live both themes (back, check, close, lock, chat, heart, photo, - camera, mic, category badges all render with correct tint). **BRAND-ICON-CUSTOM is resolved.** -- **C-DARKART-002 (theming mechanism, fixed 2026-06-28):** `MainActivity` now drives `AppCompatDelegate.setDefaultNightMode` - from the in-app `ThemeMode`, so the real Configuration `uiMode` follows the app theme and **all** art resolution — - `painterResource` AND `BrandIllustration`, `drawable-night*` included — follows the in-app theme. This supersedes the - old "only `BrandIllustration` follows the theme" caveat. -- Done earlier: This-or-That redesign (C-DARK-UI-001) + Premium-unlock modal (A13). - -> Branding **defects** (off-brand color, clipped/low-contrast art, a **light image on a dark screen**, a **generic -> Material icon**) → `ClaudeReport.md`. Pure "could be warmer / feature" ideas → `Future.md` `## QA`. **New art/glyphs to -> create** (dark variants, custom glyphs) → logged HERE as Codex-owned assets to make and verify. - ---- - -## Brand standards — MUST hold every QA round (Pass C visual + Pass H branding own these) -Two non-negotiable brand rules. A violation of either is a **bug** (`ClaudeReport.md`) **and** the asset to create is -logged here as Codex-owned image/glyph work: -1. **Every image has a LIGHT and a DARK variant that matches the IN-APP theme.** No light/pink art on a dark screen, no - dark art on a light screen. `drawable-nodpi/` = light, `drawable-night-nodpi/` = dark. Since **C-DARKART-002** - (2026-06-28) the dark variant is auto-selected by the real Configuration `uiMode`, which `MainActivity` syncs to the - in-app `ThemeMode` via `AppCompatDelegate.setDefaultNightMode` — so **both** `painterResource` and `BrandIllustration` - resolve `-night` correctly even in the decoupled in-app-Dark + system-Light state. Transparent/celebration art that - genuinely reads on **both** themes is the only exemption — **verify it, don't assume.** A surface missing its variant = bug. -2. **Every icon/glyph is a custom Closer glyph — NO generic Material icons, no generic hearts.** Material `Icons.*` - (ArrowBack, Favorite, Person, Lock, Star, PlayArrow, …) are placeholders, not brand. Each in use is replaced by a - bespoke glyph in the Closer house style, logged in the icon backlog below for Codex to make + add (`glyph_*`). - -**Image style lock:** all generated imagery must match the existing shipped flat 2D vector assets, especially -`illustration_couple_paywall`, `illustration_couple_subscription`, `illustration_couple_onboarding`, -`illustration_daily_question`, and the `pack_art_*` banners. Keep shapes simple and graphic, with pastel fills, minimal -facial/detail rendering, clean vector-like edges, and gentle gradients only. **Reject** painterly/storybook rendering, -3D-ish lighting, realistic texture, detailed hair/skin shading, dramatic cinematic glow, hard rectangular backdrops, or -anything that looks materially richer than the existing asset family. For any new batch, generate and review **one sample -first** against the existing contact sheet before saving more assets. - -## Image theme-variant coverage (light + dark per surface) — ✅ essentially COMPLETE -> Re-verified against `res/` on 2026-06-28. Every non-transparent surface now has BOTH a light (`drawable-nodpi/`) and a -> dark (`drawable-night-nodpi/`) asset with the identical filename. - -| Illustration | Light | Dark | Status | -|---|---|---|---| -| **All 22 `illustration_*`** — account_deletion_goodbye · answer_history_empty · bucket_list_empty · connection_challenges_header · couple_history · couple_invite · couple_onboarding · couple_paywall · couple_subscription · daily_question · date_match_empty · date_match_success · memory_lane_capsule · messages_empty · pairing_success · partner_activation · privacy_recovery · quiet_hours · reveal_celebration · together_empty · tonight_partner_prompt | ✅ | ✅ | **both exist** — dark variants added since the 2026-06-27 audit | -| **All 10 `pack_art_*`** (communication, deep_reflection, desire, family_commitment, fun_date, future_goals, home_life, intimacy, money_values, trust_repair) | ✅ | ✅ | **both exist**; pack banners verified live dark (C-DARKART-002 round) | -| illustration_premium_unlock · illustration_spin_wheel · illustration_streak_milestone | ✅ (transparent) | — | transparent/celebration — **exempt per rule 1 IF they read on both themes; verify** (only remaining "no dark" assets) | - -**Remaining (small):** spot-check the freshly-added dark variants in Pass C (light + dark) for contrast/clipping — they -exist + resolve correctly via the C-DARKART-002 uiMode-sync (pack art + Today hero verified live across all 4 theme/art -states); the rest resolve by the same mechanism but haven't each been eyeballed. Then verify the 3 transparent celebration -assets read on dark; add a dark variant only if one doesn't. **Orphan:** `illustration_couple_history` has both -light+dark variants but **no code reference at all** (superseded by `illustration_answer_history_empty`) — dead asset, -removal candidate. **Debug-only now:** `illustration_quiet_hours` was removed from the Notification-settings UI -(2026-06-28, unprofessional mid-list art) and is now referenced **only** in the debug `ArtPreviewScreen` gallery. - -> **⛔ CLAUDE — decoupled-theme art now works app-wide** (C-DARKART-002): raw `painterResource(R.drawable.illustration_*`/ -> `pack_art_*)` follows the in-app theme via the Configuration `uiMode` sync, so it no longer *needs* `BrandIllustration` -> to get the right `-night` variant. `scripts/painter-xml-scan.sh` (crash guard) + Pass C's decoupled check (system-light + -> in-app-Dark) remain the per-round guards. - -## Icon/glyph audit — ✅ COMPLETE (every Material icon is now a Closer glyph) -> Done 2026-06-28: all 187 `Icons.*` call sites (~49 distinct icons) were swapped to `glyph_*` via the central -> `CloserGlyphs` accessor; **0 `Icons.*` and 0 `androidx.compose.material.icons` imports remain.** The table below is kept -> for the historical mapping (Material icon → glyph) and as the reference for any *new* icon added later: never introduce a -> raw `Icons.*` — add a `CloserGlyphs` accessor + `glyph_*.xml` instead. A `scripts/` guard that fails on any `Icons.` -> call site would keep this at zero (recommended). - -| Generic icon (≈remaining uses) | Used for | Wire to `glyph_*` (✅ asset exists) | -|---|---|---| -| `ArrowBack` (31) | every top-bar back | `glyph_back` (brand chevron) | -| `Favorite` / `FavoriteBorder` (17/5) | **generic hearts** — likes, love, daily | `glyph_heart` (the Closer two-equal-halves heart, filled + outline) | -| `Lock` / `LockOpen` (18/2) | premium-locked, security | `glyph_lock` / `glyph_lock_open` (keyhole motif from the mark) | -| `Person` / `People` (16/1) | avatars/partner fallback | `glyph_person` / `glyph_couple` | -| `Check` / `Done` / `CheckCircle` (11/1/1) | confirm/selected/sent | `glyph_check` | -| `ArrowForward` / `ArrowForwardIos` (10/4) | row chevrons, next | `glyph_forward` | -| `PlayArrow` (8) | Play tab / start | `glyph_play` | -| `Close` (7) | dismiss/close | `glyph_close` | -| `Star` (6) | premium/★ ideas | `glyph_star` (brand sparkle) | -| `Visibility` / `VisibilityOff` (5/2) | password reveal | `glyph_eye` / `glyph_eye_off` | -| `ContentCopy` (4) | copy invite code | `glyph_copy` | -| `Sync` (3) · `Image`/`PhotoLibrary`/`PhotoCamera`/`AddAPhoto` (3/2/2/2) · `Send` (3) · `Chat` (3) · `Delete` (3) | retry · media pickers · send · messages · delete | `glyph_sync` · `glyph_photo`/`glyph_camera` · `glyph_send` · `glyph_chat` · `glyph_trash` | -| `Home` (2) · `Settings` (1) · `Notifications`/`NotificationsNone` (2) | bottom nav + settings | `glyph_home` · `glyph_settings` · `glyph_bell` | -| `LocalFireDepartment` (2) | **streak flame** (generic) | `glyph_streak` (brand flame/spark) | -| `Mic`/`Pause` · `Timeline`/`TrendingUp`/`Psychology` · `Fingerprint`/`Key`/`Shield` · `Edit`/`Add`/`Share`/`Refresh`/`Warning`/`CalendarToday`/`Cake`/`CardGiftcard`/`AttachMoney`/`HourglassEmpty`/`QuestionAnswer`/`Palette`/`OpenInNew` (1 each) | voice · progress · security · misc | one bespoke `glyph_*` each, in house style | - -Replace each `Icons.*` call site with `ImageVector.vectorResource(R.drawable.glyph_*)` + `Icon(tint=…)` (the existing -wiring pattern — see any wired glyph for the idiom). The glyphs now all exist, so a remaining `Icons.*` call site is an -**unfinished wiring = a logged brand defect**, not acceptable for ship. A `scripts/` scanner that greps `Icons\.` call -sites (excluding files that already wire the matching glyph) would make this measurable per round — currently ~196 remain. - ---- - -## R10 brand walk (2026-06-26) — existing art integration clean, 0 defects -R10 visual sweep doubled as the Pass-H existing-art integration check: Today (paired-books daily-question art), -Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render -on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md` -bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient -+ PlayArrow glyph and read as intentional action banners, so no illustration is warranted there. - -## This or That gameplay brand plan (Codex QA, 2026-06-27) — ✅ IMPLEMENTED + verified live R13 (2026-06-27) -> **Done (R13, working tree).** `ThisOrThatScreen.kt` `ChoicePromptBackdrop` replaced: the two-circle + diagonal-line -> "diagram" is gone — now a soft theme-aware glow + two faint paired-card silhouettes low in the frame, never crossing -> the prompt. `OptionCard` A/B map to `colorScheme.primary`/`secondary` with high-contrast `onSurface` body text, visible -> accent borders, and a rich filled selected state (`onPrimary`/`onSecondary`). `VersusBadge`, progress bar, the -> `N/total` + "This or That" pills, the mood number-circle, and `TotLengthChips` are all theme-aware (no fixed -> `CloserPalette` darks). Light/dark previews added. Verified live both themes (5554 dark / 5556 light), 0 FATAL — closes -> C-DARK-UI-001. Results screen left as-is (verified clean in R12 dark). The plan below is retained for history. - -Live review used a dedicated QA launcher/device (`CloserCodexQA`) with a fresh admin-created test couple -(`codex-this-or-that-*` / `codex-partner-*`). Screenshots checked the mood picker and active gameplay in light and dark -mode. The visual issue is real: light-mode option buttons are directionally good, but dark mode makes the current prompt -backdrop feel like an accidental placeholder. - -**Verdict:** The current `ChoicePromptBackdrop` two-circle + diagonal-line drawing should be treated as a branding defect -for active gameplay. In dark mode the diagonal line cuts through the question, the circles turn muddy, and the whole -motif reads like a technical diagram instead of a warm private ritual for two. - -**Plan** -- Replace the two circles + line with a Closer-native "private choice" motif: two soft sealed answer cards, paired-card - silhouettes, or a subtle C-heart/keyhole accent. Keep it decorative and low-contrast behind the prompt; no line should - cross the question text. -- Keep the light-mode option card shape, spacing, and button feel. Make the colors theme-aware rather than reusing fixed - purple/pink values in both themes. -- Dark-mode option cards need stronger contrast: readable body text, visible borders, and selected states that feel rich - instead of dim. Disabled/other-selected states should still be legible. -- Keep `closerBackgroundBrush()` as the foundation, but consider one very subtle game-local glow or paired-card vignette - in brand colors. The background should support focus, not compete with the question or options. -- Re-check the mood picker after gameplay is fixed. The numbered mood circles are acceptable, but paired-card glyphs or - warmer step badges would feel more branded than plain numbered bubbles. -- Results can reuse `illustration_reveal_celebration` and existing heart/petal particles for high-match moments; the game - surface itself should stay code-native rather than needing a full raster illustration. - -**Implementation map** -- Update `app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt`, especially `ChoicePromptBackdrop`, - `OptionCard`, and `VersusBadge`. -- Add local theme-aware helpers for This-or-That option A/B colors, selected colors, disabled colors, border colors, and - prompt-backdrop alphas. Validate against both `MaterialTheme.colorScheme.background` and `surface`. -- Prefer Compose `Canvas`/shape drawing for the prompt motif. Generate raster art only if the code-native motif cannot - carry the brand; if generated, use transparent PNG art with no readable text. -- Add or update light/dark Compose previews for mood selection, active prompt, selected answer, waiting/disabled, and - results. - -**Acceptance checks** -- Light and dark screenshots show no decorative element crossing or competing with the prompt text. -- Long prompts, long options, selected, unselected, and disabled states stay readable. -- The light-mode cards retain the current friendly button feel. -- Dark mode feels intentionally Closer-branded: aubergine/lavender/pink, soft and private, not a placeholder diagram. -- Logs are checked after opening a game from notification/deep link before assuming the route worked. - -## Generated Art 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~~ **REMOVED 2026-06-28** (out-of-place mid-list illustration → cleaner standalone toggle); asset now in debug Art-preview gallery only | n/a | -| 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** | -| A13 `premium_unlock` (transparent) | **✅ Wired R13** — one-time Premium unlock modal (`PremiumUnlockOverlay`) | **live both themes** | - -All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification. A7 pack art is -**N/A** because all 10 question packs already have `pack_art_*`. - -A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data were -not all reachable on the baseline couple, but their render path is proven through the shared tile + gallery. - -**Premium unlock modal — ✅ IMPLEMENTED + verified live R13 (2026-06-27).** `ui/components/PremiumUnlockOverlay.kt` -(`PremiumUnlockViewModel` + `PremiumUnlockOverlay`), hosted at the `AppNavigation` root next to `MessageBubbleOverlay`, -so it surfaces over any screen. It's driven off `CouplePremiumChecker.isPremium()` (which already OR-combines both -partners) — so it fires for **both** the purchaser (own entitlement active) **and** the partner (couple-shared Premium -turns on), without depending on the push route. One-time per activation via a new persisted `premiumUnlockCelebrated` -flag on `SettingsRepository`/`SettingsDataStore` (set on dismiss; auto-reset when Premium lapses so a re-activation -celebrates again — mirrors `lastCelebratedStreakMilestone`). The modal shows `illustration_premium_unlock` (transparent, -floats via `BrandIllustration(tile=false)`) + "Premium unlocked ✨" + "Start exploring". **Verified live:** admin toggle -QA premium ON → modal on BOTH 5554 (dark) and 5556 (light); dismiss → relaunch → no re-show (gate holds); 0 FATAL. - -## Glyph Status - -**~63 `glyph_*.xml` now exist** in `app/src/main/res/drawable-nodpi/` — the original G/G2 set PLUS the full Material-icon -replacement set (back, heart, heart_outline, lock, lock_open, person, couple, check, forward, play, close, star, eye, -eye_off, copy, sync, photo, camera, send, chat, trash, home, settings, bell, streak, mic, pause, timeline, trending_up, -psychology, fingerprint, key, shield, edit, add, share, refresh, warning, calendar, cake, card_giftcard, attach_money, -hourglass, question_answer, palette, open_in_new, …). Source SVGs: `docs/brand/generated-art/glyphs/source-svg/`; -Android vectors: `docs/brand/generated-art/glyphs/android-vector/`. - -**Wired in code (23 distinct, verified 2026-06-28):** `paired_cards`, `how_well`, `sealed_answer`, -`connection_challenge`, `memory_capsule`, `date_card_heart`, `question_packs`, `bucket_list`, `past_games`, `spin_wheel` -(Play-hub cards + WaitingForPartner); `couple_premium`, `privacy_lock`, `delete_account`, `settings` (Settings); plus -`couple`, `daily_card`, `forward`, `home`, `lock`, `play`, `streak`, `chat`, `closer_heart_keyhole`. - -**Created but NOT yet wired (~40):** every glyph in the icon-audit table whose Material equivalent still appears in code — -`back` (vs ArrowBack ×31), `heart`/`heart_outline` (vs Favorite ×12/×5), `person` (vs Person ×16), `check` (×11), -`close` (×7), `star` (×6), `eye`/`eye_off` (×5/×2), `copy` (×4), `send` (×3), `sync` (×3), `photo`/`camera`, `trash` (×3), -`bell`, `mic`, `pause`, `timeline`, `trending_up`, `psychology`, `fingerprint`, `key`, `shield`, `edit`, `add`, `share`, -`refresh`, `warning`, `calendar`, `cake`, `card_giftcard`, `attach_money`, `hourglass`, `question_answer`, `palette`, -`open_in_new`, `quiet_hours_moon`, `export_data`, `closer_mark`. **This is the open work** — see the icon-audit table. - -White monochrome vectors are re-tinted by `Icon(tint = ...)` and loaded via `ImageVector.vectorResource(...)`. - ---- - -## Prompt Style Reference - -Use this only when a future pass creates a new prompt. Existing completed art should not be regenerated. - -> Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**, -> gentle smooth gradients, calm and intimate. **Palette only:** aubergine `#24122F`, deep purple `#56306F`, lavender -> `#B98AF4`, soft pink `#F7C8E4`, soft lavender `#D9B8FF`, blush white `#FFF8FC`. Mood: **warm, quiet, equal, -> intentional — a private ritual for two.** Recurring motifs available: paired/sealed cards, floating hearts and -> petals, a small wooden table with mugs + a lit candle + a lavender sprig, moon/window for quiet hours, a -> calendar/date-card, a sealed capsule/box. If people appear: **two balanced, inclusive figures, simple friendly -> faces, no dominant partner.** Soft abstract floral/heart shapes in the background. -> **Do NOT include:** any readable text, answer/prompt/message content, invite codes, emails, app UI/buttons, generic -> dating-app clichés, stock-photo realism, other brands' logos/wordmarks/app-store badges, -> alarm/urgency/surveillance/fire imagery. - -> **The Closer brand mark** (use ONLY where a prompt explicitly calls for it — otherwise keep art mark-free): a -> pink-to-lavender **"C"** whose upper arc is soft pink and lower sweep is lavender, curving around a **heart-shaped -> negative space** with a small **dark aubergine keyhole** at its center (the keyhole = trust/privacy; the heart = -> the couple). Keep it faithful — do not redraw it as a plain `C`, turn the keyhole into a heart, add a key/lock -> shackle, add faces or text. On dark/aubergine surfaces use a white keyhole; on light/blush use the aubergine keyhole. - -**Aspect/format conventions** -- Hero/onboarding/paywall: portrait **4:5** (matches `illustration_couple_*`). -- Empty states: roughly **square 1:1**, generous padding, sits centered above text. -- Celebration: **1:1**, transparent or blush-white background. -- Glyphs: **single-color** simple vector, square, legible at 20–32 dp. -- Pack/category art: **landscape ~16:9** banner, object-led (no people), like `pack_art_*`. -- Always readable on **both** blush-white (light) and aubergine (dark) surfaces — keep a soft self-contained vignette - rather than a hard rectangular background; export PNG with transparency where noted. - -**Existing assets (reuse before generating):** the generated A1-A12 illustration set above, `illustration_couple_*`, -`illustration_daily_question`, `illustration_tonight_partner_prompt`, `illustration_partner_activation`, -`illustration_reveal_celebration`, `illustration_streak_milestone`, `illustration_together_empty`, -`illustration_premium_unlock`, all 10 `pack_art_*` assets, `particle_heart`, and `particle_petal`. These are Android -assets now; only generate new art when a future QA pass finds a specific missing surface or replacement-worthy defect. - ---- - -## Screen-By-Screen Audit - -Legend: ✅ on-brand / no art needed · ➕ reuse/wire existing art · 🔤 brand-copy/color/code touch - -| Screen / surface | Current brand state | Opportunity | -|---|---|---| -| Onboarding carousel | ✅ couple art + warm copy | ✅ strong already | -| Welcome (Create / I have account) | heart mark + privacy line | ✅ on-brand; could rotate privacy messages 🔤 | -| Sign up / Login / Forgot password | plain form | 🔤 add small heart mark + one privacy line above the form (no big art — keep forms clean) | -| Create profile — name / sex / photo | plain steps | 🔤 light: small step glyphs; ✅ otherwise (forms stay clean) | -| Pair: invite (create code) | `illustration_couple_invite` wired | ✅ | -| Pair: accept code / pairing success | `illustration_pairing_success` wired | ✅ | -| Home (paired) | cards, warm copy | ✅ good | -| Home (unpaired "bring your person in") | couple art present | ✅ on-brand | -| Today / daily question | clean card | ✅; reveal moment is the place for art (below) | -| Answer reveal (mutual) | `illustration_reveal_celebration` wired | ✅ | -| Answer history | `illustration_answer_history_empty` wired | ✅ | -| Play hub | game glyphs wired | ✅ | -| This or That (setup/play) | light buttons good; dark prompt backdrop off-brand | 🔤 replace two-circle/line motif + theme option colors | -| This or That / How Well / Desire Sync **results** | score + rows | ➕ reuse `reveal_celebration`; particles on high match | -| How Well / Desire Sync intro | icon + copy | ✅ | -| Spin the Wheel | nice wheel art | ✅ wheel is on-brand | -| Wheel complete / results | text reveal | ➕ celebration header (reuse particles) | -| Connection Challenges (series list) | `illustration_connection_challenges_header` wired | ✅ | -| Connection Challenges (active day) | clean | 🔤 small streak/heart glyph; ✅ otherwise | -| Memory Lane (list) | `illustration_memory_lane_capsule` empty state wired | ✅ | -| Memory Lane (sealed capsule) | lock + date | ➕ optional: reuse capsule art on sealed-card detail, no new art | -| Date Match (deck) | clean cards | ✅ | -| Date Match (your matches / empty) | empty + success art wired | ✅ | -| Plan Date / Date Builder | form | 🔤 small date-card glyph; ✅ otherwise | -| Bucket List (empty) | `illustration_bucket_list_empty` wired | ✅ | -| Question Packs (library) | all 10 `pack_art_*` assets present | ✅ | -| Messages (inbox empty) | `illustration_messages_empty` wired | ✅ | -| Conversation | chat | ✅ keep clean; quiet-hours art is in settings | -| Past Games (empty/list) | `illustration_past_games_empty` wired | ✅ | -| Your Progress / Activity | stats | 🔤 brand-colored charts; reuse `streak_milestone` for milestones | -| Paywall / Subscription | couple art present | ✅ strong (couple illustration + “one subscription for both”) | -| Premium unlock modal | `illustration_premium_unlock` wired in `PremiumUnlockOverlay` (R13) | ✅ one-time modal for purchaser + partner; verified live both themes | -| WaitingForPartner | per-game glyphs + copy | ✅ | -| Settings + sub-pages | dense lists | ✅ keep clean — **no illustrations**; brand via section headers/color only 🔤 | -| Security / Recovery phrase | `illustration_privacy_recovery` wired | ✅ | -| Privacy & Terms | settings row uses `glyph_privacy_lock` | ✅; page can stay text-first | -| Delete account | `illustration_account_deletion_goodbye` wired | ✅ | -| Quiet hours (settings) | illustration REMOVED 2026-06-28 (looked unprofessional mid-list) — now a clean standalone toggle | ✅ matches "Settings stays clean — no illustrations" | -| Notifications (system) | `ic_notification_closer` used | ✅ no glyph swap needed | - ---- - -## Generation Backlog - -**Status (2026-06-28 re-audit) — the asset-generation backlogs are essentially DONE; the remaining work is CODE:** -1. **Dark illustration variants — ✅ DONE.** `drawable-night-nodpi/` has a dark variant for every non-transparent - surface (all 22 `illustration_*` + all 10 `pack_art_*`). Remaining: verify the 3 transparent celebration assets - (`premium_unlock`, `spin_wheel`, `streak_milestone`) read on dark, and Pass-C spot-check the new variants. -2. **Custom glyphs — ✅ DONE.** Assets created AND wired: all 187 `Icons.*` call sites swapped to `glyph_*` via the - central `CloserGlyphs` accessor; 0 Material icons / 0 material-icon imports remain. BRAND-ICON-CUSTOM resolved. - -Do not regenerate **completed** illustration/glyph art unless a QA pass logs a specific defect. - ---- - -## Notes for the asset hand-off -- Match filenames to the existing scheme: `illustration_.png` (nodpi), `pack_art_.png`, `glyph_.xml`, - `particle_.png`. Provide @1x in `drawable-nodpi/` (illustrations) or density buckets where the existing asset has them. -- For the generated G-set, use the source SVGs in `docs/brand/generated-art/glyphs/source-svg/` for review and the - Android-ready vectors in `docs/brand/generated-art/glyphs/android-vector/` for app wiring. -- After adding art, re-run Pass C (visual, light + dark) on those screens to confirm contrast + no clipping, and - re-export store graphics per `docs/brand/visual-identity.md` if the palette/mark changed. diff --git a/ClaudeGamesReview.md b/ClaudeGamesReview.md deleted file mode 100644 index 11d7dbf7..00000000 --- a/ClaudeGamesReview.md +++ /dev/null @@ -1,125 +0,0 @@ -# ClaudeGamesReview — Full Games, Mechanics, Retention & UI Review - -> **Session 2026-07-07 (Claude, senior-dev review round).** Scope: all 7 games verified live on a -> fresh 2-emulator couple, mechanics checked against their real-world inspirations (researched), -> retention assessment, full-app UI warmth sweep, code-quality audit + fix batches. -> Bugs are cross-referenced by ID; canonical defect state lives in `ClaudeReport.md`. -> Test couple: **Ava + Ben** (`j39xR7PVZLLCveWnmenU`), created through the real signup → invite-code -> → recovery-phrase pairing flow on `CloserQA2` (5554, light) + `CloserCodexQA` (5556, dark). -> Premium was granted via admin (user-authorized), verified (partner unlock modal + push), then -> revoked (locks confirmed restored). 0 active sessions at session end. - -## 1 · Per-game verdicts (mechanics fidelity + live results) - -| Game | Real-world analog | Fidelity | Live 2-device result | -|---|---|---|---| -| This or That | Rapid-fire preference quiz | ✅ Faithful | **PASS** — engineered answers → exactly 3/5 "in sync"; simultaneous symmetric reveal; warm framing ("Lots in common 💛", neutral "Differ" chips) | -| How Well Do You Know Me | The Newlywed Game | ✅ Core faithful; no round-2 role swap (see R-2) | **PASS** — subject/guesser roles correct on both intros; engineered 2/5 exact; near-miss scale renders amber "4 → 3" (isClose softening); low score framed "Getting there!" | -| Desire Sync | Mojo Upgrade-style mutual-desire matching | ✅ Canonical privacy contract, verbatim in intro copy | **PASS** — engineered intersection → exactly "2 shared desires / 3 stayed private"; non-mutual yeses never rendered on either device; count doesn't leak whose/which; answers `enc:v1:` at rest | -| Spin the Wheel | Conversation-deck wheel | ✅ Honest randomness (category picked during spin; decorative stop angle) | **PASS** — identical 10-question set both sides; blind until both answered; side-by-side reveal; completion gate blocks finish with unanswered prompts | -| Connection Challenges | 7-day couples habit challenge | ✅ Day-gating real (can't complete 2 days in one day) | **PASS** — premium challenge opened with entitlement; day-1 complete → "waiting for partner"; ⚠ day-2 prompt text visible a day early (spoiler, see U-13) | -| Memory Lane | Time capsule / letter to future selves | ✅ | **PASS** — sealed capsule shows only title + "Opens in 29 days"; title AND body `enc:v1:` at rest (admin-verified) | -| Date Match | Tinder swipe-to-match | ✅ incl. one-sided-like privacy | **PASS** — simultaneous mutual-LOVE race → exactly **1** match doc; celebration modal + "It's a match!" push on BOTH devices | - -**Shared engine seams, verified live:** one-active-game gate converges the second starter into the -active session (Ben's How Well attempt → "Ava is playing a Wheel game / Join the game"); -first-finisher `partner_completed_part` fires as an in-app banner (foreground) and real push -(killed app, cold-start tap opened the app cleanly, 0 FATAL); mid-game Quit abandons correctly -(admin: 0 active sessions after); "you both finished" results banner on both sides. - -## 2 · Bugs found (canonical detail in ClaudeReport.md) - -| ID | Sev | Summary | Status | -|---|---|---|---| -| DQ-MISMATCH-001 | **P1** | Partners can resolve **different daily questions** for the same day: server assignment is the unresolvable `q_default_daily` rollout fallback → each device falls back locally, and the fallback **pool depends on the viewer's premium state at that moment** (per-user entitlement, transient failures→false, mid-day flips). Reveal then cross-compares answers to different questions. Reproduced live (Ava: premium pool "tiny win" q; Ben: free pool "tiny debate" q; same clock/tz). | **FIXED** (paired fallback pinned to free pool — premium-independent) + follow-up filed to seed real server assignments | -| DQ-SLUG-001 | **P1** | Daily reveal renders the partner's answer as a raw option-id slug ("fake_awards_should_be_mandatory") when the id isn't in this device's question config (same class as HW-BREAKDOWN-001). | **FIXED** (humanize fallback in both reveal summaries) | -| GAME-HANG-001 (B1) | **P2** | ToT/HowWell/DesireSync answer listeners swallowed snapshot errors → game stuck on WAITING forever, no feedback (Wheel/Capsule already had the correct `close(err)` pattern). | **FIXED** (sources `close(err)` + VM `.catch` → retryable ERROR with "Try again" re-attach) | -| DST-001 (B2) | **P2** | `assignDailyQuestion.ts` hardcoded `CST_OFFSET_HOURS=-6`: wrong date labels + reveal times shifted 1h for the ~8 CDT months. | **FIXED** (Intl/America-Chicago helpers, matches streakReminder.ts pattern; 5 DST regression tests incl. spring-forward round-trip) | -| A-003b | P3 | Challenges catalog still shows 🔒 Premium badge after couple premium unlock (gate itself honors premium — opens fine). | Filed | -| BANNER-LIFE-001 | P3 | "You both finished · View" game banner persists across screens/games long after results were viewed (still up entering a NEW game's setup, and shown on top of the results screen it links to). | Filed | -| AUTH-TRUNC-001 | P3 | Welcome/splash privacy tagline clips mid-sentence without ellipsis ("Only you and your paired") — first-run first impression. | Filed | -| Copy/theming P3s | P3 | "Daily Fun Mc" internal category name as user-facing pill; unthemed teal date picker; DOB asked twice in signup→profile funnel; "It is a match!" vs push "It's a match!"; HW matched-row question text low contrast in dark; Date Match card-underlay text bleed; joiner screen titled "Waiting for X" when it's your turn. | Filed | - -## 3 · Retention assessment (Hooked / Fogg / SDT lens) - -**Strong foundations:** daily-question blind-reveal is a genuine daily anchor with a variable -reward (partner's answer) · streaks with repair + grace (aligned with Duolingo's leniency findings) -· Memory Lane = stored-value investment · Weekly Recap + Your Progress = progress evidence · -first-finisher nudges close the async loop (verified: banner + push both fire) · notification suite -respects quiet hours. - -**Prioritized recommendations:** -- **R-1 (P1): Fix the daily anchor supply chain.** `q_default_daily` fallback means the server - never really assigns; seed the Firestore `questions` pool (or make the callable assign a real id - from a synced catalog) so both devices always agree. (Client-side consistency fix landed this - session; assignment seeding is the durable fix.) -- **R-2 (P2): Role-swap rematch in How Well.** Newlywed Game's round structure swaps roles; the - results screen should offer "Now you guess — swap roles" (starter becomes guesser). Cheap, doubles - sessions per sitting, and answers "what do we do next?" at the emotional peak. -- **R-3 (P2): Return-tomorrow beat on results screens.** After every reveal the only CTAs are - "Play again / Back to Play". Add the next anchor: tomorrow's question teaser, challenge day N+1, - or streak state ("Day 2 tomorrow — keep it alive"). -- **R-4 (P2): Repeat-avoidance for game pools.** Desire Sync's effective pool is 102 binary items - drawn `shuffled().take(n)` per session (no cross-session memory) and Date Match's deck is - deterministic (identical first card for every couple, every install). Persist seen-ids per couple. -- **R-5 (P3): Purify the How Well pool.** `getQuestionsForPrediction()` pulls all choice/scale - questions including 500 `daily_fun_mc` novelty MCs ("laugh-track move") — dilutes the - partner-knowledge premise. Exclude `daily_fun_mc` (or curate a prediction-worthy tag). -- **R-6 (P3): Challenges — hide tomorrow's prompt** (spoiler kills the daily ritual) and consider a - visible 7-day chain with both partners' marks (social accountability). -- **R-7 (P3): Analytics funnel gaps.** `RetentionEvent` has GameStarted/GameCompleted but no - first-answer, reveal-viewed, waiting-abandoned, or notification-tap-attribution events — the async - dead-end (biggest churn risk) is unmeasurable today. -- **Content notes:** 48/150 sexual_preferences items unreachable by Desire Sync (non-binary configs - — fine, Wheel uses them) · `daily_fun_mc` rows carry `sex='neutral'` (currently benign only because - of the binary filter; retag to avoid a future landmine) · This or That mood pools healthy (60–75 - per mood vs 15 max draw); `rebuilding_trust` (45 q) reachable only via "All topics". - -## 4 · UI warmth sweep (vs docs/copy-guide.md) - -Scores 1–5 per cluster (tone / emotional safety / welcome / consistency): -- **Games (all 7): 5/5/–/4.** Best-in-class results framing (no shaming anywhere: "Getting - there!", "Differ" not ✗, "Nothing in common this round" never blames). Consistency dinged for the - banner lifecycle + slug leak (fixed) + per-game accent divergence (DS magenta is intentional and - works). -- **Onboarding/pairing: 4/–/5/3.** "You're in / Now bring your person in", value-prop checklist, - recovery-phrase ceremony with word-confirmation are excellent. Dinged: tagline truncation, DOB - asked twice, unthemed date picker, dead-end Create-Profile when signed out. -- **Home/Today: 4/5/5/3.** Daily card + mode system (Tiny Win Tuesday live-verified) is a strong - anchor; "Daily Fun Mc" pill leak; Home-card question can disagree with Today tab (DQ-MISMATCH). -- **Empty states: 5.** Memory Lane's "No capsules yet" is the pattern to copy everywhere. -- Both themes render cleanly on every screen driven this session (fresh-couple run covered light - on 5554 + dark on 5556 throughout); HW matched-row contrast is the one dark-mode ding. - -## 5 · Code-quality fixes landed this session - -- **Batch 1 (correctness):** B1 observer `close(err)` ×3 + VM `.catch`→retryable ERROR ×3 (+ - `GameCopy.SYNC_ERROR`); DQ paired-fallback premium-independence; reveal slug humanization ×2; - DST-safe Chicago date helpers + 5 regression tests. -- **Batch 2 (dead code / hygiene):** see ClaudeReport.md fix log. -- **Filed (structural, specced in Future.md):** generic session-game engine (ToT/HW/DS VMs are - ~80% clones; ~250-line state machine ×4), generic replay VM/screen (3× ~60-line clones), shared - waiting/length-picker composables, premium-check invocation helper, `GameConstants` for divergent - `ADVANCE_DELAY_MS` (420/380), dispatcher injection, error-surfacing standardization for 6 - silent-load VMs, `npm audit` 10 moderate (GCP transitive). - -## 5.1 · R30 polish + experience follow-up (2026-07-07) - -All Tier-A P3s and Tier-B UX gaps from the review shipped and were live-verified (detail: ClaudeReport R30). Highlights that change the player experience: -- **Daily question is fixed at the root.** The server pool was empty (every couple got the unresolvable `q_default_daily`); now Firestore `questions` is seeded with the free weekday pool and the server picks the same mode-aware, deterministic question the client would — so both partners get the identical themed question, even across time zones. Verified: server assigns `daily_single_choice_weekly_v1_072` (Tiny Win Tuesday), both devices resolve it, no slugs. -- **Games no longer dead-end:** NextBeatCard ("Tonight's question is ready →" / streak) under every results screen; How Well offers a Newlywed-style role swap. -- **Content freshness:** Desire Sync remembers served questions; Date Match shuffles per couple and skips already-swiped ideas (no more identical first card for everyone). -- **Waiting isn't passive:** a "Send a little nudge 💜" button on the waiting screen (reuses the quiet-hours-safe thinking-of-you callable). -- **Onboarding polish:** themed M3 date pickers; the birth date now survives an app restart mid-signup (no more double-ask). -- **A11y + copy:** score visuals carry TalkBack descriptions; banners speak per-game ("guess their answers"); the internal "Daily Fun Mc" label never faces users. - -## 6 · Coverage notes - -- Verified live: all 7 games happy path 2-device · result extremes (0-match impossible for DS - reveal? engineered 3/5, 2/5, 2-shared, 3/5-sync variants) · one-active gate · abandon · cold-start - push join · simultaneous-LOVE race · premium grant/unlock/revoke lifecycle · daily answer→reveal · - day-0 check-in · pairing + recovery-phrase ceremony · deep-link scheme audit (only /join/ links - exist; game entries are notification-driven, all tested). -- Deferred (unchanged from plan): full Pass E matrix, Pass K money path, release build, monkey, - TalkBack full pass (spot: n/a this round), TodayWidget interactive smoke (code-read only), - am-kill mid-session resume (cold-start-from-push covered the recovery class). diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md deleted file mode 100644 index a68e78fa..00000000 --- a/ClaudeQACoverage.md +++ /dev/null @@ -1,170 +0,0 @@ -# Claude QA Coverage Matrix - -> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. - -> **R30 (2026-07-07, polish + UX + seed):** all remaining P3s + 8 UX gaps FIXED & live-verified (see ClaudeReport R30 / ClaudeGamesReview §5). **Daily-question pool SEEDED + server made mode-aware/deterministic + deployed** — both devices resolve the same real server-pinned question (DQ-MISMATCH/DQ-SLUG closed at the root, verified live). Gates: unit pass · functions 58 · cold-start smoke run. Still deferred: TodayWidget interactive, TalkBack full pass, release/minify (O), device/OS matrix. - -> **R29 (2026-07-07, games/mechanics/retention/UI review):** Pass B (all 7 games) **re-run live 2-device on a fresh couple = pass** (engineered-answer verification; see ClaudeGamesReview.md) · onboarding/signup/pairing/recovery-ceremony/day-0 check-in **pass (live, first full run since R24)** · daily answer→reveal loop **fail→DQ-MISMATCH-001/DQ-SLUG-001 → FIXED e5868bd, re-verified** · premium grant/unlock/revoke lifecycle **pass** · cold-start push open **pass (organic, killed app)** · one-active gate/abandon/simultaneous-match race **pass** · TodayWidget interactive + TalkBack spot + am-kill mid-session resume **todo (deferred; cold-start-from-push covered the recovery class)** · Pass D at-rest spot-check (game answers + capsule on new couple) **pass (`enc:v1:`)**. -> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.** -> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.** -> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b). -> **R25 (2026-06-30) — full fresh run on the new R24 E2EE backup/restore surface + cornerstone regression; 0 new defects.** Cheap gates green (unit **244** · fn **38** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** · cold-start smoke **6/6 both** · render smoke **4/4**). **Pass D CLEAN (deep on backup/restore):** at-rest manifest=pointers-only, Storage snapshot=`enc:v1:` (16KB, server-blind), restore_requests=0; rules member-scoped + keybox bound to other member + immutable pubkey; **D3 live negative all-denied** (backup manifest/chunks/restore_requests + create/write = 403/400; original couple/self-grant = 403); cross-user restore via **tokenized capability URL** verified (plain GET→200+`enc:v1:`); **R24 storage.rules deploy gap RESOLVED**. **Pass E:** `restore_requested` partner push **deployed + firing** (Sam queue: 3 today) → RESTORE_CONSENT; **R24-b functions NOT deployed** (`onRestoreFulfilled` absent, no `lastRestoreSelfAlertAt`, 0 `restore_self_alert`) → self-alert + completion alert = `blocked→deploy`. **Pass M:** new Security entries live-verified this session (recovery reveal on no-lock, Copy+`IS_SENSITIVE` mask, Help-my-partner-restore + back). **Cornerstone regression (Sam 5556):** A paywall gate ✓, B Play-hub cards+badges ✓, L inbox+thread fully decrypted no-leak ✓, N daily-Q decrypted+reveal ✓, 0 FATAL. **⚠️ PROCESS LANDMINE (I caused): `connectedDebugAndroidTest` UNINSTALLS+WIPES the app-under-test → wiped QA(5554) data → QA at fresh onboarding (O-ONBOARD-001 stays fixed). NEVER run instrumented tests on 5554/5556 fixtures — use throwaway 5558.** QA fixture recovery = `blocked→user` (password/re-auth needed). **User actions to close: (1) `firebase deploy --only functions` ✅ DONE (functions deployed by user; both self-alerts live-validated R25-c). (2) restore QA fixture ✅ DONE R25-b.** -> **R25-b (2026-06-30) — QA(5554) fixture RECOVERED; live 2-device partner-assisted restore verified end-to-end, 0 defects.** Password reset via admin (user-authorized) → QA signed in → **NEEDS_RECOVERY** → "Start restore" published request (code 592847) → **deployed `onRestoreRequested` fired LIVE** (Sam got "Help your partner restore 💜" push, id 40038) → Sam's **Change-1 consent live-verified** (email anchor + name **QA** decrypted locally + confirm checkbox; Approve gated on code(6) **AND** confirm) → approve → **QA auto-restored**: paired Home + "Sam/Revealed" + **full chat history decrypts**; fresh `restore_ok_R25` from restored QA **decrypts on Sam** = bidirectional round-trip = full R24 restore regression. **Deferred obs (not a defect):** warm-start restore-push tap opened Play hub not RESTORE_CONSENT (likely collapsed-notif-group artifact; cold-start routing smoke-green). -> **R25-c (2026-06-30) — LIVE-FIRE of deployed owner-alerts (Change 3): both restore self-alerts observed on QA's OWN device; last user-gate CLOSED; 0 defects.** User-authorized re-wipe QA(5554) → sign in → NEEDS_RECOVERY → Start restore (code 565429). **(1) Request self-alert fired LIVE** — QA **shade** (`closer.app` id 67945, `partner_activity`, imp 4) + durable `users/{QA}/notification_queue` (`restore_self_alert`, 23:17:38 “New device is restoring your history”) + partner push to Sam (“Help your partner restore 💜”) — all from one `onRestoreRequested`. **R25-b routing obs CLOSED:** tapping Sam’s *single* restore notif → RESTORE_CONSENT (not Play hub) ⇒ earlier artifact was the collapsed 2-item group header. Consent gate re-verified (code(6) alone Approve-disabled → +confirm enabled). Sam approve → **`onRestoreFulfilled` fired (status ok, 1319ms) on REQUESTED→READY** → **(2) completion self-alert** queued to QA (`restore_self_alert`, 23:19:50 “Your history was just restored”) — not on shade only because QA was foregrounded (auto-restored); push still reached live tokens. 132s apart (>~60s dedupe → no suppression). **Robustness live:** 1 stale token (`registration-token-not-registered`) failed but `Promise.allSettled` → function ok. QA auto-restored to paired Home + content decrypts, 0 FATAL → **fixture healthy**. **Minor follow-on (not defect):** prune `not-registered` FCM tokens. -> **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes R25-c follow-on); build + 47 fn tests green; NOT deployed (user-gated).** New `notifications/pruneTokens.ts` (`isDeadTokenError` prunes ONLY `registration-token-not-registered`/`invalid-registration-token` — never transient/`invalid-argument`, so a bug can't wipe all tokens; `pruneDeadTokens` best-effort, never throws, batch-deletes dead `fcmTokens` docs + legacy field, only on an actual dead token). Reuses each caller's `tokens`+`allSettled` results → 1 line wired into **all 19 push sites** (questions/dates/couples/games/notifications/billing/users/backup). `pruneTokens.test.ts` +9 tests (fn **38→47**). tsc verified every db/uid/tokens/results ref; `dist/notifications/pruneTokens.js` emitted. Takes effect on next `firebase deploy --only functions`. -> **R21 (2026-06-29) — brand-voice + Home-bubble polish, then full QA re-run; 0 new defects, 0 FATAL.** Copy: `prompt→question` (~26 strings) + clinical→Closer voice (Outcome/check-in feature, "Your Progress"→"Growing together", "Private sync"→"Just for two", Home eyebrow "Your daily question", paywall "…and growth"). Home partner bubble upgraded (Coil `SubcomposeAsyncImage` + gradient ring + a11y; partner photo verified live). Cheap gates all green (210 unit · 24 fn · theme-scan CRIT 0 · painter-xml 0 · smoke 6/6 both). **Reveal-when-answered verified LIVE end-to-end** (both answer → Home "Reveal is ready / Reveal together" → AnswerReveal shows both picks). Multi-angle nav verified (daily Q via Today+Home, reveal via Home card, Settings→Growing together, Play→Question Packs "250 questions"). Cornerstones A/B/D carry from R20 (no rules/crypto/games-logic change — diff is copy + Home-bubble UI); E re-verified (smoke). Also landed this session (uncommitted): recovery-UX partner-as-backup copy + change-phrase desync guard, `SECURITY.md`, first instrumented test `FirstRunRenderSmokeTest`. See `ClaudeReport.md` R21. -> **R20 (2026-06-29) — fresh full ClaudeQAPlan run; found + FIXED 2 escaped bugs.** Build HEAD `62696a6` + R20 fixes (uncommitted: `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`). Cheap gates all green (unit **210** · fn **24** · theme-scan CRIT **0** · painter-xml **0** · wiring 🔴**0** · smoke **6/6 both**). Cornerstones A/B/D/E live-clean, 0 FATAL. **B-ABANDON-001 (P2)** — Quit/abandon on any game silently `PERMISSION_DENIED` (full `saveSession` set drops server-only flags → rule rejects removed `affectedKeys`) → stranded session; **fixed** (targeted `update(status,completedAt)`), verified live (quit→active=0→new game starts). **B-COPY-001 (P3)** — Home GAME_WAITING hero falsely claimed "partner already played their part"; **fixed** (neutral partner-named copy), verified live both devices. Both pending 1 confirm. See `ClaudeReport.md` R20 run-state. -> Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **Deploy status:** `functions/` + `firestore.rules` **DEPLOYED by user** (join push live; Tier-2 self-constraint **verified live** — member own-uid add 200, foreign-uid/removal 403). No remaining deploy gates. Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** -> -> **Scope expanded (plan review):** the playbook now has first-class passes **K–O** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **Device/OS matrix = `blocked→needs-device` (pre-ship):** all per-round QA runs on two **identical** emulators (5554/5556, same API + screen) — minSdk/targetSdk · small/large screen · ≥1 physical device are NOT covered; don't claim "device matrix ✓". -> -> **First-run / cold-path = `blocked→fixture` (run the fresh-install lane):** the recurring emulators are **paired + signed-in + onboarding-complete**, so the recurring passes **cannot reach onboarding / sign-up / login / auth-logo / pairing / new-device-recovery / day-1 empty states** — this is the fixture blind-spot that hid **O-ONBOARD-001** (P0, every fresh install crashed). Cover it on a **throwaway** device (e.g. `emulator-5558` / fresh AVD — never `pm clear` 5554/5556) on any onboarding/auth/pairing/branding/`res/drawable` change + pre-ship. Don't claim "first-run ✓" off the logged-in fixtures. **R20 (2026-06-29) — FULL FRESH-PAIRING LANE RUN ✅ (user-requested), 0 FATAL:** two throwaways uninstalled→fresh-installed (5558=Avery + booted CloserCodexQA/5560=Riley). A: notif-permission → **onboarding all 3 slides** ("Answer honestly / No peeking / Grow Closer" — CtaSlide logo renders, O-ONBOARD-001 stays fixed) → auth landing (AuthLogoMark renders) → **sign-up** → **3-step profile** (name · inclusive gender Female/Male/Non-binary/Prefer-not-to-say · optional photo skip) → unpaired handoff → **invite code 47V-JCZ** + recovery phrase shown. B: **Skip onboarding path** verified (slide 1 → auth) → sign-up → profile → **Accept-instead → enter 47VJCZ → "Pair up"** → "You're connected / Riley & Avery". **New couple `3sRSEvky7HdSUOY9F1z0` (2 userIds, `encryptionVersion=2`) created server-side**; both devices flipped to paired in real-time; B's day-1 check-in modal + first daily Q render. No new bugs. Teardown: throwaways uninstalled, 5560 powered off, QA/Sam (5554/5556) untouched + clean (active=0). (Residual isolated throwaway Firestore couple left as disposable test data.) -> -> **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — contains architecture, security model, data model, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section that backs every fix-and-pruned ID below. -> Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is -> confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.) - -## Status at a glance -| Pass | Coverage | Status | -|---|---|---| -| A — Couple-shared premium | R18: re-verified live (Sam free) — Desire Sync + premium **Boundaries** pack → Paywall; Mixed pack free prompt opens (gate not over-broad); Free filter graceful empty. R13 A-201 (Date Match) holds. | ✅ pass (multi-surface gate re-confirmed R18) | -| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. **R20: B-ABANDON-001 (P2) FIXED** — that abandon (+ ToT/HowWell Quit; all route through `abandonSession`) actually failed `PERMISSION_DENIED` server-side (full `saveSession` `doc.set()` dropped the server-only flags → session-update rule rejected the removed `affectedKeys`) so the session stayed stranded `active`; fix = targeted `update(status,completedAt)`; verified live (quit→active=0→a different game starts immediately). **R20: B-COPY-001 (P3) FIXED** — Home GAME_WAITING hero no longer falsely claims "partner already played their part." | ✅ pass · finish-gate + submit-retry + **abandon now actually works (B-ABANDON-001)** (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | -| C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. R18b: **C-ORIENT-001 RESOLVED** — `MainActivity` locked to portrait (no landscape design; verified `requestedOrientation=PORTRAIT` holds under forced rotation). C-DARKART-002 confirmed + pruned. | ✅ theme-scan CRIT 0 · C-DARKART-002 + C-ORIENT-001 resolved · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.md` | -| D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds | -| E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. **R18b: NEW `partner_joined_game` (joiner avatar) + standardized durable in-app game banner** — all foreground game pushes route through the themed banner (started/joined transient, your-turn/results persistent + tappable; foreground OS dupe suppressed, background OS unchanged); verified live per kind + Pass-E smoke 6/6 both emulators (join push pending functions+rules deploy). | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · banner standardized · Doze/battery = needs-device | -| F — Resilience | R12: concurrency (F-RACE-001 atomic-start code + R8 live) · process-death (smoke `am kill`×5 → push → cold-start recovered each) · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred | -| G — Account creation / fake-account | R10: abuse live via D3 (non-member denied, no self-grant) + invite rules; happy/validation R5-clean (unchanged) | ✅ pass | -| H — Branding & artwork | R13: ToT redesign + Premium-unlock modal done. **2026-06-27 brand audit opened 2 backlogs.** | ⚠️ **BRAND-DARK-COVERAGE** (light-only illustrations need dark variants) + **BRAND-ICON-CUSTOM** (~60 generic Material icons → bespoke `glyph_*`) — full asset lists in `ClaudeBrandingReview.md` | -| I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression | -| J — Accessibility | R13: **J-OBS FIXED + verified live** (composer media/voice/retry buttons → 48dp; measured 126px=48dp both axes); font scale 2.0 reflows + reduce-motion×7 (R10) hold | ✅ done · J-OBS fixed (pending 1 confirm) | -| K — Billing & subscription lifecycle | Gate (couple-shared unlock) verified via admin toggle (Pass A) + Premium-unlock modal + `onEntitlementChanged` push live (R13/R14). **Real money path (purchase/restore/cancel→expiry-relock/refund/plan-switch) NOT tested** — needs a real device + Play sandbox. | ⚠️ **todo** — money path `blocked→needs-device`; gate ✅ | -| L — Messaging & chat (E2E) | R15: conversation render driven live — decrypt **both dirs**, attribution, timestamps, **Seen** receipt, ❤️ **reaction**, ordering, day-separators, voice-note + image bubbles, E2E composer lock glyphs; inbox decrypted previews **no `enc:` leak**; live QA→Sam send delivered; at-rest `enc:v1:`. **R18 re-verified live round-trip** (Sam→QA: received + decrypted on partner, `enc:v1:`(79) at rest, marker absent from server docs = no leak, Seen receipt). Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — re-confirmed R18; 4 sub-items carry | -| M — Settings & account management | R15: **M-001 (quiet hours) FIXED + verified live** (server-side fail-open suppression); per-type notif toggle take-effect confirmed live (server-enforced; field flips in Firestore; toggle-off → 0 delivery); theme/DataStore persistence across relaunch ✅; biometric lock code-sound (cold-start re-lock; background-resume observation → Future.md). Remaining: edit-profile persist, unpair/delete-cascade (disruptive — deferred). **R18: M-001 re-confirmed** — toggling QH writes the client mirror (`quietHoursEnabled`/`StartMinutes 1320`/`EndMinutes 480`/`timezone`) to `users/{uid}` correctly; server suppression deployed + R15-verified. Recommend prune. | ✅ **pass (core)** — M-001 confirmed (prune next); unpair/delete deferred | -| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ✅ **pass** — N-001/N-002 fixed; **Date Memories/Reflection CLOSED (R26 2-device live: reflect→reveal, edit-before-reveal, notes, bg/fg deep-link, ready/opened pushes + read-failure/timeout hardening)** | -| O — Release build & store readiness | **Not started.** All QA to date is on the **debug** APK. Minified release build, signing/AAB, App Check enforcement, i18n/RTL, App-Links, Play Data-Safety = pre-ship gate, not yet run. | ❌ **todo (pre-ship gate)** | -| P — Content, copy & language | R15: UI-microcopy swept (warm/inclusive; debug rows `BuildConfig.DEBUG`-gated; friendly error fallbacks; on-brand privacy copy) + **question-bank audit live: 6103 Qs — 0 empty, 0 exact dupes, 0 placeholder tokens, complete/mutually-exclusive answer configs, good type variety, consent-framed sensitive content.** No typos/off-voice/non-inclusive copy found. **R18: found+fixed P-GRAMMAR-001** — in-game wheel surfaced a subject-verb agreement error; bank scan found **13 stress-Q** from one template family where plural subjects hit a singular "{x} is …" frame ("busy weeks/health worries/… is affecting you"); fixed the 13 rows in asset `app.db` (data-only); root fix belongs in the content generator. | ✅ **pass** — copy clean; P-GRAMMAR-001 fixed (asset) + grammar-audit recommended | - -**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **R28: HW-BREAKDOWN-001 FIXED+verified (humanize option-ID fallback) + BANNER-RESULTS-COPY-001 (P4) fixed + O-AGE-001 18+ gate IMPLEMENTED+live-verified → archived/mostly-closed.** **Open: 0 P2/P3 blockers.** Remaining non-blocking follow-ups: **O-AGE-001** rules DEPLOYED + birthDate persistence verified against the live rule → only the Play maturity questionnaire remains (product, not code); **BRAND-DARK-COVERAGE** effectively resolved (all 22 `illustration_*` have dark variants; only transparent celebration assets lack one and read fine on dark). - ---- - -## Pass A — Couple-shared premium (neither / partner-only / self) -| Feature | neither→locked | partner→both unlock | self→unlock | Status | -|---|---|---|---|---| -| Chat media + reactions | pass | pass | pass | ✅ pass (reference pattern) | -| Play: Desire Sync | pass | pass | pass | ✅ pass | -| Play: Memory Lane | pass | pass | pass | ✅ pass | -| Play: Connection Challenges | pass | pass | pass | ✅ pass | -| Question Packs (premium) | pass | pass | pass | ✅ pass | -| Wheel: Category Picker / Spin / History | pass | pass | pass | ✅ pass | -| Date Match / Plan Date | pass | pass | pass | ✅ pass | -| Subscription screen (own status) | n/a | n/a | n/a | ✅ pass (by-design per-user) | - -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.) - -## Pass B — Games lifecycle (start / play / finish + results, 2-device, real user-nav) -All 7 played one complete time through on both devices via the real in-app path; gameplay all PASS. -| Game | starts | plays | finishes/results | no crash | Evidence | -|---|---|---|---|---|---| -| 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 | pass | pass | QA answered 5 (incl. 1–5 scale); Sam predicted via hub → 4/5, wrong one marked ✗ on both, scoring accurate. | -| 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 | 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) | 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 | pass | pass | Spun → category → both answered all 10, per-Q You/partner breakdown matches both, session synced. | -| 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: 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. - -**R18b — FINISH-GATE (every question must be answered before a game can finish):** Grounding found only **Spin the Wheel** let a player finish with blanks (explicit Skip; `Next` advanced when empty; `End session` submitted the rest as "Skipped"; it's the only game with text boxes). Per user decision ("Hybrid"): Wheel now uses an index-keyed nullable answer store + an **`attemptFinish()` gate** — skip/blank is allowed mid-play, but Finish bounces to the first unanswered prompt with an a11y "N left to finish" banner and submits only when none are blank (enforces non-empty text + ≥1 choice; scale always has a value). The other three already require a pick to advance (ToT/Desire Sync auto-advance on tap; How Well's Continue is disabled until selected) — verified by code + Pass-B observation. **Live (both emulators):** full 2-player Spin-the-Wheel (all 10, mixed written/choice) → completed reveal with **no "Skipped"**; gate bounce + persistent "N left" banner + walk-forward confirmed; then a full 2-player This-or-That ("5/5 in sync"). 3 new `WheelSessionViewModelTest` cases (gaps→bounce/no-submit; all-answered→submit no "Skipped"; completion-walk). 0 FATAL. - -## Pass C — Visual (light + dark), all ~50 routes -~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back). -- **Theme-scan execution tracking (MANDATORY):** every Pass C round must record the counts exactly as - `scripts/theme-scan.sh` reports them in its `## Summary` section, then file the appropriate items: - | Round | Scanner run | CRITICAL | MAJOR | REVIEW | CRITICAL filed to ClaudeReport.md | MAJOR filed (if any break themes) | - |---|---|---|---|---|---|---| - | R15 | ✅ | 9 | 8 | 32 | ✅ (C-THEME-001..009) | — | - | R16 | — | — | — | — | — | — | - - R15 scanner: 9 CRITICAL hardcoded surface/background colors filed as C-THEME-001..009; 8 MAJOR (1 component - color override + 7 direct `painterResource` hits) and 31 REVIEW items deferred to the visual sweep. Update this - row if any MAJOR is promoted to a filed defect. -- **R10 re-sweep (both themes where relevant):** Messages inbox ✅ (dark+light: conversations, avatars, unread dot, previews decrypted, no `enc:` leak), Conversation ✅ (image/voice/text/reaction/read-receipt/date-sep, E2E lock glyphs, correct attribution both dirs), per-question Discussion thread ✅, Today/daily-question ✅ (dark+light, paired-books art on-brand), Activity/Together ✅ (dark). **5 P2 found:** C-HOME-001 (Home shows top pending action twice — `primaryAction` hero + `buildPendingActions` row), C-NAV-002 (wheel results→BACK re-enters finished play screen, no popUpTo), C-NAV-003 (Wheel History/Past Games + PartnerHome double app-bar — route in `shellBackRoutes` while screen owns a TopAppBar; C-CC-001 class), C-PW-001 (dark paywall "What's included" pills light-on-light, `BenefitPill` onSurface text), C-SEC-001 (accepter recovery copy). Premium-locked Wheel History state renders. Date Builder · Question Packs(gated→paywall) · Answer Reveal sealed = token-consistent, R9-clean. -- **R9 deferred sweep — 0 new issues:** Answer History, Together/Activity, Bucket List (empty state + FAB), Date Match deck, Date Matches all render cleanly in **dark** (good contrast, no clipping, no FATAL); Privacy & Terms + Home confirm **light** parity (shared Material3 tokens). Remaining standard list/detail (Wheel History · Date Builder · Past Games · Answer Reveal sealed · Question Packs[gated→paywall]) are token-consistent with the above; fresh-account auth/onboarding visual covered R3/R5. No C findings. - -## Pass D — Security & encryption (D1–D6) — clean, no P0/P1 -- **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). -- **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. -- **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**. -- **D4/D5/D6:** wrapped couple key + KDF; App Check (client), gitignored SA JSONs, `allowBackup=false`; analytics metadata-only. Unchanged, hold. -- One hardening note → `Future.md` (App Check off on Firestore). _(R15 correction: `users/{uid}` update rule enforces a **field allowlist** — not arbitrary; extended R15 for `quietHours*`+`timezone`.)_ - -## Pass E — Notifications (type × {foreground / background / killed} + tap-to-open, both clients) -Full live two-device run (games + messages): -- **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. -- **partner_started_game** ✅ — channel `game_activity`, "QA is playing… Tap to join!" (content-free); tap → joins the active session. -- **partner_finished_game / results** ✅ — results push delivered to backgrounded partner, channel `game_activity`, content-free; tap → per-session results (per E-003 fix). -- **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. -- **partner_answered (couple-key reveal, 2026-06-26)** ✅ live both-client — `onAnswerWritten` fires on each answer; the second answer hits the **both-answered "Your answers are unlocked ✨"** copy (recipient already answered). `onAnswerRevealed` ✅ fires when `isRevealed` flips false→true → "opened your answers" push to the partner (witnessed live). Privacy gate (raw API): partner content **403** until both answer, **200** after; non-member **403** throughout. Reveal screen shows the partner's answer **both directions**. At-rest: content-free metadata + gated `enc:v1:` `secure/payload`. -- **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). -- **R18b (2026-06-28) — full live re-run, 0 FATAL:** - - **Cold-start crash-triage smoke 6/6 on BOTH emulators** (`qa/entrypoint_smoke.sh`): launcher + `partner_started_game`/`partner_completed_part`/`partner_finished_game`/`chat_message`/`partner_answered` each killed (`am kill`) → real push → tapped from shade → **opens & stays** (0 fail, 0 blocked). This is the shared cold-start path (splash/onCreate) where the splash-exit crash class hid — clean. - - **Routing (background→tap, landed-screen verified, not just "opens"):** Sam received all 7 types; QA received 3 (both-client). chat_message(conv=main)→**exact conversation thread** (composer + Seen); partner_answered & daily_question→**Today/daily-question**; partner_started_game & partner_completed_part(tot)→**game screen**; partner_finished_game(wheel,session)→**per-session wheel results** ("Here's how you each answered", completed→results not a dead active session); date_match→**"Your Matches"**. Every tap: correct destination, app alive, **0 FATAL**. - - **Foreground in-app delivery (onMessageReceived intercepts; no OS tray):** `partner_started_game`→**in-app banner** "Your partner started a game · This or That" with Join/dismiss ✅; `chat_message`→**draggable chat-head bubble** ✅ (verified via real open-thread→back→Home→send flow + a distinct conv id; the `conversation_id=main` suppression seen on a process-death-restored back stack is **by-design read-suppression**, `ActiveThreadMonitor`, that clears on normal back-nav — not a defect). - - **Malformed / stale intents (all graceful, 0 FATAL):** unknown type→no nav, no crash; chat_message w/o `conversation_id`→**Messages inbox** (fallback); partner_started_game w/o `game_type`→**Play hub** (fallback); partner_finished_game w/ **deleted session id**→graceful "waiting" state w/ Back-to-Play/End escape (no crash, no dead-end). - - **Payload privacy (P0) — code audit of every sender + at-rest D1:** `onMessageWritten`, `onGameSessionUpdate` (+ part-finished), `onAnswerWritten`, `onAnswerRevealed`, `createDateMatch`, `onCoupleLeave` — each `data` block carries only routing IDs (type, couple_id, conversation/question/game/session id) + optional **public** avatar URL; titles use only the partner's display name; bodies are static. **No message/answer/date/swipe content, no keys/invite codes/recovery phrases.** At-rest cross-check: latest 6 `conversations/main/messages` all `enc:v1:` (server-blind source). Cross-checks D6. - - **Real `onMessageWritten` live re-drive NOT re-run this round** (UI-automation thrash on the composer send button) → carried from **R18 live** (exact copy "Sam sent a message"/"Tap to read and reply", no content) + this round's payload code audit + at-rest D1. - - **Doze / battery-optimization / App-Standby delivery → `blocked → needs-device`** (emulators never enter these states; the #1 real-world "notifications don't work" cause). Run on a physical device before any store push (`dumpsys deviceidle force-idle`, app Optimized→Restricted). -- **R18b — `partner_joined_game` + standardized durable in-app game banner (FEATURE):** new push when the non-starter opens an active session → the **starter** gets " joined your game" with the **joiner's avatar** (server-visible join via `joinedByUsers`; one-time `joinNotifiedAt`; `onGameSessionUpdate` branch). All four foreground game pushes now route through the themed `GamePromptBanner` with the partner's avatar + `sender_name`: **started/joined transient (~9s), your-turn/results persistent until tapped**; foreground OS duplicate suppressed; **background OS notification unchanged** (already shows the avatar large-icon — the purple banner is in-app only). **Live (5554):** all four kinds rendered correctly with avatar+name; **RESULTS still shown at 15s vs STARTED auto-dismissed by 12s**; 0 foreground OS dupes; 0 FATAL. **Pass-E cold-start smoke 6/6 on both emulators** (shared path regression-clean). `PartnerNotificationTypeTest` covers the new type's mapping + routing. **`blocked→deploy`: the join push only fires once `functions/` + `firestore.rules` are deployed** (the `joinedByUsers` client write is rule-gated; best-effort + swallowed until then). The banner standardization for already-deployed types works immediately. - -## Pass F — Resilience / lifecycle / concurrency / time -- **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.)* -- **Offline:** airplane mode → Today renders from cache, no crash. -- **Lifecycle:** rotation/config-change → state preserved; ~6 cold restarts → clean to Home (auth persists). -- **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` 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 ~42–45dp 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) -- **R15** — gap-closing round (Passes L/M/N/P + regression smoke); **3 bugs found, 2 fixed.** **M-001 (P2 quiet hours)** — local-only window didn't suppress backgrounded/killed partner pushes; fixed via server-side fail-open `recipientInQuietHours()` in the 4 partner-action senders + client window/tz sync + rules allowlist; verified live (fn log suppress vs notify); deployed prod. **N-001 (P1) Bucket List fully non-functional** (coupleId never set → all CRUD no-ops) — FIXED (VM resolves couple in init) + verified live (add `enc:v1:`/complete/delete/render). **N-002 (P2) Date Builder "Create Plan" no-op** (wrote to unread prefs collection; ids never wired) — FIXED (re-pointed to create a PLANNED `DatePlan` via `savePlan` → Home "Date coming up") + verified live. L chat-core, P copy+question-bank (6103 Qs) clean; smoke 6/6 GREEN. Corrected stale "users/{uid} allows arbitrary fields" claim (there's an allowlist). -- **R14** — full fresh A–J ClaudeQAPlan run (pure QA, no code), FLAWLESS, 0 new findings: confirmation round on the R13 build — premium enforcement + couple-shared unlock + entitlement push (live); Desire Sync/How Well/Spin-the-Wheel full 2-device + first-finisher nudge; Memory Lane create+seal, CC resume, Date Match deck; decoupled-theme-art mandate; cornerstone live (403s + enc:v1:); offline + process-death; jank 5.25%; J-OBS 48dp holds. The 5 R13 fixes held → pruned (archived line). -- **R13** — open-backlog fix pass + full fresh A–J, FLAWLESS (0 open P0–P3): fixed C-DARK-UI-001 (ToT dark redesign), C-DARK-UI-002 (check-in label), C-DARK-UI-003 (bottom insets), C-ART-EDGE-002 (8 opaque heroes feathered), J-OBS (48dp targets); confirmed A-201 live→pruned; shipped Premium-unlock modal (one-time, both partners, couple-shared, verified live). Pass D cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, at-rest enc:v1:). Diff UI-only → E/F/G carried. 0 FATAL both emulators. -- **R12** — FRESH FULL A–J run + fix phase, FLAWLESS (0 open P0–P2): found+fixed **A-201** (P1 Date Match premium bypass — gated via CouplePremiumChecker→Paywall, verified live); 4 async games full 2-device E2E; security cornerstone live-clean (non-member 403 read+write, self-grant 403); smoke 6/6; jank 4.10%; new P3 C-ART-EDGE-002 (hero edges, deferred); C-DARKART-001+C-ART-EDGE-001 held→pruned; Pass A retrospective added (badge≠gate). -- **R11** — confirmation round, FLAWLESS (0 open P0–P2): fixed C-DARKART-001 (P2, art follows in-app theme via `LocalAppInDarkTheme` + config-overridden context) + C-ART-EDGE-001 (P3, edge feathering) in shared `BrandIllustration`/`EmptyState`, verified live both decoupled theme directions (system-light+app-Dark→dark art · system-dark+app-Light→light art), 0 FATAL; re-confirmed + pruned the 5 R10 P2 fixes (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001); entrypoint smoke 6/6 green on fresh APK (launcher + 5 notif cold-starts open & stay). Art fixes in working tree; rest committed `2cd0af6`. -- **R10** — FULL run A–J + fix phase: 5 P2 found+fixed+verified-live (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dup app bar · C-PW-001 dark paywall · C-SEC-001 recovery wrong-store); E-GAME-002 confirmed live (start push+banner+Join) & pruned; concurrency double-start→1 session; security D1–D7 clean; perf/a11y no regression. 0 open P0–P2 (5×P2 pending 1 confirm). -- **R7** — security/concurrency deep dive (multi-angle): cornerstone clean; F-RACE-001 found+fixed+verified. 0 new open. -- **R6** — branding drop + Future.md backlog regression: 0 new open. -- **R5** — Cloud Functions deployed (E-OBS/E-003) + new Pass G clean: 0 open. -- **R2–R4** — play-as-user game restart + fix phase; all P0–P2 fixed + verified (archived IDs above). diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md deleted file mode 100644 index 10332487..00000000 --- a/ClaudeQAPlan.md +++ /dev/null @@ -1,1515 +0,0 @@ -# Claude QA Playbook — Full-App QA → Fix → Re-QA until flawless - -> Reusable QA plan for the Closer app. Run report-only first, fix everything, then re-QA until a clean round. -> Progress/state is tracked in **ClaudeReport.md** (issues) + **ClaudeQACoverage.md** (coverage matrix), which are -> the authoritative source of truth. See the Continuity section before resuming. -> -> **Program roadmap:** **Part 1** = Android QA (this doc) → **Part 2** = build the iOS app to Android's current -> 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). - -## ⛔ This is a LIVING document — improve it whenever you see a gap (do this automatically) -This playbook, the coverage matrix, and the `scripts/`/`qa/` scanners are **yours to evolve every round** — that is part -of the job, not a separate task. Whenever a round teaches you something the plan doesn't yet capture, **edit it in the -same chunk** (no need to ask): -- A bug **escaped** a prior round, was hard to diagnose, or recurred → add the generalized reflex to the right Pass + - the durable substance to the Engineering Manual landmine (the MANDATORY-retrospective rule), and if the class is - greppable, **add/extend a scanner** (`scripts/theme-scan.sh`, `scripts/wiring-scan.sh`, `qa/entrypoint_smoke.sh`). -- A step is **wrong, contradictory, or stale** (e.g. it told you to do something a standing rule forbids) → fix the - wording so the next agent isn't misled. -- A new **route / feature / notification / collection / gate / asset** appeared → fold it into the relevant Pass + - `ClaudeQACoverage.md` (Living discovery ritual). -- The plan is **unclear or bloated** → tighten it; lead with the answer; keep one canonical home per fact (don't restate - a lesson in four places — link by ID). -Leave the plan better than you found it each round. When you change a scanner, update its header; when you change a -process rule, make sure it doesn't contradict the Guardrails. - -## ✅ Per-round execution checklist (the literal flow — details in the sections below) -1. **Resume:** read `ClaudeReport.md` run-state + `ClaudeQACoverage.md` (the authoritative state); `adb devices` shows - both emulators; **installed build == HEAD** (rebuild+install if unsure — never QA a stale APK); baseline clean - (both free, 0 active sessions, logcat 0 FATAL). -2. **Discovery ritual:** reconcile routes/notifications/features/assets/backend with coverage; fold new surfaces in. -3. **Run the cheap gates FIRST (before live driving):** (a) the **automated test suites** — `./gradlew testDebugUnitTest` - + `cd functions && npm test` (they cover the fragile logic: encryption format, rate limiter, quiet hours, streak, - entitlement math) — **a red suite is a P0/P1 regression gate, stop and fix before QA'ing a build**; (b) the **scanners** — - `qa/entrypoint_smoke.sh` (both serials), `scripts/theme-scan.sh` (Pass C), `scripts/wiring-scan.sh` (Pass N), - `scripts/painter-xml-scan.sh` (crash guard — `painterResource()` on a non-`` XML drawable throws on render; - caught O-ONBOARD-001 class — exit≠0 is a P0 gate); (c) the **instrumented render smoke** (when an emulator is attached) - — `./gradlew :app:connectedDebugAndroidTest` runs `FirstRunRenderSmokeTest` (first-run composables paint in light+dark; - the on-device net for the "composes fine, crashes on first paint" class — a red run is a P0 gate). **⛔ RUN THIS ON A - THROWAWAY (5558), NEVER on the 5554/5556 fixtures — `connectedDebugAndroidTest` UNINSTALLS the app-under-test on - completion, which WIPES its data (auth + couple keys + App-Check debug token), same effect as the forbidden `pm clear`; - R25 wiped QA(5554) this way and had to surface a user-gated re-auth to recover it.** (d) optional - **monkey fuzz** `adb shell monkey -p app.closer --throttle 300 --pct-touch 90 -v 5000` (any crash = bug). File 🔴/🟠 to - `ClaudeReport.md`; record counts in coverage. -4. **Run the passes report-only**, sub-batched to one context window each — recurring set **A–N + P** (K money-path + - O release gates only when a sandbox device / pre-ship is in scope). Checkpoint the MD files after each chunk. -5. **Fix phase** (after all passes): by severity P0→P1→P2→P3, one at a time, verify each live via the **real path** + - re-run the relevant scanner, flip the row to Fixed, capture the durable substance in the Engineering Manual. -6. **Re-QA loop** until **flawless** (see Definition of Done). Prune confirmed-fixed rows. -7. **You never `git commit`/push — the user commits.** Your durable state is the MD files (they survive compaction). - -## 📖 Architecture reference (read BEFORE testing the matching area) - -For each Pass below, before you start, read the relevant section of [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — it documents the architecture, the wire-format contracts, the security invariants, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) (bugs that cost real debugging time and are easy to re-introduce). - -**This is bidirectional — the manual is a LIVING document, not a read-only reference.** Read it before; **write back to it after.** Whenever a round fixes a bug, changes a contract/flow/gate, or finds the manual stale or missing something, update the manual in the same chunk (see *Where every finding goes*, the *Docs update rule*, and the *MANDATORY retrospective* — all now route durable engineering truth here). Treat it as part of every fix, same as `ClaudeReport.md`/`ClaudeQACoverage.md`. - -| Pass | Manual section to read first | -|---|---| -| A — Couple-shared premium | [Premium-gated features and gate pattern](docs/Engineering_Reference_Manual.md#premium-gated-features-and-gate-pattern) · [Billing](docs/Engineering_Reference_Manual.md#billing) | -| B — Games lifecycle | [Game session push semantics (idempotent flag-claim)](docs/Engineering_Reference_Manual.md#game-session-push-semantics-idempotent-flag-claim) · [Foreground game-alert banner](docs/Engineering_Reference_Manual.md#foreground-game-alert-banner-r10) · [F-RACE-001](docs/Engineering_Reference_Manual.md#f-race-001-duplicate-game-start-push-on-rapid-partner-update) | -| C — Visual (light+dark) | [Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle) · [C-NAV-001](docs/Engineering_Reference_Manual.md#c-nav-001-back-from-home-resurfaces-onboarding-auth) · [Back-stack gotchas](docs/Engineering_Reference_Manual.md#back-stack-gotchas-c-nav-002-c-nav-003) · [C-HOME-001](docs/Engineering_Reference_Manual.md#home-duplicate-pending-action-card-c-home-001) | -| D — Security & encryption | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Firestore security rules](docs/Engineering_Reference_Manual.md#firestore-security-rules) · [Encryption versions](docs/Engineering_Reference_Manual.md#encryption-versions) | -| E — Notifications | [Notifications](docs/Engineering_Reference_Manual.md#notifications) · [Notification deep-link routing](docs/Engineering_Reference_Manual.md#notification-deep-link-routing) · [E-GAME-001](docs/Engineering_Reference_Manual.md#e-game-001-notification-deep-link-landed-in-stale-finished-game) · [E-GAME-002](docs/Engineering_Reference_Manual.md#e-game-002-game-start-push-easy-to-miss-when-app-is-foreground) | -| F — Resilience | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Known limitation: single-device keys](docs/Engineering_Reference_Manual.md#known-limitation-single-device-keys) | -| G — Account creation / fake-account | [Authentication and pairing flow](docs/Engineering_Reference_Manual.md#authentication-and-pairing-flow) · [Rate limiting on accept](docs/Engineering_Reference_Manual.md#rate-limiting-on-accept) | -| H — Branding & artwork | `ClaudeBrandingReview.md` (this repo) · `docs/brand/visual-identity.md` | -| I — Performance | [Engineering conventions](docs/Engineering_Reference_Manual.md#engineering-conventions) · [Where to look first](docs/Engineering_Reference_Manual.md#where-to-look-first) | -| J — Accessibility | [CloserTheme](docs/Engineering_Reference_Manual.md#ios-specific-notes) · [Engineering conventions](docs/Engineering_Reference_Manual.md#engineering-conventions) | -| K — Billing & subscription lifecycle | [Billing](docs/Engineering_Reference_Manual.md#billing) · [Premium-gated features and gate pattern](docs/Engineering_Reference_Manual.md#premium-gated-features-and-gate-pattern) | -| L — Messaging & chat (E2E) | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Notifications](docs/Engineering_Reference_Manual.md#notifications) | -| M — Settings & account management | [Authentication and pairing flow](docs/Engineering_Reference_Manual.md#authentication-and-pairing-flow) · [Notifications](docs/Engineering_Reference_Manual.md#notifications) | -| N — Daily question & interactive features | [Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle) | -| O — Release build & store readiness | [Firestore security rules](docs/Engineering_Reference_Manual.md#firestore-security-rules) · [Engineering conventions](docs/Engineering_Reference_Manual.md#engineering-conventions) | -| P — Content, copy & language | `docs/brand/visual-identity.md` (Store voice) · `seed/questions/QUESTION_CONTENT_GUIDE.md` (v3) | - -**If you find a bug that LOOKS like it might be a re-introduction of a known landmine** (above table or [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes)), stop and verify the fix is still in place before filing a new ID — it may be a regression on a known issue, not a new bug. - -## 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 (P0–P3) + 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) | -| **Automated scanner findings** | **`ClaudeReport.md`** (CRITICAL/MAJOR that break themes/functionality) **+** `ClaudeQACoverage.md` (execution counts + filing status) | ID + file:line + pattern + fix suggestion | -| **Durable engineering knowledge** — a fixed bug's root cause + how it's easy to re-introduce, a new architecture fact / data path / wire-format contract / security invariant / gate pattern, or anything the manual is now stale/missing about | **[`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md)** (esp. [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes)) | New landmine entry (ID + cause + the guard) and/or an updated architecture/gate/flow section | - -- 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`. -- **WRONG LANGUAGE IS A BUG (not a Future.md idea).** A typo, grammar/punctuation error, off-brand or cold/salesy voice, - non-inclusive/assumptive wording, leaked placeholder/dev/raw-error text, copy that doesn't match behavior, or a - broken/duplicate/off-guide question → **`ClaudeReport.md`** (see **Pass P**). Only genuinely-working copy that *could be - warmer/clearer* (a rewording for delight) goes to `Future.md`. "Confusing copy" that actually misleads the user is a bug. -- **ONE canonical home per fact; everywhere else is a pointer (ID/anchor), never a paraphrase.** This is the rule that - keeps the five docs from duplicating each other (and wasting tokens re-stating the same lesson). Route by *purpose*: - the **defect** (repro/severity/status) → `ClaudeReport.md` (transient — prunes to an ID after one confirm); the - **substance** (root cause / why it's fragile / how to not re-introduce it) → the **Engineering Reference Manual** - (permanent, engineer-facing); the **reflex** (how to FIND the class next round) → this `ClaudeQAPlan.md` Pass - (generalized, citing the ID); **coverage status** → `ClaudeQACoverage.md`; **cross-session ops not in the repo** - (accounts, tooling, auth) → `memory/`. State a fact in its home once; elsewhere cite the ID. Don't restate a fix in - four docs. -- **The Engineering Reference Manual is a LIVING document — read it before a pass, write back to it after.** When a - round teaches the codebase something durable (a fixed bug's re-introduction risk, a new/changed architecture fact, - data path, contract, gate, flow, collection/Function/route, or the manual disagreeing with reality), update the manual - in the **same chunk**. **A fix is not complete until its durable substance is in the manual** (see the - MANDATORY-retrospective rule). The report row and the Pass reflex just reference the manual's landmine ID — they don't - re-tell it. -- 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 -Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Core QA dimensions (cornerstones): -1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**. -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. -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. -5. **Notifications** — the **full suite**: every type delivers to the right partner (foreground/background/killed), - deep-links correctly, opens the right destination on **both clients**, covers all **game/join-game** flows, handles - stale notifications, and leaks no private content. - -These five are the original cornerstones; the playbook has since grown to cover the rest of the app as first-class -passes (see **QA passes** below): **K Billing & subscription lifecycle** (the real purchase/restore/cancel/expiry money -path, not just the admin entitlement toggle), **L Messaging & chat** (E2E send/receive/react/media, both clients), -**M Settings & account management** (every toggle persists + takes effect; biometric lock, quiet hours, unpair/delete), -**N Daily-question/reveal/check-ins + Bucket List/Date Builder/Activity** (the interactive non-game features), and -**O Release build & store readiness** (the **minified release** build, signing/AAB, App Check, i18n, deep/App-Links, -Play Data-Safety — everything else runs on the debug APK), and **P Content, copy & language** (typos/grammar, brand -voice, inclusive language, and the **question-bank** content — *wrong language is a bug, not a Future.md idea*). Plus the -existing **F resilience**, **G account/abuse**, **H branding**, **I performance**, **J accessibility**. **Pass letters are stable IDs — never renumber** (issue IDs and -coverage rows reference them; note D/E/G are not in strict alphabetical position for that reason). - -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 -`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 -`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. - -## Execution mode — run to completion (autonomous; do NOT stop) -- **Do not stop to check in or ask for approval.** Run all passes (A–P — recurring set A–N + P each round; K's real-money path and O's release/store gates run when a sandbox device / pre-ship is in scope) → the fix phase → re-QA rounds **continuously - until a flawless round** (zero open P0–P2, Passes D + E clean, every game fully played through, all notification - 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 - 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 - findings are still logged and fixed in the fix phase. -- **"Once executed, complete it":** never declare done before the Definition of Done is met — keep cycling fix → re-QA - until flawless, then stop. -- **Context limits ≠ stopping — do NOT hand back to the user when context fills.** The harness auto-summarizes a long - conversation and continues in the next window; you continue **without the user**. (You cannot self-invoke `/compact` - — and you don't need to; auto-compaction handles it.) The **committed `ClaudeReport.md` run-state + `ClaudeQACoverage.md` - are the authoritative state** and survive any compaction — after a summary, **re-read them and continue at the next - chunk**. Never pause a run merely because context is getting long; only stop for a true blocker (a denied gated action - even with standing auth, or the macOS requirement for iOS). -- **Checkpoint (save the working-tree MD files + run-state) before anything interruptible** so a mid-chunk compaction never loses progress (the user commits — never run git yourself). Keep chunks atomic; if a - chunk is cut off mid-way (e.g., a game session left active), the **session-start ritual recovers it** (clear the stuck - session via in-app "End their game", then redo that chunk). Right-sized chunks (see Batch sizing) make this rare. -- **Don't pause for "by-design vs bug":** log the ambiguous finding and keep going (don't unilaterally rewrite - deliberate design — the log captures it). Never halt the run to ask. -- **Only true stop = a gated action you cannot perform.** Production deploys, admin Firestore writes/seeds, and - entitlement toggles still need per-occurrence authorization (the classifier enforces this regardless of this doc). - If one is genuinely required to proceed and is denied, do **all** other work first, then surface only that single - blocker — don't halt the whole run for it. - -## Methodology (every pass) -- **EVIDENCE OVER ASSUMPTION — read the logs, never assume, always verify (the #1 rule).** Every conclusion — - `pass`, `fail`, `fixed`, "it works", "the notification didn't open" — must be backed by **observed evidence**, never - by what the UI *appears* to do or by reasoning about the code. Concretely: - - **Read `logcat` on EVERY action, not only when something looks wrong.** `logcat -c` before a tap/flow, then after, - scan for `FATAL EXCEPTION`/ANR/`PERMISSION_DENIED`/exceptions. **Absence of a visible symptom ≠ success** — a screen - that "looks fine" can be masking a swallowed exception, a denied read, or a crash on another device. - - **Verify with ground truth, not appearance:** confirm persisted state via **admin reads** (Firestore), confirm - delivery via `notification_queue`/`dumpsys notification`, confirm routing via the landed screen + back stack, - confirm encryption via the raw stored bytes. "Looked right" is not verified. - - **Don't theorize a root cause — reproduce it and read the stack.** If behavior is "didn't work / closed / flashed", - pull the crash log FIRST (this session's bug was misdiagnosed by reasoning until the live stack named the splash NPE). - - **Don't trust a synthetic pass** (`am start`, admin write, direct call) for launch/notification/permission paths — - verify through the **real** channel (see Reproduction fidelity). A green that didn't exercise the user's path is not green. -- Devices: **5554 (QA)**, **5556 (Sam)**, paired; one **fresh throwaway account** for pre-pairing flows. -- Drive via adb tap/swipe; resolve coords from `uiautomator dump` bounds; downscale screenshots to read; - scan `logcat` for `FATAL EXCEPTION`/ANR on each screen. -- Premium toggled via `scratchpad/set_premium.js` (admin, **user-authorized each time**). -- Theme toggled via **Settings → Appearance (Light/Dark)** (`MainActivity` `ThemeMode`). -- **REPORT-ONLY during passes — never fix mid-pass.** -- **THINK AS A CONSUMER — approach everything from different angles.** Beyond "does it work", constantly ask *"is this - what a real person would expect / want here? is this delightful, confusing, or annoying?"* Come at each flow from - multiple angles (first-time user, returning user, the partner who didn't start it, someone tapping fast, someone - reading carefully, the skeptic, the impatient). Vary inputs, depths, orders, and entry points (don't repeat one - happy path). A thing can be bug-free yet still *worse than it should be* — notice that too. -- **CAPTURE IMPROVEMENT / FEATURE IDEAS → `Future.md` (section `## QA`).** Bugs (broken/incorrect behavior) go to - `ClaudeReport.md` as always. But anything that *works yet could be better* — confusing copy, a missing affordance, - a rough-but-not-broken flow, a "it'd be great if…" feature idea — append it to **`Future.md` under `## QA`** with a - short title, what prompted it, and the suggested improvement. This is an idea backlog, **not** the bug log; logging - here is never a substitute for filing an actual defect in `ClaudeReport.md`. -- **Environment (senior-QA rec):** prefer the **Firebase Local Emulator Suite or a dedicated staging project** over - production — isolates test data, makes seeding / entitlement toggles / D3 negative tests **free** (no gated prod - writes), and avoids polluting real users. Caveat: App Check, RevenueCat IAP, and real FCM/APNs push need real - services — run those against staging/prod with test accounts. (We've been on prod with test accounts — works, but - every seed/toggle/deploy hits the gate.) -- **Device/OS matrix (pre-ship gate — currently NOT met; track it honestly):** per-round QA runs on our **two - identical emulators (5554/5556, same API + screen size)** — that's the realistic recurring setup, not full coverage. - Before any store push, certify across **minSdk + targetSdk**, a **small** and a **large** screen, and at least one - **physical device** (App Check / Play Integrity behave differently on emulators). Because this is unmet today, keep a - `blocked→needs-device` row for it in `ClaudeQACoverage.md` (alongside Pass K money-path + Pass O) so the gap stays - visible rather than silently assumed-covered — don't claim "device matrix ✓" off two same-size emulators. -- **⛔ First-run / cold-path lane (fixture blind-spot — learned from O-ONBOARD-001, a P0 we shipped past).** The two - recurring emulators (5554/5556) are **paired + signed-in + onboarding-complete** — a stable fixture that is great for the - A–N passes but **structurally cannot reach the entire first-run surface**: onboarding (every slide + **Skip**), sign-up, - login, the auth-screen logo, pairing/invite-accept, recovery-on-new-device, and day-1 empty states. A bug anywhere in - that region is **invisible no matter how thorough the passes are** — O-ONBOARD-001 (every fresh install crashed on the - last onboarding slide) sat undetected precisely because the fixtures skip it. **So: run a fresh-install lane on a - THROWAWAY device** (e.g. `emulator-5558`, or a fresh AVD — **never `pm clear` 5554/5556**, it breaks the App Check debug - token): install the build, walk onboarding all the way through (+ Skip) → sign-up → login → pairing → first daily Q, - asserting 0 FATAL and each screen renders. **Trigger it every time you touch onboarding / auth / pairing / branding / - launcher or any `res/drawable` asset, and always before a store push.** Keep a `first-run` row in `ClaudeQACoverage.md` - so this blind-spot stays visible instead of assumed-covered. -- **Render-level coverage gap (the other half of why O-ONBOARD-001 slipped).** Our cheap gates are all static or - logic-level — unit/functions tests, `theme-scan`, `wiring-scan`, `painter-xml-scan` — **none of them actually render a - composable.** `painterResource` on a `` compiled fine, passed all 205 unit tests, and only threw on first paint. - There is a whole class of "composes fine, crashes on render" bugs (resource resolution, `LocalContext` casts, bad - `painterResource`). **R20 added the first on-device net for it:** `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt` - renders the first-run crash composables (`CtaSlide` + `AuthLogoMark`, light+dark — the exact O-ONBOARD-001 sites) via a - Compose `createComposeRule()` and asserts they paint; proven to FAIL on the reintroduced bug. Run it with - `./gradlew :app:connectedDebugAndroidTest` (needs a connected emulator; filter a class with - `-Pandroid.testInstrumentationRunnerArguments.class=…`). It currently covers **only the first-run leaf composables** — - most routes still have no render test, so the **fresh-install lane above remains the net for the rest** until the smoke - grows (see `Future.md` — extend toward sign-in→pair→daily-Q→game with a Hilt test runner, and/or a Roborazzi/Paparazzi - screenshot suite). Treat both the fresh-install lane and (when an emulator is attached) `connectedDebugAndroidTest` as - part of the render-crash net. -- **Automate the regression smoke:** capture the smoke checklist as a runnable script (adb/Maestro) so every round - re-checks it cheaply instead of by hand. **Built:** `qa/entrypoint_smoke.sh ` (+ helper - `qa/qa_push.js`) — the cold-start / entry-point launch-integrity smoke. It launches via the launcher AND sends a - **real** push to a killed (`am kill`) app and **taps the actual OS notification** for each type, asserting the app - **opens and STAYS** (process alive, 0 FATAL, off the launcher). This is the smoke that catches the "opens-and-closes" - splash-crash class that `am start` can't. Run it **every round and after any change touching MainActivity / splash / - theme / manifest / nav / notifications**. `FAIL` = an app crash (real bug); `BLOCK` = push not delivered (flaky - emulator FCM — rerun, not a bug). -- **Run the project's OWN test suites every round (they are the cheapest, most deterministic regression net).** Before - the scanners and live driving, run `./gradlew testDebugUnitTest` (19 unit tests — `FieldEncryptorTest`, - `SealedAnswerEncryptorTest`, `NotificationRateLimiterTest`, `QuietHoursManagerTest`, `StreakCalculatorTest`, - `ChallengeStateMachineTest`, `PartnerNotificationManagerTest`, `HomePriorityEngineTest`, `DateMatchRepositoryImplTest`, - `CloserBrandCopyTest`, …) and `cd functions && npm test` (`entitlementLogic.test.ts`). **A failing test is a regression - bug (P0/P1) — file it and do not QA a build with a red suite.** A fix that breaks a test isn't "Fixed" (see Fix phase). - These guard the exact invariants this QA chases (ciphertext format, rate limiting, quiet-hours suppression, - entitlement math), so a green run is a precondition, not a bonus. (**Instrumented coverage (R20):** the first on-device - test now exists — `FirstRunRenderSmokeTest` (a Compose render smoke of the first-run screens); run it when an emulator - is attached with `./gradlew :app:connectedDebugAndroidTest`. It's still **first-run-only** — broader UI/nav/DB-DataStore - behavior remains uncovered, so the live passes + scanners are still the main UI-behavior net; grow the suite per `Future.md`.) -- **Stress / monkey fuzz (cheap random-crash net the manual nav-fuzz misses):** once per build run - `adb shell monkey -p app.closer --throttle 300 --pct-touch 90 -v 5000` on each emulator with `logcat` capturing — - any `FATAL EXCEPTION`/ANR it triggers is a bug (file it with the monkey seed). This complements Pass C's *targeted* - nav fuzzing with broad random input. -- **Run associated automated scanners BEFORE the manual pass.** Every pass with a supporting script must start with it: - - **Pass C:** run `scripts/theme-scan.sh` and review `/tmp/claude-theme-scan-.md` before looking at any screen. - - **Pass N (+ discovery ritual):** run `scripts/wiring-scan.sh` and review `/tmp/claude-wiring-scan-.md` before - driving the interactive features — it catches the **silent dead-feature class** (N-001 Bucket List, N-002 Date - Builder): 🔴 a `setX()` ViewModel setter with **no caller**, 🟠 a repository read method with **no `ui/` caller** - (data written but never displayed), 🟡 `if (x.isEmpty()) return` bail-guards to confirm the state is actually - provided. Every 🔴 is a likely dead feature — prove the feature works by persisting real data and reading it back - from Firestore (admin), not by trusting the empty-state render. - - If a scanner does not yet exist for a pass but the pass is highly automatable (e.g. touch-target sizing for Pass J, - `enc:v1:` leak grep for Pass L, redundant-read count for Pass I), consider building it and adding it here. - - Scanner findings narrow the manual sweep: every 🔴 CRITICAL must be verified (both themes for C; live persist→read - for N); 🟠 MAJOR must be reviewed for theme/art breakage or orphan data; 🟡 REVIEW is checked during the sweep. - - If a manual finding is something the scanner should have caught, improve the scanner (see Living discovery ritual). -- **Test-data hygiene:** keep known test accounts; clean up artifacts (stray messages/reactions/sessions) between - rounds so they don't masquerade as bugs. -- **Evidence standard:** every filed bug must be reproducible from text alone: build/commit, device, account, theme, - app/process state, screen/route, exact tap/input sequence, expected result, actual result, and whether logcat showed - a crash/ANR/permission denial. Screenshots/videos are helpful but never the only evidence because session artifacts - may not survive compaction. -- **Flake policy:** if something fails once and then passes, do not dismiss it. Repeat from a clean state, vary timing - (rapid tap / slow network / background-resume), inspect logs, and file it as intermittent if it cannot be made fully - deterministic. Intermittent routing, notification, encryption, duplicate-write, or crash behavior is still a bug. -- **Reproduction fidelity (how we catch DEEP bugs) — the test harness must exercise the SAME path as the user.** A - synthetic shortcut (`am start` extras, admin writes, calling a function directly, `am force-stop`) can **pass while the - real path crashes** — the splash-handover NPE only fires on a real notification cold-start, and `am force-stop` can't - even receive FCM. So for launch / notification / permission / IPC / deep-link behavior, reproduce through the **real OS - mechanism** (real push tapped from the shade, real launcher cold-start, real permission dialog). Record **which angle** - proved it in `ClaudeQACoverage.md`; "synthetic/UI-shortcut only" is **not** a pass for these paths. -- **Symptom→inspection reflexes (apply before theorizing a root cause):** (1) "opens-and-closes / flashes / silently - fails" ⇒ it's a **crash until the stack says otherwise** — `logcat -c` then capture `FATAL EXCEPTION` from the live - repro **before** proposing a cause (don't fix by reasoning, like the routing red-herring on this very bug). (2) - **Many features break at once ⇒ inspect the SHARED code path** (launch/`onCreate`/splash/auth/key-load), not each - feature. (3) "worked before, broken now" ⇒ **diff & history-check before you fix**: `git blame`/`git log -L`/`git diff` - the failing line to the introducing commit (**incl. other agents' commits — Codex/kimi/Ripley co-edit this repo**), and - search the Engineering Manual landmines + the report's archived-ID line for a prior fix of the same symptom — a match - means **regression, not a new bug** (full procedure: the Fix-phase **Regression triage** step). (4) - Treat cosmetic/branding/theme/manifest/splash commits as **capable of deep crashes** — re-run the cold-start + - notification smoke after them. - -## Living discovery ritual (before each round, and whenever reality disagrees with the docs) -The app is allowed to grow; the QA plan must keep up. Before a pass or chunk, quickly inventory the current code/app -surface and reconcile it with `ClaudeQACoverage.md`: -- **Routes/screens:** inspect `core/navigation/AppRoute.kt`, navigation graph call sites, Settings sub-pages, dialogs, - bottom tabs, deep links, and any new composables reachable by buttons/cards. -- **Notifications:** inspect notification type enums/classes, Cloud Function triggers, Android intent/deep-link handling, - notification channels/actions, FCM token registration, and Android runtime notification permission paths. -- **Features/gates:** grep for premium checks, permission requests, media pickers, billing/paywall entry points, - destructive actions, account/couple lifecycle actions, and admin/server-only writes. -- **Assets/content:** inventory new drawables, `drawable-night*` variants, pack art, empty states, strings, feature flags, - remote config, and any debug-only screens that should not ship. -- **Backend/rules:** inspect Firestore rules, indexes/queries, Functions triggers/callables, Storage paths, scheduled - jobs, and migrations for new data shapes or access paths. -- **⛔ Reverted-then-reinstated code (this is exactly how Date Memories/Reflection slipped R25):** diff the working - tree against the coverage matrix — `git status --short` staged **additions** (`A `) and the recent `git log` for - `Revert`/`re-add` churn. A feature that was reverted and later re-added is **new to QA even if the commits look old**; - re-enter it into the relevant passes. Cross-check that every `AppRoute`/notification type/Function trigger present in - code has a coverage row. -- **Docs update rule:** if the inventory finds a page, feature, notification, asset, state, backend path, or edge case - missing from the playbook/coverage, update `ClaudeQAPlan.md` and `ClaudeQACoverage.md` before marking the chunk done. -- **Scanner update rule:** if a manual finding is a pattern an existing scanner *should* have caught (e.g. a hardcoded - surface color the theme scanner missed, a route the smoke should have exercised), improve that script and document the - change in its header. If no scanner exists for a repeated failure mode, consider writing one and adding it to - **Methodology**. - If it is product polish, also add it to `Future.md`; if it needs new artwork, add it to `ClaudeBrandingReview.md`. - **And if the discovery is a durable engineering fact (new route/collection/Function/flag/contract, a changed wire - format, a renamed file, a gate/flow that the manual describes wrongly or omits), update - [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) in the same chunk** — the discovery - ritual is exactly when the manual drifts out of date, so reconcile it then, not "later". - -## Multi-angle attack mandate (go DEEPER than "does the happy path work") -A capability can pass via the UI yet fail when hit directly. Probe each meaningful capability (read/write a private -field, gate a premium feature, deliver/route a notification, start/finish a game, pair/unpair, create an account) -from as many **independent angles** as apply — not just the in-app happy path: -- **Real UI** (play-as-user) — the baseline angle. -- **Crafted intent / deep-link** — fire the exact intent a notification/link carries (bypasses UI nav) to test routing - in isolation; also send **malformed/missing extras** → must route gracefully or no-op, never crash. -- **Raw API against the DEPLOYED backend** — hit Firestore/Storage/Functions REST **directly** with a real token, - as a **member AND a non-member**, to exercise rules + App Check from OUTSIDE the app. A non-member (or no-App-Check) - request must be **DENIED** — App Check `403` or rules `PERMISSION_DENIED`. The member request characterizes which - layer enforces. **Any unauthorized `200` returning couple data = P0.** -- **Admin inspection (ground truth)** — read the RAW stored docs/objects (admin bypasses rules) to assert what is - actually persisted: ciphertext only, no plaintext, no raw keys/invite-seeds, no private content in pushes. -- **Concurrency / race** — two partners (or two rapid taps) hit the same thing at once. -- **Killed / cold state** — kill with **`am kill `**, NOT `am force-stop`: a force-stopped app is in Android's - *stopped* state and is **excluded from FCM broadcasts** (`GCM broadcast …result=CANCELLED`), so the push never - arrives and you get a false "no notification". Then deliver a **real** push and **tap the actual OS notification** - (one at a time — clear the shade first; tapping a *grouped summary* launches with no extras and falsely lands on - Home). `am start … --es type …` is **not** equivalent to a real notification tap (different launch path — see the - crash-triage note in Pass E). Also cold-start straight onto a deep link. -- **Malformed / abusive input** — oversized, empty, rapid-fire, injection-ish, forged FCM payloads, replayed/expired - tokens & invite codes. -- **Offline / flaky** — drop network mid-action → graceful failure, recover on reconnect. - -Record **which angles** were tried per area in `ClaudeQACoverage.md`. For security- or data-sensitive capabilities, -"UI happy path only" is **not** a `pass`. **D3/Pass G negative access MUST be executed live via the raw-API angle each -round — never deferred to "only 2 emulators."** (Mint a token for a non-member UID via admin → exchange for an ID -token via the Identity Toolkit REST `signInWithCustomToken` → use it as Bearer against the Firestore REST API.) - -## Continuity & resumability (this effort WILL span many context windows — don't lose state) -State lives in **files**, not memory: -- **`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. -- **`ClaudeQACoverage.md`** = the coverage matrix: every screen×mode, feature×premium-state, game×lifecycle, - 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; - `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 | - NEXT ACTION: …` — first thing to read, last thing to update before stopping. -- **Stable issue IDs**: `A-001 / B-002 / C-… / D-… / E-…` (pass-letter + number); coverage references the ID for - every `fail`. Never renumber or reuse. -- **Source of truth**: the two MD files are authoritative; the TodoWrite list is scratch for the current chunk only. - Update the MD files + run-state header *before* ending a session. -- **Living playbook rule:** when QA discovers any new app surface or recurring lesson — a new page/route, feature, - setting, game state, notification type/action/channel, entry point, background/killed-state behavior, asset/art - placement, repeatable bug class, missed edge case, fragile route, confusing state, image/layout failure mode, - security angle, or anything else that should be checked every future round — update **this `ClaudeQAPlan.md`** in the - relevant pass before ending the chunk. Also add the matching row/cell to `ClaudeQACoverage.md` if it needs recurring - verification. **And update [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) when the - discovery is durable engineering truth** (a new architecture fact, data path, contract, gate, flow, or a fixed bug's - re-introduction risk) — the QA plan captures *what to re-test*, the manual captures *what the system is and why it's - fragile*; both are living and both get updated. Do this even after the immediate bug is filed/fixed so the lesson or - newly discovered surface is not lost to memory or git history. -- **Learn from every ESCAPED or DEEP bug — MANDATORY retrospective (do this automatically, not only when asked).** - Any bug that (a) **escaped a prior round**, (b) needed **non-obvious diagnosis** (a crash, an "opens-and-closes", - a "didn't work", an intermittent, a wrong-root-cause first guess), or (c) **recurred** triggers a short retrospective - the moment it's fixed — the fix is **not complete** until all four are done: - 1. **Add the guard that would have caught it** — a new `qa/` smoke check, a coverage row, or a concrete pass step - (e.g. the cold-start bug → `qa/entrypoint_smoke.sh`). If an existing smoke missed it, extend the smoke. - 2. **Capture the lesson in its ONE canonical home, then link by ID elsewhere — never paraphrase it twice.** Split by - purpose: the **reflex** (how to *find* this class next round) goes in the relevant Pass of **this doc**, written - *generalized* and citing the bug ID as an example (do NOT re-narrate the bug here); the **substance** (root cause + - where it lives now + re-introduction risk + the guard) goes in - [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) → [Known landmines and recent - fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) (and update the matching - architecture/gate/flow section if the fix changed it). The manual is the next engineer's first read; a landmine - that isn't in it will be re-introduced. **Do NOT copy the fix into `memory/`** — per the memory rules, memory holds - only cross-session facts NOT in the repo (emulator↔account map, admin tooling/commands, standing auth, - never-commit); past fixes belong to the manual, so memory just points to the landmine ID if needed. - 3. **Name the missing state/angle/entry-point** that let it hide and add it to the multi-angle / state matrices so it's - exercised every round (e.g. "real notification tap on an `am kill`'d app", not just `am start`). - 4. **Note any wrong turn in diagnosis** so the misstep isn't repeated (e.g. "synthetic test passed while the real - path crashed → don't fix by reasoning; reproduce via the real channel + read the stack"). - This is how the plan self-improves between rounds — treat the human pointing out a missed bug as a signal the plan had - a gap, and close the gap here, not just the bug. -- **Checkpoint cadence**: save `ClaudeReport.md` + `ClaudeQACoverage.md` + run-state after each pass and each chunk (the **user** commits — never run git yourself; see Guardrails). -- **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 - online; (3) **installed build == current HEAD** (rebuild+reinstall if unsure — never QA a stale APK); (4) continue - 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) -A pass is a **category**, not a unit of work. Execute each pass as **sub-batches (chunks)**, where a chunk = the -**largest coherent unit that reliably finishes AND checkpoints within one context window, with margin**. End every chunk -by saving the MD files + run-state (the user commits — never run git yourself). If a chunk starts overflowing, split it; -if chunks feel trivial, merge them. -**Why:** in Round 1, A & D fit as single batches, but B/C/E were too large → got cut off → deferred. Sub-batching -prevents half-done/lost work and gives cleaner per-chunk verification + revertable history. - -Default small: if a chunk requires two-device live driving, screenshots/montage review, logcat checks, or admin/API -verification, keep it to **one small route family, one game phase, or one notification type**. A chunk is too large if -it cannot produce a precise coverage update, issue log, and file-checkpoint before context gets tight. Split before starting -rather than leaving a half-tested matrix behind. **Prefer Claude-friendly micro-batches**: smaller chunks let the agent -fully inspect screenshots, tap every CTA, vary app states, update files accurately, and avoid shallow "covered" rows. - -| Pass | Chunk granularity | ~chunks | -|---|---|---| -| A Premium | one gated-feature family per chunk if live toggles are needed; otherwise free-state sweep → couple-shared verify | 2–4 | -| B Games | **one game per chunk max**; split complex games into lifecycle/playthrough chunk + join/resume/results/notification-entry chunk | 7–14 | -| C Visual | **one small route family per chunk** (both themes, ~2–3 screens/states, screenshots reviewed + nav/back + image-fit + all CTAs for that family) — never "all screens" or a broad tab at once | 16–25 | -| D Security | one security assertion group per chunk: D1 at-rest · D2 rules static · D3 live negative raw API · D4 keys/recovery · D5/D6 leaks · D7 migration | ~6 | -| E Notifications | **one notification type per chunk** with the full contract below; split a type into direction/state subchunks if needed, but do not mark the type pass until both clients + source screens + fg/bg/killed + stale/malformed + payload/back-stack are covered | 16–30 | -| 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 small route family per chunk** (~2–3 screens/states) consumer brand walk + ready-to-paste art prompts + existing-image integration verdict | 8–14 | -| 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 | -| K Billing | **one money-path per chunk** (purchase · restore · plan-switch · cancel→expiry-relock · refund · webhook auth) — needs a real device/sandbox | ~6 | -| L Messaging | **one chat dimension per chunk** (send-types both dirs · reactions/receipts/typing · failed-send/offline · media perms · inbox/entry-points · delete/moderation) | ~6 | -| M Settings | **one settings group per chunk** (appearance · notif toggles · quiet hours · biometric lock · edit profile · unpair/delete · security/recovery) | ~6 | -| N Interactive features | **one feature per chunk** (daily-question loop · outcomes/check-ins · Bucket List · Date Builder · Activity feed) | ~5 | -| O Release/store | **one gate per chunk** (minified release smoke · signing/AAB · App Check (staging) · deep/App-Links · permissions/manifest · i18n · Data-Safety/store) — pre-ship, not per-round | ~6 | -| P Content/language | **one surface per chunk** (UI microcopy of a route family · voice/tone sweep · inclusive-language sweep · question-bank by category/depth · legal/store copy) | ~5 | - -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. - -## Guardrails & efficiency -- **⛔ NEVER `git commit` / `git push` — the USER does ALL commits.** This overrides every "commit" verb elsewhere in - this doc: wherever a step says "commit," read it as **"checkpoint = save the working-tree files (`ClaudeReport.md` + - `ClaudeQACoverage.md` + run-state, plus any code/docs)"** and leave the actual `git commit` to the user. Your durable - state lives in those files (they survive compaction), not in a commit you make. Never stage, commit, push, branch, or - amend. -- **Never `pm clear` / wipe app data** — breaks the App Check debug token. Pre-pairing QA: sign-out → fresh sign-up. -- **Never run `connectedDebugAndroidTest` (or any instrumented test) on the 5554/5556 fixtures** — AGP UNINSTALLS the - app-under-test after the run, wiping its data (auth + couple keys + App-Check token) exactly like `pm clear`. Run the - render smoke on a **throwaway (5558)**. (R25 wiped QA/5554 this way → needed a user-gated re-auth to recover.) -- **Never run `seed/build_db.py`.** Admin seeds/writes, entitlement toggles, and any deploys are **user-authorized per occurrence**. -- **By-design vs bug:** if a finding may be intended behavior, **log it and keep going** (don't stop to ask; don't unilaterally rewrite deliberate design — the log captures it). -- **Pass C parallelism:** set **5554 = Dark, 5556 = Light** to capture both themes at once. -- Never log decrypted message/answer content. - -## Severity scale (label every issue) -- **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. -- **P2 Minor** — readability/contrast, clipping/overflow/truncation, theme not adapting, inconsistent styling, wrong/double-back navigation. -- **P3 Polish** — spacing/alignment/copy nits. - -## QA passes (Round 1 = baseline) - -### Pass A — Couple-shared premium (target: either partner 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. -Features: Play-hub games (Desire Sync + any premium-badged), Connection Challenges, Memory Lane; Question Packs; -Spin the Wheel / Category Picker / Wheel History (+ any premium wheel categories); Date Match / Plan Date / Date -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`, -`ui/wheel/{CategoryPicker,SpinWheel,WheelHistory}*`, `ui/questions/QuestionPackLibrary*`, -`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. -- **ENFORCEMENT, not just a checker-usage grep (mandatory — RETROSPECTIVE from A-201, R12).** A feature can carry an - `isPremium` **content flag** + a cosmetic `PremiumBadge` with **NO gate at all** — that's exactly how Date Match - shipped a premium **bypass** (free users could view/like/match ★Premium date ideas; `getDateIdeas()` returned - `DateIdeaSeed.all`, no `CouplePremiumChecker`, badge only). Prior rounds missed it because the audit grepped for - `CouplePremiumChecker` *usages* and found the gated features, never noticing the feature that had **no** checker. - So every round: (1) **grep for `isPremium` / `PremiumBadge` / premium content flags** (`DateIdea.isPremium`, - `category.access=="premium"`, `challenge.isPremium`, …) and for **each** confirm a real enforcement path exists — - a `CouplePremiumChecker` filter OR a paywall-on-interaction — **not just a badge**; (2) **actually TRY TO USE the - premium content as a free user** (like/open/play it), don't just confirm the lock renders — "badge shows" ≠ "gated". - A badge with no enforcement = **premium bypass** (P1+). Inspection lesson: *"shows a Premium badge" is a display - fact, not a gate; prove the gate by using the content while free.* - -### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through ALL different play stayles of the game) -Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match. -- **PLAY AS THE USER (mandatory mindset for this pass):** drive every game **the way a real user would** — reach it - through the actual in-app navigation a person would tap (Play hub → the game's card → its buttons), **not** via - deep-links, admin pokes, forced state, or any shortcut a user doesn't have. **Expect what the user expects:** if a - tap/button/flow doesn't do the obvious thing, or a screen doesn't behave the way a normal user would assume, **that - itself is a finding** — log it. -- **When something doesn't work: REPORT FIRST, then a minimal workaround (in that order).** Do **not** silently - engineer around breakage by taking extra steps the user wouldn't take. The moment the natural user path fails: - (1) **log the issue** in `ClaudeReport.md` with severity + the exact user action that failed and what was expected; - (2) **only then** apply the smallest workaround needed to keep the pass moving. The workaround **never replaces** - the report — a flow that needs a workaround to proceed is, by definition, broken and must be filed to fix. If a - workaround is impossible, mark the game `fail→` (blocked) and continue with the next. -- **A launch/crash check is NOT sufficient. Each game MUST be played one full way through, end-to-end, on BOTH - devices** — start → answer/interact through **every** step/round/question on each device → reach the - **finish/reveal/results** screen → confirm the result renders correctly for both partners. Verify each - 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. -- 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. -- **FIRST-FINISHER → WAITING-PARTNER NOTIFICATION (mandatory state — async games):** explicitly exercise the asymmetric - state where **one partner finishes their part and the OTHER is idle/away**. The waiting partner MUST get a "your turn - to play" nudge (`partner_completed_part` via `onGamePartFinished`) the moment the first finishes — async games - (this_or_that / wheel / how_well / desire_sync) only flip to `completed` (→ `partner_finished_game`) once BOTH answer, - so without the first-finish nudge the waiting partner is told nothing. Verify the **idle partner** (on Home, or - backgrounded/killed) actually receives + can tap into the game. (This state was missed for a long time precisely - because QA always played both sides through; "one finishes, the other never played" is its own required angle.) -- **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: - - **Different DEPTHS and QUESTION COUNTS — cover the matrix, don't settle for one combo:** play each game across - **every depth/mood** (Light, Everyday, Deep, All-topics/shuffle) AND **every round length / number of questions** - (5 / 10 / 15), in *different pairings* across runs (e.g. Light×5, Deep×15, Everyday×10, All×5) — short *and* long - sessions, shallow *and* deep content. Different depths surface different question sets, tones, and edge content - (e.g. Deep/Desire-Sync sensitive prompts); different counts stress pacing, progress, and the both-answered gate. - Also exercise **each distinct answer type** (A/B, Yes/No, True/False, 1–5 scale, multi-select, free-text). - - **Different answer *patterns* that change the result** — all-match vs all-mismatch vs partial; both-yes vs both-no - vs split (so reveals show "shared", "all private", "0 matches", "perfect/zero score" — verify each renders right). - - **Different turn orders / who-starts** — partner A starts vs partner B starts; the guesser opens before vs after - the subject finishes; both open simultaneously (race); one device much slower than the other. - - **Different exit/resume styles** — finish normally; quit mid-game; background mid-game then resume; cold-kill - 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. - - **⛔ VERIFY QUIT/ABANDON ACTUALLY ENDS THE SESSION (server-side, by admin read — RETROSPECTIVE from B-ABANDON-001).** - "Quit" / "End their game" navigating away is **not** proof the session ended — the abandon write is best-effort and - **swallowed** (`runCatching{…}.onFailure{ Log.d }`), so a `PERMISSION_DENIED` looks like success in the UI. After any - quit/abandon, confirm the session is actually `completed` via an admin read (**0 active sessions**) AND that a *new/ - different* game can then be started; watch `logcat` for `PERMISSION_DENIED` on the `sessions/{id}` doc during the quit. - A session that "won't clear" between rounds is a **bug to root-cause, not a test-data nuisance** — B-ABANDON-001 (the - full-`saveSession` `doc.set()` dropping server-only flags → rule rejects the removed `affectedKeys`) hid for several - rounds precisely because it was dismissed as cleanup difficulty. See the manual's [B-ABANDON-001 landmine](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes). - - **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, 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. -- 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. -- **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 (MANDATORY: run scan BEFORE sweep) -> **⛔ CLAUDE: Run the automated theme scan (below, Automated Tier 1) before starting the visual sweep. -> Read the output at `/tmp/claude-theme-scan-.md` and file findings to ClaudeReport.md first. -> The sweep must verify every flagged screen in BOTH themes.** - -Every route in `core/navigation/AppRoute.kt` (~50), in **both** modes: text contrast/readability (no invisible/ -low-contrast), no clipping/overflow/ellipsis breakage, icons visible, backgrounds adapt, controls legible. Groups: -auth/onboarding/pairing (fresh acct); Home (solo + paired); Play + every game; Today + reveal/history; Messages -(inbox + conversation); Packs; Dates (Match/Builder/Matches/Bucket List); Wheel (picker/session/complete/history); -Settings + all sub-pages (Account, Notifications, Appearance, Privacy, Subscription, Relationship, Security, Delete -Account); Paywall; Your Progress/Activity; Recovery. -- **Images must belong to the screen:** during the UI sweep, visually inspect every illustration, glyph, banner, - empty-state image, pack art, celebration asset, and dark/light variant in context. It should feel intentionally - integrated with the page hierarchy, copy, spacing, and action area — not like a forgotten placeholder dropped into - an empty slot. Check crop, scale, padding, alignment, corner radius, background/tile treatment, theme variant, - **edge treatment**, loading/fallback state, and whether the image competes with or clarifies the primary task. If it is - broken, clipped, low-contrast, off-brand, stale, or placeholder-looking, file a bug in `ClaudeReport.md`; if the screen - works but would benefit from new/better art, log the prompt need in `ClaudeBrandingReview.md`. -- **SOFT EDGES — art must fade into the screen, not show a hard tile edge (mandatory):** every displayed illustration - should **blend/feather softly into the background**, not sit as a hard-edged rounded rectangle/card with a visible - boundary or border line. Inspect each illustration's edges against the screen on **both themes** — a crisp tile edge, - outline/border, or a pale block floating on the surface is a finding (C-ART-EDGE-001). (**Fixed R11:** `BrandIllustration` - now feathers its 4 edges to transparent via `Modifier.graphicsLayer{compositingStrategy=Offscreen}` + `drawWithContent` - `BlendMode.DstIn` linear gradients — `clip`+`border` removed — and `EmptyState` routes its illustration through - `BrandIllustration`, so all tiled art melts into the surface. Recurring check: verify it still holds and that any NEW art - helper / direct `painterResource` tile also feathers.) Fix pattern (if it regresses): feather the edges to transparent, - or a vignette matching the surface, or ship transparent-edged art — applied in the shared `BrandIllustration`/`EmptyState` - helpers so it's consistent everywhere. -- **⛔ CLAUDE — RUN THE AUTOMATED THEME SCAN FIRST (MANDATORY, BEFORE THE VISUAL SWEEP):** - Do NOT start the manual visual sweep until the automated scan has completed and you have reviewed its results. - The scanner is `scripts/theme-scan.sh`. Run it from the project root and save the report: - - ```bash - cd /home/kaspa/.openclaw/Projects/relationship-app - ./scripts/theme-scan.sh > /tmp/claude-theme-scan-$(date +%Y%m%d).md - cat /tmp/claude-theme-scan-$(date +%Y%m%d).md - ``` - - The script reports findings by severity and ends with a `## Summary` section showing the exact counts. - Record those counts in `ClaudeQACoverage.md` under Pass C **before** starting the visual sweep. - - - **🔴 CRITICAL** — container/surface/background set to a hardcoded color. Will produce visible light/dark - mismatches. Example: `Surface(color = Color.White)` inside a dialog in dark mode. - - **🟠 MAJOR** — component color overrides or direct `painterResource` that bypasses `BrandIllustration`. - Likely to break theme adaptation or decoupled-theme art. - - **🟡 REVIEW** — hardcoded text/icon/border/gradient colors that may be correct on a branded container but - must be verified in both themes. - - **⛔ CLAUDE: You are explicitly allowed to improve `scripts/theme-scan.sh` and this Pass C methodology - whenever you discover a new light/dark failure mode.** Examples: new Compose patterns that evade the - current grep, a new color token that should be checked, better false-positive filtering, or converting - the output to JSON/CSV. Keep the script runnable from the project root and update the script header - with what changed. Do not remove existing patterns unless they are provably wrong. - - **After running the scan:** read the report, file all CRITICAL and MAJOR findings to `ClaudeReport.md` as - Pass C theme defects, then proceed to the manual visual sweep. Any screen flagged CRITICAL/MAJOR must be - verified in BOTH themes during the sweep. If you fix hardcoded colors during the QA round, re-run the - scan to confirm they are gone. - - **Tier 2 — Theme definition validation:** `scripts/theme-scan.sh` also validates that `darkColors` in - `Theme.kt` has every required Material3 slot explicitly defined. If a slot is missing, log it to - `ClaudeReport.md` as a P2 theme defect. - - **Tier 3 — Compose screenshot diff suite (endgame, not yet implemented):** - The true "catch everything" solution is an automated screenshot comparison pipeline that renders every - route in light mode, renders the same route in dark mode, and pixel-diffs them — flagging any screen - where the dark version has white backgrounds, invisible text, or wrong-variant art. This catches - compositional and gradient-based mismatches that static analysis cannot. When implemented, use - `papAROS`, `Shot`, or Roborazzi with a custom `darkTheme = true` test parameter for each route. - Log this to `Future.md` as "Tier 3: Compose screenshot diff for visual regression". -- **THEME-VARIANT ART must follow the IN-APP theme, not just the system (mandatory — RUN THE DECOUPLED STATE):** the app - has its own theme toggle (Settings → Appearance → Light/Dark/Device) that swaps Compose colors but does **not** change - the Android config `uiMode`, while `-night` drawables (`drawable-night-nodpi/`) and `painterResource` resolve off the - **system** `uiMode`. So art can mismatch the UI when the two disagree. **Test the decoupled state explicitly, every - round:** force system light then set the app to **Dark**, and force system dark then set the app to **Light**, and on - every screen that has a dark art variant confirm the illustration matches the **in-app** theme (no bright/light tile on - a dark screen, no dark tile on a light screen). Commands: - `adb -s shell cmd uimode night no` (system light) / `… night yes` (system dark); then toggle the in-app theme - in Appearance. Screens with `-night` variants to check: Security (privacy_recovery), Memory Lane, Bucket List, Answer - History, Date Match (empty + success), Connection Challenges header, Pairing success, Messages empty, Past Games, - Quiet-hours, Account-deletion, + any new `illustration_*` added to `drawable-night-nodpi/`. **Restore `cmd uimode night - auto` after.** Light art on a dark screen (or vice-versa) when the in-app theme is switched = bug (P2 theme-not-adapting; - see C-DARKART-001). (**Fixed R11:** `CloserTheme` provides `LocalAppInDarkTheme`; `BrandIllustration` loads each drawable - through `context.createConfigurationContext(cfg)` whose `UI_MODE_NIGHT_*` is set from `LocalAppInDarkTheme`, so the - `-night` variant follows the IN-APP theme, not the system. Verified live R11 both decoupled directions. Recurring check: - re-run the decoupled state and confirm it still holds, including any newly added `-night` art.) Fix pattern (if it - regresses): drive the resource `uiMode` from the in-app theme as above, or `AppCompatDelegate.setDefaultNightMode`/config - override, so `painterResource` picks `-night` per the app's own setting. -- **EVERY image needs BOTH a light AND a dark variant matching the theme (mandatory — audit every image-bearing page).** - It is not enough that the `-night` mechanism works — the dark asset must **exist**. Go page by page through every screen - that shows an illustration / hero / banner / empty-state / pack art and confirm there is a real **dark variant** in - `drawable-night-nodpi/` (and a light one in `drawable-nodpi/`) that **matches the in-app theme** — a light/pink image - shown on a dark screen (because only the light asset exists) is a **bug**, even with feathered edges. Cross-check each - page against the **Image theme-variant coverage** table in `ClaudeBrandingReview.md`; a missing variant is filed as a - bug in `ClaudeReport.md` **and** the dark/light asset to create is logged in `ClaudeBrandingReview.md` as a prompt to - be made. (2026-06-27 audit found all `illustration_couple_*` heroes, `daily_question`, `partner_activation`, - `tonight_partner_prompt`, `together_empty`, and all 10 `pack_art_*` are **light-only** — these still need dark variants.) - Only genuinely theme-agnostic transparent/celebration art is exempt, and only after you **verify** it reads on both. -- **EVERY icon/glyph must be a CUSTOM Closer glyph — no generic Material icons, no generic hearts (mandatory).** On every - screen, inspect each icon: any generic Material icon (`Icons.Filled.*`/`Icons.AutoMirrored.*`/`Icons.Default.*` — - ArrowBack, Favorite/FavoriteBorder, Person, Lock, Star, PlayArrow, Check, Close, Send, …) is a **placeholder, not - brand** → a finding. File it as a brand defect in `ClaudeReport.md` and log the **custom `glyph_*` to make** in - `ClaudeBrandingReview.md` (see its **Icon/glyph audit**). Reflex grep to find them: - `grep -rE "Icons\.(Filled|Outlined|Rounded|Default|AutoMirrored)\." app/src/main/java/app/closer/` — every hit is a - generic icon that needs a bespoke Closer glyph (`ImageVector.vectorResource(R.drawable.glyph_*)` + `Icon(tint=…)`). - (2026-06-27 audit: ~60 distinct Material icons across ~201 call sites still to replace.) -- **States, not just happy path:** empty / loading / error / not-paired / locked-premium / signed-out / - stale-or-deleted-target / populated-with-many where they exist; many need data setup (seeding is user-gated) — note - unreachable states in coverage rather than skipping silently. -- **Text/data stress:** test long names, long relationship labels, long question/answer text, emoji, multiline content, - empty optional fields, many list items, and both partners having similar names. Verify no clipping, overlap, - confusing attribution, broken sorting, or hidden actions. -- **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**.) -- **Orientation / form-factor (the app is NOT portrait-locked — `AndroidManifest.xml` declares no `screenOrientation`, so - it DOES rotate to landscape).** Don't only check "rotation doesn't lose state" (that's Pass F) — verify the **landscape - layout actually renders correctly** on the text-heavy / game / paywall / dialog screens (`adb shell settings put system - accelerometer_rotation 1` then rotate, or use the emulator rotate control): no clipped/cut-off content, no broken - scrolling, dialogs and bottom CTAs still reachable. Spot-check a **large-screen / tablet** AVD too. If landscape is not - a supported experience, the correct fix is to **lock portrait in the manifest** — file that as the finding (see the - app-finding note) rather than shipping an unverified landscape layout. -- **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 - 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. -- **Every link, CTA, and mission must prove its destination:** actively hunt for dead buttons, wrong targets, generic - Home fallbacks, no-op taps, stale routes, and confusing affordances. Example class: a Reveal card saying - **"Tiny Mission: Send one flirty text"** must open the relevant Messages/conversation flow, not do nothing. For every - button/card/chip/row, record the expected destination before tapping, then verify the actual destination, state, - payload, and back stack. Broken/no-op/wrong-destination CTA = bug (usually P2; P1 if it blocks a core flow). -- **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 - 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 - follow where it goes. Then try the *combinations and sequences* a curious user hits: - - **Every order:** switch bottom tabs in many orders, mid-flow (open a game, jump to Messages, come back); enter a - deep screen then tab away then back; open A→B→C then back-back-back. - - **Rapid / repeated input:** double- and triple-tap navigation targets (especially "open game", "Play now", - "Create/Start session", notification taps) to surface double-push/duplicate-screen/stale-route bugs (cf. B-004). - - **Interrupt mid-navigation:** background/rotate/lock during a transition; tap a notification while already on that - screen, on a different screen, and while logged-out/unpaired; cold-start straight onto a deep link. - - **Dead-ends & traps:** from *every* screen confirm there's always a way out (back/close/home) — no screen that - strands the user, needs two backs, exits the app unexpectedly, loops, or lands blank. Re-check the asymmetric-game - waiting screens, replay/results screens, and paywall specifically. - - Log **every** wrong/duplicate/dead destination with the exact tap sequence to reproduce. Wrong/double-back or - dead-end = **P2** (P1 if it traps the user or loses their progress). -- **Back-stack / "double back":** from every entry point, **system back AND the in-app back arrow** return to the - correct previous screen — no dead-ends, no exiting the app unexpectedly, and **no screen that requires pressing - back twice** (duplicate/stacked destinations on the back stack = bug). Bottom-tab reselection and deep-link/ - notification entries must land with a sane back stack (back → Home, not off the app or a blank screen). Wrong/ - double back or a dead-end = **P2** (P1 if it traps the user). -- **UI consistency / polish defects:** compare each screen against sibling patterns in the same area and across the - app. Headers, labels, status chips, partner names, connected-state copy, spacing, card treatments, and button - hierarchy should feel intentional and consistent. Awkward or out-of-place UI such as a Settings relationship row - where **"Connected with ..."** looks visually odd, cramped, misaligned, or unlike the rest of Settings is a finding: - file as a bug if it looks broken/inconsistent; log to `Future.md` only if it is purely a product/content improvement. -### Pass D — Security & encryption (cornerstone; findings default to P0) -> Read first: manual's [E2EE model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · -> [Firestore rules](docs/Engineering_Reference_Manual.md#firestore-security-rules) · -> [Encryption versions](docs/Engineering_Reference_Manual.md#encryption-versions). The cornerstone: every private field -> is ciphertext at rest, rules hold against non-members, keys/recovery are sound. **D3 (live negative raw-API) is -> MANDATORY every round** — never deferred to "only 2 emulators" (mint a non-member token via admin → Identity Toolkit -> `signInWithCustomToken` → Firestore REST). Run all of D1–D7: - -- **D1 At-rest coverage:** admin-read RAW docs/objects, assert ciphertext for every private type — chat text + - `lastMessagePreview` (`enc:v1:`), chat media bytes (Tink `01 69 59 51 f0…`), answers (`sealed:v1:`/`enc:v1:`), - date plans + `date_swipes`, **date reflections** (`date_reflections/{dateId}/answers/{uid}/secure/payload` = - `enc:v1:`; the `date_history` metadata doc is intentionally plaintext title/category/timestamp — no private words), - Memory Lane capsules, Bucket List. Also: **wrappedCoupleKey** + recovery material never - plaintext; **invite code (KDF seed) never stored raw**; **no push payload carries private content**. - - **Date-reflection "private until both" gate + edit-seal (R25):** before you reflect, a D3 raw-API read of your - partner's `secure/payload` must be **denied**; after you reflect, it's allowed. And the author's edit-before-reveal - (secure `update`) must be **denied once the partner has reflected** (the seal holds) — verify both live via the - raw-API angle, not just the UI. -- **D2 Rules audit (static):** member-only reads, author/server-only writes, ciphertext enforced on every private - field, immutability, **no premium self-grant**, entitlements write:false; re-audit conversations/typing/reactions - + entitlement partner-read; **no catch-all** `match /{document=**}`; list/query not enumerable; `get()`-rules don't - over-expose; **no legacy plaintext/downgrade path** (`coupleEncryptionEnabled` holds; no disabled-encryption branch). -- **D3 Negative access tests (EXECUTE LIVE via raw API — do not defer):** a **non-member** account is *denied* reading - messages/answers/dates/entitlements/sessions/capsules, writing plaintext to encrypted fields, self-granting premium, - and any cross-couple access. Run it the **raw-API angle**: mint a non-member ID token (admin custom token → - Identity Toolkit `signInWithCustomToken` REST) and issue Firestore REST GET/PATCH against the couple's docs — expect - App Check `403` or rules `PERMISSION_DENIED` on every attempt. Also issue the **same** reads with a **member** token to - characterize the enforcement layer (App Check vs rules). Any unauthorized `200` with couple data = **P0**. -- **D4 Key exchange / management / recovery (E2EE crux):** couple key client-generated, only leaves device **wrapped** - (KDF from invite seed; server holds only `wrappedCoupleKey`+`kdfSalt`/`kdfParams`+`encryptedRecoveryPhrase`); **KDF - strength**; Tink AEAD = AES-GCM/256 with **AAD=coupleId**, no weak/custom crypto/nonce reuse; keybox/sealed/commitment - integrity; **recovery-wrap server-blind**; **unpair revokes decrypt**; invites CSPRNG + single-use + expiry. - - **NEW-DEVICE / LOST-PHONE RECOVERY — drive it end-to-end, don't just verify the phrase is revealed (the make-or-break - data-continuity path for an E2E app).** The keys are single-device (a known limitation); the recovery phrase is the - only bridge. Infra: `crypto/RecoveryKeyManager.kt`, `data/local/RecoveryPhraseStore.kt`, `ui/pairing/RecoveryViewModel.kt`, - `crypto/CoupleKeyStore.kt`. Exercise the **full flow on a fresh install / second device**: sign in → enter the - recovery phrase → the couple key is rebuilt → **prior `enc:v1:`/`sealed:v1:` messages and answers actually DECRYPT - and render** (not just new ones). Then the failure paths: a **wrong/typo'd phrase** fails gracefully (clear error, no - crash, no corruption); a user who **lost the phrase** is told honestly what is/isn't recoverable; and throughout, the - **partner's** device keeps working (one side recovering must never break the other). Confirm the server stayed blind - (only `wrappedCoupleKey`/`encryptedRecoveryPhrase` ever transit — verify via admin read). Without this, "I got a new - phone" silently loses the relationship history. (Also exercised from the account-lifecycle angle in Pass F and the - Settings → Security flow in Pass M.) - - **CONVERSATION BACKUP + FULL PARTNER-ASSISTED RESTORE (R24) — server-blind + the OOB-code gate.** Send messages → - a backup accrues (`couples/{id}/backup/manifest` + `.../chunks/{seq}` — admin-read shows ONLY `enc:v1:` payloads; - snapshot blob at Storage `users/{uid}/backups/{id}` is ciphertext). **Self-restore:** on a device with the couple - key, "restore" repopulates the local cache; admin confirms the server held only ciphertext. **Full partner-assist - (no phrase) — the headline:** simulate device loss WITHOUT `pm clear` (clear only `couple_crypto_secure` + - `user_key_secure` + `conversation_cache.db` via `run-as`) → recipient A "Ask your partner to restore" → shows a - 6-digit code → partner B gets `restore_requested` push → B **types the code** → A's key + content restore, **never - entering the phrase**. Admin confirms only `keybox:v1:`/ciphertext on the server. **Negative (rules):** non-member - read of backup/restore docs **403**; partner writing a keybox to a non-partner request **403**; creating a - restore_request for another uid **403**; post-unpair fulfil **403**. **OOB-code binding:** a mismatched code is - **rejected** (B's device refuses to wrap); a swapped pubkey yields a different code. Files: `data/backup/*`, - `crypto/CoupleKeyTransfer.kt`, `data/remote/FirestoreBackupDataSource.kt`, `functions/src/backup/onRestoreRequested.ts`. - Unit coverage: `CoupleKeyTransferTest` + `BackupCodecTest`. See the Eng Ref Manual **R24-BACKUP** landmine. -- **D5 App Check / Functions / secrets:** App Check enforced; callables validate auth+membership; webhook authenticity; - admin-only writes rejected from clients; service-account JSONs never committed; no plaintext/secrets in logcat; temp - files deleted. -- **D6 Leak vectors:** no private content in analytics/crash; `allowBackup=false` + backup rules exclude sensitive data; - deep links re-check membership; clipboard user-initiated; consider `FLAG_SECURE`; repo scan for committed secrets. -- **D7 Encryption migration:** test the `encryptionVersion` paths (0 plaintext → 1 migrating → 2 strict) on a legacy - couple — migration completes without exposing plaintext or losing/garbling old content, and a half-migrated couple - is safe (no mixed read failures, no downgrade). This is the riskiest data path for existing users. - -### Pass G — Account creation, validation & fake-account abuse (MANDATORY — both the happy path AND the attacks) -Cover **every account-creation avenue a real user takes** and **every fake/abusive creation attempt an attacker would -try.** Use throwaway test accounts (sign-out → fresh sign-up; never `pm clear`). Report-first like every pass. -- **Real creation flows (happy path + validation):** sign-up (email/password and any social/anonymous path), profile - creation, and pairing — both **create-invite** and **accept-invite** sides. Verify field validation (invalid/empty - email, weak/short password, mismatched confirm, name length/emoji/unicode), the **error copy is friendly** (no raw - SDK/Firebase error leaking — cf. A-OBS), loading/disabled states, and that a brand-new unpaired account lands on the - correct "create or accept invite" home (not a broken/blank or paired view). -- **Duplicate / conflicting creation:** sign up with an **already-registered email** (clear "already in use", no crash, - offer sign-in); create a second account while one is signed in; re-run onboarding after completing it; accept an - invite while **already paired** (must be rejected cleanly); two devices accepting the **same invite** (single-use — - the second must fail gracefully). -- **Fake / malicious creation attempts (security — expect DENY, never crash or leak):** create an account that is - **NOT a member** of the test couple and attempt every cross-couple action (read messages/answers/dates/entitlements, - write to the couple, self-grant `premium`/`hasPremium`, join/hijack pairing with a guessed/expired/reused invite - code) — all must be **denied by rules** (this is the live execution of **D3**). Probe **invite-code abuse**: replay a - used code, use an expired code, brute-force/guess attempts (CSPRNG entropy + single-use + expiry must hold). Probe - **App Check**: a request without a valid token is rejected. Confirm a malformed/forged sign-up can't bypass profile - or membership requirements. **Any successful unauthorized create/read/write = P0.** -- **Account lifecycle around creation:** sign-out → sign-in (state restores, no stale couple); **delete account** then - re-create with the same email (clean slate, partner notified/unpaired); an unpaired/just-created account tapping a - stale notification or deep link is handled gracefully (no crash, sane landing). -- **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. - -### Pass E — Full notification suite, deep-links & join-game navigation (every type, both clients, every app state) -Run the **complete** suite across **both clients** (QA→Sam AND Sam→QA). Each type verified end-to-end: **trigger fires -→ delivered to the right partner (never self/non-member/ex-partner) → correct channel + copy with no private content → -tap opens exactly the right item (loaded, not generic Home/dead-end) → sane back stack → privacy/authz re-checked on -open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones. -- **Notification chunk contract (small chunks, complete coverage):** each chunk owns **one notification type** (or one - explicit subchunk of that type, e.g. `chat_message QA→Sam foreground/source-screen sweep`, then - `chat_message Sam→QA background+killed+stale`). Before starting, write the chunk's matrix in `ClaudeQACoverage.md`; - after finishing, mark each cell `pass | fail→id | blocked→id | not implemented→Future.md`. A notification type is - not complete until all applicable cells below are covered: - - **Directions:** QA→Sam and Sam→QA; sender must not receive their own push unless intentionally designed. - - **Process states:** foreground, background/warm, killed/cold-start, force-stopped if deliverable, screen locked, - and resumed after rotation/process recreation when relevant. - - **Current screens:** Home, Play hub, active game/waiting/results, Today/reveal, Messages inbox, exact conversation, - Settings/sub-settings, Paywall, unrelated deep screen, logged-out, unpaired, and stale prior-partner context. - - **Entry surfaces:** foreground in-app banner/head, Android system tray tap, any push action button, crafted - deep-link/intent matching the payload, repeated/double tap, and tap after the target has changed. - - **Targets:** fresh target, already-open target, completed target, stale/expired/deleted target, unauthorized target, - wrong couple/session/item ID, malformed/missing extras, and no-network-on-open. - - **Assertions:** correct recipient, correct channel/priority/copy, no private payload/log content, exact destination, - membership/auth/entitlement re-check, no duplicate route/session, sane back stack, logcat clean, and coverage/docs - updated before the chunk ends. -- **Notification tap crash triage (mandatory):** never conclude "the notification didn't open" from UI behavior alone. - Before each notification/deep-link tap, clear or timestamp logcat; after the tap, inspect both devices for - `FATAL EXCEPTION`, ANR, ActivityTaskManager errors, `RuntimeException`, navigation/deep-link exceptions, - `PERMISSION_DENIED`, and swallowed repository/decryption errors. If the app returns Home, stays put, flashes, - restarts, or silently fails, classify whether it was wrong routing, missing extras, stale data, permission denial, or - a crash. Any notification tap that crashes (example class: tapping a game notification to open **Spin the Wheel**) - is a filed bug with stack trace + exact payload/session/game type, not a vague "didn't open" note. - - **Test the REAL launch path, not a synthetic one.** `adb am start … --es type=…` does **not** reproduce a real - notification tap: the OS notification tap launches the activity through the **SysUILaunch splash handover** - (`reportSplashscreenViewShown` → `handOverSplashScreenView`), which `am start` skips. A whole bug class - (e.g. the **splash-exit `provider.iconView` NPE** — the handover delivers a splash view with **no icon**, - `SplashScreenView: Icon: view: null`, on notification cold-starts only) crashes onCreate → "Force finishing - activity" → the app **opens-and-closes**, yet `am start` AND the normal launcher icon both pass. Verdict: for - cold-start/notification routing, a synthetic-intent pass is **not** a pass — confirm with a real push tapped from - the shade on an `am kill`'d app. - - **"Opens and closes / flashes / returns to launcher" ⇒ assume a crash; pull the stack FIRST.** `logcat -c` - before the tap, then grep `FATAL EXCEPTION|AndroidRuntime|Force finishing|getIconView`. A real repro + the stack - trace beats code-reasoning every time (this bug was misdiagnosed as deep-link routing until the live stack named - `MainActivity.kt` + `SplashScreenViewProvider.getIconView`). Confirm crashes reach **Crashlytics** so field cold-start - crashes surface. - - **Many notification types "broken" at once ⇒ suspect the SHARED entry path (splash/`onCreate`/launch), not each - handler.** When chat AND every game's results push all fail identically, the bug is in what they share (the - cold-start path), not per-type routing. Re-run a **cold-start smoke after ANY change to** `MainActivity` / splash / - theme / manifest / launchMode / branding-"loading state" commits — these cosmetic-looking changes broke the launch. - - **For "worked before, broken now": `git blame` / `git log -L` the crashing line/function** to pin the introducing - commit, then re-test that exact path on it. -- **Both-client × app-state matrix (per type):** QA→Sam and Sam→QA, each in **foreground / background / killed - (cold-start)**, plus **already on the target screen**, **on a different screen**, **logged out**, **unpaired**, with - a **stale/expired/completed/deleted target**, and **both users opening around the same time**. Not a `pass` unless it - works from both clients in every state that applies. -- **Current-screen/source-screen matrix (per type):** do not test notifications only from Home or only from a clean - launch. For each notification type, vary where the receiving client is when the notification arrives/taps: **Home, - Play hub, active game/waiting/results, Today/reveal, Messages inbox, exact conversation, Settings/sub-settings, - Paywall, an unrelated deep screen, app backgrounded from each major tab, and app fully closed/killed**. Foreground - banners, system-tray taps, warm-start `onNewIntent`, and cold-start launch must all route to the exact target. A tap - that lands on generic Home, stays on the old screen, opens the wrong tab, loses extras, duplicates the destination, - or needs a second tap is a bug. -- **Permission/token health:** cover Android `POST_NOTIFICATIONS` granted, denied, "don't ask again"/system-disabled, - and re-enabled states; Settings notification toggles; sign-out/sign-in token refresh; same account on two devices; - partner/account switch; stale token cleanup; app reinstall/update; and notification channel migration. Denied/system - disabled notifications should fail gracefully with in-app state still correct, never with lost data or broken routing - after permission is restored. -- **Doze / battery-optimization / background-restriction delivery (real-device gate — emulators NEVER enter these states, - so per-round emulator passes systematically miss the #1 real-world "notifications don't work" cause).** Scheduling is - entirely server-side (no client `WorkManager`), so the only thing standing between a fired push and the user is the OS - power state. On a **physical device**, verify each push type still delivers when the recipient device is dozing / - the app is battery-optimized or "Restricted": `adb shell dumpsys deviceidle force-idle` (then send a real partner - action + a scheduled push), app set to **Optimized** then **Restricted** in battery settings, and App Standby buckets. - Assert: high-priority FCM (partner actions) wakes the device and delivers; lower-priority/data-only pushes degrade - *predictably* (document which); scheduled pushes (daily question, capsule unlock, reminders) still arrive within the - expected window. Because our recurring setup is two emulators, keep this as a `blocked→needs-device` row in - `ClaudeQACoverage.md` (with the device-matrix gate) rather than silently assuming delivery — and run it before any - store push. OEM battery-killers (Xiaomi/Samsung/etc.) are even more aggressive; note them for the device matrix. -- **Six assertions per notification:** (1) trigger fires correctly — right event, not early, not twice, sender doesn't - get their own (unless intended), retry/idempotency doesn't duplicate; (2) delivered to the right person — correct - token, old tokens unused after sign-out/account-switch; (3) copy + channel correct — friendly, right channel/ - priority, no raw Firebase error/raw IDs, no private content in text/payload/logs/analytics/crash; (4) tap opens the - exact destination — specific conversation/session/capsule/match/question/settings/pairing, never blank, never a crash - on missing/stale/malformed/unauthorized data, no duplicate/stacked copies, completed→results/replay, expired/deleted→ - graceful fallback; (5) back stack sane — back returns sensibly (Home/prev context), no double-back, no unexpected - exit/loop/blank; (6) deep-link re-checks auth + couple membership + pairing + entitlement + target ownership + - session status + existence — a non-member/logged-out/stale/unpaired open must NOT reach private content and must fail - gracefully. -- **`qa/qa_push.js` is faithful to the PUSH, not the TRIGGER — assertion #1 needs ≥1 real in-app action per round.** - `qa_push.js` sends the FCM via admin (`messaging().send`), so it faithfully reproduces delivery + channel/copy + - cold-start launch + tap-routing (use it for the bulk of the type×state matrix and the `entrypoint_smoke.sh` smoke). - But it **bypasses the Cloud Function** — no `onMessageWritten`/`onGameSessionUpdate`/`onAnswerWritten`/`createDateMatch` - actually ran. So a `qa_push.js`-only round can **never** satisfy assertion #1 (**trigger fires correctly**): a broken - or un-deployed trigger (Firestore-path change, deploy regression, rules change) is **invisible** to synthetic pushes. - Each round, drive **≥1 real in-app partner action** (send a chat, finish a game, answer the daily Q) and confirm the - matching push lands on the partner. (UI-automation tip: the chat composer's send button is the rightmost control in - the composer row, content-desc `Send`; if `uiautomator` taps mis-fire, verify the action via admin read — the new - message/answer doc exists `enc:v1:` — rather than claiming the trigger from a synthetic push.) -- **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) · - `partner_completed_part`(**onGamePartFinished** → waiting partner → game; fired when the FIRST player finishes an - async game so the partner is told "your turn" — async games complete only when BOTH answer, so without this the - waiting partner got nothing between first-finish and both-finish) · - `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) · - `thinking_of_you`(**sendThinkingOfYouCallable** ← partner-bubble sheet "💜 Thinking of you" → partner → Home; - generic copy, **no name**; rate-limited 10/rolling-24h; **quiet hours suppresses the push but still writes the - in-app/Together record**; tapping the push → Home, not a dead-end) · - `date_reflection_partner`/`date_reflection_ready`(**onDateReflectionWritten** → partner → the date reflection; - "your turn" when one reflects, "ready to reveal" when both; gated `notifPartnerAnswered`+quiet hours) · - `date_reflection_opened`(**onDateReflectionRevealed** → partner → "opened your reflection", after both reveal) · - `date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) · -   ⚠️ **date deep-link regression guard (R25 — Changes 2 & 3):** for every `date_*` type, tapping must - open the **exact date's reflection** in **BOTH background (OS tray) and foreground** — background nearly broke - because `MainActivity` dropped `date_id` from the payload (fell back to DATE_MEMORIES); and the in-app - **Together-feed** row for these types must route to **DATE_MEMORIES**, not DATE_MATCHES (the old - `"date" in type` substring bug). Test the feed row AND the OS notification, not just one. · - `restore_requested`(**onRestoreRequested** → partner → the restore-consent screen; high-signal help request, NOT - suppressed by the routine partner-activity toggle, only quiet hours) · `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). For each game - type, including **Spin the Wheel**, notification taps must be paired with logcat review so crashes are caught even if - the visible symptom looks like a no-op or generic Home fallback. -- **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) -- **Concurrency / realtime races (two partners at once):** both answer the daily question simultaneously; both - start/join the same game; both swipe a date / react at once; one quits while the other submits; both tap a - 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 - 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. - - **Deterministic state-restoration ("Don't keep activities" — do NOT rely only on `am kill`).** `am kill` is - non-deterministic; enable **Developer options → Don't keep activities** (`adb shell settings put global - always_finish_activities 1`) so the Activity/process is destroyed on *every* backgrounding, then walk each primary - flow (sign-up, pairing, a game mid-answer, an unsent message draft, capsule/Date Builder in progress, paywall) and - background→return at each step. Assert **no lost** form input, scroll position, draft, in-progress game state, or - nav back-stack — i.e. `rememberSaveable`/`SavedStateHandle` actually persist it. Restore with - `adb shell settings put global always_finish_activities 0` after. -- **Interruptions mid-flow (the OS or another app steals focus):** incoming phone call, alarm, another app taking the - foreground, screen-off/on, **split-screen / multi-window**, and picture-in-picture during a game/answer/message-compose - → returning resumes cleanly with no lost state, no crash, no duplicate submit, and audio/camera (voice note, photo) - releases + re-acquires sanely. -- **Cold-start launch integrity from EVERY entry point (Pass F OWNS this — it's the shared path no other pass owned, and - where the splash-crash hid):** the app must **open AND stay** (no crash, no "opens-and-closes", lands off the launcher) - when cold-started from: the **launcher icon**, **each notification type tapped from a killed (`am kill`) app**, a - **deep link**, and any widget/quick-action. This is the `MainActivity`/splash/`onCreate`/auth-bootstrap path; a crash - here (e.g. splash-exit `iconView` NPE) breaks **all** notifications at once. **Run `qa/entrypoint_smoke.sh` here every - round and after any MainActivity/splash/theme/manifest/nav/notification change.** Reproduce via the REAL push tapped - from the shade (not `am start`); "opens-and-closes" ⇒ pull the FATAL stack (see Pass E crash-triage). -- **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. -- **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, - 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 - 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. -- **Install/update/migration lifecycle:** fresh install, update over an existing signed-in install, app data retained, - Room/DataStore/SharedPreferences migrations, notification channel migration, cached encryption/key material, - pending deep links/notifications across update, and version-skew between partners if one device updates first. No - sign-out loops, stale build routing, lost local state, broken permissions, or migration crashes. **When local state - is lost but Firestore is intact (fresh device / cleared data), already-answered content must reconcile back rather - than re-prompt** — see the **R23-DQ-001** daily-question reconcile check in Pass N (a re-answer offered against an - immutable `secure/payload` is silent data loss). -- **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?) -**Branding review is a MANDATORY part of QA every round** (not an optional polish pass) — its findings + the assets to -create are logged in `ClaudeBrandingReview.md`. A consumer-mindset pass focused on **brand presence and delight** AND -two hard brand standards. Walk **every screen and surface** and ask: *does this feel like Closer (private, warm, equal, -intentional — a ritual for two)? Could brand color, the heart mark, a brand message, or an illustration make it warmer -or clearer without clutter?* Output is **artwork descriptions written as ready-to-paste ChatGPT image-generation -prompts** — the user generates the images; we only describe them. -- **MANDATE 1 — every image has a light AND a dark variant (theme-matched).** Cross-check every image-bearing page - against the **Image theme-variant coverage** table in `ClaudeBrandingReview.md`; a light-only image shown on dark (or - vice-versa) is a **bug → `ClaudeReport.md`** and the missing variant is a **prompt to make → `ClaudeBrandingReview.md`**. - (Shares the per-page audit with Pass C; H owns producing the prompts + tracking the coverage table.) -- **MANDATE 2 — every icon/glyph is a CUSTOM Closer glyph (no generic Material icons / generic hearts).** Audit all - icons in use (`grep -rE "Icons\.(Filled|Outlined|Rounded|Default|AutoMirrored)\."`); each generic icon is a brand - defect → `ClaudeReport.md` + a **custom `glyph_*` to make → `ClaudeBrandingReview.md`** (the **Icon/glyph audit** - table). The bar for ship: **zero generic Material icons** — every icon is bespoke and on-brand. -- **Existing art integration check:** judge the art as part of the whole page, not as a standalone asset. Confirm each - image supports the screen's job, aligns with the surrounding typography/actions, has enough breathing room, and uses - the right light/dark treatment. Art that looks generic, unfinished, randomly placed, or visually disconnected is a - finding even if the bitmap itself is technically valid. -- **Soft edges (art melts into the surface):** illustrations should **fade/feather into the screen background**, not read - as a hard-edged tile/card with a crisp boundary or outline. Confirm edge treatment on both themes; a hard tile edge is - a finding (C-ART-EDGE-001). Generated art should carry **transparent/feathered edges** (no baked-in rounded-rect block); - if rendered, the shared helper should fade the edges to the surface. Record the desired edge treatment in each prompt. -- **First, lock the house style (do this once per round, refresh if the art evolved):** read `docs/brand/visual-identity.md` - + `docs/brand/asset-system.md` AND open 2–3 existing illustrations (`illustration_couple_onboarding`, - `illustration_reveal_celebration`, `pack_art_*`) to capture the *actual* look. New screens/features since the last - brand review must be folded in. Keep the canonical **house-style prompt prefix** + palette in the branding deliverable - (`ClaudeBrandingReview.md`) so every prompt reuses it and **all generated art matches the existing artwork.** -- **House style (must hold for every prompt):** flat 2D pastel vector illustration; soft rounded shapes, no harsh - outlines, gentle gradients; palette aubergine `#24122F` / deep purple `#56306F` / lavender `#B98AF4` / soft pink - `#F7C8E4` / soft lavender `#D9B8FF` / blush white `#FFF8FC`; motifs = two-equal-halves heart, paired/sealed cards, - floating hearts + petals, candle/mug/lavender-sprig warmth, moon/quiet-hours, calendar/date-card, capsule; mood = - warm, quiet, equal, intentional. Couple figures balanced + inclusive, faces simple. **Never** show readable answer/ - prompt/message text, invite codes, emails, dating-app clichés, stock photos, alarm/urgency/surveillance imagery. -- **Per screen, decide the brand opportunity** (pick the lightest that fits — don't over-decorate): - - none needed (already on-brand, or a dense list/form where art would clutter) — say so; - - **color/typographic** brand touch (palette, heart mark, a rotating privacy message); - - **small glyph** (brand glyph for a relationship concept — describe it for the glyph set); - - **hero/empty-state/celebration illustration** (the high-value case → write the full ChatGPT prompt). -- **Each artwork item records:** screen/route · placement (hero / empty / header / card / celebration) · why it helps · - filename to match the existing scheme (`illustration_*`, `pack_art_*`, `glyph_*`, `particle_*`) · **the ChatGPT - prompt** (house-style prefix + the specific scene) · aspect ratio/size + light/dark behavior. Cross-check the - brand doc's "Needed additions" / empty-state list and **mark which already have assets vs still need art** (e.g. - Android may still lack illustrations that iOS has). -- **Prioritize** the screens a user feels most: onboarding/pairing, Home, paywall/subscription, reveal/celebration, - empty states (no messages/dates/capsules/history), Memory Lane, Connection Challenges, date match, quiet-hours. -- 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`. - -### 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 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 — - e.g. **DateMemories** derives each row's reflection state with per-row `hasReflected` gets; confirm they're - cached per `dateId` and not re-fetched for every history-snapshot tick (R25 improvement). -- **Memory leaks (beyond listener leaks):** add **LeakCanary** in the debug build (or take heap dumps) and navigate - in→out of every heavy screen (conversation with media, game, image viewer, Memory Lane) repeatedly — flag retained - Activities/Composables/bitmaps/Contexts. A leak that grows per navigation = bug (P2; **P1** if it OOMs). -- **StrictMode in debug (catch main-thread I/O + leaked closeables cheaply):** enable a `StrictMode` thread + VM policy - in the debug `Application` (`detectDiskReads/Writes/Network`, `detectLeakedClosableObjects`); any violation logged on a - primary flow is a finding (disk/network on the main thread → jank/ANR risk). -- **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. -- **⛔ Keyboard / IME overlap (run `scripts/ime-scan.sh` FIRST — it must PASS):** the app is edge-to-edge - (`adjustResize` doesn't resize the window), so a text-input screen missing `imePadding()`/`safeDrawingPadding()` - lets the soft keyboard **cover the fields** — the exact "you can't type in Date Reflection" bug (R25). The - scanner flags any text-input file lacking IME handling (allowlisting components whose host handles it); a - MISSING hit is a bug. Then **live-verify per input screen**: focus each field with the keyboard open and - confirm the focused field stays visible and typable (don't assume — the daily flow is choice-only, so it - never exercises this). Input screens: auth (login/signup/forgot), onboarding/profile, pairing/invite/recovery, - Messages conversation, Bucket List, Date Builder, **Date Reflection**, Change/Delete/Edit in Settings, Wheel. -- **Free-text length + truncation policy (R25 UI review):** every free-text input is bounded **at entry in its - ViewModel** — the caps are centralized in `ui/components/TextInputLimits.kt` (`MESSAGE` 2000 · `DISCUSSION_MESSAGE` - 500 · `WRITTEN_ANSWER` 2000; the conversation / discussion / question-detail / question-thread / wheel VMs alias - those, and chat/discussion/wheel/written-answer also `.trim()` on send). Content is bounded on the way IN, **never - truncated at display** — so the rule is **ellipsize chrome (TopAppBar titles, one-line labels/rows, pills, counts), - never content or errors.** A `maxLines`+`TextOverflow.Ellipsis` on a message/answer bubble, a question, or an error - string is a bug (it silently hides what a partner wrote). The shared written-answer field surfaces a character - counter only within `TextInputLimits.COUNTER_THRESHOLD` of the cap. -- **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). -- **Don't rely on color alone (color-blind / WCAG 1.4.1):** any state conveyed by color must also carry a non-color cue - (icon, label, shape, position). Audit the **match/mismatch** rendering (e.g. `AnswerRevealScreen`), status chips, - selected/disabled states, and any red=bad/green=good signal — they must be distinguishable in grayscale / with a - color-blindness simulation (`adb shell settings put secure accessibility_display_daltonizer_enabled 1`). Color-only - status = bug. -- **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`. - -### Pass K — Billing & subscription lifecycle (the REAL money path, not the admin toggle) -**Pass A tests the GATE (couple-shared unlock via an admin entitlement toggle); Pass K tests how the entitlement is -actually earned, kept, and lost.** This is the revenue path and it is almost entirely unexercised by the admin toggle. -Read the manual's [Billing](docs/Engineering_Reference_Manual.md#billing) section first. **Needs real services** -(Google Play Billing sandbox + a Play **license tester** + RevenueCat) — emulators can't do real IAP, so run on a -**physical device** with a sandbox account, or mark each money-path row `blocked→needs-device` (the admin toggle is -**not** a substitute for these). -- **Purchase, end to end:** Paywall → select a plan → Play billing sheet → buy as a sandbox tester → RevenueCat → - `revenueCatWebhook`/`syncEntitlement` → `users/{uid}/entitlements/premium` flips active → features unlock for **both** - partners (couple-shared, Pass A) → `onEntitlementChanged` fires the partner push → the one-time **Premium-unlock modal** - (`PremiumUnlockOverlay`) shows once for **each** partner. -- **Restore purchases:** Paywall "Restore" on a reinstall / second device / after sign-out→in → entitlement restored, - no double-charge, features unlock. -- **Plan switching:** monthly ↔ annual upgrade / downgrade / crossgrade → correct proration + entitlement continuity. -- **Trial / intro pricing** (if configured); **price + currency are displayed from the store, localized, never - hardcoded**; plan list + benefits render; offline/SDK-error paywall is friendly (A-OBS), Continue hidden until plans load. -- **Cancel → expiry → RE-LOCK:** cancel keeps access until period end (`expiresAt`); at expiry, `CouplePremiumChecker` - reports inactive → premium features **re-lock for BOTH** and the premium-unlock "celebrated" flag re-arms. Test the - `expiresAt` boundary (admin: set it just-past) — the couple-shared checker must treat a lapsed entitlement as inactive. -- **Billing retry / grace period / account hold / pause** (Play states) → entitlement + UI reflect the state; no hard - crash, clear messaging. -- **Refund / revocation:** RevenueCat `CANCELLATION`/`EXPIRATION`/refund webhook → entitlement removed promptly → re-lock. -- **Security (overlaps D3/D5):** server-only entitlement writes (client self-grant → 403); **webhook authenticity** - (forged/replayed RevenueCat webhook rejected); no client-trusted entitlement; receipt validated server-side. -- **Error/abuse:** cancel the billing sheet mid-flow, kill network mid-purchase, double-tap buy, rapid unlock taps → - no false unlock, no duplicate purchase, retry recovers. -- **Settings → Subscription** reflects the live status; "Manage subscription" deep-links to Play. -- Done = purchase + restore + switch + cancel→expiry-relock + refund all verified on a real device (or each explicitly - `blocked→needs-device` with the admin-toggle gate covered in Pass A). - -### Pass L — Messaging & chat (E2E, both clients, the whole feature) -Chat is a core couple feature with no functional home until now (Pass C covers its visuals, Pass E its -`chat_message` push). Drive the **main couple conversation AND the per-question "Discuss" threads** QA↔Sam, both -directions. Read the manual's [E2EE model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model). -- **Send every type, both directions:** text, emoji, **image (gallery + camera)**, **voice note** → arrives on the - partner's device **decrypted**, correct attribution/timestamp/ordering, day separators. -- **E2E at rest (overlaps D1):** every sent item is ciphertext (`enc:v1:` / Tink media bytes), `lastMessagePreview` - encrypted, decrypts only on member devices; raw-API read by a non-member = 403. -- **Interactions:** reactions (add / change / remove), read receipts ("Seen"), typing indicator, message ordering under - rapid exchange. -- **Failed send & offline:** airplane mid-send → failed-message row → **retry / dismiss** (the 48dp controls), offline - queue flushes on reconnect, **no duplicate on retry** (idempotency, overlaps F); double/triple-tap send guarded. -- **Delete / moderation:** delete a message (own / both) + deleted-message rendering; block/report a partner if such a - flow exists. -- **Media:** gallery + camera + mic permission granted **and denied** → graceful; premium-gated media is couple-shared - (Pass A); oversized image handled; image viewer opens/zoom/back. -- **Inbox:** conversation list, unread badge, decrypted last-message preview, recency sort; open a conversation from - inbox **and** from "Discuss" **and** from a notification (Pass C/E) — all reach the same thread with a sane back stack. -- **Foreground chat-head bubble** for an incoming message while the app is open but that thread isn't on screen - (`MessageBubbleOverlay`) → tap opens it (Pass E overlap). -- **Realtime + perf (overlaps I):** snapshot listener detaches on leaving the conversation; long-history scroll pages, - no jank/leak. **Quiet hours** suppress the chat push (Pass M). Long/emoji/multiline/RTL text renders without clipping. - -### Pass M — Settings & account management (functional: settings PERSIST and TAKE EFFECT) -Pass C checks Settings **looks** right; Pass M checks each control **does** something, persists across relaunch, and -takes real effect. Read [Authentication and pairing flow](docs/Engineering_Reference_Manual.md#authentication-and-pairing-flow). -- **Appearance theme** (Light / Dark / Device) → applies app-wide immediately, **persists across process death + - relaunch**, and the decoupled-art behavior holds (Pass C). -- **Notification toggles** (daily reminder · partner answered · chat · streak) → toggling one **OFF actually suppresses - that push** (verify by triggering it), ON re-enables; survives relaunch. -- **Quiet hours** → set a window covering "now" → partner-triggered pushes are suppressed/deferred during it - and deliver outside it; the partner-action vs promotional rate-limit split holds. **MUST test with the recipient - BACKGROUNDED/KILLED, not just foreground (RETROSPECTIVE — M-001):** a partner push carries a `notification` block - the OS renders directly when the app isn't foreground, so any client-side `QuietHoursManager.isInQuietHours` check - (which only runs in `onMessageReceived`, foreground-only) is bypassed exactly when quiet hours matters. Verdict bar: - with QH on + recipient backgrounded, send a real partner action (chat/answer/game) → assert **0** notification in the - shade AND the Cloud Function log says it suppressed (`recipientInQuietHours`); then QH off → same action delivers. - Generalize: **any "don't notify when X" setting (quiet hours, snooze, DND, per-type opt-out) must be enforced - server-side where the push is SENT** — verify the setting reaches Firestore and the sender honors it, not just the - client. (Reminder: the `users/{uid}` update rule is a **field allowlist** — a newly-synced pref field is silently - denied until added to it; confirm the write actually lands via an admin read, not just the UI toggle.) -- **Biometric app-lock** → enable → background-return / cold-start prompts for biometric; correct unlock proceeds, - cancel keeps it locked, disable removes the lock. (Security-relevant: no bypass.) -- **Edit profile** → name, sex/gender (inclusive options), photo upload → persists, reflects on the **partner's** side, - ciphertext/storage correct at rest. -- **Relationship / unpair** → unpair returns **both** to the unpaired state, **revokes decrypt** (D4), notifies the - partner (`partner_left`), makes couple data inaccessible; re-pair works cleanly. -- **Delete account** → confirmation → account + couple data cascade (`onUserDelete`), partner unpaired + notified, - re-create with the same email is a clean slate (overlaps G). -- **Security** → recovery-phrase reveal for **both** accepter and inviter (C-SEC-001), server-blind (D4); regenerate if - supported; **and the full new-device recovery flow — enter the phrase on a fresh install → existing history decrypts** - (canonical steps + failure paths in **D4**). **Subscription** → "Manage subscription" → Play (Pass K). **Privacy & - Terms / data export** links open (the export *contents* are verified in Pass O). -- **Analytics / funnel-event correctness (not just the leak check in D6).** The app ships a real analytics tracker - (`core/analytics/FirebaseAnalyticsTracker.kt`, wired via `di/ObservabilityModule.kt`); D6 only asserts *no private - content* leaks into it — nobody verifies the events actually **fire correctly**, so the business funnel can silently - break. Enable Firebase **DebugView** (`adb shell setprop debug.firebase.analytics.app app.closer`) and confirm the key - lifecycle events fire **once, at the right moment, with correct params**: signup, pair, paywall_view, purchase/restore - (Pass K), game_complete, daily_answer/reveal. Also confirm analytics honor any **consent / opt-out** (privacy): if a - toggle or first-run consent exists, opting out must actually stop collection. Wrong/missing/duplicated events = bug; - still no private content in any event (D6). -- Every toggle survives **process death + reinstall-with-data** (overlaps F). - -**⛔ Notification Enforcement Matrix (the gap that let the dead Daily/Streak toggles ship — RETROSPECTIVE).** -Crashes/visuals probing isn't enough; trace every toggle end-to-end and prove `off ⇒ suppressed`. Run -`scripts/wiring-scan.sh` first — its Tier-4 check flags any `notif*` field mirrored to `users/{uid}` that **no** -Cloud Function reads (a dead toggle). Then fill this matrix in `ClaudeQACoverage.md`: - -| Toggle / setting | Local store key | `users/{uid}` field | Function(s) that READ it | off ⇒ suppressed? | -|---|---|---|---|---| -| Partner answered | `partner_answered` | `notifPartnerAnswered` | `onAnswerWritten` | … | -| New chat message | `chat_message` | `notifChatMessage` | `onMessageWritten` | … | -| Daily question | `daily_reminder` | `notifDailyReminder` | `dailyQuestionReminder` | … | -| Shared-rhythm (streak) | `streak_reminder` | `notifStreakReminder` | `streakReminder` | … | -| Tips & nudges (promo) | `promotional_notifications` | `notifPromotional` | `reengagement` + `gameRetention`(challenge) | … | -| Quiet hours window | `quiet_hours_*` | `quietHoursEnabled`/`*StartMinutes`/`*EndMinutes`/`timezone` | `recipientInQuietHours` (ALL senders) | … | - -Matrix rules: (1) a toggle with **no `users/{uid}` mirror** or **no function reader** is a DEAD setting — file it, -don't pass it. (2) **Scheduled/cron senders are in scope** — do NOT blanket-defer them to `needs-device`: audit by -code (does the sender read the pref + `recipientInQuietHours`?) and invoke manually where possible (Functions shell / -temporary schedule). Senders to cover: `dailyQuestionReminder`, `streakReminder`, `reengagement`, `gameRetention` -(capsule + challenge), `scheduledOutcomesReminder`. (3) prove `off` live: flip off → trigger → assert **0** push + -**0** `notification_queue` for that user; on → delivers. - -**Standard-settings completeness checklist (presence, not just correctness).** A missing standard control is its own -defect class — audit that each EXISTS: -- [ ] **OS-notification-permission-off awareness** — when `areNotificationsEnabled()` is false, a banner + "Open - system settings" deep-link (`Settings.ACTION_APP_NOTIFICATION_SETTINGS`), re-checked on `ON_RESUME` — else every - toggle is silently dead. -- [ ] **Promotional / marketing opt-out** — a toggle for non-essential nudges, enforced server-side (`notifPromotional`). -- [ ] **Customizable quiet hours** — user-settable Start/End (not a hardcoded window), mirrored + server-enforced. -- [ ] **Sign out** ✓ · **Delete account** ✓ · **Subscription** (Pass K) · **Security** (app-lock + recovery) · **Appearance/theme**. -- [ ] **Export my data** (GDPR — SECURITY.md P2) and a **Help/Support** surface (contact · FAQ · report-a-bug · app - version) — currently GAPS; flag in Future.md, don't silently pass "Settings looks complete". - -### Pass N — Daily question, reveal, check-ins & the other interactive features -> **⛔ CLAUDE: Run `scripts/wiring-scan.sh` BEFORE driving these features** (review `/tmp/claude-wiring-scan-.md`, -> record counts in `ClaudeQACoverage.md`). Every 🔴 dead-setter / 🟠 orphan-reader is a likely silent dead feature — -> this is the exact class that hid N-001 (Bucket List) + N-002 (Date Builder) behind innocent-looking empty states. - -The non-game interactive surfaces that have no functional home (Pass B is games only). Read -[Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle). -- **Date Memories / Reflection (NEW — added R25; reverted-then-reinstated, so it slipped earlier rounds):** - log a date (Date Match → mark done → `date_history` row) → Home "Reflect on your date" nudge (fires for - **any** recent un-reflected date, not just the latest) → DateMemories timeline → tap a date → - DateReflectionScreen. Drive the full loop on **both** devices: type all **4** fields (favorite / surprised / - appreciated / free-form notes) — **confirm the keyboard does not cover the fields (Pass J / ime-scan)** — - save → AWAITING_PARTNER (with **Edit** affordance: edits allowed only until the partner reflects) → partner - reflects → both flip to the side-by-side REVEAL. Negative/edge: neither can read the other early (Pass D - gate); a **blank/deep-linked bad `dateId`** shows an error, not a malformed write; a **locked vault** - (key unavailable) shows "Locked", not blank dashes; DateMemories **read failure** shows an error state - (not an infinite spinner); long-press a memory → **Remove** (confirm) deletes it. Notifications in Pass E. -- **Daily-question loop (the core daily ritual):** assignment (6 PM CST, `assignDailyQuestion`) → answer (each answer - type) → **both-answered gate** (neither sees the other's answer until both submit) → **mutual reveal** → per-question - **Discuss** thread (Pass L) → **Answer History** → **streak** increment + milestone celebration (`streak_milestone`) - → reveal `isRevealed` retry (the `onAnswerRevealed` push). Verify the premium daily-question fallback - (`DailyQuestionResolver` per-user) does **not** desync the couple's shared daily Q. - - **R23-DQ-001 — fresh-device / cleared-local-DB reconcile (data-loss guard):** after answering, simulate the - Room↔Firestore desync (new device, reinstall with data cleared, or a wiped local answer store) so Firestore holds - the answer but local Room/prefs don't. Home must **not** show a stale "your turn", and opening the daily question - must show the **submitted/reveal** state (NOT an editable re-answer form) — because the `secure/payload` doc is - immutable (`allow update:false`), so a re-answer would be silently rejected and a *changed* pick lost. The guard is - `reconcileLocalAnswerFromFirestore` (Room-first; rebuilds from the read-gated couple-key payload) wired into - `DailyQuestionViewModel` + `HomeViewModel`; covered by `ReconcileLocalAnswerTest`. -- **Relationship check-ins / Your Progress (outcomes):** baseline check-in (gated to show once), 30/60/90-day - follow-ups, slider inputs persist (`submitOutcomeCallable`), the progress view renders patterns/milestones, - `scheduledOutcomesReminder` fires, "No baseline yet" → check-in dialog (C-DARK-UI-002 area). Submit + Skip both work. -- **Partner bubble → quick-actions sheet (R22):** tapping the Home partner avatar opens the bottom sheet (NOT the old - dead-end into "Together"). Verify the glance (avatar + name + "💜 N nights · together since {Mon yyyy}"; streak clause - hidden when 0); **Message** → inbox, **Together** → feed, **Your relationship** → relationship settings, header → - partner page; **💜 Thinking of you** sends the nudge (Pass E `thinking_of_you`) → "Sent 💜" + in-flight disable + - friendly rate-limit/error message on failure; **unpaired account → the bubble still opens the invite flow** (never an - empty sheet); a missing/locked partner name (E2EE key absent) shows **"Your partner"**, never ciphertext/🔒. -- **"Together" feed is actionable (R22):** rows deep-link by type (message→inbox, game→Play, capsule→Memory Lane, - challenge→Challenges, date→Date Matches, answer/reveal→Today); affection/reminder rows (`thinking_of_you`/ - `gentle_reminder`/`streak`) have no deeper target and stay non-tappable; opening the feed clears the unread dot; - sent `thinking_of_you` nudges show up here. -- **Date Memories & Replay (R22) — the private→reveal loop on real dates:** on a mutual match, **"We did this"** - → logs `date_history` (idempotent; admin shows PLAINTEXT title/category + completedAt) → opens the reflection. - Both partners answer the 3 prompts privately; **admin read of the partner's `date_reflections/.../secure/payload` - is DENIED until both have reflected** (the privacy gate, same proof as the daily question) → then both **reveal - side-by-side** (real-time, no refresh). The **Date memories** timeline (entry on Date Matches) lists completed - dates newest-first with the reflection chip (Reflect / Waiting / View); empty state shows - `illustration_date_memories_empty`. Locked-key → placeholder, never ciphertext. **Home nudge:** while a completed - date has no reflection from you, Home surfaces a **"Reflect on your date with [partner] 💭"** card - (`glyph_date_replay`) → opens the Replay timeline; it clears once you've reflected. Notifications covered in - Pass E (`date_reflection_*` / `date_logged`). -- **Bucket List:** add / check-complete / edit / delete an item; empty state; both-device sync; at rest encrypted (D1); - premium state if applicable (A). -- **Plan a Date / Date Builder:** build a plan (shape/steps) → save → **persists + the partner sees it**; date plan + - `date_swipes` ciphertext at rest (D1); submit-outcome path. -- **ACTUALLY PERSIST + verify via admin read — an empty list can be a DEAD feature, not an empty one (RETROSPECTIVE — - N-001/N-002).** For every interactive feature, create real data through the UI and confirm it **lands in Firestore** - (admin read) AND **renders back**; don't accept the empty/initial state as "works." Bucket List looked like an empty - list but was fully non-functional (`coupleId` never set → every op silently `return`ed); Date Builder's "Create Plan" - silently no-ops (`dateIdeaId` never wired) and writes to a collection no screen reads. Reflex: any VM that gates on - `if (someId.isEmpty()) return` and expects the screen to call `setX(...)` is suspect — `grep` for the `setX` caller; if - none, it's dead. Also confirm there's a **display surface** for whatever a "save/create" writes (a save into an unread - collection is an incomplete feature, not a working one). -- **Activity / Together feed:** shared activity entries render + sort, unread count, navigation in/out. -- Each feature: empty / loading / error / not-paired states, two-device realtime sync, no stuck/orphaned state. - -### Pass O — Release build, store readiness & pre-launch security -**Everything above runs on the DEBUG build; the shippable artifact is the minified RELEASE build — test THAT.** This is -a pre-ship gate, not a per-round pass (run it before any store push and after build-config / dependency / keep-rule -changes). -- **Release/minified build (R8 + resource-shrink):** build the **release** APK/AAB and run `qa/entrypoint_smoke.sh` + - a representative slice of A–N on it. R8 can strip/obfuscate classes that **Firebase/Firestore/Tink/RevenueCat/Gson/ - kotlinx-serialization/Compose** need via reflection → crashes that never appear in debug. Verify keep-rules; 0 FATAL - on launch + each core flow; **upload the ProGuard mapping to Crashlytics** so release crashes deobfuscate. -- **Signing & packaging:** release signing config + upload key; build the **App Bundle**; install the signed AAB via - bundletool / Play internal-app-sharing and smoke it; 64-bit + target-SDK compliance. -- **App Check enforcement (pre-launch — currently OFF in dev per standing instruction; do NOT enable the dev project):** - in **staging**, enable enforcement on Firestore + Functions → a valid-token app works, a raw/no-token request → **403** - (extends D3/D5 beyond rules-only); confirm Play Integrity on a real device vs the debug provider. -- **Deep links / Android App Links:** `closer://` **and** any `https` App Links (`assetlinks.json`) open the correct - screen with auth/membership re-checked (overlaps E). -- **Permissions & manifest:** the manifest declares only what's used; runtime prompts (POST_NOTIFICATIONS, camera, mic, - Android-13+ photo picker / `READ_MEDIA_IMAGES`) appear and degrade gracefully when denied; `allowBackup=false` holds (D6); - and the **`screenOrientation` decision is explicit** — today the manifest sets none, so the app rotates to an unverified - landscape layout (Pass C). Either lock portrait or certify landscape; don't ship it undecided. -- **Age gate / content rating / maturity (the app has adult/intimacy content — Desire Sync — and currently NO age gate).** - Confirm an appropriate **18+ / age-appropriate gate** exists where required, the Play **content/maturity rating - questionnaire** matches the actual content, and any IAP/intimacy content complies with store policy. A missing age gate - on adult content is a **store-rejection + legal risk** — file it (see the app-finding note). -- **Localization & formats (i18n):** strings are externalized (no hardcoded user-facing text), the longest translations - don't clip (overlaps C/J), **RTL** mirrors correctly, dates/numbers/**subscription prices+currency** format per locale - (overlaps K). Even if English-only today, confirm there's no layout that assumes English length. -- **Play Store readiness:** the **Data Safety** form matches the actual data flows + E2E encryption; privacy-policy URL - live; version code/name bumped; store listing/screenshots are the brand pass (H); min/target-SDK **device matrix** - (Methodology) covered. -- **Data-rights compliance (GDPR/CCPA — verify the CONTENTS, not just that a link opens).** Pass M confirms the export / - privacy links resolve; here, confirm **right-to-access** actually returns the user's real data (and that E2E content is - handled correctly — exported decrypted to the owner, or documented as unrecoverable), and **right-to-erasure** (delete - account, Pass M/G) genuinely cascades server-side (`onUserDelete`). A privacy policy that claims flows the app doesn't - do (or omits ones it does) is a finding. - -### Pass P — Content, copy & language quality (voice, grammar, inclusivity, the question bank) -**Wrong language is a BUG, not a "nice-to-have."** Typos, grammar/punctuation errors, off-brand or cold/salesy voice, -non-inclusive or assumptive wording, leaked placeholder/dev text, raw SDK/Firebase/RevenueCat errors shown to users, -copy that doesn't match behavior, and broken/duplicate/low-quality questions are all **defects → `ClaudeReport.md`**. -Only genuinely-working copy that *could be warmer/clearer* goes to `Future.md`. **Read first:** -`docs/brand/visual-identity.md` (**Store voice**) and `seed/questions/QUESTION_CONTENT_GUIDE.md` (**v3** — readability -test, no-AI-writing, duplicate prevention, variety, fun/relationship-first/premium rules). This is a recurring pass. -- **UI microcopy audit (every screen + state):** read ALL visible text — titles, labels, button verbs, helper text, - empty states, dialogs/confirmations, toasts, loading + **error** copy, and notification text — for: typos, grammar, - punctuation, capitalization/casing consistency, consistent terminology (feature names, "partner"/the partner's name, - "couple"), and copy that **matches the actual action/state** (a button says what it does; "Day N of 7" matches the real - state; correct names/counts/attribution). A label that misstates its destination or effect is a bug (overlaps C's CTA check). -- **No raw / placeholder / dev text ever reaches a user:** no Lorem, "TODO", debug strings, untranslated keys, or raw - exception/Firebase/RevenueCat error text surfaced to users (the A-OBS class) — always friendly copy. -- **Brand voice & tone (against `visual-identity.md` Store voice):** copy is **warm, quiet, equal, calm, specific** — a - private ritual for two. **Off-voice = finding:** cold/clinical, salesy/hype, urgent/alarmist, guilt- or streak-shaming, - competitive, surveillance-y, or "we'll FIX your relationship" promises. Scrutinize paywall, notification, and streak copy. -- **Inclusive & non-assumptive language:** no heteronormative or relationship-structure assumptions, no assumption about - who initiates or about bodies/ability/culture; gender-neutral where the design calls for it (the de-gendering effort — - `seed/degender_*.py`); sensitive topics (Desire Sync, intimacy) phrased with care + a consent framing, never crude or - clinical. Assumptive/exclusionary wording = bug. -- **Question-bank content QA (against `QUESTION_CONTENT_GUIDE.md` v3):** spot-check questions across **every category, - depth, and answer type** as the user SEES them (live-rendered, not just the DB) for: passes the **readability test**; - no **AI-writing tells**; **no duplicates / near-duplicates**; sensible **variety** + emotional mix; **answer options - complete + mutually exclusive + sensible** (no overlapping, joke-only, or placeholder options); the **answer type fits** - the prompt; **fun-rule / relationship-first** tone; and **no broken/empty/garbled/offensive/unsafe** prompts (cf. - `seed/fix_depth5_grammar.py`, `seed/validate_question_variety.py`). A shipped question that's broken, duplicated, - off-guide, or unsafe is a bug. -- **Legal / store / monetary copy accuracy:** paywall benefit claims are truthful (no over-promise); subscription terms + - renewal/price wording present and accurate (overlaps K); Privacy & Terms links resolve; store-voice rules hold. -- **Localization correctness (overlaps O):** no clipped/awkward strings from concatenation, correct pluralization, dates/ - numbers/currency per locale, RTL grammar intact — even if English-only today, flag any English-length/grammar assumption. -- **Method:** harvest strings from `res/values/strings.xml` + in-code literals AND read them **in context on-device** (a - string can be fine in isolation yet wrong/cramped/ambiguous in place). Routing: incorrect/off-voice/non-inclusive/ - placeholder/inaccurate/unsafe = **bug → `ClaudeReport.md`** (P2 default; **P1** if it misleads, blocks, leaks a raw - error, or is offensive/unsafe; P3 for pure nits); "could be warmer/clearer" → `Future.md`; new copy/voice work that's - out of scope → note it. - -## Reporting → ClaudeReport.md (living QA report) -- Header: date, build, devices, round number + run-state header. -- One section per pass (A–P), each a table: **ID | Area | Screen/Route | Mode | Severity | Description | Repro - | Evidence | Suggested fix | Status**. -- 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` and keep it through the **next** re-QA round. Once that round re-verifies it, **delete the row** — the durable - root-cause/fix detail lives in the **Engineering Manual landmine** (mandatory for any escaped/deep bug) + git history - (the row cites the landmine ID, and the commit hash once the user commits), so nothing is lost. Don't rely on "the - commit message" as the only home — **you don't commit** (the user does, often batched), so the manual landmine is the - reliable record. Don't carry confirmed-fixed issues across multiple rounds. - - **Make the archived-ID line a usable duplicate-fix lookup, not bare IDs.** When you prune a row, attach a **2–4 word - tag** to its archived ID (e.g. `C-PW-001 dark paywall pills`) so the Fix-phase **Regression triage** can search it by - symptom. **Any fix whose class could plausibly recur gets at least a one-line Engineering Manual landmine entry** — - not only the "escaped/deep" bugs the MANDATORY-retrospective requires — so a future regression check never lands on an - ID with no description. (This is why a separate fix-history file is unnecessary: the manual landmines + this tagged - archived line + git already are the fix history.) -- **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 P0–P3; security clean") - before any detail. -- **Tables over prose** for issues; **short rows**. Put long root-cause analysis in the **Engineering Manual landmine** - (the durable home), not the row — the row gets a one-sentence description + repro + the landmine ID (and commit hash - once the user commits). -- **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 manual/landmine 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) -- Work strictly by severity: **all P0 → P1 → P2 → P3**. -- **⛔ Regression triage — DIFF & history-check BEFORE you write a fix (every bug, not just crashes — don't fix blind).** - First answer *"is this NEW, or did we break/relapse something that worked?"* — fixing without this risks re-fixing a - known issue a different way (divergent fixes) or masking the real regression: - 1. **Have we fixed this before? (duplicate-fix / regression check.)** Search the **fix history** for the same - symptom/area/ID — the canonical home is the Engineering Manual - [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) (root - cause + the guard that should hold it) plus `ClaudeReport.md`'s `Resolved & confirmed` archived-ID line. **A match ⇒ - this is a REGRESSION, not a new bug:** re-open under the **original ID**, and fix *why the guard lapsed* (a scanner/ - test/pass-step that was supposed to catch it) — do **not** re-implement a fresh fix from scratch. - 2. **What changed? (diff before you fix.)** `git log` / `git diff` / `git blame` / `git log -L` the failing area to pin - the introducing change — **including OTHER agents' recent commits** (this repo is co-edited by Codex / kimi / Ripley, - so "what changed" is frequently not your own work; `git log --since` / `git log ` across authors). Read that - commit's diff and fix the **actual cause it introduced**, not the surface symptom. ("worked before, broken now" - ⇒ always bisect to the change first.) - 3. Only after you know **new-vs-regression** and **what introduced it** do you design the fix. -- **One issue at a time**: implement → `./gradlew :app:assembleDebug` → install both → verify THAT fix live (correct - device/theme) + regression smoke (launch/no-crash, send text, inbox loads, a game opens, **content still ciphertext - in Firestore**, **`./gradlew testDebugUnitTest` + functions `npm test` still green** — a fix that reds a test isn't - Fixed) → flip its row to **Fixed** + capture the durable substance in the Engineering Manual landmine → next - (the **user** commits per issue/cluster — never run git yourself; see Guardrails). Don't start the next until the - current is verified. -- **Real-path verification gate (do NOT mark Fixed without it):** verify the fix through the **same path the user hits**, - not a synthetic shortcut. A crash/launch/notification fix is only "Fixed" once reproduced-then-cleared via the REAL - channel (real push tapped from the shade on an `am kill`'d app; real launcher cold-start) — `am start`/`am force-stop` - passes don't count. For any cold-start/notification/launch fix, the gate is **`qa/entrypoint_smoke.sh` green**. (This - session's miss: a routing "fix" was declared on `am start` evidence while the real bug was a splash crash on the FCM - cold-start. Don't repeat it.) -- **Couple-shared premium fix**: replace direct `isPremium()` gates with - `CouplePremiumChecker.coupleHasPremium(partnerId)` in every gated VM/screen (partner-entitlement read rule deployed). - **High regression risk** — re-verify each feature in BOTH self-premium and free states. -- **Re-run associated scanners after fixing.** If the fix touches UI colors/surfaces (Pass C), re-run - `scripts/theme-scan.sh` and confirm the relevant CRITICAL count dropped. If the fix touches launch/splash/notifications, - re-run `qa/entrypoint_smoke.sh`. Update the coverage matrix with the new counts; a "Fixed" row is only valid when the - scanner (and the live visual sweep) both agree. -- 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. - -**Definition of done:** a **pass** is done when every coverage row is `pass`/`fail→id`/`not implemented→Future.md`/ -`blocked→id`; a **round** is done when all **recurring** passes (A–N + P) are done; **flawless** = one full round with -**zero open P0–P2 and Passes D + E + L + P fully clean** (no open P0/P1 in I/J), **every game fully played through, -every notification type verified or explicitly `not implemented→Future.md`, chat (L) + the couple-shared premium gate -(A) + settings-take-effect (M) + **interactive features (N: daily-Q/reveal, outcomes, Bucket List, Date Builder work -end-to-end — created data persists AND is read back, `scripts/wiring-scan.sh` 🔴=0)** + content/language (P: no typos/ -off-voice/non-inclusive copy, question bank on-guide) verified, all join-game navigation paths and all back-stack checks -verified**, **the unit + functions test suites GREEN (`./gradlew testDebugUnitTest` + functions `npm test`)**, **and -`qa/entrypoint_smoke.sh` GREEN on -both emulators (0 FAIL — every entry-point cold-start opens and stays)**. Then stop (P3s optional). **Pass O (release -build + store readiness) and Pass K's real-money path are pre-ship / real-device gates** — they don't block a per-round -"flawless" but **must be GREEN before any store submission**. Don't re-open a clean pass within the same round. - -## Re-QA loop (until flawless) -After the fix phase, re-run Passes A–N + P (regression + confirm fixes; Pass K money-path when a sandbox device is -available, Pass O when prepping a release). Repeat **fix → re-QA** rounds until a full -round yields zero P0–P2 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. diff --git a/ClaudeReport.md b/ClaudeReport.md deleted file mode 100644 index b24aebd7..00000000 --- a/ClaudeReport.md +++ /dev/null @@ -1,262 +0,0 @@ -# Claude QA Report — Full-App QA (living report) - -> **Verdict (2026-07-07): R30 = POLISH & EXPERIENCE round on top of R29 — closed all remaining P3s + 8 UX gaps + SEEDED the daily-question server pool (R29's R-1). 8 commits, functions deployed, all live-verified on the Ava+Ben pair.** -> **Batches (each built + installed + live-verified + committed):** -> **C1 defect-polish (951ce87):** A-003b Challenges 🔒-badge-after-unlock (verified via grant/revoke) · BANNER-LIFE-001 results banner now consumed on the session's screen + replaced across sessions · joiner "Your turn — {name} already played their part" copy · How Well matched-row dark-mode contrast (theme-aware pair) · Date Match top-card opaque (no underlay bleed) · **+found live:** false "finished — see results" push on abandoned sessions → guarded on completedByUsers≥2. -> **C2 funnel+gating (258674f):** both DOB pickers → shared Compose M3 DobPickerDialog (verified purple in dark) · SignupHandoff → DataStore per-uid so **DOB survives process death** (verified: am-kill mid-profile → no re-ask) · Challenges day-N+1 spoiler hidden behind a "unlocks tomorrow 🌙" teaser (statusDay exposed + 2 tests). -> **C3 game-loop UX (e08374f):** NextBeatCard under every game's results ("Tonight's question is ready →" / streak line; verified live on How Well) · How Well **role-swap** CTA "Your turn — let {name} guess you" (verified) · Date Match per-couple shuffle + skip-already-swiped (verified: swiped card gone) · Desire Sync seen-question memory (SeenDesireQuestionStore) · How Well pool excludes daily_fun_mc novelty MCs. -> **C4 nudge+copy+a11y (66ab59c):** "Send a little nudge 💜" on the waiting screen via thinkingOfYou callable (verified: partner_activity push landed) · per-game banner copy ("Your turn — guess their answers" for How Well etc., client + server mirror; verified) · merged contentDescription on the ToT badge + How Well score ring. -> **SEED daily-question pool (54b6125) — R29 R-1 DONE + deployed:** seeded Firestore `questions` with the 75 FREE daily_fun_mc weekday questions (ids match the asset DB); rewrote server selection `pickDailyQuestionId(date)` to be **mode-aware + deterministic** (mirrors the client's frozen DOW_DEFAULTS + epochDay offset). **Verified live end-to-end:** callable now assigns a REAL id (`daily_single_choice_weekly_v1_072`, Tiny Win Tuesday) not `q_default_daily`; **both devices resolve the IDENTICAL question**; theming preserved ("Tiny Win Tuesday"); reveal shows real option text, **no slugs**. Also fixes partners in different timezones (server-authoritative now, was device-local weekday). Reveal category chip "Daily Fun Mc"→"Daily Fun" (2nd surface of the C1 pill fix, central helper). Functions deployed OK (assignDailyQuestion + callable + game triggers). -> **Gates:** unit **BUILD SUCCESSFUL** · functions **58** green · assembleDebug clean · entrypoint cold-start smoke run (nav/notifications touched). **State clean:** 0 active sessions, premium revoked (locks confirmed). All batches committed per-batch, no push. Ben was signed out→back-in mid-round (throwaway signup for DOB checks) — pairing intact. Test passwords now recorded (`qa/README.md` 85b875d). - - -> **Verdict (2026-07-07): R29 = SENIOR-DEV GAMES/MECHANICS/RETENTION/UI REVIEW round (user-mandated) — all 7 games verified live 2-device on a FRESH couple created through the real signup→invite→recovery-phrase flow; found & FIXED 2×P1 + 2×P2; two fix batches committed.** -> **New fixture couple:** Ava(5554/CloserQA2, light) + Ben(5556/CloserCodexQA, dark), coupleId `j39xR7PVZLLCveWnmenU` (old QA fixture's device state was lost environmentally; QA-account credential ops are blocked in auto mode, so the round created a real couple — which itself live-verified onboarding/signup/pairing/recovery-ceremony/day-0 check-in). -> **Games (all PASS live, engineered-answer verification):** ToT exactly 3/5 in-sync · HowWell 2/5 with amber near-miss (isClose) + correct subject/guesser roles · DesireSync exactly "2 shared desires / 3 stayed private", non-mutual answers never rendered, `enc:v1:` at rest · Wheel identical 10-q set + completion gate · Challenges real day-gating (premium challenge opened under grant) · Memory Lane sealed capsule (title+body ciphertext, no list leak) · Date Match simultaneous-LOVE race → exactly 1 match + both pushes. Engine seams: one-active-game convergence ("Join the game"), first-finisher banner+push, cold-start push open (0 FATAL), quit-abandon → 0 active sessions (admin-verified). -> **Premium lifecycle re-verified live:** admin grant (user-authorized) → partner unlock modal + "Premium unlocked ✨" push on Ben → revoke → 🔒 restored both devices. -> **FIXED (commit e5868bd):** 🔴 **DQ-MISMATCH-001 (P1)** partners resolved DIFFERENT daily questions same day (server assigns unresolvable `q_default_daily` → per-device local fallback whose POOL depends on the viewer's premium state; reproduced live, fixed by premium-independent paired fallback; post-fix both devices verified identical). 🔴 **DQ-SLUG-001 (P1)** partner's reveal rendered raw option-id slugs cross-question (humanize fallback added, HW-BREAKDOWN-001 pattern). 🟠 **GAME-HANG-001 (P2)** ToT/HowWell/DesireSync listeners swallowed errors → eternal WAITING (close(err) + retryable ERROR w/ re-attach). 🟠 **DST-001 (P2)** assignDailyQuestion hardcoded -6 offset (Intl/Chicago helpers + 5 regression tests; **needs `firebase deploy --only functions`**). -> **FIXED (commit c36a101, hygiene):** dead ui/theme/Color.kt deleted · orphaned EditProfileScreen wrapper removed (Content stays — audit's "whole file dead" was wrong) · ArtPreview + PairedHomePreview debug-gated out of release graph · wheel_history route collapsed into game_history · CouplePremiumChecker now injects AuthRepository · copy: "It's a match!", Home pill "Daily Fun" (was "Daily Fun Mc"), welcome tagline no longer clips (AUTH-TRUNC-001). -> **OPEN (filed this round):** P3 A-003b stale 🔒 badge on Challenges catalog post-unlock · P3 BANNER-LIFE-001 results banner lingers across screens/games · P3 pack: unthemed date picker, DOB asked twice (signup+profile), HW matched-row dark contrast, Date Match card-underlay text bleed, joiner-screen "Waiting for X" copy when it's your turn, Challenges shows tomorrow's prompt today. Structural/retention recommendations + full per-game fidelity verdicts live in **ClaudeGamesReview.md**; refactor specs (session-game engine dedupe etc.) in Future.md. -> **Gates:** unit 244 green · functions **58** green (5 new DST tests) · assembleDebug clean ×2 · live regression post-fix (ToT 5/5 reveal + identical daily question both devices). **Server state clean:** 0 active sessions, 1 date_match, capsule sealed+encrypted, premium revoked. Session rule note: this round COMMITS per batch (supersedes older "user commits" convention). Emulator note: fixture pair is now Closer2(Sam, untouched)+CloserQA2/CloserCodexQA(Ava/Ben); CloserScreenshots solo fixture untouched. - - -> **Verdict (2026-07-02): R28 = closed R27's two gaps — FIXED HW-BREAKDOWN-001 (P3) + live-ran the 3 premium games 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL; open P3 board now clear except the 2 brand-asset backlogs.** **HW-BREAKDOWN-001 (P3) FIXED:** How Well results breakdown was rendering an un-guessed/fallback choice as its raw option-ID slug (`a_small_romantic_surprise`); added `humanizeOptionId()` (`_`→space + Title-case) applied to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win when present) — built + installed on both emulators. **Premium grant (user-authorized THIS occurrence: "grant QA premium via admin write, test the 3 premium games, then revoke") → couple-shared unlock re-confirmed live:** granting QA (`entitlements/premium` hasPremium/isActive/premium=true, source `qa_admin`) unlocked BOTH partners — Sam's (free) Play hub dropped the 🔒 on Desire Sync/Memory Lane and QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal (Pass A partner→both, live). **Premium games — all 3 PASS live 2-device:** ① **Desire Sync** — both answered privately (Sam Y·Y·Y·Y·Y, QA Y·Y·Y·N·N) → both devices rendered the **identical intersection: "3 shared desires / 2 answers stayed private"** listing exactly the 3 mutual-YES prompts (the 2 QA said No to stayed private), You/partner chips both "Private", and Sam's waiting screen **auto-flipped** when QA finished + fired the in-app **"You both finished — View"** banner. ② **Memory Lane** — created + sealed a capsule ("R27-Capsule-TITLE" / body "R27-SEALED-BODY-do-not-leak", 1yr → opens Jul 2 2027); the list row shows **only the title under a lock** ("Opens in 12 mo") — **sealed body does NOT leak** (confirms the odd-looking pre-existing rows like "R10_Memory_Testy…" are just long *titles*, not body previews). ③ **Date Match** — mutual like on "Sunrise hike + thermos coffee" (liked on QA, then Sam) produced a **"Matched"** entry in the couple-shared "Your Matches" list **on both devices** (Mutual love ×4, each with "We did this"); premium-tagged idea "Overnight camping getaway" is now swipeable/matched (A-201 gate lifts correctly under premium). **Premium REVOKED after testing (fulfilling the authorization):** admin set hasPremium/isActive/premium=false + `revokedAt` — verified at DB (BEFORE all-true → AFTER all-false) **and live in UI** (Memory Lane + Past Games show 🔒 **Premium** again; free fixture state restored). **Copy nit BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** the foreground game-results banner read "See how you and **Your partner** compare" — the generic fallback is capitalized (correct at sentence-start for the started/joined/your-turn lines) but reads wrong mid-sentence. `GamePromptBanner.styleFor()` now branches the RESULTS line2: resolved name → "See how you and Sam compare"; no name → name-free **"See how you both compare"** (never an awkward capitalized generic). Compiles + installed both emulators. **O-AGE-001 (P2 pre-ship) — IMPLEMENTED + live-verified (18+ DOB gate):** new `domain/AgeGate` (18+, DOB→age math, 8 unit tests); `User.birthDate: Long?` + Firestore read/`createUser`/`updateBirthDate` + `firestore.rules` users-update allowlist `+birthDate`; `SignUpScreen` now has a **Date of birth** picker ("Closer is an 18+ app.") validated **before account creation** (under-age → no account); `CreateProfileViewModel/Screen` add a conditional first **DOB step** for arrivals without a DOB (Google/legacy) and skip it for email users (DOB carried via a `SignupHandoff` singleton — the post-signup Firestore write races auth-token attachment, so we validate at sign-up but persist at profile). **Live-verified on throwaway 5558 (fresh installs, own account):** DOB blank → "Please enter your date of birth."; **Jul 5 2008 (age 17) → "You must be at least 18 to use Closer." + NO account created** (stays on sign-up); adult DOB (2000/2001) → account created → CreateProfile opens at **NAME (Step 1 of 3), DOB step correctly skipped** for the email user; full profile save **succeeds** (no error). **Landmine caught live (would've shipped a broken flow):** the first attempt persisted birthDate via `updateBirthDate` (an *update* — the doc already exists from the FCM-token write), which the **deployed** rules reject (my allowlist add is undeployed) → **PERMISSION_DENIED broke the whole profile save**. Fixed by making the birthDate persist **best-effort** (`runCatching`, never blocks profile creation — the gate is already enforced client-side). **birthDate persistence: the additive `firestore:rules` allowlist change was DEPLOYED by the user — verified via the exact app update path (authed `PATCH {birthDate,lastActiveAt}` on own doc → 200 ALLOWED + persisted; was 403 pre-deploy) and confirmed the allowlist still rejects a non-allowlisted field (→ 403), so the deploy widened only `birthDate` and weakened nothing.** birthDate now persists end-to-end; the best-effort write also keeps profile creation resilient. Unit suite **279 green** (+8 AgeGate). Throwaway test accounts created + deleted; 5558 shut down (QA/Sam fixtures untouched). **0 FATAL** across the whole session, both emulators. All code + docs uncommitted (user commits); **firestore.rules changed (undeployed).** -> -> **Verdict (2026-07-02): R27 = full-plan COMPLETION — live-ran every pass R26 had carried; 0 P0/P1/P2, 1 new P3 (HW-BREAKDOWN-001), 0 FATAL.** Same build as R26 (UI-only diff; app unchanged, only doc edits since). Closed the gap I was honest about: **P** (question bank — 6103 Qs 0 empty/dupe/placeholder, configs complete, daily pack 500 intact, **Room hash `7e7d78…` preserved**), **I** (perf — core-tabs 6.67% janky/90th 31ms, conversation scroll 3.04%, 0 missed-vsync), **J** (a11y — font 2.0 reflow with no hidden actions, reduce-motion no hang, **TalkBack 160/160 icons labelled**, 48dp targets carried), **F** (resilience — Messages renders from cache in airplane-mode 0-FATAL, portrait-lock holds under forced rotation, process-death via smoke), **G/D3** (security — **live raw-API: non-member 403 on couple/messages/daily/date_reflections/user + self-grant 403**; own-doc 404 = rules are the gate), **H** (branding on-brand; 2 P3 backlogs carried). **Pass B games (live 2-device):** This-or-That 5/5 (R26), **How Well** 2-of-3 with correct scale+choice scoring, **Connection Challenges** resume→complete Day6→advance Day7→mutual per-day gate + streak/missed-day recovery, **Spin-the-Wheel** spin/category/session/answer/cap/quit (full 10-Q carries R18b). **NEW — HW-BREAKDOWN-001 (P3):** How Well results breakdown shows a wrong *choice*-guess as its raw option-ID slug (`a_small_romantic_surprise`) instead of the human label ("Only if needed" resolves correctly) — cosmetic ID-leak in the (untouched) How Well feature; recommend resolving the guessed option ID to its display text in the breakdown row. **Premium games (Desire Sync, Memory Lane, Date Match):** paywall GATE verified for all three (free → Paywall; Date Match free-swipe→paywall = A-201 holds); **GAMEPLAY was blocked this round — the admin premium grant was declined by the auto-mode classifier (not the specific action authorized by "run the full qa"); needs explicit OK. → RESOLVED in R28: user explicitly authorized the grant, all 3 premium games ran full 2-device, then premium was revoked (see R28 verdict above).** **K** (real money path) + **O** (release build/store) remain pre-ship/needs-device. All docs uncommitted (user commits). -> -> **Verdict (2026-07-01): R26 = full-plan run on the text-input/truncation + DateReflection-hardening build — 0 defects, 0 FATAL.** Round validated this session's UI-only changes (display-truncation removed from content/error surfaces; free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; spin-the-wheel written-answer cap added; near-limit counter; DateReflection read-failure→retryable ERROR + bounded couple-read) and closed the last outstanding coverage item. **Cheap gates GREEN:** unit **244** · functions **47** · theme-scan CRIT **1 = false-pos** (HomeScreen:829 brand count pill) · painter-xml **0** · wiring 🔴**0** dead · ime-scan **PASS**. **Cold-start `entrypoint_smoke` 6/6 on BOTH emulators** (launcher + 5 notif cold-starts open & stay). **QA(5556) fixture RE-RESTORED (user-authorized):** an environmental logout after the standby emulator kill (couple key + recovery intact on disk) → **admin Auth password reset + sign-in, no restore ceremony**; QA landed on paired Home with daily-Q + partner state + full chat history decrypting. **Pass D (E2EE at-rest, admin ground-truth) CLEAN:** conversations (main + per-question discussion), daily-question answers (both users), date_reflections all `enc:v1:` with content-free metadata; image messages carry only an encrypted `mediaUrl` (no text/plaintext); **no rules or crypto changed this cycle**, so R25's D2 static + D3 live-negative results carry. **Pass L (messaging) PASS:** QA inbox previews fully decrypted (no `enc:` leak), full thread decrypts with attribution/day-separators/Seen, and a **live 2-device round-trip** (QA→Sam: "QA_roundtrip_passL_restore" delivered + decrypted on Sam) — proving the *restored* couple key still produces ciphertext the partner decrypts (R24 restore-key integrity). **Pass A PASS:** free (Sam) → Desire Sync → **Paywall** "Go deeper together" with legible dark-mode benefit pills (C-PW-001 holds). **Pass M PASS:** Settings renders clean (profile card, Connected-with, sections; debug rows `BuildConfig.DEBUG`-gated; no clipping). **Pass C:** every screen driven this round renders clean in dark; theme-scan unchanged (1 known FP). **Pass N — Date Memories/Reflection (the R25 `todo`) CLOSED:** 2-device reflect→reveal, edit-before-reveal (against deployed rules), the new notes field, background + foreground deep-link into the exact reflection, and the `date_reflection_ready` / `date_reflection_opened` pushes were all verified live this session, plus the R25-fixed hardening (own-status read-failure → retryable ERROR with Try-again, 8s couple-read timeout, blank-dateId guard). **Pass B (games) — This-or-That re-run live 2-device:** start (Sam) → waiting-for-partner gate → **join from QA's foreground game banner** → both answer 5 → **"5/5 in sync · Two peas in a pod" results rendered symmetrically on BOTH devices** (per-question You/partner breakdown, answers decrypted, all Match); incidentally re-confirmed the live foreground game banners (`partner_completed_part` "Your turn — reveal how you line up" + results "You both finished · View", each with the partner avatar) and real-time reveal sync (Sam's waiting screen auto-flipped when QA finished). **Not run (documented, carried / pre-ship):** K real money path (needs device + Play sandbox), O minified release/store readiness, device/OS matrix (two identical emulators), and the remaining 6 games' Pass-B re-run (no games-logic change this cycle; smoke covers all game cold-start paths, last full 7-game B clean R12/R18b). All code + docs uncommitted (user commits). -> -> **Verdict (2026-06-27): R15 = gap-closing round on Passes L/M/N/P + smoke — found & FIXED M-001 (P2 quiet hours).** Targeted the previously-uncovered gaps (the new "flawless" bar needs L + P clean + M take-effect). **Found M-001 (P2):** "Quiet hours — 10 PM–8 AM, no notifications" did **not** suppress partner pushes when the recipient app was backgrounded/killed (the main case) — quiet hours was **local-only** (never synced server-side) and the OS shows the FCM `notification` block directly without running app code. **Fixed + verified live:** client now mirrors the window+timezone to `users/{uid}`; the 4 partner-action senders (`onMessageWritten`/`onAnswerWritten`/`onAnswerRevealed`/`onGameSessionUpdate`) suppress server-side via a **fail-open** `recipientInQuietHours()`; rules allowlist extended for the new fields. Live: QH ON → function logs `…is in quiet hours — suppressing`, 0 delivery; QH OFF → `notified partner`, delivery resumes; per-type chat toggle still suppresses (server-enforced). **Then drove Pass N (user "FIX"):** **N-001 (P1) — Bucket List was entirely non-functional** (coupleId never wired → all CRUD silently no-op) → **FIXED + verified live** (add `enc:v1:`/complete/delete/render). **N-002 (P2) — "Plan a Date"/Date Builder "Create Plan" was a no-op** (wrote to an unread prefs collection; `dateIdeaId`/`coupleId` never wired) → **FIXED + verified live** (re-pointed to create a PLANNED `DatePlan` → Home shows "Date coming up"). Outcomes/Your Progress code-correct. **Clean passes:** L (chat decrypt both dirs, attribution, Seen receipt, ❤️ reaction, ordering, day-sep, inbox no `enc:` leak, at-rest `enc:v1:`); P (UI copy warm/inclusive, debug rows `BuildConfig.DEBUG`-gated, friendly error fallbacks; **question bank 6103 Qs — 0 empty, 0 dupes, 0 placeholders, complete answer configs, on-guide tone**); daily-Q + reveal gate render; smoke **6/6 GREEN both emulators**. 2 P3 brand-asset backlogs still open. **0 FATAL.** -> -> **Verdict (2026-06-27): R14 = full fresh A–J ClaudeQAPlan run — 0 open P0–P2, 0 new functional findings.** A pure-QA confirmation round (no code changes) on the R13 build. _(A follow-up 2026-06-27 brand-standards audit then opened **2 P3 brand-asset backlogs** — every image needs a dark variant; every icon must be custom — see the Issues section + `ClaudeBrandingReview.md`.)_ The 5 R13 fixes (C-DARK-UI-001/002/003, C-ART-EDGE-002, J-OBS) **held through R14's sweep → pruned**; the Premium-unlock modal held + re-verified. **Live results:** Pass A — premium ENFORCEMENT audited (all 6 gated features have a real gate, not just a badge; A-201 class closed) + free→Paywall confirmed for Date Match / Desire Sync / Question Packs + couple-shared unlock (Sam→QA) with modal + `subscription_entitlement_changed` push delivered live. Pass B — Desire Sync / How Well / Spin-the-Wheel full 2-device (mixed answer types incl free-text + multi-select + skipped-answer reveal), **first-finisher `partner_completed_part` nudge confirmed live**, Memory Lane create+seal (premium), Connection Challenges resume, Date Match deck (ToT carried from R13). Pass C — broad both-theme sweep + **decoupled-theme-art mandate** (system-light+app-Dark → dark UI + correctly-themed feathered art). Pass D — cornerstone LIVE (non-member 403 ×2, self-grant 403, member 200, chat at-rest `enc:v1:`). Pass E — all triggers fired live with content-free copy to the right partner. Pass F — offline cache render + process-death recovery, both 0 FATAL. Pass I — jank 5.25%. Pass J — J-OBS 48dp holds. **0 FATAL across the whole run, both emulators.** - -> **Verdict (2026-06-27): R13 = fixed the entire open backlog + full fresh A–J — FLAWLESS (0 open P0–P3).** Took over and **fixed all 3 open Codex dark-mode findings** (C-DARK-UI-001 P2 This-or-That redesign; C-DARK-UI-002 P3 check-in label/value; C-DARK-UI-003 P3 bottom-inset clipping) plus the 2 carried P3s (C-ART-EDGE-002 direct-call hero feathering; J-OBS 48dp touch targets), and **confirmed A-201 (P1) live → pruned**. Also shipped the **branding Premium-unlock modal** (`illustration_premium_unlock`, one-time, shown to BOTH partners on couple-shared activation). All verified live on both emulators (5554 dark / 5556 light), **0 FATAL**. Full fresh A–J run clean: Pass D security cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, chat at-rest `enc:v1:`); A premium gates → Paywall (Date Match + Desire Sync); B ToT full both themes + Wheel launch; I jank 6.43% (perf-safe); J 48dp confirmed. Diff is **UI-only** (no rules/functions/crypto change) → E/F/G carried. All app changes in the working tree — user commits. - -> **Verdict addendum (2026-06-27): ad hoc DARK-MODE UI/brand review on dedicated Codex emulator COMPLETE.** Built + installed the current debug APK on my own `CloserCodexQA` emulator (`emulator-5558`), forced system dark mode, created a fresh real paired couple through the app invite flow, and swept profile/onboarding, unpaired invite, paired Home, Play, This-or-That, Settings, Notifications, Paywall, Messages, and Today. **Button text is generally readable** across profile/Home/Settings/Notifications/Messages/Paywall, but the sweep found **1 open P2**: This-or-That active gameplay has low-contrast dark option text and an off-brand diagonal/circle backdrop crossing the prompt. Also found **2 open P3s**: first-launch check-in modal label/value collision and recurring bottom-inset clipping on scroll content near nav/gesture areas. Logs checked after navigation/game entry: **0 app FATAL/ANR/force-finish**; only uiautomator/system noise plus a non-crashing BillingClient unbind warning. - -> **Verdict (2026-06-27): R11 confirmation round COMPLETE — FLAWLESS (0 open P0–P2). Fixed the last open P2 (C-DARKART-001 — dark art now follows the in-app theme) + the open P3 (C-ART-EDGE-001 — art feathers into the surface), both in the shared `BrandIllustration`/`EmptyState` helpers, verified live on both decoupled theme directions (system-light+app-Dark → dark aubergine art; system-dark+app-Light → light pastel art), 0 FATAL. Re-confirmed all 5 R10 P2 fixes hold (C-HOME-001 single card · C-NAV-002 wheel-back popUpTo present · C-NAV-003 single app bar live · C-PW-001 dark paywall pills legible live · C-SEC-001 recovery row active for accepter live) → pruned. Entrypoint launch-integrity smoke green (splash-crash class clean on the fresh APK). Only remaining: 2 freshly-fixed art items pending 1 confirm + J-OBS (P3 touch targets). Art fixes in working tree — user commits.** - -> **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md). Most fixed-and-pruned IDs above are documented in its [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section — read before re-touching the affected area. -> -> **Verdict (2026-06-26): R10 FULL ClaudeQAPlan run COMPLETE (A–J + fix phase). 0 open P0–P2; 1 P3 (J-OBS). Found 5 P2 (Home dup card, wheel back-stack, duplicate app bar, dark paywall contrast, recovery-phrase wrong store) — ALL fixed + verified live + regression-clean. E-GAME-002 confirmed live + pruned. Security cornerstone clean (D1–D7). [Pruned in R11.]** -> -> This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned -> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. - -## Run-state (current) -- **R25 (2026-06-30) — full fresh ClaudeQAPlan run focused on the new R24 E2EE backup/restore surface + cornerstone regression. 0 new defects; recurring bar stays clean. Two user-gated items surfaced (functions deploy + a fixture-recovery I caused).** **Cheap gates all green:** unit **244** · functions **38** (incl. new `onRestoreRequested` tests) · `theme-scan` CRITICAL **1 = false-positive** (`HomeScreen.kt:829` `Surface(color=CloserPalette.PinkBright)`+white text is a brand-accent count pill like the legible Settings "3" badge — not an adaptive surface; MAJOR/REVIEW all pre-existing-acceptable) · `painter-xml-scan` **0** · `wiring-scan` 🔴**0** dead setters / 🔴**0** dead notif settings (20 🟠 orphan-readers = known interface-decl false-positives) · **cold-start `entrypoint_smoke` 6/6 on BOTH emulators** · **`connectedDebugAndroidTest` FirstRunRenderSmokeTest 4/4** (first-run composables paint light+dark). Monkey fuzz skipped (AVD activity-resolution quirk, not an app bug). **Pass D (security) — CLEAN, deep on the new backup/restore surface:** D1 at-rest (admin ground-truth) — backup `manifest` holds pointers/metadata only (generation, messageCount=33, `sha256:` checksum, tokenized `snapshotUrl`, uids); the Storage **snapshot blob is `enc:v1:` ciphertext** (16455 B, 0 plaintext markers in first 2KB — server-blind); loose chunks folded to snapshot (0 now; rule enforces `isCiphertext(payload)`); `restore_requests`=0 (clean, no lingering keybox). D2 rules static — `backup/**` + `restore_requests/**` member-scoped; keybox handover bound to the couple's **other** member with immutable pubkey/nonce; recipient-only create/delete; status-flips constrained; Storage `users/{uid}/backups` uploader-scoped **write**, **read** via tokenized capability URL. D3 **live raw-API negative** — non-member token → read backup manifest / list chunks / list restore_requests / create restore_request / write chunk = **all DENIED (403/400)**; original couple/messages/self-grant negative **all 403** (no regression). **Verified the cross-user restore mechanism** (would-be bug, checked before filing): the couple manifest points to ONE uploader-scoped snapshot, but the partner reads it via the **tokenized `?token=` capability URL** (plain GET → **200 + `enc:v1:`** confirmed) stored in the couple-gated manifest — sound, not a bug. **Also discovered the R24 `storage.rules` deploy gap is RESOLVED** (snapshot uploaded + compaction ran). **Pass E (notifications):** cold-start smoke 6/6 both ✓; the NEW `restore_requested` "Help your partner restore 💜" partner push is **DEPLOYED + live-firing** — Sam's `notification_queue` has **3 entries today** (from this session's live restore loops), routes to RESTORE_CONSENT (client wiring + consent screen live-verified). **DEPLOY GAP FOUND (not a bug): the R24-b functions change is NOT deployed** — `onRestoreFulfilled` absent from the deployed list, and the couple doc has **no `lastRestoreSelfAlertAt`** + **0 `restore_self_alert`** entries → the deployed `onRestoreRequested` is the pre-R24-b (partner-push-only) version; the recipient self-alert + completion alert need `firebase deploy --only functions` (source correct + unit-tested). **Pass M (settings):** the new Security entries were all live-verified earlier this session (recovery-phrase reveal on no-lock, Copy button + `IS_SENSITIVE` masking, "Help my partner restore" + back button). **Cornerstone regression (Sam 5556, intact):** A — free Sam → premium Desire Sync → **Paywall** ("Go deeper together", pills legible, C-PW-001 holds); B — Play hub all cards render with correct premium badges; L — Messages inbox + main thread **fully decrypted** (attribution, Seen, day-separators, E2E lock glyphs, **0 `enc:` leak**, 0 FATAL); N — Today daily-question renders with decrypted answer + reveal state. Home renders. **0 FATAL across the round.** **⚠️ FIXTURE DAMAGE I CAUSED (process landmine — now documented in ClaudeQAPlan + Eng Manual): `./gradlew :app:connectedDebugAndroidTest` UNINSTALLS the app-under-test on completion, which WIPED QA's (5554) app data (auth + keys + App-Check debug token) → QA is now at fresh onboarding. Never run instrumented tests on the 5554/5556 fixtures — use a throwaway (5558).** Silver lining: confirmed **onboarding slide-1 CtaSlide art renders** on the fresh install (O-ONBOARD-001 stays fixed). **Fixture recovery is user-gated** — QA's account (`qa_1782321603516@closertest.com`) still exists server-side but I don't have the password and resetting it is a gated auth write; needs the password or authorization to reset. **Board:** 0 open P0/P1 · 1 P2 (O-AGE-001 pre-ship age gate, user) · 1 P3 (BRAND-DARK-COVERAGE, user). **Two user actions to fully close the round: (1) `firebase deploy --only functions` (self-alert + completion alert), (2) restore the QA fixture (password or re-auth).** All R24/R25 code uncommitted (user commits). -- **R25-b (2026-06-30) — QA(5554) fixture RECOVERED + live 2-device partner-assisted restore verified end-to-end; the R25 fixture-recovery gate is now CLOSED. 0 defects.** Reset QA's Auth password via admin (user-authorized) → signed QA back in → landed **NEEDS_RECOVERY** (data-wiped, no local couple key). Ran the full restore: QA "Start restore" published `restore_requests/{QA}` (code 592847) → **deployed `onRestoreRequested` fired end-to-end** (Firestore→FCM→Sam received "Help your partner restore 💜", notif id 40038, channel `partner_activity`, importance 4 — **LIVE Pass E restore-notif coverage, previously only via cold-start smoke**) → Sam's **Change-1 consent screen live-verified**: email anchor `qa_…@closertest.com` prominent + name **QA** (decrypted locally via couple key — server cannot) + "make sure this is your partner's real account before approving" + active confirm checkbox; **Approve disabled until code(6) AND confirmed** (verified both gates: code alone kept it disabled) → Sam approved (keybox sealed to QA's ECIES pubkey, request→READY, no error) → **QA auto-restored**: paired Home ("Connected with Sam" + "Sam / Revealed" decrypted-answer chip), **full chat history decrypts** (June 27→Today, all bodies readable), and a fresh send `restore_ok_R25` from restored QA **decrypts on Sam at 10:15 PM** = bidirectional round-trip proving the restored AES-256-GCM keyset is identical = **full R24 restore regression on the current build**. **Observation (deferred, low-pri, NOT filed as a defect):** tapping the restore push while Sam's app was already foregrounded (warm) opened Play hub, not RESTORE_CONSENT — most likely an artifact of tapping a *collapsed 2-item* notif group header (its contentIntent ≠ the restore notif's); couldn't cleanly re-test without re-wiping a precious fixture, and cold-start routing is smoke-green. **Still user-gated (unchanged):** `firebase deploy --only functions` for the self-alert + `onRestoreFulfilled` completion/anti-takeover pushes (blocked→deploy; source correct + unit-tested). -- **R25-c (2026-06-30) — LIVE-FIRE of the deployed owner-alerts (Change 3): both new restore self-alerts observed firing on QA's OWN device end-to-end; last user-gated item CLOSED. 0 defects.** User authorized re-wiping QA(5554) to watch the self-alerts live now that functions are deployed. `pm clear closer.app` → fresh onboarding (Allow notifications) → sign in → **NEEDS_RECOVERY** → "Start restore" published a fresh `restore_requests/{QA}` (code 565429). **(1) Request-time self-alert fired LIVE on QA's own device** — posted to QA's **system shade** (`closer.app` id 67945, channel `partner_activity`, importance 4, category social, tap contentIntent present) **and** wrote a durable in-app record (`users/{QA}/notification_queue`, `type=restore_self_alert`, 23:17:38): *"New device is restoring your history / If this wasn't you, secure your account now."* — plus the partner push to Sam (*"Help your partner restore 💜"*), both from the single `onRestoreRequested` create-trigger. **The R25-b notification-routing deferred obs is now CLOSED:** tapping Sam's *single* restore notif opened **RESTORE_CONSENT** (not Play hub) → confirms the earlier Play-hub artifact was tapping a *collapsed 2-item notif-group header* whose contentIntent ≠ the restore notif's. **Consent gate re-verified:** code(6) *alone* kept **Approve disabled** → checking "I reached QA directly…" **enabled** it (both gates required). Sam approved → **`onRestoreFulfilled` fired server-side (status ok, 1319 ms) on the REQUESTED→READY transition** → **(2) completion self-alert** durably queued to QA (`type=restore_self_alert`, 23:19:50): *"Your history was just restored / A new device now has access. If this wasn't you, secure your account now."* It did **not** post to QA's shade only because QA's app was **foregrounded** at that instant (it had auto-restored and navigated to Home) — expected foreground-FCM handling; the push still succeeded to QA's live tokens + the durable queue entry guarantees the owner sees it on any device. Both self-alerts landed **132 s apart** (> ~60 s dedupe window → no suppression, correct). **Robustness proven live:** one **stale FCM token** (`f_T4C0ri…`, `registration-token-not-registered`, left over from QA's pre-wipe install) failed the send but `Promise.allSettled` shrugged it off — the function finished `ok` and delivered to the other tokens. **QA auto-restored to paired Home** ("Connected with Sam" + "Sam / Revealed" decrypted chip), content decrypts, **0 FATAL/ANR** → fixture **left healthy** (no re-restore needed). **Net: Change 3 (owner alerts) is now fully live-validated end-to-end (request self-alert shade+queue, completion self-alert queue, READY-transition trigger, allSettled resilience) — the `firebase deploy --only functions` gate is CLOSED.** **Minor follow-on (not a defect):** prune tokens FCM reports as `not-registered` (stale-token housekeeping) — fits the existing scheduled-cleanup follow-on family. -- **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes the R25-c follow-on): shared helper + wired into ALL 19 push sites; build + 47 unit tests green. Requires user `firebase deploy --only functions` to go live.** New [`functions/src/notifications/pruneTokens.ts`](functions/src/notifications/pruneTokens.ts): `isDeadTokenError` prunes **only** `messaging/registration-token-not-registered` + `messaging/invalid-registration-token` — deliberately **NOT** `invalid-argument` or any transient/server error (`unavailable`/`internal`/quota/auth), so a payload bug or an outage can never wipe every user's tokens; `selectDeadTokens` (pure index→token map); `pruneDeadTokens` (best-effort, **never throws** — housekeeping must not fail the notify path; only touches Firestore when a dead token is actually seen; batch-deletes matching `fcmTokens` docs + clears the legacy `fcmToken` field). Each caller reuses the `tokens` array + `Promise.allSettled` results it already had → **one added line per site**. Wired into all 19 senders: questions (onAnswerWritten/onAnswerRevealed/onMessageWritten), dates (createDateMatch/onDateHistoryCreated/onDateReflectionWritten), couples (onCoupleLeave/acceptInviteCallable/scheduledOutcomesReminder), games (onGameSessionUpdate), notifications (gameRetention/dailyQuestionReminder/reengagement/streakReminder/sendGentleReminderCallable/sendThinkingOfYouCallable), billing (onEntitlementChanged), users (onUserDelete), backup (onRestoreRequested — the R25-c live repro that surfaced the stale `f_T4C0ri…` token). New [`pruneTokens.test.ts`](functions/src/notifications/pruneTokens.test.ts): **9 unit tests** (fn suite **38→47**) asserting dead-vs-transient classification, both error shapes (`errorInfo.code` + `code`), index mapping, dedupe, and fail-safe on garbage. `npm run build` clean (tsc verified every `db`/uid/`tokens`/results reference across the 19 sites) + `dist/notifications/pruneTokens.js` emitted. **Not deployed** (deploy is user-gated); pruning takes effect on the next functions deploy. (Note: the repo's own auto-commit daemon committed each file as `koga.industries@gmail.com` — I ran no git commit.) -- **R24-d (2026-06-30) — three restore-flow UX fixes per user reports ("there needs to be a back button on help your partner restore. also tapping recovery phrase does nothing" → then "add a copy button for the recovery phrase"). All LIVE-verified on QA/Sam; 244 unit tests green (no regressions).** **Why:** the manual "Help my partner restore" consent screen (R24-c) opened with no way back except gesture; on a device with **no enrolled lock**, tapping "Recovery phrase" launched `BiometricPrompt` which silently errored (only `onAuthenticationSucceeded` was overridden) → felt like a dead tap; and once revealed there was no way to copy the phrase (12 words, error-prone to hand-transcribe). **What (uncommitted):** **(1) Back button** — `RestoreScreens.RestoreScaffold` gained an optional `onBack` param rendering a `CloserGlyphs.Back` `IconButton`; `RestoreRequestScreen`/`RestoreConsentScreen` accept `onBack`, wired in `AppNavigation` to `navigateBackOrHome` (`popBackStack()` else Home). **(2) Lock-less reveal** — `SecurityScreen.launchBiometricForPhrase` now checks `BiometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) != BIOMETRIC_SUCCESS` (or null activity) and, when there's no lock to prompt, reveals the phrase directly (`viewModel.revealRecoveryPhrase()`) instead of firing a prompt that no-ops. When a lock *is* enrolled, behaviour is unchanged (still gated). **(3) Copy button** — the Recovery-phrase `AlertDialog` gained a `dismissButton` "Copy" (with `CloserGlyphs.Copy` icon) that writes the phrase to the clipboard and toasts "Recovery phrase copied". The `ClipData` is flagged **`IS_SENSITIVE`** (via `description.extras` PersistableBundle) so the phrase is **redacted from the Android 13+ clipboard-preview chip and excluded from clipboard history** — copy convenience without leaking the secret into the preview UI. **LIVE-verified:** ✅ QA (no PIN) — tapping Recovery phrase immediately shows the dialog with the 12-word phrase ("lion fair card like foot good full fame disk flat"); ✅ **Copy** → toast fires and the clipboard-preview chip shows masked dots `••••••` (sensitive flag confirmed working); ✅ Sam — Settings → Security → "Help my partner restore" opens the consent screen **with a back arrow** + the empty-state card ("There's no restore request waiting right now…"), and tapping back returns to Security. **Verification:** `:app:assembleDebug` OK + `:app:testDebugUnitTest` **244 green** (UI-only change, no test delta). Uncommitted: `ui/pairing/RestoreScreens.kt`, `ui/settings/SecurityScreen.kt`, `core/navigation/AppNavigation.kt`. -- **R24-b (2026-06-30) — hardened partner-assisted restore per user's "check the account matches the email" concern: recipient identity + active confirm gate on consent, two request-lifecycle bug fixes, and owner security alerts. 243 unit tests green; consent identity + confirm gate + re-request fix + full loop LIVE-verified on QA/Sam.** **Why:** when the partner approves, they hand over the couple key — the consent screen never showed *who* was receiving it, and the recipient's own account got no alert. Investigating surfaced two real robustness bugs too. **What (uncommitted):** **(1) Consent identity + confirm gate** (`RestoreViewModel`/`RestoreScreens`) — Sam now sees the recipient's **email (plaintext anchor) + display name (decrypted locally via the couple key; the server can't, so identity is necessarily client-side)**, plus an explicit **"I reached them directly and this is their account"** checkbox; **Approve is gated on `code.length==6 && confirmed`**. Added distinct **no-request / expired** empty states (the screen previously showed the form unconditionally on a stale deep-link). **(2) Two lifecycle bugs found + fixed** (`RestoreManager`): **Bug A** — `createRestoreRequest`'s `set()` over an existing request is a rule-denied key-changing update → a **retry silently `PERMISSION_DENIED`s**; fixed by delete-then-create. **Bug B** — `expiresAt` was never enforced; `fulfillRestore` now **rejects an expired request** before wrapping the key. **(3) Owner security alerts** (`onRestoreRequested` + new `onRestoreFulfilled`) — a **"was this you?" alert to the recipient's OWN devices** at request time and on key transfer (the real anti-takeover signal for a phished password when the owner still has a device); quiet-hours bypassed (security), deduped via `couples/{id}.lastRestoreSelfAlertAt` (60s) against create-loops, each notify branch independently try/caught; client `RESTORE_SELF_ALERT` type wired (`isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`). **Honest framing (SECURITY.md):** the code is the crypto root of trust; identity+confirm stop *accidental* approval + add social-engineering friction (they do NOT stop takeover — the couple email matches); the owner self-alert is the takeover catch; the strongest control (email-verification challenge) needs mail infra → Future.md. **Verification:** `:app:assembleDebug` + **243 unit tests** (+10: `RestoreViewModelTest` ×6 — identity/fallback/expired/no-request/approve-gating; `RestoreManagerTest` ×4 — delete-then-create order, expiry + wrong-code rejection, no-expiry-legacy allowed; functions `onRestoreRequested.test.ts` ×6 — dedupe window + READY-transition guard as extracted pure helpers) + functions **build green** + `wiring-scan` **🔴0 dead setters / 0 dead notif settings**. **LIVE on QA(5554)/Sam(5556), fresh APK:** ✅ Sam's consent shows QA's real **email `qa_1782321603516@closertest.com` + name "QA"** + the confirm checkbox using the name; ✅ **Approve stays disabled with code-only**, enables only after checking the box; ✅ **re-request after a prior request created a fresh code (370690) with NO `PERMISSION_DENIED`** (Bug A fix); ✅ approve → **QA restored key (couple_crypto recreated) + 34 messages with no phrase** and returned Home (R24 regression through the new gate). **FOUND + FIXED — misleading Recovery-phrase empty-state on a partner-restored device (user-asked "ensure Recovery Phrase displays as it should").** After partner-assist restore, `storeTransferredKeyset` intentionally transfers the key **but not the phrase** (the phrase is one-way from the couple key), so QA's Settings → Security correctly greyed the "Recovery phrase" row — but the copy read *"…will appear here once your couple is set up on this device,"* which is **false** (QA is fully set up, chat working). Fixed `SecurityScreen`/`SecurityViewModel` to detect **key-present-but-no-phrase** (`encryptionManager.aeadFor(coupleId) != null` with `recoveryPhrase == null`) and show the accurate partner-restore copy ("You set this device up with your partner's help, so the recovery phrase isn't saved here. Ask your partner to open Settings → Security and read you theirs — either partner's phrase can restore your history."). **LIVE-verified both devices:** ✅ **Sam (original device)** — Recovery phrase row enabled → PIN-gated reveal shows the correct **12-word phrase** ("lion fair card like foot good full fame disk flat") with the right explanatory copy; ✅ **QA (partner-restored)** — greyed row now shows the accurate partner-restore message (not the "not set up" copy). **Navigation (answered):** recipient reaches restore via the `NEEDS_RECOVERY` "Unlock your history" screen → **"No phrase? Ask your partner to restore this device"** → RestoreRequestScreen; the helper reaches consent **only via the `restore_requested` notification** (OS push + in-app inbox entry once the function is deployed) → RESTORE_CONSENT — no manual Settings entry exists (candidate follow-on). **BUILT (R24-c, user-asked "we will build that entry backup") — partner-assist now also restores the recovery phrase + a manual helper entry.** After confirming a partner-restored device was fully restored (key + content) but deliberately *phrase-less*, added: **(1) phrase transfer** — the `keybox` ECIES payload became an envelope `ckx:v1:{keyset, phrase?}` (`CoupleKeyTransfer.wrapCoupleKey(...recoveryPhrase)` → `unwrapCoupleKey` returns `TransferredKey(keyset, phrase?)` → `CoupleEncryptionManager.storeTransferredKeyset(coupleId, handle, phrase?)`); `RestoreManager.fulfillRestore` includes the sender's phrase, `completeRestore` stores it. Backward-compatible (a prefix-less legacy keybox → key-only). The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key — server stays blind; both partners are meant to hold the shared phrase, so this restores parity, not exposure. **(2) Manual "Help my partner restore"** row in Settings → Security (gated on holding the couple key) → RESTORE_CONSENT, so the approver can reach consent even if the notification never arrives. **LIVE-verified QA/Sam:** ✅ full re-run (QA wiped → requested code 462926 → **Sam approved via the new manual Settings entry**, not a notification) restored key + 34 msgs; ✅ QA's Settings → Security **Recovery phrase row now enabled**, and PIN-gated reveal shows the **identical phrase** as Sam ("lion fair card like foot good full fame disk flat") — the greyed/partner-restore state is gone; ✅ `couple_crypto_secure.xml` grew 1646→1872 B (phrase now stored). Unit: `CoupleKeyTransferTest` +1 (phrase round-trip) + return-type update → **244 total green**. **GATED (user deploys):** `onRestoreRequested`+`onRestoreFulfilled` (self-alert pushes) + still-pending `storage.rules` (snapshot compaction) + R23 date functions. All uncommitted (user commits): `ui/pairing/{RestoreViewModel,RestoreScreens}.kt`, `ui/settings/SecurityScreen.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/backup/RestoreManager.kt`, `notifications/PartnerNotificationManager.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `{RestoreViewModel,RestoreManager,CoupleKeyTransfer}Test.kt`, `onRestoreRequested.test.ts`, + docs (SECURITY.md, Engineering_Reference_Manual R24-b/c, Future.md, ClaudeiOSPlan, this entry). -- **R24 (2026-06-30) — built E2EE conversation backup + full partner-assisted restore (foundation for Option B). Code-complete, 233 unit tests green, deploy-gated for live verify.** **What (uncommitted):** devices keep a couple-key-encrypted backup of all conversations so a new/wiped device restores history, and a partner can **fully restore for the other (key + content) with no recovery phrase**. **Phase A** — schema (`FirestoreCollections` backup/restore consts) + `BackupCodec` (JSON→`enc:v1:` envelope, checksum, reactions) + `FirestoreBackupDataSource` (manifest `generation`-CAS transactions, chunk append, snapshot finalize w/ delete-after-commit, restore_requests) + `firestore.rules` (backup manifest/chunks + restore_requests, new `isPublicKey` helper) + `storage.rules` (`users/{uid}/backups/` — uploader-scoped, reuses existing `onUserDelete` cleanup). **Phase B** — `BackupManager` (incremental `appendSince` + full-state `compact` that captures deletes/reactions; resolved-timestamp-only; throttled/single-flighted) + conversation backup-reads on `FirestoreConversationDataSource`; opportunistic trigger from `HomeViewModel.loadHome`. **Phase C** — separate `ConversationCacheDatabase` (Room; kept OFF the asset-backed `AppDatabase` to protect its schema hash) + `BackupRestoreManager` (download snapshot+chunks → decrypt → checksum-validate → upsert dedup-by-id). **Phase D (headline)** — `CoupleKeyTransfer` (ECIES-wrap the couple keyset to a recovering device's fresh pubkey; context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; **6-digit OOB code = SHA-256(pubkey‖nonce)**) + `RestoreManager` (A request→complete, B fulfil-after-code) + `RestoreViewModel`/`RestoreRequestScreen`/`RestoreConsentScreen` + RecoveryScreen "Ask your partner to restore" entry + nav. **Phase E** — `onRestoreRequested` Cloud Function (notifies partner; high-signal, not toggle-gated; audit-log no key material) + `RESTORE_REQUESTED` notif type wired (`fromRemoteType`/`routeFor`/isEnabled). **Verification:** `:app:assembleDebug` + **233 unit tests** (9 new: `CoupleKeyTransferTest` proves the couple key round-trips only to the right device+context, wrong key/nonce fail, OOB code binds to pubkey+nonce for swap-detection; `BackupCodecTest` round-trip/checksum/forward-compat/cursor) + functions **tsc green** + `wiring-scan` **🔴0**. **Security posture:** server holds only `enc:v1:` ciphertext + `keybox:v1:` blobs (AEAD tamper-evident); the OOB-code entry gate defeats a server/MITM pubkey swap + remote account-takeover; unpair revokes (rules require active couple); threat model + residual risks (no forward secrecy, rollback-freshness, client-enforced verification) documented in SECURITY.md. **Docs:** SECURITY.md (E2EE list + 2 threat rows + roadmap), Eng Ref Manual (**R24-BACKUP** landmine w/ wire formats + hazards), ClaudeQAPlan (Pass D backup/restore + rules-negative + Pass E `restore_requested`), ClaudeiOSPlan (byte-compat parity item), Future.md (Option B + FS + freshness + WorkManager/backfill + Settings indicator/opt-out). **LIVE-VERIFIED (2026-06-30) on QA(5554)/Sam(5556), fresh APK, `-gpu swiftshader_indirect`:** simulated device loss on QA by deleting **only** the secure prefs (`couple_crypto_secure.xml` = couple key + `user_key_secure.xml` = ECIES key) via `run-as` (NO `pm clear` — App Check token preserved) + backgrounding (KEYCODE_HOME) + `am kill` to drop the in-memory key → cold-launch QA lands on `NEEDS_RECOVERY`. **Full partner-assist (no phrase):** ✅ QA "Ask your partner to restore this device" → RestoreRequestScreen "Start restore" → published a fresh `pub:v1:` + created `restore_requests/{QA}` (rules **allowed**, no `PERMISSION_DENIED`) → showed OOB code **940687**. ✅ Sam driven to RESTORE_CONSENT (deep-link, function-independent) → typed **940687** → "Approve restore" → `fulfillRestore` verified code == `SHA-256(pubkey‖nonce)`, wrapped the couple keyset to QA's fresh pubkey (`keybox:v1:`), set status READY. ✅ **QA auto-completed with NO recovery phrase** — `couple_crypto_secure.xml` **recreated at 20:00** via `unwrapCoupleKey`+`storeTransferredKeyset`; QA navigated past `NEEDS_RECOVERY` to Home ("Connected with Sam"). ✅ **Content restored: 34 messages** across all 4 conversations (`main`:28 + 3 `q_daily_fun_*` discuss threads) upserted into QA's separate `conversation_cache.db` (confirmed via on-device `sqlite3` count) — sourced from the **Firestore `enc:v1:` chunks** (server-blind). ✅ Restored key **decrypts live content**: QA Messages inbox + main thread render the full history in plaintext ("hi"/"hey", R18 test msgs, "gu", today's pings) — not 🔒 placeholders. ✅ `completeRestore` deleted the request post-unwrap (no lingering keybox). **DEPLOY GAP FOUND (not a code bug): `storage.rules` was NOT deployed** (only `firestore.rules` was — which is why every Firestore-side op above worked). Both devices' `backupNow`→`compact` snapshot upload 403s (`GetDownloadUrlTask` "Permission denied") because the deployed storage.rules lacks the `users/{uid}/backups/` block → snapshot compaction can't run. **Backup + restore still work fully via the Firestore chunks** (the 34-msg restore proves it); only the Storage snapshot/compaction (chunk-folding) is blocked. **Fix = deploy `firebase deploy --only storage`** (rules file is correct + structurally identical to the working `chat_media` block); after deploy, compaction folds chunks → snapshot automatically on the next `backupNow`. **Verdict: R24 — headline full partner-assisted restore (key + 34 msgs, NO phrase, OOB-code-gated) VERIFIED LIVE end-to-end + server-blind; 0 FATAL. One outstanding DEPLOY-gate: user runs `firebase deploy --only storage` to enable snapshot compaction (+ still-pending `onRestoreRequested` for the partner push, and the R23 date functions). Board unchanged: 0 open P0/P1.** All uncommitted (user commits): `data/backup/*`, `data/remote/{FirestoreBackupDataSource,BackupCodec,FirebaseStorageDataSource,FirestoreConversationDataSource,FirestoreCollections}.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/local/ConversationCache*`, `di/DatabaseModule.kt`, `domain/model/ConversationBackup.kt`, `ui/pairing/{RestoreViewModel,RestoreScreens,RecoveryScreen}.kt`, `ui/home/HomeViewModel.kt`, `notifications/PartnerNotificationManager.kt`, `core/navigation/{AppRoute,AppNavigation}.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `firestore.rules`, `storage.rules`, `app/build.gradle.kts`, tests, + the docs above. -- **R23 (2026-06-30) — built Date Memories & Replay (the date-roadmap killer feature) end-to-end, then LIVE-verified the whole loop on QA↔Sam. 0 defects in the new feature, 0 FATAL.** **Work (uncommitted):** the private→reveal loop applied to real dates — mark a mutual match **"We did this"** → `couples/{c}/date_history/{matchId}` (PLAINTEXT coarse metadata, idempotent doc-id=matchId merge) → **`date_reflections/{dateId}/answers/{uid}` (+ read-gated `secure/payload`) E2EE**, mirroring the daily-question couple-key gated reveal exactly. New: `DateMemory`/`DateReflection` models, `FirestoreDateMemoryDataSource`/`FirestoreDateReflectionDataSource`, `DateReflectionScreen`+VM (EDIT→AWAITING→REVEALED), `DateMemoriesScreen`+VM (Replay timeline), `DateMatchesScreen` "We did this" + "Your date memories" entry, **Phase C Home nudge** (`HomePriorityEngine.DATE_REFLECTION_PENDING` value-action + `HomeViewModel` pending computation + `HomeActionTarget.DateMemories` + `glyph_date_replay`), 2 Cloud Functions (`onDateReflectionWritten`/`onDateHistoryCreated`), partner-sheet emoji→brand-glyph retrofit, `firestore.rules` (`date_history`+`date_reflections`, **DEPLOYED by user**), docs (SECURITY/iOS-parity/QA-plan Pass E+N). **Cheap gates GREEN:** `:app:compileDebugKotlin` + `assembleDebug` (128MB APK) + `HomePriorityEngineTest` **25/25** (2 new DATE_REFLECTION_PENDING cases) + functions `tsc`; `wiring-scan` **🔴0 dead setters / 0 dead notif settings** for the date feature. **LIVE on QA(5554)/Sam(5556), fresh APK, software-GL:** ✅ mark-done → `date_history` synced to **both** timelines · ✅ QA reflects → AWAITING ("Saved privately 💜 — waiting for Sam"), **privacy gate holds** (QA can't see Sam pre-both) · ✅ Sam reflects → **mutual reveal side-by-side**, QA screen **live-flipped with no refresh** (observeReflected), **bidirectional E2EE decrypt** with correct You/partner labels · ✅ empty-state art (`illustration_date_memories_empty`) · ✅ timeline newest-first, per-row chips **Reflect/View** · ✅ **Home nudge** "Reflect on your date with Sam 💭" appears in BOTH the *Also waiting* (pending) and *More ways to connect* (secondary) surfaces with `glyph_date_replay`, routes to the timeline, clears after reflecting · ✅ **mark-done idempotency** (both partners tapped "We did this" on the same date → timeline shows it ONCE) · ✅ **light + dark** both date screens · ✅ cold-start no crash (nav restores back-stack) · ✅ partner-sheet retrofit renders all 5 actions as brand glyphs (no emoji-as-icons). **FOUND + FIXED — R23-DQ-001 (P2) daily-question silent re-answer data loss:** answering an already-answered daily Q logged `Write failed at …/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` — the **immutable-answer guard** (`secure allow update:false`) correctly rejecting an overwrite. Root cause: the daily-Q screen + Home sourced "answered?" from **local Room only**, so on a fresh device / cleared DB (Room empty while Firestore still holds the answer) Home showed a **stale "your turn"** and the screen offered an **editable re-answer** — and a *changed* pick would be silently dropped (the secure overwrite is denied; reveal keeps the old content). NOT caused by the date work (`git diff` = only `date_*` rule additions; daily-Q rules/datasource untouched). **Fix (uncommitted):** new **Room-first** `reconcileLocalAnswerFromFirestore` (`ui/questions/LocalAnswerMapping.kt`) — if Room lacks the answer but Firestore has it, rebuild from the read-gated couple-key payload (owner can always read their own), map option-texts, and write it back to Room; persists only when the payload actually decrypts (a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal, never a lossy re-answer) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Room-first ⇒ the normal answered path is byte-identical. **Covered by 5 new unit tests** (`ReconcileLocalAnswerTest`: Room-hit short-circuit, heal+persist+option-text-map, no-prior-answer→null, assignment-rotation guard, undecryptable→answered-but-not-persisted). Full suite **224 green**, APK builds. Live fresh-device repro is `pm clear`-gated (forbidden — App Check token) so verified by unit tests + the provably-equivalent `withLocalAnswer` render path. `QuestionDetailViewModel` (pack questions) audited — **local-only writes, no gated-secure path → not vulnerable** (no change). The **date feature is also immune** (its `hasReflected` reads Firestore, not Room). **GATED (user-only):** notification pushes (`date_reflection_partner`/`date_reflection_ready`/`date_logged`) need the 2 new functions **deployed** — code-complete + `tsc`-green, not yet live. **Verdict: R23 — Date Memories & Replay shipped + LIVE-verified flawless across the full loop + Home nudge + idempotency + light/dark; 0 defects in the feature, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked. Pending: user deploys the 2 date functions → verify the 3 date pushes live.** Uncommitted (user commits): the new date `*.kt` + models + datasources + screens, `HomePriorityEngine.kt`/`HomeViewModel.kt`/`HomeScreen.kt`, `DateMatchesScreen.kt`/`DateMatchesViewModel.kt`, `AppRoute.kt`/`AppNavigation.kt`, `FirestoreCollections.kt`, `PartnerNotificationManager.kt`/`AppMessagingService.kt`, `functions/src/dates/*` + `index.ts`, `firestore.rules`, `glyph_date_replay.xml` + `illustration_date_memories_empty.png` (+night), `HomePriorityEngineTest.kt`, `SECURITY.md`/`ClaudeiOSPlan.md`/`ClaudeQAPlan.md`/`ClaudeReport.md`. -- **R22 (2026-06-29) — SECURITY.md recs #6 + #7 implemented, then full QA pass. 0 new defects, 0 FATAL.** **Work (uncommitted):** **#6 recovery-phrase save-confirmation at pairing** — the invite screen now makes the inviter **re-type one random word of the phrase** ("type word #N") before pairing feels done → "✓ Saved — you're all set." (`CreateInviteScreen.kt`). ✓ verified live on a fresh account (5558): renders, correct word → confirmed, index randomizes per entry (#5 then #8). **#7 biometric app-lock re-arms on background** — `MainActivity` lifecycle observer drops the unlocked session after the app is backgrounded past a 60s grace (`BIOMETRIC_RELOCK_GRACE_MS`), so a picked-up open phone re-prompts (not only cold-start); grace avoids re-locking on quick task-switches. Code-complete + compiles; **live re-lock pending a physical device** (emulators have no enrolled biometric/PIN). SECURITY.md/Future.md updated (#6/#7 → done). **QA run:** cheap gates ALL GREEN — build + **210 unit + 24 functions**, theme-scan CRIT **0**, painter-xml **0**; baseline both **free**, **0 active sessions**, **0 FATAL** both. Smoke: **5556 6/6** (launcher + all 5 notif cold-starts open&stay); **5554 launcher PASS + 5 FCM-delivery BLOCKs** (environmental "flaky emulator FCM, rerun" — **0 FAIL**, no crashes; shared notif code path proven by 5556). **A cornerstone live** (free → Desire Sync → Paywall, warmed "Full answer history and growth" renders). **D** carries from R20 (no rules/crypto change); **B/E** + this session's copy/bubble/#6/reveal verified R21. **INFRA finding (not app):** the 2nd/3rd emulator crashed seconds after boot with `eglMakeCurrent failed` / `Draw context is NULL` — **host GPU/EGL context exhaustion** from running 3 emulators on the hardware GPU. Fixed by killing the spare (5558) + relaunching the QA/Sam pair with **`-gpu swiftshader_indirect`** (software GL) — stable since. Saved to memory (QA-ops). **Verdict: R22 — #6 shipped+verified, #7 code-complete (needs-device), build stable + cornerstones hold, 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Uncommitted (user commits): `MainActivity.kt`, `CreateInviteScreen.kt`, `SECURITY.md`, `Future.md`, `ClaudeReport.md`, `ClaudeQACoverage.md` (+ the broader session work). **N-TODAY-001 (P3, FIXED) — Today reveal state was confusing (user-reported):** after answering + revealing, the Today tab still showed the **editable answer form + a prominent "Save privately"** (looked re-answerable) AND a card titled **"Answer revealed"** that showed only the user's *own* answer (the mutual reveal is actually behind the "View reveal" button). Fixed in `LocalQuestionContent.kt` (Today-only — sole caller `DailyQuestionScreen`): the answer form now hides once `submitted`, and the card is retitled **"Your answer"** with an accurate status line ("Saved privately — waiting for your partner" / "…ready to reveal together" / "Revealed together — open View reveal to compare"). Verified live (5554, revealed state): form gone, card reads "Your answer / Cozy / Revealed together — open View reveal to compare"; 0 FATAL. -- **R21 (2026-06-29) — brand-voice + UX polish round, then full ClaudeQAPlan re-run (user: "change the language… more Closer-aligned vs therapy/corporate", "ensure the daily question shows to reveal when answered", "run the full QA plan, get to screens different ways"). 0 new defects, 0 FATAL.** **Copy/UX work (uncommitted):** (1) **Brand-voice sweep** — `prompt → question` across ~26 user-facing strings (Play hub "10 questions", Wheel "Ten questions per spin", Question packs/category/composer/thread, Spin-the-Wheel, Answers, Memory Lane, Home, date ideas; counts/plurals handled; internal ids like `onPickPrompt`/`capsulePrompts`/`promptCountLabel`/`conversationPrompts` + data keys left); **clinical/corporate → Closer voice** — Home eyebrow "Tonight's prompt"→"Your daily question", status chips "Prompt ready"→"Question ready" + "Private sync"→"Just for two"; the **check-in/Outcome feature** rewarmed (survey "How satisfied are you with intimacy?"→"How close do you feel physically?", "How well do you communicate?"→"How easy is it to talk lately?", "Submit"→"Save", "Quick check-in"→"A little check-in"; **"Your Progress"→"Growing together"**, "Baseline/30-day check-in"→"Where you started/30 days in", "Change since baseline"→"Since you started", "…start tracking how your relationship feels…"→"…see how things feel between you two…"); paywall/subscription "…and insights"→"…and growth"; reveal "shared reflection"→"shared moment"; follow-up "ask one deeper follow-up?"→"go one question deeper?". (2) **Home partner bubble upgrade** — modern Coil `SubcomposeAsyncImage` (crossfade + centered-initials loading/error fallback), brand gradient ring, surface-ringed unread badge, a11y contentDescription; verified live (Sam's real photo loads in the ring). **Verification (R21 QA run):** cheap gates ALL GREEN — build + **210 unit + 24 functions**, `theme-scan` CRIT **0** (9 MAJOR/21 REVIEW), `painter-xml` **0**, `entrypoint_smoke` **6/6 on BOTH** emulators; baseline both **free**, **0 active sessions**. **Reveal-when-answered VERIFIED LIVE end-to-end** (the user's ask): answered the daily Q on both (QA "Cozy" via Today tab, Sam "Silly" via Home) → both Homes surfaced **"Reveal is ready / Reveal together"** + "Reveal ready" chip → tapped → AnswerReveal "Both answers are in" → revealed both picks ("Different picks. Honestly, useful."). **Multi-angle nav** (reached screens via different entries): daily Q via Today-tab + Home, reveal via Home card→reveal screen, Settings→"Growing together" (warmed labels render: "No check-ins yet"/"Where you started"/"30 days in"), Play→Question Packs ("250 questions"). All warmed copy renders correctly; **0 FATAL** across the whole session. **Cornerstones:** **E** re-verified live (smoke 6/6 both + partner_answered path); **N** (daily-Q + reveal) live-clean; **A/B/D** carry from R20 (no rules/crypto/games-logic change this session — diff is copy + Home-bubble UI only). **Verdict: R21 — brand-voice + bubble polish shipped + verified live across 5 surfaces; reveal-when-answered confirmed; all cheap gates green; 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001 pre-ship) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Also landed earlier this session (uncommitted): recovery-UX "ask your partner" copy + change-phrase desync guard, `SECURITY.md` (threat model + hardening roadmap), first instrumented test `FirstRunRenderSmokeTest` (proven to catch O-ONBOARD-001 class). **Uncommitted (user commits):** ~29 `*.kt` (copy sweep + HomeScreen/HomeViewModel + OutcomeCheckInDialog + YourProgress + recovery + crypto visibility + androidTest) + `SECURITY.md` + `docs/Engineering_Reference_Manual.md` + `ClaudeReport.md`/`ClaudeQACoverage.md`/`ClaudeQAPlan.md`/`Future.md`. -- **R20 (2026-06-29) — fresh full ClaudeQAPlan run from the start (user: "run the full ClaudeQAPlan") — found + FIXED 2 real escaped bugs (NOT a clean confirmation round).** Baseline: HEAD `62696a6` (R18b/R19 work committed; clean tree), both emulators paired + **free** (admin-confirmed), build reinstalled both. Cleared 1 stale ToT session by playing it through. **Cheap gates ALL GREEN:** unit **210** · functions **24** · `theme-scan` **CRITICAL 0** (9 MAJOR/23 REVIEW = intentional brand gradients) · `painter-xml-scan` **0** · `wiring-scan` **🔴0** · `entrypoint_smoke.sh` **6/6 on BOTH emulators (0 blocked)**. Discovery ritual: no drift (14 notif types + all fn triggers match coverage). **Cornerstones live-clean:** **A ✅** enforcement audit (every `isPremium`/`PremiumBadge` has a real `CouplePremiumChecker` gate — no badge-without-gate; A-201 class stays closed) + live both-free → Desire Sync → **Paywall** "Go deeper together" (graceful K-env "couldn't load plans", no crash). **B ✅** full 2-device This-or-That (QA joined via Home card → answered 10 → **first-finisher** → Sam got live **YOUR_TURN banner** → joined via banner → completion → **symmetric 5/10 "in sync" reveal** both devices). **D ✅** D1 at-rest `enc:v1:` (messages + lastMessagePreview + all 4 game answer-maps' per-uid values) · D2 rules static (Tier-2 self-constraint present, lines 361–374) · D3 non-member couple/messages/capsules/desire_sync reads **403** · D5 self-grant entitlement **403**. **E ✅** cold-start smoke 6/6 both + live YOUR_TURN + persistent RESULTS banners + `partner_completed_part` first-finisher push delivered. **0 FATAL across the whole live session.** **TWO BUGS FOUND + FIXED + VERIFIED LIVE:** **(1) B-ABANDON-001 (P2)** — Quit/abandon on ANY game silently failed `PERMISSION_DENIED`: `abandonSession` round-tripped through `saveSession` (a full `doc.set()`) which **drops the server-only flags** (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`); the session-update rule counts those removed keys in `affectedKeys()` → denied, so the session stayed `active` (stranded → blocks new games), failure swallowed by `Log.d`. Proven via logcat (`Write failed at .../sessions/…: PERMISSION_DENIED` → `quit-abandon no-op`). **Fix:** targeted `update(status, completedAt)` mirroring `markUserComplete` (`affectedKeys == {status, completedAt}` ⊆ allowlist) in `QuestionSessionRepositoryImpl.abandonSession`; routed the latent-twin dead method `GameSessionManager.finishGame` (0 callers) through it too. **Verified live:** Quit → no denial → `active=0`, then **started a different game immediately** (lockout resolved). **(2) B-COPY-001 (P3)** — Home "GAME_WAITING" hero hardcoded *"Your partner already played their part — take your turn to reveal"* but fires on `uid !in completedByUsers` only (for async games `completedByUsers` stays empty until BOTH finish), so it falsely claims the partner finished the instant a game is merely *started*. **Fix:** neutral, partner-named, always-accurate copy ("Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up.") — the accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN banner. **Verified live both devices** (starter + joiner). Build + **210 unit + 24 functions green** after fixes. Remaining passes carry recent-round status (zero functional diff coming in; my fixes are HomeViewModel copy + session-completion writes only, no effect on C/F/G/H/I/J/L/M/N/P): **C** theme-scan CRIT 0; **L** chat at-rest `enc:v1:` (via D1); **K** money-path + **O** release + Doze = `blocked→needs-device`. **Verdict: R20 — cornerstones A/B/D/E live-clean, all cheap gates green, 0 FATAL; found + fixed B-ABANDON-001 (P2) + B-COPY-001 (P3) live. Board: 0 open P0/P1; 1 open P2 (O-AGE-001 pre-ship, user-blocked) + 1 P2 fixed-pending-confirm (B-ABANDON-001); 1 open P3 (BRAND-DARK-COVERAGE, user-blocked) + 1 P3 fixed-pending-confirm (B-COPY-001).** Uncommitted (user commits): `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `docs/Engineering_Reference_Manual.md`. **R20 follow-up (user: "make it so" on the instrumented smoke):** added the project's **first instrumented UI test** — `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`, an on-device Compose render smoke of the first-run crash composables (`CtaSlide` + `AuthLogoMark`, light+dark — the O-ONBOARD-001 `painterResource` sites). Infra: `testInstrumentationRunner` + `ui-test-junit4` in `build.gradle.kts`; exposed `CtaSlide` as `internal`; un-blocked the androidTest source set (the stale `CanonicalVectorCaptureInstrumentTest` couldn't compile against `private RecoveryKeyManager.deriveKey` → `@VisibleForTesting internal`). **Verified on emulator-5558 (API 34): 4/4 pass; PROVEN to catch the class** — reintroducing the `` foreground failed the test with the exact `IllegalArgumentException: Only VectorDrawables…` at `loadVectorResource`, then reverted → green. 210 unit + 24 functions still green. Wired into the QA-plan cheap gates (`./gradlew :app:connectedDebugAndroidTest` when an emulator is attached) + Future.md item marked started. Added files: `build.gradle.kts`, `RecoveryKeyManager.kt`, `OnboardingScreen.kt`, `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`, `ClaudeQAPlan.md`, `Future.md`. -- **R19 (2026-06-28) — fresh full ClaudeQAPlan run from the start (user-directed, post-implementation).** Baseline reset clean: both emulators paired, both **free** (admin-confirmed premium=false), **0 active sessions** (cleared the one stale ToT by completing it 10/10). **Cheap gates ALL GREEN:** `./gradlew testDebugUnitTest` **210**, `cd functions && npm test` **24**, `scripts/theme-scan.sh` **CRITICAL 0** (11 MAJOR/25 REVIEW = intentional brand-purple/white in the wheel + banner + bubble colored-surface contexts), `scripts/painter-xml-scan.sh` **0**, `qa/entrypoint_smoke.sh` **6/6 on both emulators**. **Cornerstones live:** **A ✅** premium gate — both-free → Desire Sync → Paywall "Go deeper together" (graceful "couldn't load plans" K-env limit, no crash). **B ✅** This-or-That full 2-device → completed **10/10 "in sync"** reveal (+ this round's finish-gate / submit-retry / Quit-abandon). **D ✅** security cornerstone (raw-API): non-member couple/messages/capsules/desire_sync reads + self-grant entitlement **all DENIED 403**; messages at-rest `enc:v1:`; session/answer docs carry no plaintext. **E ✅** notifications: cold-start smoke 6/6 both + `partner_joined_game` live end-to-end (deployed) + standardized durable banner. **0 FATAL.** Remaining passes **carry their recent-round status** (per `ClaudeQACoverage.md`, code-stable — this round's diff is additive client + the deploy-gated rule): **C** theme-scan CRIT 0 + this-session dark spot-checks; **F** offline-cache (carried) + new Wheel submit-retry (unit-tested); **L** chat at-rest `enc:v1:` confirmed; **G/H/I/J/N/P** carried; **K** money-path + **O** release + Doze/battery = `blocked→needs-device` (pre-ship). **Verdict: R19 — cornerstones (A/B/D/E) live-clean, all cheap gates green, 0 FATAL; board 0 open P0/P1, 1 P2 (O-AGE-001 pre-ship), 1 P3 (BRAND-DARK-COVERAGE) — both user-blocked.** **Tier-2 rules DEPLOYED by user + VERIFIED LIVE (member-token raw-API):** own-uid add to `joinedByUsers` **ALLOWED 200** (legit path intact); foreign-uid add to `joinedByUsers`+`completedByUsers` **DENIED 403**; array removal **DENIED 403** — own-uid-only self-constraint holds without breaking markUserJoined/markUserComplete. (functions + rules all deployed; no remaining deploy gates.) -- **R18b (2026-06-28) — polish + error-handling/security/consolidation hardening (user: "look for gaps, error handling, security, what else can we improve, can we consolidate").** Bundled with the modern-feel polish. **Error handling:** **E1 (P2 bug) FIXED** — `WheelSessionViewModel.submitAndFinish` used to swallow a submit failure and navigate to the reveal anyway (silent data loss + stuck session); now it surfaces a **retryable error** (`submitFailed`/`error` + a `SubmitErrorCard` "Retry", no false reveal), matching ToT/DesireSync/HowWell. **E3** — the silently-swallowed join-writes now `.onFailure { Log.w }` in all 4 games. **Consolidation (targeted):** deduped the 3 copies of the save-error string into `ui/games/GameCopy.SAVE_ANSWERS_ERROR` (used by all four); the per-game answer/reveal logic is intentionally NOT unified (premature abstraction). **Modern feel:** `GamePromptBanner` — soft **haptic on arrival** (reuses `CelebrationOverlay` `LocalHapticFeedback`), **spring overshoot** entrance, **live green presence dot** on the avatar for JOINED, **whole-card tap-to-act + swipe-up dismiss**, `liveRegion` + `contentDescription` a11y, and `GamePromptController` now **won't let a transient (started/joined) clobber a persistent (your-turn/results)** banner; **chat bubble** got the same arrival haptic for parity; warmer brand-voice copy (banner `styleFor` = client source of truth, cross-commented with the function). **Predictive back (2026):** `android:enableOnBackInvokedCallback="true"` (safe — 0 `onBackPressed`/`BackHandler` in the app). **Tier 3:** the Wheel active screen got a **"Quit game"** escape hatch (`abandon()`→`abandonSession`) so leaving mid-wheel no longer strands a session (mirrors ToT). **Verified:** build + **210 Android unit (+1 new Wheel submit-retry test) + 24 functions** green; **live (5554):** JOINED banner shows warm copy + green presence dot + real avatar; **Pass-E cold-start smoke 6/6 on BOTH emulators** (notification path regression-clean); 0 FATAL. **Tier 2 security — DEPLOYED + VERIFIED LIVE:** `firestore.rules` now constrains `completedByUsers`/`joinedByUsers` so a member can only add **their own** uid (no spoofing the partner's join/completion, no removals; `get(...,[])` tolerates old docs). Member-token raw-API test: own-uid add **200**, foreign-uid add (both arrays) **403**, removal **403**; markUserJoined/markUserComplete still pass. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `WheelSessionViewModelTest.kt`, `GamePromptBanner.kt`, `GamePromptController.kt`, `MessageBubbleOverlay.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `ui/games/GameCopy.kt`, `AndroidManifest.xml`, `firestore.rules`, `functions/src/games/onGameSessionUpdate.ts` (sender_name), `ClaudeReport.md`, `ClaudeQACoverage.md`. **NEXT: reset + full ClaudeQAPlan A→P fresh run (R19).** -- **R18b (2026-06-28) — FEATURE: "partner joined your game" push + standardized in-app game banner (user: "notification that your paired partner joined the game … w/ their icon … make that theme the standard").** Decision (discussed): **"Standardize, keep durable."** **New `partner_joined_game`:** the non-starter opening an active session writes their uid to `joinedByUsers` (new field; client `markUserJoined` mirrors `markUserComplete`; `firestore.rules` session allowlist += `joinedByUsers`, server-only `joinNotifiedAt` deliberately excluded); `onGameSessionUpdate` adds a branch that claims a one-time `joinNotifiedAt` and notifies the **starter** " joined your game" with the **joiner's avatar**. Wired the join-write into all 4 games' non-starter paths (ToT/DesireSync/HowWell `joinSession` + Wheel `load()` resume), best-effort + off the critical path (guarded to never fire for the starter). **Standardized banner:** generalized `GamePromptController`/`GamePromptBanner` with a `kind` (STARTED/JOINED/YOUR_TURN/RESULTS) — per-kind copy/action/avatar; **STARTED/JOINED transient (~9s), YOUR_TURN/RESULTS persistent** (stay until tapped/dismissed). `AppMessagingService` now routes all 4 game types to the banner in the **foreground** (suppressing the foreground OS duplicate; background OS unchanged — already shows the avatar large-icon); `AppNavigation` routes the banner action by kind (RESULTS→per-session results, else→the game). Added `sender_name` to the game push `data` so the banner names the partner. **Build + 209 Android unit + 24 functions tests green** (added a `PartnerNotificationTypeTest` case for the new type's mapping + routing). **Live (5554, foreground, real avatar+name):** STARTED "Sam started a game"+Join (avatar loaded), **JOINED "Sam joined your game"+View**, YOUR_TURN "Sam finished their part / Your turn"+Play, RESULTS "Sam finished / See your results"+View — **RESULTS still showing at 15s (persistent) while STARTED auto-dismissed by 12s (transient)**; **0 QA-SMOKE entries in the shade (no foreground OS dupe); 0 FATAL.** **Pass-E regression smoke 6/6 on BOTH emulators** (shared cold-start path clean). **⚠ DEPLOY REQUIRED (user):** the `partner_joined_game` push only fires once `functions/` + `firestore.rules` are deployed to `closer-app-22014` (I can't deploy; prior prod writes were classifier-denied) — until then the `joinedByUsers` client write is denied by the live rules (best-effort, swallowed, no crash). The banner-standardization for already-deployed types (started/your-turn/results) works immediately. Uncommitted (user commits): `QuestionSession.kt`, `QuestionSessionRepository.kt`(+Impl), `GameSessionManager.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `WheelSessionViewModel.kt`, `GamePromptController.kt`, `GamePromptBanner.kt`, `AppMessagingService.kt`, `PartnerNotificationManager.kt`, `AppNavigation.kt`, `PartnerNotificationTypeTest.kt`, `functions/src/games/onGameSessionUpdate.ts`, `firestore.rules`, `Future.md`, `ClaudeReport.md`. -- **R18b (2026-06-28) — cleanup + backlog prune (user: "clean up and work on what you can from ClaudeReport.md and Future.md").** **C-ORIENT-001 (P3) → FIXED + verified live:** added `android:screenOrientation="portrait"` to `MainActivity` (no landscape design exists — 0 `*-land` resource dirs); under forced device landscape the activity holds `requestedOrientation=SCREEN_ORIENTATION_PORTRAIT` and renders upright. **Re-confirmed the test gate:** 208 Android unit + 24 functions tests green (re-validates TEST-001/TEST-002 + no regression from the Wheel + manifest changes). **Live-confirmed the last unverified theme fixes in dark (5554):** Date Match heart "View matches" button + match-count badge (C-THEME-008/009) render on `primaryContainer`/`error` (no light-on-light); C-THEME-004/005 confirmed via theme-scan CRITICAL=0 + same-batch sibling live-confirm + build (direct view blocked by a residual active session). **Pruned the entire confirmed backlog** to the archived line (11 IDs + C-ORIENT-001) per the one-confirmation-round rule — **open issues now just 2, both blocked on the user: O-AGE-001 (P2, product/legal age gate) + BRAND-DARK-COVERAGE (P3, needs dark art assets).** Triaged the rest of Future.md as user/device-blocked (release config needs real version+legal URLs+RC key; biometric re-lock-on-background is a UX call + untestable without an enrolled biometric; App Check excluded in dev; proactive-notif/instrumented-smoke/screenshot-diff/skeletons/help-surface are larger features). **Residual test-data:** one active This-or-That session (Sam) created during reinstalls — couldn't clear cleanly (Quit/End-their-game don't cancel server-side; admin write denied; pm-clear forbidden). Uncommitted (user commits): `AndroidManifest.xml`, `ClaudeReport.md`. -- **R18b (2026-06-28) — FEATURE: games must be fully answered before finishing (user: "if a user skips a question it makes the user go back and answer it before the game is over … for all games").** Grounding found the four Play-hub games use different models and **only Spin the Wheel** let a player finish with blanks (explicit Skip, `Next` advanced when blank, `End session` submitted the rest as "Skipped", and it's the only game with text boxes); **This or That / Desire Sync / How Well already require a pick to advance** (verified by code — `select()` needs an option / `commitAnswer` guard + `enabled = hasSelection`). Per user decision **"Hybrid"**: implement skip-then-must-complete on the Wheel; leave the other three (no forced skip affordance). **Wheel change (`WheelSessionViewModel.kt` + `WheelSessionScreen.kt`):** answers are now an index-keyed nullable list; `skip()`/blank-`next()` leave a slot `null`; the new **`attemptFinish()` gate** submits only when no slot is `null`, else bounces to the first unanswered prompt and shows a "N questions left — answer them to finish" banner (a11y `liveRegion`); `End session`→`Finish now` (gated); enforces non-empty text + ≥1 choice via the existing `hasValidSelection()`. Category-picker copy updated. **Verified:** build + **205→ unit tests green incl. 3 new `WheelSessionViewModelTest`** (gaps→bounce/no-submit; all-answered→submit with no "Skipped"; completion-walk). **Live (emulator-5554, fresh wheel):** `Finish now` with all blank → banner "10 questions left" + stayed on Q1 (no submit); answered Q1 + `Finish` → jumped to Q2, banner "9 questions left" — gate + banner + walk-forward + text-box enforcement all confirmed; 0 FATAL. **Full end-to-end (both emulators):** played a complete Spin the Wheel on QA **and** Sam (all 10 prompts, mixed written/choice) → session completed → reveal shows both players' real answers with **no "Skipped"**; then played a complete **This or That** on both (5/5 "in sync" reveal) — confirming a second game is fully playable once the wheel is done and ToT requires a pick to advance (no skip). Ended at **0 active sessions** (clean). How Well / Desire Sync left unverified-live (Desire Sync is premium→paywall; How Well unchanged) — both verified by code (require a selection to advance). Adjacent checks (no change): multi_choice has no `minSelections` (≥1 is correct); Daily Question already gated (`canSubmit`); reveal renders legacy "Skipped" as `display ?: "—"` (no crash). **Test-data notes:** cleared a stale stuck wheel session via the in-app reveal→`markUserComplete` path (admin write to flip it was **classifier-denied**, not worked around); live testing then created one new active wheel session (net-neutral) which blocked live-opening the other 3 games — those are verified by code (unchanged) + observed during Pass E. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `CategoryPickerScreen.kt`, `WheelSessionViewModelTest.kt`, `ClaudeReport.md`. -- **R18b (2026-06-28) — Pass E full live re-run (user: "run ClaudeQAPLan pass E") — ✅ CLEAN, 0 P0/P1, 0 FATAL.** Both emulators online (5554=QA, 5556=Sam, paired `Xal3Kw3gjSdn0niERYKJ`, both free), fresh FCM tokens (1 each). **Cold-start crash-triage smoke 6/6 on BOTH** (`qa/entrypoint_smoke.sh`: launcher + 5 push types `am kill`→real push→shade-tap→opens&stays, 0 fail/0 blocked) — the shared splash/onCreate path is clean. **Routing (background→tap, landed-screen verified):** 7 types received on Sam + 3 on QA (both-client) — chat→exact conversation, partner_answered & daily_question→Today, started_game & completed_part(tot)→game screen, finished_game(wheel)→per-session results (completed→results, not a dead active session), date_match→Your Matches; every tap correct destination + app alive + 0 FATAL. **Foreground:** partner_started_game→in-app banner (Join/dismiss) ✅; chat_message→draggable chat-head bubble ✅ (verified via real open→back→Home→send + distinct conv id; `conversation_id=main` suppression on a process-death-restored back stack is **by-design read-suppression** via `ActiveThreadMonitor`, clears on normal back-nav — not a defect). **Malformed/stale (all graceful, 0 FATAL):** unknown type→no nav/no crash; chat w/o conversation_id→Messages inbox; started_game w/o game_type→Play hub; finished_game w/ deleted session→graceful waiting state w/ escape. **Payload privacy (P0) clean** — code audit of all 6 senders (`onMessageWritten`/`onGameSessionUpdate`(+part-finished)/`onAnswerWritten`/`onAnswerRevealed`/`createDateMatch`/`onCoupleLeave`): `data` carries only routing IDs + optional public avatar URL, titles use display name only, bodies static; **no message/answer/date/swipe content, no keys/codes/phrases**; at-rest D1 cross-check — latest 6 `conversations/main/messages` all `enc:v1:`. **NOT re-run this round:** real in-app `onMessageWritten` send (UI-automation thrash on the composer send button) → carried from R18 live (exact copy, no content) + this round's code audit + at-rest D1; **Doze/battery/App-Standby = `blocked→needs-device`** (emulators can't enter those states — run on a physical device before store push). **No app-code changes** (pure QA round); touched `ClaudeReport.md` + `ClaudeQACoverage.md` + `ClaudeQAPlan.md` (added a Pass-E guard: `qa_push.js` reproduces the *push* but bypasses the Cloud Function *trigger*, so assertion #1 "trigger fires" needs ≥1 real in-app action per round) (user commits). Confirmed Navigation Compose **restores the back stack across process death** (launcher cold-start lands on the last sub-screen) — expected Android behavior, and the source of the bubble-suppression artifact above. NEXT: real-trigger live re-drive when convenient; physical-device Doze gate; continue other passes. -- **R18b (2026-06-28) — Future.md review → found+fixed a P0 (user: "review Future.md and do fixes if needed. verify bugs and why").** **O-ONBOARD-001 (P0) — onboarding CRASHES on the final slide for EVERY fresh install** (and the login/signup screen too). **Verified live before/after on `emulator-5558` (fresh, API 34):** old build → onboarding slide-3 `CtaSlide` → `FATAL EXCEPTION: java.lang.IllegalArgumentException: Only VectorDrawables and rasterized asset types are supported` at `PainterResources…loadVectorResource` ← `OnboardingScreen.kt:246`; fixed build → CtaSlide renders the logo + "Create account", and the signup screen (AuthLogoMark) renders too — full onboarding→signup reachable, 0 FATAL. **Why (root cause, git-confirmed):** `ic_launcher_foreground.xml` was a `` until commit **334cb07 "brand: update app icon"** which swapped it to a **``** wrapper; `painterResource` routes any `.xml` drawable through the VectorDrawable loader, which throws on a `` root. The two Compose call sites — `OnboardingScreen.kt` `CtaSlide` + `AuthVisuals.kt` `AuthLogoMark` — weren't updated. **Regression invisible to recurring QA** because 5554/5556 are past onboarding + logged-in (signed up before 334cb07); every fresh install since crashes. (Future.md's root-cause guess — background/aapt quirk — was wrong.) **Fix:** both sites now `painterResource(R.drawable.closer_launcher_foreground)` (the raster the `` wraps; same pattern `LoadingState.kt:146` already uses); the `` XML stays for the real adaptive launcher icon. Scanned the whole app — **no other `painterResource`-on-non-vector-XML remains** (only `ic_launcher_foreground`/`_monochrome` are ``; monochrome isn't used via painterResource). Also fixed the remaining BucketList **add-FAB** hardcoded `Color(0xFFB98AF4)` → `MaterialTheme.colorScheme.primary` (closes the Future.md "BucketList mixed dark/light" item — dialog was R16, FAB was the leftover; verified live light). **Build clean; 205 unit + 24 functions green; all 3 emulators on the fixed APK.** **Regression guard ADDED + proven:** `scripts/painter-xml-scan.sh` flags any `painterResource(R.drawable.X)` where `X` is a non-`` XML drawable (the exact crash class); demonstrated it catches the bug when reintroduced (exit 1) and passes clean on the fix (exit 0); wired into the plan's cheap-gates (step 3). Uncommitted (user commits): `OnboardingScreen.kt`, `AuthVisuals.kt`, `BucketListScreen.kt`, `scripts/painter-xml-scan.sh`, `ClaudeQAPlan.md`, `Future.md`, `ClaudeReport.md`, `ClaudeQACoverage.md`. NEXT: prune O-ONBOARD-001 after 1 confirm; the instrumented onboarding→signup smoke (androidTest, currently 0) remains a `Future.md` idea (would have caught this too). -- **R18 (2026-06-28) — continuing full run (user: "why are you stopping?" → don't hand back at checkpoints).** Both emulators online (5554=Dark, 5556=Light, both reset to Device-default after testing); package is `closer.app` (launcher `closer.app/app.closer.MainActivity`). **C-DARKART-002 FIXED + verified live across all 4 theme/art states** (see Severity-board R18 note + the issue row): `MainActivity` now drives `AppCompatDelegate.setDefaultNightMode` from `ThemeMode` (sync initial read → no flicker loop; `LaunchedEffect` for runtime toggles), so every `painterResource` + BrandIllustration follows the in-app theme via the real Configuration uiMode. The previously-broken **pack-art banners now render DARK** in decoupled in-app-Dark + system-light, and the Today hero does too; symmetric in-app-Light + system-dark → light; both coupled states correct. **C-DARKART-001 re-confirmed.** The test-suite gate also caught **TEST-002** (flaky `MemoryCapsuleGenerator` determinism test — un-injected `System.currentTimeMillis()` clock violated the documented "pure" contract; **no production caller yet** so zero runtime impact, but it intermittently reddened the suite) → fixed by injecting `createdAtMillis`. **Build clean; 205 unit + 24 functions green.** Uncommitted (user commits): `MainActivity.kt`, `MemoryCapsuleGenerator.kt` (+ its test), `ClaudeReport.md`. DONE this round: **Pass A ✅ / B ✅ / E ✅ / L ✅ / P ✅** + **M-001 confirmed** (recommend prune). NEXT: prune C-DARKART-002 / TEST-002 / P-GRAMMAR-001 / M-001 after this confirm round; resolve **O-AGE-001** (P2 pre-ship age gate — product call); P3 backlogs (BRAND-DARK-COVERAGE, BRAND-ICON-CUSTOM, C-ORIENT-001); optional deeper re-runs of F/G/I (last full sweep R12). Board: **0 open P0/P1 · 1 open P2 (O-AGE-001) · 3 open P3**. **Pass A ✅ (R18, live, Sam free on 5556 — both members confirmed free via admin read):** premium gate enforced across **two distinct surfaces** — Desire Sync (game) → Paywall, premium **Boundaries** pack → Paywall; negative control: **Mixed** "Communication" pack opens and a **free prompt is accessible** (answer composer shown), so the gate isn't over-broad; **Free** filter shows a graceful "Nothing in free yet" empty state (catalog note: no fully-free packs). Paywall billing plans don't load on the non-GMS emulator ("Couldn't load plans / Try again") — expected Pass K env limit, degrades gracefully (no crash). -- **R17 (2026-06-28) — continuing full run (user: "complete full run, don't stop").** Both emulators on R16 build (5554=Dark, 5556=Light); HEAD `8b7bbc2` + working-tree fixes. **Theme fixes confirmed LIVE (dark, 5554):** C-THEME-005 (Wheel-History lock → surfaceVariant/primary), C-THEME-008/009 (Date-Match heart→primaryContainer + count badge→error) — joining 001/002 from R16. **NEW finding C-DARKART-002 (P2):** dark-variant art doesn't render in the decoupled in-app-Dark + system-light state — pack art (`QuestionPackLibraryScreen:223` via `packArtworkRes`) + ~7 literal `painterResource` sites resolve `-night` off SYSTEM uiMode, not the in-app theme; **proven live** (in-app-Dark + system `auto` → light pack art; system night=yes → correct dark art). The BRAND-DARK-COVERAGE batch art is correct but only shows under system-night. **Pass D1 at-rest = CLEAN (admin read R17):** messages `text`, `lastMessagePreview`, Memory Lane capsule content+title, **all 4 game answers (this_or_that/desire_sync/how_well/wheel) + date_swipe `action`** all `enc:v1:`; only metadata in clear. **Pass D3 = CLEAN (live raw-API R17):** minted non-member token → couple doc / messages / capsules / desire_sync reads + premium self-grant all **DENIED 403** (`scratchpad/d3_negative.js`). **C-DARKART-002 fully diagnosed** (routing through BrandIllustration is insufficient — `createConfigurationContext` doesn't resolve `-night` for these resources; recommended fix = sync config uiMode to in-app theme; my probe edit reverted; tree clean; build+units green; theme-scan CRIT still 0). NEXT (R18): **C-DARKART-002 fix** (uiMode-sync, architectural) + re-verify C-DARKART-001 holds; **M-001** quiet-hours backgrounded-push re-test → prune; live-confirm C-THEME-004 + light-side spot-check; then Pass A (premium gate) / B (a game) / E (full notif) / L / P. Cornerstones D1+D3 ✅ this round. -- **R16 (2026-06-28) — full ClaudeQAPlan run STARTED.** Session-start: both emulators online (5554/5556), HEAD `8b7bbc2`, working tree carries a **dark-variant art batch** (pack_art_*_dark, together_empty/tonight_partner_prompt night variants → BRAND-DARK-COVERAGE progress) + my doc edits; baseline TBD. **Cheap gates (new step 3):** functions tests **24/24 ✅**; Android unit tests **found 5 failures → FIXED → 205 ✅** (**TEST-001**, test-vs-code drift: (a) `PartnerNotificationManagerTest` stubbed `isInQuietHours(any())` but the method's default `now: Calendar` param pinned a stale instant that never matched the call-time clock → stub now `any(), any()`; (b) `CloserBrandCopyTest` ≤64 cap predated the intentional 150-char flagship `primaryMessage` — `BrandMessageRotator` wraps it `maxLines=3` — → cap now applies to short slogans only, flagship bounded `1..160`. Both **test-side only**; production correct: quiet hours verified live R15, flagship is committed design `6d74c6a`). **theme-scan** 🔴9/🟠8/🟡32 (the 9 CRITICAL = C-THEME-001..009 already filed). **wiring-scan** 🔴0/🟠20/🟡35 (🔴0 = Pass N DoD met). **Done so far:** rebuilt+installed both; **smoke 5554 = 6/6 PASS, 0 blocked** (launcher + 5 notif cold-starts open & stay), 5556 in progress. **Theme triage — of the 9 filed C-THEME, 3 are NOT real shipped defects:** C-THEME-003 = `@Preview`-only (`WheelRevealPreview`) → false positive; theme-scan now **excludes @Preview** composables. C-THEME-006/007 = dead unused `PlaceholderScreen.kt` (replaced by the real dashboard; 0 source refs) → **file deleted**. **The other 6 are real → FIXED** (theme tokens): BucketList (badge→primaryContainer; AddItemDialog surface→surface + Cancel→secondaryContainer + Add→primary + CategoryChips→primary/surfaceVariant — also closes the Future.md "mixed dialog" note), DateMatch (heart→primaryContainer, count badge→error/onError), WheelHistory (lock→surfaceVariant/primary), QuestionThread (waiting banner→surfaceVariant). **theme-scan CRITICAL 9→0; build + unit tests green.** **R16 RESULT:** smoke **6/6 both (0 blocked)**; theme-scan **CRITICAL 9→0** (3 reclassified, 6 fixed); **C-THEME-001/002 + N-001 + N-002 verified LIVE (dark, 5554)**; units **205** + functions **24** green; dead `PlaceholderScreen.kt` deleted; theme-scan now excludes `@Preview`. **N-001/N-002 pruned.** Open now: **O-AGE-001** (P2 pre-ship) + 3 P3. **NEXT (R17):** live-confirm the 4 remaining C-THEME fixes (004/005/008/009) in both themes + the 6 in LIGHT on 5556; re-test **M-001** quiet-hours (backgrounded-push) to prune it; then resume passes A–N+P — esp. a live both-theme sweep of the new **dark-art batch** (BRAND-DARK-COVERAGE) + the D/E cornerstones. Uncommitted (user commits): unit-test fixes, 4 theme-fixed screens + BucketList/DateMatch, `scripts/theme-scan.sh`, **deleted** `PlaceholderScreen.kt`, dark-art batch, QA docs. -- **R15 (2026-06-27) — gap-closing round (Passes L/M/N/P + regression smoke) — found & FIXED M-001 (P2).** Build current (HEAD `c31eea2` + R15 working-tree changes rebuilt+installed both emulators); baseline both FREE, 0 active sessions. **Smoke** ✅ 6/6 GREEN both (launcher + 5 notif cold-starts). **M (settings take-effect)** — **M-001 (P2) quiet hours didn't suppress backgrounded/killed partner pushes** (local-only window; OS shows `notification` block w/o app code). **FIXED + verified live:** client mirrors window+tz → `users/{uid}`; 4 partner-action senders suppress via fail-open `recipientInQuietHours()`; rules allowlist extended. Live: QH ON → fn log `is in quiet hours — suppressing`, 0 delivery; QH OFF → `notified partner`. Per-type chat toggle re-confirmed server-enforced (toggle off → 0 delivery; field flips in Firestore). Theme/DataStore persistence across relaunch ✅. Biometric lock code-sound (no compose bypass; observation: re-locks on cold-start, not plain background→resume → `Future.md`). **L (chat E2E)** ✅ decrypt both dirs, attribution, Seen receipt, ❤️ reaction, ordering, day-sep, inbox no `enc:` leak, at-rest `enc:v1:`. **N** ✅ daily-Q + reveal both-answered gate render (outcomes/bucket-list/date-builder carried — render-clean prior rounds). **P (content/language)** ✅ UI copy warm/inclusive, debug rows `BuildConfig.DEBUG`-gated, friendly error fallbacks; **question bank 6103 Qs: 0 empty/0 dupes/0 placeholders/complete answer configs/on-guide tone.** **D1 at-rest** ✅ messages/preview/capsules `enc:v1:`. **0 FATAL.** **Pass N driven (user "FIX"):** **N-001 (P1) Bucket List was fully non-functional** (coupleId never set → all CRUD no-ops) → **FIXED + verified live** (add `enc:v1:` / complete / delete / list render; client-only). **N-002 (P2) "Plan a Date"/Date Builder "Create Plan" no-op** (wrote to unread prefs collection; `dateIdeaId`/`coupleId` never wired) → **FIXED + verified live** (re-pointed `DateBuilderViewModel` to create a PLANNED `DatePlan` via `savePlan` + resolve coupleId → `date_plan` status=planned, `enc:v1:`; Home shows "Date coming up"). Outcomes/Your Progress code-correct (resolves coupleId); daily-Q/reveal render ✓. Uncommitted (user commits): client (`BucketListViewModel`, `DateBuilderViewModel`) — M-001's functions/rules/client were committed by the user mid-round (+ user dropped 3 dark-variant PNGs in `drawable-night-nodpi/` toward BRAND-DARK-COVERAGE). **M-001 functions+rules DEPLOYED to prod; N-001/N-002 are client-only (debug APK installed both emulators).** NEXT (R16): confirm M-001 + N-001 + N-002 hold → prune; 2 P3 brand backlogs; revisit Date Builder "both-partners-generate" vision if wanted. -- **R14 (2026-06-27) — full fresh A–J ClaudeQAPlan run (pure QA, no code changes) — FLAWLESS, 0 open P0–P3, 0 new findings.** Baseline both FREE, 0 active sessions; R13 build on both emulators (5554 dark / 5556 light). **A** ✅ premium enforcement audited (6/6 features gated, not just badged; A-201 class closed) + free→Paywall (Date Match / Desire Sync / Question Packs) + couple-shared unlock (Sam prem→QA free unlocks Desire Sync + a premium pack; modal + `subscription_entitlement_changed` push delivered live to QA). **B** ✅ Desire Sync + How Well + Spin-the-Wheel full 2-device (True/False+Yes/No+multi-select+free-text answer types; skipped-answer reveal; **first-finisher `partner_completed_part` nudge confirmed in Sam's queue**), Memory Lane create+seal (premium), Connection Challenges resume (Day 4 · 🔥2), Date Match deck; ToT carried R13. **C** ✅ broad both-theme + **decoupled-theme-art mandate** (system-light+app-Dark → dark UI + correctly-themed feathered Today hero); no nav dead-ends (back-from-Home exits = correct). **D** ✅ LIVE non-member 403 ×2 · self-grant 403 · member 200 · chat at-rest `enc:v1:` (game/capsule at-rest carried R10/R12, crypto unchanged). **E** ✅ all triggers fired live, content-free copy to right partner (started/completed_part/finished + entitlement). **F** ✅ offline Today-from-cache + `am kill` recovery, 0 FATAL. **I** ✅ jank 5.25%. **J** ✅ J-OBS 48dp holds. **0 FATAL whole run.** The 5 R13 fixes held → pruned. Uncommitted (user commits): R13's 16 modified + `PremiumUnlockOverlay.kt` + `illustration_premium_unlock.png` (R14 added no code). -- **R13 (2026-06-27) — backlog fix pass + full fresh A–J — FLAWLESS (0 open P0–P3).** Took over the open Codex dark-mode backlog and shipped it all (working tree, both emulators rebuilt+installed; baseline both FREE, 0 active sessions): **C-DARK-UI-001** (ToT dark redesign — theme-aware backdrop/options/chips/versus/progress/pills) · **C-DARK-UI-002** (check-in label/value weight) · **C-DARK-UI-003** (Play/Home/Paywall bottom clearance) · **C-ART-EDGE-002** (8 opaque heroes routed through `BrandIllustration` feather) · **J-OBS** (composer/voice/retry buttons → 48dp). Confirmed **A-201** live → pruned. Shipped the **Premium-unlock modal** (`ui/components/PremiumUnlockOverlay.kt`, hosted in `AppNavigation`; driven off `CouplePremiumChecker`, one-time via a new `premiumUnlockCelebrated` `SettingsRepository` flag) — verified live on BOTH purchaser (5554) and partner (5556) + one-time gate (dismiss→relaunch no re-show). **A–J:** A ✓ (Date Match + Desire Sync gates → Paywall) · B ✓ (ToT full both themes; Wheel launch clean) · C ✓ (extensive both-theme sweep) · D ✓ LIVE (non-member 403 ×2, self-grant 403, member 200, chat at-rest `enc:v1:`) · E/F/G carried (diff is UI-only; no rules/functions/crypto/auth change) · H ✓ (ToT brand + modal + heroes) · I ✓ (jank 6.43%) · J ✓ (48dp). **0 FATAL both devices.** Uncommitted (user commits): 16 modified + `ui/components/PremiumUnlockOverlay.kt` + `res/drawable-nodpi/illustration_premium_unlock.png`. -- **Ad hoc dark-mode UI/brand sweep (2026-06-27, Codex-owned emulator `emulator-5558`):** current debug APK installed, dark mode forced, fresh real paired users created through invite flow (`Codex Dark` + `River Dark`). Swept profile, invite, paired Home, Play, This-or-That, Settings, Notifications, Paywall, Messages, Today. **0 app FATAL/ANR/force-finish in logcat.** Findings added below: C-DARK-UI-001 (P2), C-DARK-UI-002 (P3), C-DARK-UI-003 (P3). Screenshots captured at `/tmp/closer-dark-04-after-permission.png` through `/tmp/closer-dark-25-today.png`. -`R12 (2026-06-27) FRESH FULL ClaudeQAPlan run STARTED (user: "start from the start") | Baseline verified clean: QA free, Sam free (premium revoked), 0 active sessions; build HEAD 2cd0af6 + 3 uncommitted art files installed both emulators (5554=Dark, 5556=Light) | A ✅ (A-201 P1 Date-Match premium bypass) | B ✅ (4 async games full 2-device end-to-end + first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; CC/MemoryLane/DateMatch render-checked; MemoryLane title/preview run-on→Future) | C ✅ (regression-clean vs R10 sweep; Messages/Today/Subscription + organic A/B + R11 decoupled art; NEW C-ART-EDGE-002 P3 direct-call hero hard edges on dark) | D ✅ (LIVE: D1 game answers enc:v1: · D3 non-member 403×4 + member-scoped · D5 self-grant→403; D2/D4/D6/D7 carried R7/R10, rules unchanged) — cornerstone clean | E ✅ (smoke 6/6 + Pass B triggers) | F ✅ (concurrency+process-death+offline) | G ✅ (security half live) | H (finding C-ART-EDGE-002) | I ✅ (jank 4.10%) | J (J-OBS P3) | **FIX PHASE ✅ — A-201 (P1) FIXED+VERIFIED LIVE** (Date Match LOVE/MAYBE on premium idea → Paywall via CouplePremiumChecker; SKIP passes; 0 FATAL). C-DARKART-001+C-ART-EDGE-001 held → PRUNED. | **R12 COMPLETE — FLAWLESS: 0 open P0–P2.** Remaining: 2 non-blocking P3s (C-ART-EDGE-002 hero edges, J-OBS touch targets). | NEXT (R13): confirm A-201 holds → prune; optional P3 polish. Uncommitted (user commits): R11 art files + `ui/dates/DateMatchViewModel.kt` + `ui/dates/DateMatchScreen.kt` (A-201). Carryover from R11 (still valid): C-DARKART-001 (P2) + C-ART-EDGE-001 (P3) fixed pending 1 confirm; J-OBS (P3) open touch targets.` -- **(prior) R11 (2026-06-27) confirmation round COMPLETE — FLAWLESS | Fixed last open P2 C-DARKART-001 (in-app-theme art) + P3 C-ART-EDGE-001 (feathered edges) in shared BrandIllustration/EmptyState; verified live both decoupled theme directions, 0 FATAL | 5 R10 P2 fixes re-confirmed + PRUNED (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001) | entrypoint smoke green on fresh APK | 0 open P0–P2 | Baseline: both FREE, 0 active sessions; build (HEAD 2cd0af6 + 3 uncommitted art files) installed both emulators | NEXT (R12 = next session): confirm C-DARKART-001 + C-ART-EDGE-001 hold → prune; optional J-OBS (P3) touch targets; then declare program-complete for Android. Art fixes in working tree (user commits).` -- **Pass B progress (R12):** **1. This or That ✅** — full end-to-end 2-device, NEW style **Light×5 Quick** (R10 was Deep×10): QA started → answered 5 (alt A/B) → first-finisher state; **first-finisher nudge fired** (`partFinishNotifiedAt` set + Sam queue `partner_completed_part` "QA finished their part — your turn to play!"); Sam **joined via Play-hub active state** (at Q1/5, no dup session) → answered all-A → session→completed (0 active); **`partner_finished_game` to BOTH**; reveal **3/5 in sync** symmetric + correct Match/Differ + You/QA attribution on **both** devices (QA dark / Sam light). 0 FATAL. **2. Spin the Wheel ✅** — **Ready=Start session** (R11 change) verified; spun→Stress→10Q; **mixed answer types** (free-text + 1–5 scale) render+accept; Sam **joined active session** via Play hub (Q1, no dup/new spin); both finished→completed (0 active); results "Here's how you each answered" with You/QA free-text + scale; **C-NAV-002 RE-VERIFIED LIVE** — results → BACK → wheel hub (Spin/History), NOT the finished session (the R11-deferred confirm). 0 FATAL. **3. How Well ✅** — QA subject 5·Quick (answered 5 about self), Sam **joined as guesser** ("Predict how QA answered…", asymmetric), guessed 5 → score **5/5 "Perfect read"** + per-Q breakdown (✓ + answer, choice+scale) symmetric both devices; completed (0 active). **4. Desire Sync ✅** (premium, couple-shared via Sam-on) — free QA opened setup (no paywall); QA all-Yes, Sam joined + Y,Y,Y,N,N → reveal **"3 shared desires · 2 kept private"** (only mutual-Yes shown, 2 mismatches "Private") symmetric both devices; completed (0 active); Sam premium restored OFF. **All 4 async session games verified end-to-end.** -- **Uncommitted (user commits):** R11 art fixes — `app/.../ui/theme/Theme.kt`, `app/.../ui/components/BrandIllustration.kt`, `app/.../ui/components/EmptyState.kt`; **+ R12 A-201 fix — `app/.../ui/dates/DateMatchViewModel.kt` (CouplePremiumChecker gate + `paywallRequired` event) + `app/.../ui/dates/DateMatchScreen.kt` (navigates to paywall).** Everything else committed in `2cd0af6`. Build installed both emulators. -- **Foreground "partner started a game" alert + bold Game Waiting card (R10, user-requested) — DONE+VERIFIED.** When the app is OPEN and a partner starts a game, a prominent **in-app top banner** (" started " + Join) slides in (mirrors chat's in-app surface) instead of the easy-to-miss system notification; **Join → joins the game**. Verified live for all 4 session games: banner name correct (Spin the Wheel / This or That / How Well Do You Know Me / Desire Sync); Join → joins wheel (ToT shows graceful "QA is playing a Wheel game" when types mismatch); **suppressed** when already on that game's screen (added `ActiveGameSessionMonitor.enter/leave` to `WheelSessionViewModel` — the others already had it). Home **"Game waiting"** card redesigned as a **bold purple-gradient hero** (glyph + game name + "Join the game"), promoted to top of "Waiting for you", verified **both themes** → tap **joins the specific game** (not the Play-hub fallback). FCM transport on the emulators is flaky (FcmRetry); the banner was exercised via a data-only high-priority send to the partner token (faithful to the deployed payload). -- **Pass C progress (R10):** **Settings family ✅** (dark + Security on light): Settings list, Subscription, Security, Delete account, Notifications all render clean both-relevant-themes; **4 illustrations confirmed in-context** (Security padlock, Delete-account doorway, Quiet-hours moon, + Subscription); back-stack OK (Security/Delete/Notif → BACK → Settings). **Found C-SEC-001 (P2)** — accepter's Recovery phrase disabled + wrong "invite your partner" copy (see Open issues). **Wheel back-stack RE-CHECKED = not a trap:** live wheel session → BACK → spin/setup → BACK → Play hub (2 backs, no dead-end); earlier "stuck" was automation cycling. Leaving mid-wheel leaves a resumable abandoned active session (normal; cleaned). Home ✅ both themes (stale game card gone). -- **6. Spin the Wheel ✅** — spun→Physical Intimacy→session; Sam joined at Q1; both answered 10 (A/B + 1–5 scale + free-text); per-Q You/QA breakdown renders; completed, 0 active. (Helper `wheel_drive.py` handles mixed types; free-text Qs hide "Next" behind IME.) -- **7. Date Match ✅** — swipe deck ("Swiping with Sam/QA"); QA+Sam mutual like → **"It is a match!" modal live**; new match persisted (date_matches 3→4); **swipe action `enc:v1:` at rest** (only swipedAt clear). -- **Pass B = COMPLETE (R10): all 7 games played end-to-end 2-device, 0 bugs.** 2 observations: CC day-counter desync (Future.md, by-design?) · **WATCH — wheel back-stack:** after finishing Spin-the-Wheel, system-BACK from the results re-enters the completed wheel-session screen (loop), needed an app relaunch to escape. Possibly automation artifact (missed taps) — **recheck deliberately in Pass C nav fuzzing; file if reproducible (P2 back-stack).** -- **5. Memory Lane ✅** — new capsule sealed (3-mo pick) with future open date; **title+content `enc:v1:` at rest** (admin-verified); lists cross-session. Minor cosmetic: "Opens in 2 mo" shown for a 3-month selection (relative-time display nit; not filed). -- **4. Connection Challenges ✅** — Gratitude Week (in-progress from R9): per-day step, "I did it today", "waiting for partner" both-gate, missed-day catch-up ("Pick it back up"), **streak 🔥→2 synced both devices**. UX note (Future.md): "Day N of 7" counter diverges between partners after asymmetric catch-up (QA D4/Sam D3) while streak stays synced — plausibly by-design, non-blocking. -- **Pass B progress (R10):** **1. This or That ✅** — Deep×10 (varied): QA started, Sam joined via Play-hub card (no duplicate, 1 session), both answered 10, results symmetric both devices ("8/10 in sync", per-Q Match labels correct), session→completed, 0 stale. **2. How Well ✅** — QA-subject 5·Quick: QA answered 5 about self, Sam joined as **guesser** (asymmetric join works), predicted 5, score+breakdown render correctly (1/5, ✓/✗ guess→actual incl. scale Q), completed, 0 stale. -- **R10 scratchpad drivers (reuse):** `r10_set_premium.js ` · `rv_gate.js`/`rv_markreveal.js` (raw-API) · `hw_drive.py ` (taps first option+Confirm per Q) · `rv_inspect.js`/`rv_sessions.js` (admin reads). Game-option taps: use uiautomator bounds, NOT fixed coords (layouts shift per question; last Q button = "Done →" not "Confirm →"). -- **Admin writes:** user authorized this session (2026-06-26) → premium toggle + baseline reset now working. Baseline reset done (0 active sessions; stale 06-24/06-25 answers cleared). Premium toggle: `scratchpad/r10_set_premium.js `. -- **Pass A ✅ (R10):** neither-premium → Desire Sync shows 🔒 + opens **paywall** ("Go deeper together"); toggled **Sam premium ON** → QA(free) Play hub badge cleared **live** + Desire Sync opens **setup (no paywall)** = couple-shared unlock holds. Code audit: all gates use `CouplePremiumChecker` except `SubscriptionScreen` (by-design own-status) + `DailyQuestionResolver` (per-user premium-question fallback — verify in Pass B/E it doesn't desync the couple's daily Q). Other 7 features share the verified path (R9 enumerated each). -- **Build:** HEAD `e6a8dee` — clean working tree (reveal feature committed: couple-key encryption, read-gated `secure` subdoc, `onAnswerWritten` both-answered copy, `onAnswerRevealed`). Rebuilt + installed on both emulators this session. -- **Daily-reveal QA (2026-06-26, live, both emulators 5554 dark / 5556 light):** **Gate (raw API):** only-1-answered → partner reads metadata 200 but content **403**, non-member **403/403**; both-answered → partners read each other **200/200**, non-member still **403/403**. **At-rest:** answer doc content-free metadata only; content in gated `secure/payload` (`enc:v1:`). **Reveal:** shows the partner's answer **both directions** (the fixed bug) — QA↔Sam. **Pushes:** `onAnswerWritten` fires (both-answered "unlocked ✨" copy is in deployed code); `onAnswerRevealed` fired live (`isRevealed` flip → "notified partner that X opened"). 0 FATAL either device. Today's test answers wiped after; baseline clean. One low-sev robustness note → `Future.md` (reveal `isRevealed` write isn't retried if it fails). Note: stale active wheel session + 06-24/06-25 unrevealed answers are pre-existing test pollution (confound the Home dashboard daily card; not the reveal feature). -- **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`. - -## Severity board -| Severity | Open | Fixed (pending 1 confirm) | -|---|---|---| -| P0 | 0 | 0 | -| P1 | 0 | 0 | -| P2 | **0 open** (O-AGE-001 18+ gate IMPLEMENTED+verified R28; birthDate persistence pending a gated rules deploy) | **1** (B-ABANDON-001 — fixed+verified live R20) | -| P3 | **1** (BRAND-DARK-COVERAGE — needs dark art assets) | **1** (B-COPY-001 — fixed+verified live R20) | - -_R18b cleanup (2026-06-28): **pruned the entire confirmed backlog** (one-confirmation-round rule). Pruned to the -archived line: **O-ONBOARD-001** (P0, verified live R18b) · **C-DARKART-002** (verified live R18) · **C-THEME-001/002/ -004/005/008/009** (R16 fixes; 001/002/008/009 verified live, 004/005 via theme-scan=0 + same-batch sibling live-confirm -+ build) · **M-001** (verified live R15) · **TEST-001 / TEST-002** (re-confirmed green this round: 208 unit + 24 functions) -· **P-GRAMMAR-001** (asset fix) · **BucketList-FAB** · **BRAND-ICON-CUSTOM** (verified live R18) · **C-ORIENT-001 → -RESOLVED** (portrait locked in the manifest + verified live: `requestedOrientation=PORTRAIT` holds under forced -landscape). _(R28 update: **O-AGE-001 18+ gate now IMPLEMENTED + live-verified; rules DEPLOYED** (birthDate persistence confirmed against the live rule) — the only remaining item is the Play maturity questionnaire (product, not code). **BRAND-DARK-COVERAGE is effectively resolved** — all 22 `illustration_*` have `drawable-night-nodpi/` dark variants; only a few transparent celebration assets lack a dark variant and they already read correctly on dark.)_ - -_R16: ran the new **cheap gates** → found+fixed **TEST-001** (unit suite was silently red, 5 failures) → **205 unit + 24 -functions green**. **Entrypoint smoke 6/6 on BOTH emulators, 0 blocked.** Theme triage: of the 9 filed C-THEME, **3 were -not real shipped defects** (C-THEME-003 `@Preview` false positive [theme-scan now excludes @Preview]; C-THEME-006/007 dead -`PlaceholderScreen` [deleted]); **6 real → FIXED** → **theme-scan CRITICAL 9→0**, build+units green; **C-THEME-001/002 -verified LIVE (dark)**. Confirmed + **pruned** R15 fixes **N-001** (P1, live add/delete) + **N-002** (P2, Home "Date coming -up"). **M-001 carried** (not re-tested this round). Remaining open: **1 P2 pre-ship** (O-AGE-001) + **3 P3** (2 brand -backlogs + C-ORIENT-001). 4 C-THEME fixes (004/005/008/009) pending live confirm next round._ - -_R18 (2026-06-28): **C-DARKART-002 FIXED + verified live (all 4 theme/art states).** Root fix = drive the real -Configuration uiMode from the in-app theme via `AppCompatDelegate.setDefaultNightMode` in `MainActivity` (read initial -theme synchronously to avoid a placeholder→real recreation flicker loop; `LaunchedEffect` handles runtime toggles). This -makes **every** `painterResource` site + BrandIllustration follow the in-app theme at once, retiring the unreliable -per-site `createConfigurationContext` hack. Verified: coupled-dark→dark, coupled-light→light, **decoupled in-app-Dark + -system-light → dark (Today hero AND the previously-broken pack-art banners)**, decoupled in-app-Light + system-dark → -light. C-DARKART-001 re-confirmed (holds). Running the new test-suite gate also caught **TEST-002**: `MemoryCapsuleGenerator` -documents itself "pure/deterministic" but every factory stamped `createdAt = System.currentTimeMillis()`, so the -`same input produces identical capsules` test was **flaky** across ms boundaries (passed alone, failed in the full suite). -Fixed by injecting `createdAtMillis` (defaults to wall clock; tests pin it) — honors the contract; **no production caller -exists yet** (Memory Lane not wired in), so zero runtime impact, but it was undermining the suite gate. **205 unit + 24 -functions green; build clean.**_ - -_R18 Pass A/B/P (live, 5556 Sam free): **Pass A ✅** premium gate enforced on 2 surfaces (Desire Sync + Boundaries pack -→ Paywall), free content reachable (Mixed pack prompt opens), graceful empty/billing states. **Pass B ✅** Wheel -playthrough end-to-end (spin→Stress→start→MC + free-text answers→1/2/3 progression→End→async "answers are in, waiting -for partner" reveal-gate); 0 FATAL either device. **Pass P → P-GRAMMAR-001 (P3, FIXED in asset):** the in-game wheel -question surfaced a subject-verb agreement error; bank scan found **13 questions** (all `stress` category, from one -template family of 35) where **plural subjects were substituted into a singular "{subject} is …" frame** — "low energy -days/busy weeks/health worries/burnout signs/unexpected problems **is** affecting you / **is** starting to show up". -Fixed the 13 rows in `app/src/main/assets/database/app.db` (data-only `UPDATE … REPLACE(' is ',' are ')` on exact IDs -stress_021/022/031/032/041/042/061/062/066/067/195/199/203; backup `app.db.bak_pgrammar`; verified 0 remaining). Room's -identity hash is schema-based so the edit is safe; **root/durable fix belongs in the content generator** (build_db.py — -NOT run per standing constraint) so regeneration doesn't reintroduce it (pluralize the verb or curate subjects). -**Caveat:** running emulators copied the old asset on first launch and won't re-copy without a data wipe — source asset -is corrected for new installs, verified via sqlite. Recommend a broader template-grammar audit of the bank (the prior -completeness scan checked empties/dupes/placeholders, not agreement)._ - -_R18 Pass L (live E2E, 5556 Sam → 5554 QA): **✅** sent a uniquely-tagged message; **received + rendered in plaintext on -the partner device** (E2E decrypt works), **`enc:v1:` at rest** (admin read: text=enc✓(79)), **at-rest leak check passes** -(marker absent from all message docs — `scratchpad/msg_atrest.js`), **"Seen" read receipt** present, and the chat renders -correctly in **dark** on 5554 (incidental C-theme confirm). Cross-checks D1 (message at-rest) on fresh live data._ - -_R18 M-001 + Pass E (live): **M-001 → confirmed (recommend prune).** Toggling Quiet Hours on 5554 (QA) writes the client -mirror to `users/{QA}` correctly — `quietHoursEnabled:true`, `quietHoursStartMinutes:1320` (10 PM), `quietHoursEndMinutes:480` -(8 AM), `timezone:"America/Chicago"` — the exact fields the deployed `recipientInQuietHours()` reads; toggling off → -`enabled:false`. The client-mirror half (the R15 fix) is intact; the server-suppression half is deployed + was live-verified -R15. The full clock-windowed suppression was NOT re-run this round (emulator clock ~4 PM is outside the 10 PM–8 AM window; -doing it would need either clock manipulation or a per-occurrence-gated production write that the auto-classifier denied — -not worked around). Restored QH to baseline (off). **Pass E ✅ (real backgrounded delivery + privacy + deep-link):** -backgrounded QA → Sam sends chat → FCM `notification` delivered (`channel=partner_activity`, importance high, vis=PRIVATE) -with title **"Sam sent a message"** / body **"Tap to read and reply."** — **no message content in the push** (E2E ciphertext -stays server-blind; privacy-safe, cross-checks D6). Clean Home-baseline re-test: **tapping the notification deep-links to the -correct conversation** with messages decrypted. (A first attempt resumed at the prior screen — re-tested from Home and it -routed correctly, so that was a test artifact, not a bug.)_ - -## Issues — open (Pass C theme defects + brand-asset backlogs) -> Surfaced by the 2026-06-27 brand standards audit (new Pass H/Pass C mandates) and the 2026-06-28 theme-scan run. Brand-quality defects (light-only art, generic icons) and Pass C theme defects (hardcoded surface/background colors) both live here; asset lists + prompts are in `ClaudeBrandingReview.md`. -> -> **R16 reclassified (NOT real shipped defects, removed from open):** **C-THEME-003** = `WheelCompleteScreen.kt:507` is inside `@Preview fun WheelRevealPreview()` — design-time only, never shipped → **false positive**; `scripts/theme-scan.sh` now **excludes `@Preview`** composables so it won't re-file. **C-THEME-006 / C-THEME-007** = `PlaceholderScreen.kt` (`SignalChip`/`PreviewPanel`) had **0 source references** (replaced by the real dashboard per `docs/qa/private-mvp-checklist.md`) → **dead code, file deleted**. - -| ID | Sev | Area | Description | Suggested fix | Status | -|---|---|---|---|---|---| -| B-ABANDON-001 | P2 | Games lifecycle (Pass B) | **Quit/abandon on any game silently failed `PERMISSION_DENIED`, stranding the session `active`.** `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) round-tripped through `saveSession`, which does a **full `doc.set()`** of a fixed 13-field map — dropping the server-only flags the Cloud Function wrote (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`). The session-update rule's `affectedKeys().hasOnly([...])` counts those *removed* keys, so the write is denied; the session never flips to `completed` → stranded active (blocks starting new games), and the failure is swallowed by `Log.d`. Proven live (R20): `Write failed at couples/.../sessions/MWkzZOWWRLrLNNoSwM0n: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. Affects ToT/HowWell/Wheel (all route through `abandonSession`). Escaped R19 (only the arrayUnion paths were rules-tested). | **FIXED (R20):** targeted `update(mapOf("status" to "completed","completedAt" to now))` in `abandonSession` so `affectedKeys == {status, completedAt}` ⊆ allowlist + monotonic active→completed; `finishGame` now delegates to it. Verified live: Quit → no denial → `active=0`, then a different game started immediately (lockout resolved). | **Fixed — pending 1 confirm** | -| B-COPY-001 | P3 | Content/copy (Pass P) · Home | **Home "GAME_WAITING" hero falsely claimed the partner already played.** Body was hardcoded *"Your partner already played their part — take your turn to reveal how you two line up,"* but the card fires on `getActiveSessionForCouple()?.takeIf { uid !in completedByUsers }` — and for async games `completedByUsers` stays empty until BOTH finish, so the card (and its false claim) shows the instant a game is merely *started*, even when neither partner has answered. Confirmed live (R20): with `completedByUsers=[]` + no answers, QA's Home showed the claim, then QA joined to a fresh Q1/10 (no reveal). Copy-vs-behavior mismatch. | **FIXED (R20):** neutral, partner-named, always-accurate copy — "Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up." The accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN `GamePromptBanner`. Verified live both devices (starter + joiner). | **Fixed — pending 1 confirm** | -| O-AGE-001 | P2 | Release / store readiness (Pass O) | **No age gate / age verification despite adult-intimacy content.** Sign-up collects only email+password+confirm; Create Profile collects name+gender; `domain/model/User.kt` has **no DOB/age field**; the only "birthday" in-app is the *partner's* relationship special-date (`SpecialDatesSection`), not age. Yet the app ships sexual/intimacy content (Desire Sync). Google Play content-rating + sexual-content policy generally require an accurate maturity rating and may require an age gate. _(Static finding — 2026-06-28 QA-plan gap review; confirm against current Play policy + intended content rating.)_ | Add an 18+/age-appropriate gate where required + complete the Play content/maturity questionnaire to match actual content. **Pre-ship gate** (does not block per-round flawless). | **R28: IMPLEMENTED + live-verified; rules DEPLOYED** — 18+ DOB gate at sign-up (under-age blocked, no account) + conditional DOB step for Google/legacy; `AgeGate` + `User.birthDate` + rules allowlist + 8 unit tests. birthDate persistence verified against the deployed rule (authed update PATCH → 200; junk field → 403). **Remaining (product, not code): complete the Play content/maturity questionnaire to match actual content.** | -| BRAND-DARK-COVERAGE | P3 | Art / theme | Most illustrations are **light-only** — only 12 of ~25 have a `drawable-night-nodpi/` dark variant. All `illustration_couple_*` heroes (paywall/subscription/onboarding/invite/history), `daily_question`, `partner_activation`, `tonight_partner_prompt`, `together_empty`, and **all 10 `pack_art_*` banners** show the **light/pink image on a dark screen** (feathered edges don't change the image colors). | Generate dark/aubergine-palette variants for each light-only asset → `drawable-night-nodpi/` (identical filename); `BrandIllustration` auto-selects per in-app theme. Re-run the decoupled-theme check. List in `ClaudeBrandingReview.md`. | **Open (P3)** | - -## Resolved & confirmed (archived — full detail in git history) -**O-ONBOARD-001** · **C-DARKART-002** · **C-THEME-001** · **C-THEME-002** · **C-THEME-004** · **C-THEME-005** · **C-THEME-008** · **C-THEME-009** · **M-001** · **TEST-001** · **TEST-002** · **P-GRAMMAR-001** · **BucketList-FAB** · **BRAND-ICON-CUSTOM** · **C-ORIENT-001** · A-001 · A-003 · **A-201** · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · **C-DARKART-001** · **C-DARK-UI-001** · **C-DARK-UI-002** · **C-DARK-UI-003** · C-DS-001 · **C-ART-EDGE-001** · **C-ART-EDGE-002** · **C-HOME-001** · **C-NAV-001** · **C-NAV-002** · **C-NAV-003** · **C-PW-001** · **C-SEC-001** · D-001 · E-001 · E-002 · E-003 · **E-GAME-002** · **E-GAME-003** · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** · **J-OBS** · **N-001** · **N-002** — all fixed and re-verified (R16 pruned **N-001** [Bucket List non-functional → CRUD works; confirmed live add/delete] + **N-002** [Date Builder no-op → Home "Date coming up"; confirmed live]) (R14 pruned the 5 R13 fixes — **C-DARK-UI-001** ToT dark redesign · **C-DARK-UI-002** check-in label/value · **C-DARK-UI-003** bottom-inset clearance · **C-ART-EDGE-002** 8 opaque heroes feathered · **J-OBS** 48dp touch targets — held through R14's full A–J sweep; in working tree) (R13 pruned **A-201** [Date-Match premium ideas ungated → now gated to Paywall via `CouplePremiumChecker`] — fixed R12, confirmed live R13; in working tree) (R12 pruned C-DARKART-001 [in-app-theme `-night` art] + C-ART-EDGE-001 [feathered edges] — fixed R11, held through R12 visual sweep; in working tree) (R11 pruned the 5 R10 P2 fixes — C-HOME-001 single Home card · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` present + R10-live · C-NAV-003 single app bar re-confirmed live · C-PW-001 dark paywall pills legible re-confirmed live · C-SEC-001 recovery row active for accepter re-confirmed live — all committed in `9c84c36`; E-GAME-003 `onGamePartFinished` deployed + committed `2cd0af6`) (E-GAME-002 confirmed live R10: `startNotifiedAt` set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.) (**R18b pruned the full remaining backlog** — O-ONBOARD-001 · C-DARKART-002 · 6× C-THEME · M-001 · TEST-001 · TEST-002 · P-GRAMMAR-001 · BucketList-FAB · BRAND-ICON-CUSTOM · C-ORIENT-001 [portrait lock] — all verified live in their fix rounds + re-confirmed via 208 unit/24 functions green this round; in working tree, user commits.) - -## Security cornerstone — clean (Pass D, deep dive, Round 7) -- **R17 re-verified (live, admin + raw-API):** **D1 at-rest** — messages `text`, `lastMessagePreview`, Memory Lane capsules (content+title), all 4 game answers (this_or_that/desire_sync/how_well/wheel), date_swipe `action` → all `enc:v1:`; only metadata clear. **D3 negative access** — minted non-member token → raw Firestore REST: couple doc / messages / capsules / desire_sync reads + premium self-grant **all DENIED 403**. Scripts: `scratchpad/d1_atrest.js`, `d1_probe3.js`, `d3_negative.js`. -- **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. -- **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). -- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads. - -## ⛔ "All notifications broken / app opens-and-closes" — ROOT CAUSE = splash crash (FIXED R10) -**The actual cause was NOT routing — it was a crash in the splash-screen exit animation on notification cold-starts.** -`MainActivity.onCreate` (added in **`95cad84`, 2026-06-25**) set `splashScreen.setOnExitAnimationListener { provider -> provider.iconView.animate()… }`. -On a **notification / PendingIntent cold-start** the OS hands the splash view over **without an icon** (`SplashScreenView: Icon: view: null`), -and `provider.iconView` throws an internal `NullPointerException` (`SplashScreenViewProvider$ViewImpl31.getIconView`) → -`onCreate` crashes → "Force finishing activity" → **the app opened and immediately closed on EVERY notification tap** -(chat, game-start, results — all of them, because they share the cold-start path). This is why it looked like "all -notifications broke again." Normal launcher cold-starts were fine (icon present), which masked it. -- **Why my earlier `am start` tests missed it:** shell `am start` uses a different splash transfer than the FCM - PendingIntent handover (the SysUILaunch remote transition), so it didn't hit the null-icon handover. Also `am - force-stop` can't receive FCM at all (stopped-package broadcast exclusion) — must use `am kill` to test killed-app push. -- **Fix (R10, working tree):** `MainActivity` wraps the icon scale in `runCatching` (best-effort) and the view fade in - `runCatching { … }.onFailure { provider.remove() }` so the splash is **always** removed and onCreate **never** crashes. -- **Verified live:** real FCM notification → killed (`am kill`) Closer2 → tapped the OS notification → cold-start logs - `Icon: view: null` then `remove starting view`, **0 FATAL, process stays alive, lands on Home** (was the crash). - Normal launcher cold-start still animates + works. - -## Notification deep-link routing — SINGLE mechanism (do NOT reintroduce a second one) -**Invariant:** an app-posted notification carries the resolved route in **one** place — the `app_route` **extra** — -and routing is `MainActivity.deepLinkRouteFromIntent` → `pendingDeepLink` → `AppNavigation` `navigateRoute`. Do **not** -also set an `ACTION_VIEW` + `closer://` **data Uri** on the notification intent: for routes that have a `navDeepLink` -(conversation / answer_reveal / daily_question / question_thread / home / play) the NavController auto-handles that Uri -**in addition** to `pendingDeepLink` → a race/duplicate nav. That dual path is what kept re-breaking notifications. -- **Why it broke "again" (root cause, traced via git):** `aaab768`/`1b9d8cf`/`b9b1560` built routing on the - `closer://` **data Uri** (NavController auto-handle) + a `pendingDeepLink` gated on **`currentRoute == HOME`**; - then `38fdc6d` added the `app_route` extra **on top** without removing the data Uri → two mechanisms for the same - tap. The HOME-only gate also meant a **warm** tap from any non-Home tab set `pendingDeepLink` but never consumed it. -- **Fix (R10, working tree):** `PartnerNotificationManager.showNotification` no longer sets `ACTION_VIEW`/data Uri — - `app_route` extra only. `AppNavigation` pendingDeepLink gate broadened from `== HOME` to `!in entryRoutes` (fires once - past onboarding, on any main screen). **Verified live (0 FATAL):** killed-app tap → chat opens the conversation; all - 4 game **results** pushes (`partner_finished_game`) load the real per-session results (wheel "Here's how you each - answered" · This-or-That "5/5 in sync" · How Well "Perfect read 5/5" · Desire Sync "5 shared desires"); app_route-only - path (no Uri) loads results; **warm tap from Settings now routes** (was the stuck case). - -## Round history (one line each) -- **R14 (2026-06-27) — full fresh A–J run (pure QA, no code), FLAWLESS, 0 new findings.** Confirmation round on the R13 build: A premium enforcement audit + couple-shared unlock + entitlement push live; B 3 async games full 2-device + first-finisher nudge + Memory Lane/CC/Date Match core; C decoupled-theme-art mandate; D cornerstone live (403s + enc:v1:); E triggers/copy live; F offline + process-death; I jank 5.25%; J 48dp holds. 0 FATAL both emulators. The 5 R13 fixes held → pruned to the archived line. -- **R13 (2026-06-27) — open-backlog fix pass + full fresh A–J, FLAWLESS (0 open P0–P3).** Fixed all 5 carried/open UI items (C-DARK-UI-001 ToT dark redesign · C-DARK-UI-002 check-in label · C-DARK-UI-003 bottom insets · C-ART-EDGE-002 8 opaque heroes feathered · J-OBS 48dp targets), confirmed A-201 live → pruned, and shipped the branding **Premium-unlock modal** (one-time, both partners, couple-shared). A–J: D security cornerstone re-verified LIVE (non-member 403, self-grant 403, at-rest `enc:v1:`); premium gates → Paywall; ToT both themes; jank 6.43%. Diff UI-only → E/F/G carried. 0 FATAL both emulators. App changes in working tree (user commits). -- **R12 (2026-06-27) — FRESH FULL A–J run + fix phase, FLAWLESS (0 open P0–P2).** Found **A-201 (P1): Date Match - premium ideas ungated** (free users could like/match ★Premium ideas — `getDateIdeas()=all`, no checker, badge only; - escaped prior Pass A rounds) → **fixed + verified live** (gated LOVE/MAYBE via `CouplePremiumChecker`→Paywall, SKIP - passes). Pass B: all 4 async games full 2-device E2E (ToT/Wheel/HowWell/DesireSync) + first-finisher nudge + C-NAV-002 - + Ready=Start re-verified live. Pass D LIVE clean: non-member 403 (read+write), self-grant→403, game answers enc:v1:. - Pass E smoke 6/6. Pass I jank 4.10% (art change perf-safe). New P3 C-ART-EDGE-002 (direct-call hero hard edges, - deferred). C-DARKART-001+C-ART-EDGE-001 (R11) held → pruned. Retrospective added to Pass A (badge≠gate; try to USE - premium content as a free user). Fixes in working tree (user commits). -- **R11 (2026-06-27) — confirmation round, FLAWLESS (0 open P0–P2).** Fixed the last open P2 **C-DARKART-001** (dark-mode - art now follows the in-app theme: `LocalAppInDarkTheme` CompositionLocal in `CloserTheme` → `BrandIllustration` loads the - `-night` drawable via a `createConfigurationContext` whose `UI_MODE_NIGHT_*` comes from the app theme, not the system) and - the open P3 **C-ART-EDGE-001** (tiled art feathers its 4 edges to transparent via `graphicsLayer{Offscreen}` + - `BlendMode.DstIn` gradients instead of hard `clip`+`border`; `EmptyState` now routes through `BrandIllustration`). Verified - **live both decoupled theme directions** (5554 system-light+app-Dark → dark aubergine art; 5556 system-dark+app-Light → - light pastel art; both feathered), 0 FATAL, both apps alive. Re-confirmed + pruned the 5 R10 P2 fixes (C-HOME-001 single - Home card · C-NAV-002 wheel-back `popUpTo` present · C-NAV-003 single app bar live · C-PW-001 dark paywall pills legible - live · C-SEC-001 recovery row active for accepter live). Entrypoint launch-integrity smoke green on the fresh APK (launcher - + notification cold-starts open & stay — splash-crash class clean). Art fixes in working tree; everything else committed - (`2cd0af6`). -- **E-GAME-003 (2026-06-27) — FIXED+VERIFIED+DEPLOYED: async-game first-finisher left the waiting partner un-notified.** - Async games (this_or_that/wheel/how_well/desire_sync) write answers to `couples/{c}/{gameType}/{sessionId}` and the - session only flips to `completed` when BOTH answer — so `onGameSessionUpdate` (watches the session doc) never fired on - a single finish, and the waiting partner got nothing ("Closer2 finished a game but the partner was never notified"). - Fix = new Cloud Function **`onGamePartFinished`** (trigger on the answer doc; on exactly-1 answer, idempotently claim - `partFinishNotifiedAt` on the session + send `partner_completed_part` "X finished their part — your turn to play!"). - Verified live: QA finished ToT part → session `partFinishNotifiedAt=true`, Sam queue got 1 `partner_completed_part`, - posted on Sam's device, tap → opened ToT, 0 FATAL. Deployed (`onGamePartFinished` created, `onGameSessionUpdate` - updated). Funcs source uncommitted (user commits). -- **R10 (2026-06-26) — FULL ClaudeQAPlan run A–J + fix phase.** Found 5 P2 in report-only passes, fixed + verified all live: C-HOME-001 (Home dup pending card), C-NAV-002 (wheel results→BACK re-entered finished session), C-NAV-003 (duplicate app bar on Wheel History/PartnerHome), C-PW-001 (dark paywall pills light-on-light), C-SEC-001 (Security read wrong recovery-phrase store → accepter couldn't view phrase; E2EE recovery itself sound). E-GAME-002 confirmed live (startNotifiedAt set + partner_started_game→right partner + foreground banner + Join→joined active ToT) → pruned. D1–D7 security clean (non-member denied all raw-API reads/writes, no self-grant, secure-subdoc gate correct, argon2id+AAD=coupleId). Concurrency double-start→1 session. Perf jank 5.53% / a11y font-2.0 reflows — no regression. Build OK, both emulators reinstalled, 0 FATAL, content still `enc:v1:`. App fixes in working tree (user commits). -- **Notif→game fix + dark art + QA sweep (2026-06-26, uncommitted).** **E-GAME-001 (P1, FIXED+VERIFIED):** game notifications "led nowhere" — backgrounded/warm taps landed on Home (MainActivity was standard launch mode → `onNewIntent` never delivered the tap's extras → `pendingDeepLink` unset), and even when routed, the game screen showed *setup* instead of joining (one-shot `getActiveSessionForCouple` raced the post-push Firestore sync → returned stale-empty). Fixes: `AndroidManifest` `MainActivity launchMode=singleTop` + `QuestionSessionRepositoryImpl.getActiveSessionForCouple` now SERVER-first (cache fallback). **Verified live:** Sam backgrounded → taps partner_started_game → lands IN the active This-or-That (1/10), joined, no duplicate session; back-stack sane (game→back→Home→back→exit, C-NAV-001 holds). Generic across game types (shared routing + getActiveSession). **Dark-theme art:** 12 `_dark` variants → `drawable-night-nodpi/` (light names) so dark mode auto-swaps; verified live (Security shows the aubergine variant on dark; light unchanged). **QA sweep:** tabs both themes, deep-link back-stack, all 12 illustrations both themes — **0 FATAL**, baseline intact. -- **Brand art drop (2026-06-26) — wired + QA-swept, 0 issues.** All 11 generated illustrations (A1–A12, 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 P0–P2. -- **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 P0–P2. -- **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. -- **R1–R4** — baseline Passes A–F report-only; every P0–P2 found was fixed + verified (see archived IDs). - -## Operational constants -- **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. -- **Hardening backlog → Future.md:** App Check not enforced on Firestore. _(Correction R15: the `users/{uid}` update rule is NOT open — it enforces a **field allowlist** (`firestore.rules` ~L198, `hasOnly([...])`); R15 extended it for `quietHours*`+`timezone`. Keep that list in sync with `FirestoreUserDataSource` when adding a client-written field.)_ diff --git a/ClaudeiOSPlan.md b/ClaudeiOSPlan.md deleted file mode 100644 index a99083df..00000000 --- a/ClaudeiOSPlan.md +++ /dev/null @@ -1,140 +0,0 @@ -# Claude iOS Plan — Part 2 (build to parity) + Part 3 (iOS QA) - -> Program: **Part 1** = Android QA (`ClaudeQAPlan.md`, in progress) → **Part 2** = build the iOS app to Android's -> CURRENT state → **Part 3** = run the same QA passes on iOS. This doc covers Parts 2 & 3. - -## Decisions (locked) -- **Approach:** native **SwiftUI** + Firebase iOS SDK (the `iphone/` scaffold already chose this: SwiftPM + XcodeGen, - feature dirs mirroring Android, `ARCHITECTURE_AUDIT.md`). Do NOT switch to KMP/Compose-MP. -- **E2EE:** **full Tink-compatible** crypto — iOS must byte-match Android's wire formats so an **iOS↔Android couple - decrypts each other**. (Overrides the audit's "skip E2EE for MVP" note — encryption is the cornerstone.) -- **Scope:** **working parity build** (Simulator + real device, TestFlight optional). **No App Store submission.** - -## ⚠️ Hard constraint — macOS required (we are on Linux) -iOS build/run/QA needs **macOS + Xcode + iOS Simulator**; there is **no adb-equivalent from Linux**. Therefore: -- **On this Linux box** I can only: author/refresh Swift source, refresh the audit, write the plan/specs, and reason - about the code. **I cannot compile, run the Simulator, or do interactive/visual QA here.** -- **Everything that requires building or running is deferred to a Mac** (a Mac, cloud-Mac, or macOS CI runner). -- Part 2 done-on-Linux = "code authored + self-reviewed"; Part 2 *truly* done = builds green in Xcode and runs on - Simulator + device (on a Mac). **Part 3 (iOS QA) is entirely macOS-gated.** - -## Current iOS state (as found) -`iphone/` = scaffold only: ~19 stub Swift files, `Crypto/` **empty**, most feature dirs empty, one `FirestoreService`. -`ARCHITECTURE_AUDIT.md` is thorough but generated from Android **v0.2.0** — it **predates** Messages/conversations, -chat media (photo/GIF/sticker/voice), reactions/typing/read-receipts/pagination, and couple-shared premium (A-001). -So this is a near-full build, not a top-up. - ---- - -## Part 2 — Build iOS to current Android parity - -### 2.0 Refresh the audit to CURRENT Android (do first) -Update `iphone/ARCHITECTURE_AUDIT.md` from the live Android code: add the **Messages** tab (conversations inbox + -conversation chat: text/photo/GIF/sticker/voice, reactions, delete/tombstone, typing, read-receipts, pagination, -chat-head bubble), the conversations Firestore model, the new notification types, and **couple-shared premium** -(`CouplePremiumChecker` — either partner premium unlocks all features for both). Re-tally screens/models/functions. - -### 2.1 Project + dependencies (Mac) -XcodeGen/`project.yml`; SPM: Firebase Auth/Firestore/Functions/Messaging/Storage, RevenueCat (`purchases-ios`), -GoogleSignIn; `GoogleService-Info.plist`; entitlements (push, keychain); **App Check** via DeviceCheck/App Attest -(Android uses Play Integrity); APNs setup for FCM. - -### 2.2 Core infrastructure -`AppDependencies` (manual DI), `AuthService` + `AuthRateLimiter` (mirror Android limits), `FirestoreService` + -per-domain services (User/Couple/Invite/Question/Answer/Game/Date/Conversation), `NotificationService` -(APNs+FCM + deep-link routing mirroring `PartnerNotificationType`), navigation (TabView + NavigationStack per tab, -`AppRoute` mirror, deep-link handler), `CloserTheme` (light+dark), reusable components. - -### 2.3 E2EE — full Tink-compatible crypto (HIGHEST RISK; cornerstone) -Implement byte-compatible Swift crypto for every Android wire format: -- `SealedAnswerEncryptor` (AES-256-GCM, 96-bit IV, AAD `"{coupleId}|{questionId}|{userId}"`, `sealed:v1:`), - `FieldEncryptor` (`enc:v1:`, AAD=coupleId) — used by messages/previews/dates **and now profile metadata - (`displayName` + `sex` in `users/{uid}`)**, - `AnswerCommitment` (SHA-256, `sha256:`), `UserKeyManager` (ECIES P-256 HKDF-HMAC-SHA256 AES128-CTR-HMAC, keypair in - Keychain, `pub:v1:`), `ReleaseKeyEncryptor` (`keybox:v1:`), `RecoveryKeyManager` (**Argon2id m=46MiB, t=3, p=1**, - BIP39-style wordlist), `CoupleEncryptionManager`, Keychain-backed key stores. -- **Interop test harness (the acceptance gate):** (a) decrypt Android-produced fixtures (`sealed:v1:`/`enc:v1:`/ - `keybox:v1:`) on iOS; (b) have Android decrypt iOS-produced ciphertext; (c) verify an Android-generated recovery - phrase unlocks on iOS and vice versa. Wire formats must match byte-for-byte. If a format can't be matched with - CryptoKit, use a vetted Swift lib (e.g. swift-sodium/SwiftArgon2) — never a non-interoperable shortcut. -- **Golden vectors** for the deterministic primitives (checked into both repos): `AnswerCommitment` SHA-256 and - `RecoveryKeyManager` Argon2id (fixed salt/params) must produce **identical bytes** on both platforms — assert in unit - tests on each side. AEAD/ECIES use random IVs so they can't be golden-matched; cover those with the **round-trip** - harness above. Generate the Android fixtures now (on Linux) so iOS has them ready. -- **⛔ Profile-metadata decrypt-on-read (REQUIRED before iOS launch — R22).** Android now encrypts `displayName` + - `sex` under the couple key. iOS reads these raw today ([HomeViews](iphone/Closer/Home/HomeViews.swift), - [SettingsViews](iphone/Closer/Settings/SettingsViews.swift)) and will show `enc:v1:…` until it decrypts them on - read (tolerant of legacy plaintext; show a 🔒 placeholder when the key is missing) — mirror Android's - `FirestoreUserDataSource` chokepoint + the pairing/legacy **migration** and **unpair-revert**. Until this ships, - iOS shows the locked placeholder for name/gender (acceptable in dev; **not** for release). -- **⛔ Date reflections (REQUIRED before iOS launch — R22).** The new Date Memories & Replay feature adds an E2EE - `couples/{id}/date_reflections/{dateId}/answers/{uid}` collection with the same couple-key + read-gated - `secure/payload` reveal as the daily question (`date_history` is plaintext). iOS must implement the matching - reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` / - `date_logged` notification types; until then iOS can't participate in date reflections. -- **⛔ Conversation backup + full partner-assisted restore (REQUIRED before iOS launch — R24).** Android now keeps a - couple-key-encrypted conversation backup (`couples/{id}/backup/manifest` + `.../chunks/{seq}` `enc:v1:`; snapshot - blob at Storage `users/{uid}/backups/{id}`) and a partner-assist flow (`couples/{id}/restore_requests/{uid}` with - a fresh `pub:v1:` + partner-written `keybox:v1:`; ECIES context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; - 6-digit OOB code = `truncate6(SHA-256(pubkey‖nonce))`). For **cross-platform** restore, iOS must byte-match the - `BackupCodec` JSON envelope, the couple-key wrap, and the keybox context/code so an iOS device can restore (self or - partner-assisted) from an Android partner and vice-versa — same class as the existing E2EE interop gate. Also wire - the `restore_requested` notification type. Until then iOS can't back up/restore or help a partner restore. -- **⛔ Partner-assist consent hardening (R24-b).** iOS's consent screen must mirror Android: resolve the recipient - via the user service and show their **email (plaintext anchor) + locally-decrypted display name**, and gate - Approve on **both the 6-digit code AND an explicit "I reached them" confirmation**. Mirror the two lifecycle - fixes — **delete any existing `restore_requests/{uid}` before re-creating** (a `set()` over an existing doc is a - rule-denied key-changing update) and **reject an expired request at fulfil**. Handle the new `restore_self_alert` - notification type (route to account security). The server (`onRestoreRequested`/`onRestoreFulfilled`) is shared, - so this is purely the iOS client half. - -### 2.4 Screens & features to parity (~48 + new messaging) -All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation -with full media/reactions/typing/read-receipts/pagination) and the games (full, not stubs), dates, wheel, settings, -paywall. Mirror the Android UX + the couple-shared premium gating (route gated features through the iOS -`EntitlementChecker` couple-shared equivalent). - -### 2.5 Build & smoke (Mac) -Xcode build green; run on Simulator + a real device; smoke each major flow (auth → pair → home/today/play/messages/ -settings) with no crashes. **This step + 2.1/2.5 runs on a Mac.** - ---- - -## Part 3 — iOS QA (reuse `ClaudeQAPlan.md` passes A–E on iOS) -Same methodology, severity scale (P0–P3), report-only→fix→re-QA-until-flawless loop, continuity (use -`ClaudeReport-iOS.md` + `ClaudeQACoverage-iOS.md` + run-state header), and **autonomous run-to-completion** mode. -**macOS-gated.** Tooling adaptation from Android: - -| Android (Part 1) | iOS (Part 3) | -|---|---| -| `adb` install/tap/screencap | `xcrun simctl` install/io screenshot; XCUITest or `idb` to drive taps | -| 2 emulators (5554/5556) | 2 iOS Simulators (or Simulator + device) for the two partners | -| `adb logcat` FATAL/ANR | `xcrun simctl spawn booted log stream` / Console crash logs | -| `cmd uimode night` for theme | Simulator Appearance (light/dark) per device | -| premium toggle via `set_premium.js` | same admin script (server-side) | - -Run all passes on iOS: -- **A** couple-shared premium (gates + paywall), **B** each game one full playthrough on both, **C** visual - light+dark all screens + navigation-from-every-entry + back-stack/double-back, **D** security/encryption, **E** - notifications (APNs/FCM delivery + tap-to-open every type). -- **NEW — cross-platform pass (X):** a **mixed couple (Android + iOS)** — messaging, answers, dates, premium, and - notifications work cross-platform, and **E2EE decrypts both ways** (the Part 2 interop gate, verified live on real - paired devices). This is the make-or-break for the cornerstone. -- **iOS-native dimensions (add to the passes):** Dynamic Type (largest sizes), **VoiceOver**, safe-area / notch / - Dynamic Island, multiple device sizes (SE → Pro Max; iPad if supported), **edge-swipe-back gesture** + interactive - pop (the iOS "double-back"/nav-stack analog), `scenePhase` background/foreground, dark/light. -- **Real-device / sandbox needs:** **App Attest/DeviceCheck** and **APNs push** don't fully work on the Simulator — - use a real device (or `xcrun simctl push` with a payload for local notif routing); **RevenueCat IAP** needs a - StoreKit config file or a sandbox Apple ID. Plan Pass A/E around this. - -## Definition of done -- **Part 2:** iOS builds green + runs on Simulator + device at feature parity with current Android; E2EE interop - harness passes (Android↔iOS decrypt both ways). -- **Part 3:** an iOS QA round is flawless (zero P0–P2, D+E clean, every game fully played, nav/back verified) AND the - cross-platform pass X is clean. - -## What can proceed now (Linux) vs needs a Mac -- **Now (Linux):** 2.0 audit refresh; author Swift for models/services/crypto/screens; write iOS QA tooling scripts; - prepare Android-produced crypto fixtures for the interop harness. -- **Needs a Mac:** 2.1 project/deps, all compiling, 2.5 build+run, and **all of Part 3**. Surface this as the blocker - when execution reaches it (per the autonomous-mode rule: do all non-gated work first, then flag the Mac requirement). diff --git a/README.md b/README.md index 6bf9b6bc..457c2928 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@

+

+ Android + iOS + Firebase + RevenueCat + License +

+ --- Closer is a native relationship app for couples who want a quieter way to check in with each other. @@ -40,7 +48,7 @@ likes, followers, or infinite-scroll loops. The product loop is small on purpose answer honestly -> reveal together -> keep the conversation going ``` -## Screenshots +

Screenshots

Fresh Android dark-mode captures from the current emulator build. @@ -72,7 +80,7 @@ Fresh Android dark-mode captures from the current emulator build. -## What makes it different +

What makes it different

@@ -87,7 +95,7 @@ Fresh Android dark-mode captures from the current emulator build.
-## Product surface +

Product surface

| Area | Free | Premium | | --- | --- | --- | @@ -107,7 +115,7 @@ Fresh Android dark-mode captures from the current emulator build. Data export is not currently offered. The in-app privacy copy intentionally says so until a real export flow exists. -## Platform status +

Platform status

| Platform | Status | Notes | | --- | --- | --- | @@ -119,7 +127,7 @@ flow exists. Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration, and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability. -## Architecture +

Architecture

```text Android (Kotlin/Compose) iOS (SwiftUI) @@ -141,7 +149,7 @@ Core rules: - Server-verified premium. Clients observe entitlements; they do not self-grant access. - No anonymous auth. Accounts use email/password or Google sign-in. -## Security and privacy +

Security and privacy

- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history on-device before sync. @@ -154,7 +162,7 @@ Core rules: The canonical technical reference is [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md). -## Local development +

Local development

Prerequisites @@ -228,22 +236,21 @@ npm run serve
-## QA and release +

QA and release

The repo keeps repeatable QA checks close to the code: | Check | Purpose | | --- | --- | -| [qa/README.md](qa/README.md) | Ava + Ben emulator fixtures and cold-start smoke notes | | `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions | | `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks | | [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist | | [docs/release/](docs/release/) | Internal testing, store assets, and release prep | -Current Ava + Ben emulator sessions are documented in [qa/README.md](qa/README.md). Do not clear those -app sessions unless you are intentionally rebuilding the fixture. +Live emulator fixtures and related credentials are intentionally local-only. Keep them in gitignored +files under `qa/`, never in the tracked README or public docs. -## Repository map +

Repository map

```text app/ Android app @@ -252,12 +259,12 @@ functions/ Firebase Cloud Functions server/ Optional Express service seed/ Question content and seed tooling scripts/ Static QA scanners -qa/ Re-runnable emulator QA notes and scripts +qa/ Local-only emulator fixture notes and smoke helpers docs/ Architecture, release, screenshots, and QA docs firestore.rules Firestore rules source of truth ``` -## Roadmap +

Roadmap

In progress: @@ -273,12 +280,11 @@ Out of scope for now: - Wear OS / watchOS companions - Live video or voice sessions -## Project docs +

Project docs

| Doc | Purpose | | --- | --- | | [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines | -| [qa/README.md](qa/README.md) | Emulator fixtures and autonomous QA smokes | | [docs/brand/](docs/brand/) | Visual identity, asset system, generated art | | [docs/release/](docs/release/) | Release prep and store assets | | [docs/qa/](docs/qa/) | Manual QA checklist | @@ -286,6 +292,6 @@ Out of scope for now: | [HISTORY.md](HISTORY.md) | Changelog and release notes | | [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions | -## License +

License

Private project. All rights reserved. diff --git a/qa/README.md b/qa/README.md deleted file mode 100644 index c4e0c87b..00000000 --- a/qa/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# qa/ — re-runnable QA smokes (autonomous) - -Durable, committed QA tooling so each round re-checks the fragile paths cheaply instead of by hand. -See `ClaudeQAPlan.md` (Pass E crash-triage, Pass F cold-start ownership, Definition of Done) for how these fit the run. - -## entrypoint_smoke.sh — cold-start / entry-point launch integrity -The smoke that catches the **"app opens and immediately closes"** class (e.g. the splash-exit `iconView` NPE on a -notification cold-start). That class breaks **every** notification at once (shared cold-start path) and is **invisible to -`adb am start`** — only a REAL push tapped from the shade on a genuinely killed app reproduces it. - -```bash -# both emulators; run after ANY change to MainActivity / splash / theme / manifest / nav / notifications, and every round -bash qa/entrypoint_smoke.sh emulator-5554 Y05AKO2IlTPMa0JQW1BiNIM0uzK2 -bash qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2 -``` -Asserts each entry (launcher icon · each notification type tapped from a killed app · …) **opens AND stays** (process -alive, 0 FATAL, off the launcher). `PASS`/`FAIL`/`BLOCK`: -- **FAIL** = a real app crash (the bug). Pull the stack: `adb -s logcat -d | grep -E "FATAL EXCEPTION|getIconView"`. -- **BLOCK** = the push didn't reach the killed app (flaky emulator FCM / FcmRetry) — environmental, just rerun. -- Runtime ~3–7 min (FCM retries); run in the background and read the matrix. Exit 0 only if 0 FAIL. - -## qa_push.js — send a real push (helper) -Sends a faithful `notification`+`data` FCM to a user's token so a killed app shows it and tapping cold-starts via the -real OS handover. **Use `am kill` (NOT `am force-stop`)** to kill — force-stopped apps are excluded from FCM. - -```bash -NODE_PATH=functions/node_modules node qa/qa_push.js partner_started_game game_type=this_or_that -NODE_PATH=functions/node_modules node qa/qa_push.js partner_finished_game game_type=wheel # auto-resolves a completed session -NODE_PATH=functions/node_modules node qa/qa_push.js chat_message conversation_id=main -``` - -## Test couple (current emulators) -- coupleId `Xal3Kw3gjSdn0niERYKJ` -- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2` -- Admin SA JSON: `~/.config/closer/closer-admin-sa.json` (kept OUTSIDE the repo; override path via `SA_JSON`). For tools using Application Default Credentials: `export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json`. - -## Fixture accounts & passwords -Live test-account emails, uids, AVD mapping, and passwords are kept OUT of this repo in the -gitignored `qa/fixtures.local.md` (dev-project throwaway creds). Ask the operator or read that -local file. Never commit credentials. diff --git a/qa/entrypoint_smoke.sh b/qa/entrypoint_smoke.sh index b57ed208..67119639 100755 --- a/qa/entrypoint_smoke.sh +++ b/qa/entrypoint_smoke.sh @@ -12,8 +12,8 @@ # Emulator FCM-to-killed-app is flaky (FcmRetry); undeliverable cases are reported BLOCKED, not FAIL — # only an actual open-and-close / crash is a FAIL. # -# Usage: qa/entrypoint_smoke.sh [recipient_uid] -# qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2 +# Usage: qa/entrypoint_smoke.sh +# qa/entrypoint_smoke.sh emulator-5556 "$QA_RECIPIENT_UID" # Env overrides: PKG, SA_JSON, COUPLE_ID. # Runtime: ~3–7 min (flaky emulator FCM adds retries). Run in the background and read the matrix. # Exit 0 only if zero FAIL (BLOCK = undeliverable push, environmental — rerun, not an app bug). diff --git a/qa/qa_push.js b/qa/qa_push.js index abf68c2b..6c4fea9b 100644 --- a/qa/qa_push.js +++ b/qa/qa_push.js @@ -10,7 +10,7 @@ // node qa/qa_push.js partner_finished_game game_type=wheel # auto-resolves a completed session // node qa/qa_push.js chat_message conversation_id=main // node qa/qa_push.js partner_answered -// Env overrides: SA_JSON, COUPLE_ID. +// Env: SA_JSON, COUPLE_ID. const admin = require('firebase-admin') const path = require('path') const os = require('os') @@ -19,7 +19,11 @@ const os = require('os') // working tree). Override with SA_JSON if yours is elsewhere. const SA = process.env.SA_JSON || path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json') -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' +const COUPLE = process.env.COUPLE_ID +if (!COUPLE) { + console.error('COUPLE_ID is required. Keep fixture IDs in a local, gitignored env/file.') + process.exit(2) +} admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) const db = admin.firestore() diff --git a/scratchpad/d1_atrest.js b/scratchpad/d1_atrest.js deleted file mode 100644 index aa1b7561..00000000 --- a/scratchpad/d1_atrest.js +++ /dev/null @@ -1,59 +0,0 @@ -// D1 at-rest ciphertext verifier (READ-ONLY). Prints only field -> enc✓ / NOT-ENC + length. -// NEVER prints decrypted/plaintext content (per the QA logging rule). -const admin = require('firebase-admin') -const path = require('path') -const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' -admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) -const db = admin.firestore() - -const CIPHER = /^(enc:v1:|sealed:v1:)/ -// fields that MUST be ciphertext if present -const PRIVATE_FIELDS = ['text','content','lastMessagePreview','title','description','duration','note','message','answer','body','preview'] - -function checkVal(v) { - if (typeof v !== 'string') return null - if (CIPHER.test(v)) return `enc✓(${v.length})` - // base64 Tink media or short metadata? flag anything that isn't enc/sealed - return `NOT-ENC?(len=${v.length})` -} - -async function sampleColl(ref, label, limit = 3) { - try { - const snap = await ref.limit(limit).get() - if (snap.empty) { console.log(` [${label}] empty`); return } - let i = 0 - snap.forEach(doc => { - if (i++ >= limit) return - const d = doc.data() - const hits = [] - for (const f of PRIVATE_FIELDS) { - if (d[f] !== undefined) hits.push(`${f}=${checkVal(d[f])}`) - } - console.log(` [${label}] ${doc.id}: ${hits.length ? hits.join(' ') : '(no private fields)'}`) - }) - } catch (e) { console.log(` [${label}] ERR ${e.code || e.message}`) } -} - -(async () => { - console.log('=== D1 at-rest check, couple', COUPLE, '===') - // 1) couple doc subcollections - const coupleRef = db.collection('couples').doc(COUPLE) - const subs = await coupleRef.listCollections().catch(() => []) - console.log('couple subcollections:', subs.map(s => s.id).join(', ') || '(none)') - for (const s of subs) await sampleColl(s, `couples/${COUPLE}/${s.id}`) - // 2) common top-level collections keyed by coupleId - for (const c of ['messages','conversations','answers','bucket_list','date_plans','date_swipes','memory_capsules','capsules','date_plan_preferences','game_sessions']) { - const ref = db.collection(c).where('coupleId','==',COUPLE) - await sampleColl(ref, c) - } - // 3) conversations -> messages (nested) - try { - const convs = await db.collection('conversations').where('coupleId','==',COUPLE).limit(2).get() - for (const cv of convs.docs) { - await sampleColl(cv.ref.collection('messages'), `conversations/${cv.id}/messages`) - } - } catch (e) { console.log(' conv->messages ERR', e.code || e.message) } - console.log('=== done ===') - process.exit(0) -})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/scratchpad/d1_probe2.js b/scratchpad/d1_probe2.js deleted file mode 100644 index e346cdd0..00000000 --- a/scratchpad/d1_probe2.js +++ /dev/null @@ -1,42 +0,0 @@ -// D1 deep probe: dump FIELD NAMES + per-string enc-status/length (NEVER values). -const admin = require('firebase-admin') -const path = require('path') -const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' -admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) -const db = admin.firestore() -const CIPHER = /^(enc:v1:|sealed:v1:)/ -const MEDIA = /^(01|AAAA|CiQ|Ei)/ // tink/base64-ish prefixes seen for media - -function fieldReport(d) { - return Object.entries(d).map(([k, v]) => { - if (typeof v === 'string') { - const tag = CIPHER.test(v) ? 'enc✓' : (v.length > 40 ? `RAWSTR?(${v.length})` : `meta(${v.length})`) - return `${k}:${tag}` - } - if (Array.isArray(v)) return `${k}:[${v.length}]${v.every(x => typeof x === 'string' && CIPHER.test(x)) && v.length ? 'enc✓' : ''}` - if (v && typeof v === 'object') return `${k}:{${Object.keys(v).join(',')}}` - return `${k}:${typeof v}` - }).join(' ') -} - -async function dump(ref, label, n = 2) { - try { - const s = await ref.limit(n).get() - if (s.empty) return console.log(`[${label}] empty`) - s.docs.slice(0, n).forEach(doc => console.log(`[${label}] ${doc.id}\n ${fieldReport(doc.data())}`)) - } catch (e) { console.log(`[${label}] ERR ${e.code || e.message}`) } -} - -(async () => { - const c = db.collection('couples').doc(COUPLE) - // messages nested under each conversation - const convs = await c.collection('conversations').limit(3).get() - for (const cv of convs.docs) { - await dump(cv.ref.collection('messages'), `conv:${cv.id}/messages`, 2) - } - for (const g of ['this_or_that','desire_sync','how_well','wheel','date_swipes','sessions']) { - await dump(c.collection(g), g, 1) - } - process.exit(0) -})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/scratchpad/d1_probe3.js b/scratchpad/d1_probe3.js deleted file mode 100644 index 37b79644..00000000 --- a/scratchpad/d1_probe3.js +++ /dev/null @@ -1,33 +0,0 @@ -// D1 nested-answer probe: for game answers map, report each uid's answer value enc-status (NEVER values). -const admin = require('firebase-admin') -const path = require('path') -const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' -admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) -const db = admin.firestore() -const CIPHER = /^(enc:v1:|sealed:v1:)/ - -function classify(v) { - if (typeof v === 'string') return CIPHER.test(v) ? `enc✓(${v.length})` : `RAW?(${v.length})` - if (Array.isArray(v)) return `[${v.length}: ${v.map(classify).join(',')}]` - if (v && typeof v === 'object') return '{' + Object.entries(v).map(([k, x]) => `${k}=${classify(x)}`).join(' ') + '}' - return typeof v -} - -async function probe(coll, mapField) { - const c = db.collection('couples').doc(COUPLE) - const s = await c.collection(coll).limit(1).get() - if (s.empty) return console.log(`[${coll}] empty`) - const d = s.docs[0].data() - const m = d[mapField] - if (!m) return console.log(`[${coll}] no ${mapField}`) - for (const [uid, val] of Object.entries(m)) { - console.log(`[${coll}] ${mapField}.${uid.slice(0, 6)}…: ${classify(val)}`) - } -} - -(async () => { - for (const g of ['this_or_that', 'desire_sync', 'how_well', 'wheel']) await probe(g, 'answers') - await probe('date_swipes', 'actions') - process.exit(0) -})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/scratchpad/d3_negative.js b/scratchpad/d3_negative.js deleted file mode 100644 index d831f33e..00000000 --- a/scratchpad/d3_negative.js +++ /dev/null @@ -1,53 +0,0 @@ -// D3 negative access (LIVE): mint a NON-MEMBER token → raw Firestore REST → expect 403/PERMISSION_DENIED. -const admin = require('firebase-admin') -const path = require('path') -const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') -const PROJECT = 'closer-app-22014' -const APIKEY = 'AIzaSyDAD7FnEYzhMsil41SzJ1XMjUNnJWmjie8' -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' -const FAKE_UID = 'qa-nonmember-' + Date.now() -admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) - -const docBase = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents` - -async function getIdToken() { - const custom = await admin.auth().createCustomToken(FAKE_UID) - const r = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${APIKEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: custom, returnSecureToken: true }) - }) - const j = await r.json() - if (!j.idToken) throw new Error('no idToken: ' + JSON.stringify(j)) - return j.idToken -} - -async function tryGet(token, p, label) { - const r = await fetch(`${docBase}/${p}`, { headers: { Authorization: `Bearer ${token}` } }) - const ok = r.status === 200 - const verdict = ok ? '❌❌ LEAK (200)' : `✅ DENIED (${r.status})` - console.log(` ${label}: ${verdict} [${p}]`) - return ok -} -async function trySelfGrant(token, uid) { - const r = await fetch(`${docBase}/users/${uid}/entitlements/premium?updateMask.fieldPaths=active`, { - method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ fields: { active: { booleanValue: true } } }) - }) - console.log(` self-grant premium: ${r.status === 200 ? '❌❌ ALLOWED (200)' : `✅ DENIED (${r.status})`}`) - return r.status === 200 -} - -(async () => { - console.log('=== D3 non-member negative access (uid', FAKE_UID, ') ===') - const tok = await getIdToken() - let leaks = 0 - if (await tryGet(tok, `couples/${COUPLE}`, 'read couple doc')) leaks++ - if (await tryGet(tok, `couples/${COUPLE}/conversations/main/messages`, 'list messages')) leaks++ - if (await tryGet(tok, `couples/${COUPLE}/capsules`, 'list capsules')) leaks++ - if (await tryGet(tok, `couples/${COUPLE}/desire_sync`, 'list desire_sync')) leaks++ - if (await trySelfGrant(tok, FAKE_UID)) leaks++ - // cleanup the throwaway auth user - await admin.auth().deleteUser(FAKE_UID).catch(() => {}) - console.log(leaks === 0 ? '=== D3 PASS: all denied ===' : `=== D3 FAIL: ${leaks} LEAK(S) — P0 ===`) - process.exit(leaks === 0 ? 0 : 1) -})().catch(e => { console.error('FATAL', e.message); process.exit(2) }) diff --git a/scratchpad/last_marker.txt b/scratchpad/last_marker.txt deleted file mode 100644 index 0d6419cc..00000000 --- a/scratchpad/last_marker.txt +++ /dev/null @@ -1 +0,0 @@ -L_R18_pass_1782681418 \ No newline at end of file diff --git a/scratchpad/msg_atrest.js b/scratchpad/msg_atrest.js deleted file mode 100644 index b651e0d3..00000000 --- a/scratchpad/msg_atrest.js +++ /dev/null @@ -1,28 +0,0 @@ -// Pass L / D1: read latest messages in couples/{COUPLE}/conversations/main/messages. -// Print ONLY metadata + enc-status (never decrypted/plaintext content). Also assert a plaintext -// marker does NOT appear anywhere (at-rest leak check). -const admin = require('firebase-admin') -const path = require('path') -const fs = require('fs') -const SA = path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' -const MARKER = (fs.existsSync(path.join(__dirname,'last_marker.txt')) ? fs.readFileSync(path.join(__dirname,'last_marker.txt'),'utf8').trim() : '') -admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) -const db = admin.firestore() -const CIPHER = /^(enc:v1:|sealed:v1:)/ -const enc = v => typeof v==='string' ? (CIPHER.test(v)?`enc✓(${v.length})`:`RAW?(${v.length})`) : typeof v -;(async () => { - const col = db.collection('couples').doc(COUPLE).collection('conversations').doc('main').collection('messages') - const snap = await col.orderBy('createdAt','desc').limit(6).get() - console.log(`marker=${MARKER||'(none)'} latest ${snap.size} messages (newest first):`) - let leak = false - snap.forEach(d => { - const m = d.data() - const ts = m.createdAt && m.createdAt.toDate ? m.createdAt.toDate().toISOString() : m.createdAt - console.log(` ${ts} sender=${(m.senderId||'').slice(0,6)}… text=${enc(m.text)}`) - // leak check across the whole doc JSON - if (MARKER) { const blob = JSON.stringify(m); if (blob.includes(MARKER)) { leak = true; console.log(' ❌❌ PLAINTEXT MARKER LEAK in this doc') } } - }) - if (MARKER) console.log(leak ? '=== LEAK: marker found in plaintext ===' : '=== OK: marker NOT present in plaintext (encrypted or not yet sent) ===') - process.exit(0) -})().catch(e=>{console.error('FATAL',e.message);process.exit(1)}) diff --git a/scratchpad/premium_state.js b/scratchpad/premium_state.js deleted file mode 100644 index 392b1f51..00000000 --- a/scratchpad/premium_state.js +++ /dev/null @@ -1,26 +0,0 @@ -const admin = require('firebase-admin') -const path = require('path') -const SA = path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') -const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' -admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) -const db = admin.firestore() -function shallow(v){ if(typeof v==='string') return v.length>40?`str(${v.length})`:v; if(Array.isArray(v)) return `[${v.length}]`; if(v&&typeof v==='object') return '{'+Object.keys(v).join(',')+'}'; return v } -;(async () => { - const c = await db.collection('couples').doc(COUPLE).get() - const cd = c.data() || {} - console.log('=== couple top-level keys ===') - for (const k of Object.keys(cd)) console.log(` ${k} = ${shallow(cd[k])}`) - // find uid-like values - const uids = new Set() - for (const [k,v] of Object.entries(cd)) { - if (typeof v==='string' && /^[A-Za-z0-9]{20,}$/.test(v)) uids.add(v) - if (Array.isArray(v)) v.forEach(x=>{ if(typeof x==='string'&&/^[A-Za-z0-9]{20,}$/.test(x)) uids.add(x) }) - if (v&&typeof v==='object'&&!Array.isArray(v)) Object.keys(v).forEach(x=>{ if(/^[A-Za-z0-9]{20,}$/.test(x)) uids.add(x) }) - } - console.log('=== candidate member uids ===', [...uids].map(u=>u.slice(0,10)+'…')) - for (const uid of uids) { - const ent = await db.collection('users').doc(uid).collection('entitlements').doc('premium').get() - console.log(` ${uid.slice(0,10)}… entitlements/premium:`, ent.exists?JSON.stringify(ent.data()):'MISSING(free)') - } - process.exit(0) -})().catch(e=>{console.error('FATAL',e.message);process.exit(1)}) diff --git a/scratchpad/qh_mirror.js b/scratchpad/qh_mirror.js deleted file mode 100644 index 2737f1fe..00000000 --- a/scratchpad/qh_mirror.js +++ /dev/null @@ -1,12 +0,0 @@ -const admin = require('firebase-admin'); const path=require('path') -admin.initializeApp({credential:admin.credential.cert(require(path.join(__dirname,'..','closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')))}) -const db=admin.firestore() -const QA='Y05AKO2IlTPMa0JQW1BiNIM0uzK2', SAM='imDjjOTTQv' -;(async()=>{ - for(const uid of [QA]){ - const d=await db.collection('users').doc(uid).get(); const x=d.data()||{} - const keys=Object.keys(x).filter(k=>/quiet|timezone|tz|notif/i.test(k)) - console.log(uid.slice(0,8)+'… quiet/tz keys:', keys.length?JSON.stringify(Object.fromEntries(keys.map(k=>[k,x[k]]))):'(none present)') - } - process.exit(0) -})().catch(e=>{console.error(e.message);process.exit(1)})