feat: quiet hours notifications, settings UI, game session updates, docs

This commit is contained in:
null 2026-06-28 10:00:25 -05:00
parent c31eea2549
commit 37ed7cebec
26 changed files with 649 additions and 77 deletions

View File

@ -3,14 +3,93 @@
Living status document for Closer's brand artwork pass. It records which illustrations/glyphs are live, which surfaces 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. should reuse existing assets, and which brand issues belong in implementation or QA rather than image generation.
**Current state:** no active image-generation prompts remain, and the last two implementation items are now **done **Asset ownership:** Codex is responsible for making all needed images, dark variants, and custom glyphs. Do not leave
(R13, 2026-06-27)**: the **This-or-That gameplay brand redesign** (C-DARK-UI-001) and the **one-time Premium-unlock image work as user-generated prompt handoff; backlog items here are Codex-owned assets to generate, add to the repo, and
modal** (A13) are both implemented + verified live in both themes. A1-A13 illustrations and the G/G2 glyph sets exist verify.
and are wired. No open branding implementation work remains — only ongoing QA verification.
> Branding **defects** (off-brand color, clipped/low-contrast art) → `ClaudeReport.md`. Pure "could be warmer / feature" **Current state (2026-06-27 audit):** the This-or-That redesign (C-DARK-UI-001) + Premium-unlock modal (A13) are done.
> ideas → `Future.md` `## QA`. Only add new prompts here when a future QA pass proves an existing/code-native treatment **But two large brand backlogs are now OPEN** (found by the 2026-06-27 asset audit): (1) **most illustrations are
> cannot carry the brand. light-only** — only 12 of ~25 have a dark variant, and **all 10 `pack_art_*` banners + all `illustration_couple_*`
heroes are light-only**, so they show light/pink art on a dark screen; (2) **~60 distinct generic Material icons** are
used across ~201 call sites (generic hearts, Person, Lock, Star…) — **brand rule: every icon must be a custom Closer
glyph.** See **Brand standards** + the two backlog tables below.
> 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 (auto-selected by
`BrandIllustration` via the in-app-theme config override). Transparent/celebration art that genuinely reads on **both**
themes is the only exemption — **verify it, don't assume.** A surface missing its variant = bug + add the asset below.
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) — backlog: make the missing DARK variants
> Goal: every image row has BOTH a light and a dark asset. Audited 2026-06-27.
| Illustration | Light | Dark | Action |
|---|---|---|---|
| account_deletion_goodbye · answer_history_empty · bucket_list_empty · connection_challenges_header · date_match_empty · date_match_success · memory_lane_capsule · messages_empty · pairing_success · past_games_empty · privacy_recovery · quiet_hours | ✅ | ✅ | none — both exist |
| **illustration_couple_paywall** (Paywall) | ✅ | ❌ | **MAKE DARK** (`drawable-night-nodpi/`) |
| **illustration_couple_subscription** (Subscription) | ✅ | ❌ | **MAKE DARK** |
| **illustration_couple_onboarding** (Onboarding) | ✅ | ❌ | **MAKE DARK** |
| **illustration_couple_invite** (Pairing/invite) | ✅ | ❌ | **MAKE DARK** |
| **illustration_couple_history** | ✅ | ❌ | **MAKE DARK** (or confirm unused) |
| **illustration_daily_question** (Today hero) | ✅ | ❌ | **MAKE DARK** |
| **illustration_partner_activation** (Home) | ✅ | ❌ | **MAKE DARK** |
| **illustration_tonight_partner_prompt** (Home) | ✅ | ❌ | **MAKE DARK** |
| **illustration_together_empty** (Activity) | ✅ | ❌ | **MAKE DARK** |
| **pack_art_*** ×10 (communication, deep_reflection, desire, family_commitment, fun_date, future_goals, home_life, intimacy, money_values, trust_repair) | ✅ | ❌ | **MAKE DARK** for each pack banner (or prove the banner reads correctly on dark) |
| reveal_celebration · streak_milestone · premium_unlock · spin_wheel | ✅ (transparent) | n/a | transparent/celebration — **verify reads on both themes**; add dark only if it doesn't |
**Prompt for the missing dark variants:** regenerate each light asset above in the **dark/aubergine** house palette
(deep aubergine `#24122F` ground, lavender `#B98AF4` / soft-pink `#F7C8E4` accents, blush highlights) so it reads on a
dark surface, **same composition + flat 2D vector style + transparent/feathered edges** as the light version, exported to
`drawable-night-nodpi/` with the **identical filename**. Re-run Pass C's decoupled-theme check after adding each.
## Icon/glyph audit — generic Material icons to replace with custom Closer glyphs (backlog: make these)
> Audited 2026-06-27: **~60 distinct Material icons across ~201 call sites.** Brand rule #2 — each becomes a bespoke
> `glyph_*` in the house style. Existing custom glyphs (reuse/extend, don't regress): the G/G2 set
> (`glyph_paired_cards`, `glyph_how_well`, `glyph_sealed_answer`, `glyph_connection_challenge`, `glyph_memory_capsule`,
> `glyph_date_card_heart`, `glyph_question_packs`, `glyph_bucket_list`, `glyph_past_games`, `glyph_spin_wheel`,
> `glyph_couple_premium`, `glyph_privacy_lock`, `glyph_delete_account`, `glyph_how_well`, + closer_mark/daily_card/
> quiet_hours_moon/export_data). **To make** (high-traffic first):
| Generic icon (≈uses) | Used for | Make custom glyph |
|---|---|---|
| `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). Until a glyph exists, the Material icon is a **placeholder = a logged brand defect**, not acceptable for ship.
--- ---
@ -218,8 +297,14 @@ Legend: ✅ on-brand / no art needed · reuse/wire existing art · 🔤 bran
## Generation Backlog ## Generation Backlog
**None.** Do not regenerate completed illustration or glyph art unless a future QA pass logs a specific defect that **Two OPEN backlogs (2026-06-27 audit) — see the tables up top:**
requires replacement. 1. **Dark illustration variants** — make `drawable-night-nodpi/` versions of every light-only image: all
`illustration_couple_*` heroes, `daily_question`, `partner_activation`, `tonight_partner_prompt`, `together_empty`,
and all 10 `pack_art_*` banners (see **Image theme-variant coverage**).
2. **Custom glyphs to replace ~60 generic Material icons** (see **Icon/glyph audit**) — every icon must be a bespoke
`glyph_*`, starting with the highest-traffic (back, heart, lock, person, check, forward, play, close, star).
Do not regenerate **completed** illustration/glyph art unless a QA pass logs a specific defect; the above are net-new.
**Generated glyph files:** `glyph_closer_mark`, `glyph_paired_cards`, `glyph_daily_card`, `glyph_sealed_answer`, **Generated glyph files:** `glyph_closer_mark`, `glyph_paired_cards`, `glyph_daily_card`, `glyph_sealed_answer`,
`glyph_memory_capsule`, `glyph_date_card_heart`, `glyph_quiet_hours_moon`, `glyph_couple_premium`, `glyph_memory_capsule`, `glyph_date_card_heart`, `glyph_quiet_hours_moon`, `glyph_couple_premium`,

View File

@ -1,7 +1,9 @@
# Claude QA Coverage Matrix # Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> Build `2cd0af6` + R11/R12/R13 working-tree changes (rebuilt + installed both emulators). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: R13 = open-backlog fix pass + full fresh AJ — FLAWLESS, 0 open P0P3.** 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), confirmed **A-201** live → pruned, shipped the **Premium-unlock modal** (one-time, both partners). Pass D cornerstone re-verified LIVE. All app changes in the working tree (user commits); diff is UI-only (no rules/functions/crypto). > Build HEAD `c31eea2` + **R15 working-tree changes** (functions + rules **deployed to prod**; client rebuilt+installed both emulators). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: R15 = gap-closing round (Passes L/M/N/P + smoke) — found & FIXED M-001 (P2 quiet hours).** Quiet hours didn't suppress backgrounded/killed partner pushes (local-only); fixed via server-side fail-open suppression + client window/tz sync + rules allowlist — verified live (fn log suppress vs notify). L (chat E2E render + decrypt + receipts + reactions + at-rest), P (UI copy + 6103-Q bank, clean), N (daily-Q/reveal gate), smoke 6/6 GREEN. **0 open P0P2** (M-001 fixed, pending 1 confirm); 2 P3 brand backlogs open. 0 FATAL. App+functions+rules changes in working tree (user commits); functions+rules already deployed.
>
> **Scope expanded (plan review):** the playbook now has first-class passes **KO** (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).**
> >
> **📖 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. > **📖 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 > Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is
@ -12,16 +14,22 @@
|---|---|---| |---|---|---|
| A — Couple-shared premium | R13: **A-201 confirmed live → pruned** (free QA → Date Match Love ★Premium → Paywall, deck didn't advance) + Desire Sync free→Paywall re-verified; couple-shared unlock holds | ✅ pass (all gates couple-shared incl. Date Match) | | A — Couple-shared premium | R13: **A-201 confirmed live → pruned** (free QA → Date Match Love ★Premium → Paywall, deck didn't advance) + Desire Sync free→Paywall re-verified; couple-shared unlock holds | ✅ pass (all gates couple-shared incl. Date Match) |
| 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) | ✅ pass (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | | 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) | ✅ pass (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) |
| C — Visual (light+dark) | R13: ToT setup+gameplay both themes, Play/Home/Paywall insets, Today/Paywall heroes, conversation, Premium modal both themes — all verified live | ✅ **C-DARK-UI-001/002/003 + C-ART-EDGE-002 all FIXED + verified live R13** (ToT theme-aware redesign · check-in label weight · bottom clearance · 8 opaque heroes feathered); pending 1 confirm | | C — Visual (light+dark) | R13: ToT setup+gameplay both themes, Play/Home/Paywall insets, Today/Paywall heroes, conversation, Premium modal both themes — all verified live | ✅ R13 fixes pruned · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — 2026-06-27 audit: many illustrations light-only (no dark variant); 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 | | 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 | R12 LIVE: Pass B verified start/first-finisher(`partner_completed_part`)/finish triggers→correct partners+copy; cold-start tap smoke **6/6** (launcher + 5 notif types open & stay) | ✅ pass · splash-crash class clean on fresh APK | | E — Notifications | R12 LIVE: Pass B verified start/first-finisher(`partner_completed_part`)/finish triggers→correct partners+copy; cold-start tap smoke **6/6** (launcher + 5 notif types open & stay) | ✅ pass · splash-crash class clean on fresh APK |
| 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 | | 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 | | 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 gameplay brand redesign + Premium-unlock modal (A13) both implemented + verified live; 8 opaque heroes feathered | ✅ see `ClaudeBrandingReview.md` (no open branding implementation work remains) | | 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 | | 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) | | 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:`. Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — text/decrypt/receipts/reactions/inbox/at-rest verified; 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). | ✅ **pass (core)** — M-001 fixed (pending 1 confirm); unpair/delete deferred |
| N — Daily Q / reveal / check-ins / interactive | R15: daily-Q + **reveal both-answered gate** render confirmed live (privacy copy exemplary). Outcomes loop / Bucket List CRUD / Date Builder save / Activity feed render-clean (prior rounds) — not re-driven this round. | ⚠️ **partial** (core render-clean; CRUD loops carry) |
| 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. | ✅ **pass** — copy + question bank clean |
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS. Pending one confirm: **F-RACE-001**. **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. **0 open P0P2; 1 fixed pending confirm (M-001 quiet hours); 2 open P3 brand backlogs.**
--- ---
@ -63,7 +71,7 @@ Note: exit each game via "Back to Play" between games so the session closes (B-0
- **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. - **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**. - **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. - **D4/D5/D6:** wrapped couple key + KDF; App Check (client), gitignored SA JSONs, `allowBackup=false`; analytics metadata-only. Unchanged, hold.
- Two hardening notes → `Future.md` (App Check off on Firestore; `users/{uid}` update rule allows arbitrary non-`hasPremium` fields). - 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) ## Pass E — Notifications (type × {foreground / background / killed} + tap-to-open, both clients)
Full live two-device run (games + messages): Full live two-device run (games + messages):
@ -114,6 +122,8 @@ Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` →
--- ---
## Round history (one line each) ## Round history (one line each)
- **R15** — gap-closing round (Passes L/M/N/P + regression smoke); **found + 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 to `users/{uid}` + rules allowlist; verified live (fn log suppress vs notify; positive control delivers); deployed prod. L chat-core, P copy+question-bank (6103 Qs), N daily-Q/reveal verified clean; smoke 6/6 GREEN. Corrected stale "users/{uid} allows arbitrary fields" claim (there's an allowlist).
- **R14** — full fresh AJ 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 AJ, FLAWLESS (0 open P0P3): 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. - **R13** — open-backlog fix pass + full fresh AJ, FLAWLESS (0 open P0P3): 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 AJ run + fix phase, FLAWLESS (0 open P0P2): 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). - **R12** — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2): 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 P0P2): 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`. - **R11** — confirmation round, FLAWLESS (0 open P0P2): 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`.

View File

@ -26,6 +26,12 @@ For each Pass below, before you start, read the relevant section of [`docs/Engin
| H — Branding & artwork | `ClaudeBrandingReview.md` (this repo) · `docs/brand/visual-identity.md` | | 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) | | 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) | | 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. **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.
@ -40,6 +46,10 @@ For each Pass below, before you start, read the relevant section of [`docs/Engin
- A branding **defect** (mis-colored, clipped, off-brand, low-contrast art) is a **bug → `ClaudeReport.md`**, not a brand - 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`. 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 - **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*: 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 the **defect** (repro/severity/status) → `ClaudeReport.md` (transient — prunes to an ID after one confirm); the
@ -60,7 +70,7 @@ For each Pass below, before you start, read the relevant section of [`docs/Engin
(detail lives in git). `Future.md` ideas sit in the backlog until built. (See **Report hygiene** under Reporting.) (detail lives in git). `Future.md` ideas sit in the backlog until built. (See **Report hygiene** under Reporting.)
## Context ## Context
Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Five QA dimensions: Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Core QA dimensions (cornerstones):
1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**. 1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**.
2. **Games** — each starts, plays, **joins, resumes**, finishes, **and reopens results** correctly on both devices. 2. **Games** — each starts, plays, **joins, resumes**, finishes, **and reopens results** correctly on both devices.
3. **Full visual pass, light + dark** — every screen, text readable, nothing clipped/invisible. 3. **Full visual pass, light + dark** — every screen, text readable, nothing clipped/invisible.
@ -70,6 +80,17 @@ Drive the real app on both emulators, verify each thing live, report, fix, re-ve
deep-links correctly, opens the right destination on **both clients**, covers all **game/join-game** flows, handles deep-links correctly, opens the right destination on **both clients**, covers all **game/join-game** flows, handles
stale notifications, and leaks no private content. 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 Scope decisions: **exhaustive** visual pass (all ~50 screens, both modes); **full scope incl. pre-pairing** flows
(fresh throwaway account); **couple-shared everywhere** — per-user gates are bugs, fixed by routing through (fresh throwaway account); **couple-shared everywhere** — per-user gates are bugs, fixed by routing through
`core/billing/CouplePremiumChecker.kt`; **full notification suite** — every type, game + join-game pushes, deep-links, `core/billing/CouplePremiumChecker.kt`; **full notification suite** — every type, game + join-game pushes, deep-links,
@ -80,7 +101,7 @@ stale-notification handling, and all in-app paths into joining/resuming/results,
confirms + enumerates this; the fix phase applies couple-shared everywhere. confirms + enumerates this; the fix phase applies couple-shared everywhere.
## Execution mode — run to completion (autonomous; do NOT stop) ## Execution mode — run to completion (autonomous; do NOT stop)
- **Do not stop to check in or ask for approval.** Run all passes (AJ) → the fix phase → re-QA rounds **continuously - **Do not stop to check in or ask for approval.** Run all passes (AP — recurring set AN + 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 P0P2, Passes D + E clean, every game fully played through, all notification until a flawless round** (zero open P0P2, Passes D + E clean, every game fully played through, all notification
routes verified, navigation/back-stack verified). Don't hand control back early. routes verified, navigation/back-stack verified). Don't hand control back early.
- **Unblock yourself:** if anything **blocks progress** (a stale/blocking session, a crash, a build break, a missing - **Unblock yourself:** if anything **blocks progress** (a stale/blocking session, a crash, a build break, a missing
@ -304,6 +325,12 @@ fully inspect screenshots, tap every CTA, vary app states, update files accurate
| H Branding | **one small route family per chunk** (~23 screens/states) consumer brand walk + ready-to-paste art prompts + existing-image integration verdict | 814 | | H Branding | **one small route family per chunk** (~23 screens/states) consumer brand walk + ready-to-paste art prompts + existing-image integration verdict | 814 |
| I Performance | **one route-group per chunk** — gfxinfo/jank + read-count instrumentation (build the route smoke checklist) | ~3 | | 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 | | 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 Context-cost tips: prefer **code/admin-read audits** (cheap) before live UI sweeps; **montage** screenshots
(dark|light pairs) to review many at once; keep one chunk = one TodoWrite focus. (dark|light pairs) to review many at once; keep one chunk = one TodoWrite focus.
@ -447,6 +474,24 @@ Account); Paywall; Your Progress/Activity; Recovery.
re-run the decoupled state and confirm it still holds, including any newly added `-night` art.) Fix pattern (if it 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 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. 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 / - **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 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. unreachable states in coverage rather than skipping silently.
@ -496,6 +541,14 @@ Account); Paywall; Your Progress/Activity; Recovery.
hierarchy should feel intentional and consistent. Awkward or out-of-place UI such as a Settings relationship row 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: 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. 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 D1D7:
- **D1 At-rest coverage:** admin-read RAW docs/objects, assert ciphertext for every private type — chat text + - **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:`), `lastMessagePreview` (`enc:v1:`), chat media bytes (Tink `01 69 59 51 f0…`), answers (`sealed:v1:`/`enc:v1:`),
date plans + `date_swipes`, Memory Lane capsules, Bucket List. Also: **wrappedCoupleKey** + recovery material never date plans + `date_swipes`, Memory Lane capsules, Bucket List. Also: **wrappedCoupleKey** + recovery material never
@ -703,10 +756,20 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
- **Crash reporting:** confirm crashes/ANRs are actually captured (Crashlytics) so field issues surface. - **Crash reporting:** confirm crashes/ANRs are actually captured (Crashlytics) so field issues surface.
### Pass H — Branding & artwork (every screen: could it carry more of the brand? where would art help?) ### Pass H — Branding & artwork (every screen: could it carry more of the brand? where would art help?)
A consumer-mindset pass focused on **brand presence and delight**, not defects. Walk **every screen and surface** and **Branding review is a MANDATORY part of QA every round** (not an optional polish pass) — its findings + the assets to
ask: *does this feel like Closer (private, warm, equal, intentional — a ritual for two)? Could brand color, the heart create are logged in `ClaudeBrandingReview.md`. A consumer-mindset pass focused on **brand presence and delight** AND
mark, a brand message, or an illustration make it warmer or clearer without clutter?* Output is **artwork descriptions two hard brand standards. Walk **every screen and surface** and ask: *does this feel like Closer (private, warm, equal,
written as ready-to-paste ChatGPT image-generation prompts** — the user generates the images; we only describe them. 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 - **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 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 the right light/dark treatment. Art that looks generic, unfinished, randomly placed, or visually disconnected is a
@ -786,9 +849,172 @@ This is the deep home for a11y; the Pass C contrast/font spot-checks feed into i
- Findings: missing label / clipped-at-large-font / sub-48dp / failing contrast = bug → `ClaudeReport.md` (**P2**; **P1** - 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`. 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. **Subscription** → "Manage subscription" → Play (Pass K). **Privacy & Terms / data export** links open.
- Every toggle survives **process death + reinstall-with-data** (overlaps F).
### Pass N — Daily question, reveal, check-ins & the other interactive features
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).
- **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.
- **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.
- **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.
- **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 AN 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).
- **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.
### 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) ## Reporting → ClaudeReport.md (living QA report)
- Header: date, build, devices, round number + run-state header. - Header: date, build, devices, round number + run-state header.
- One section per pass (AJ), each a table: **ID | Area | Screen/Route | Mode | Severity | Description | Repro - One section per pass (AP), each a table: **ID | Area | Screen/Route | Mode | Severity | Description | Repro
| Evidence | Suggested fix | Status**. | Evidence | Suggested fix | Status**.
- Summary: counts by severity. Report only during passes — no fixes recorded until the fix phase. - Summary: counts by severity. Report only during passes — no fixes recorded until the fix phase.
@ -849,14 +1075,18 @@ Optimize every QA doc for a reader who has **5 seconds** to find the current sta
- **New issues found while fixing** are logged (new ID), not silently fixed beyond scope — next re-QA round catches them. - **New issues found while fixing** are logged (new ID), not silently fixed beyond scope — next re-QA round catches them.
**Definition of done:** a **pass** is done when every coverage row is `pass`/`fail→id`/`not implemented→Future.md`/ **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 passes (AJ) are done; **flawless** = one full round with **zero open P0P2 `blocked→id`; a **round** is done when all **recurring** passes (AN + P) are done; **flawless** = one full round with
and Passes D + E fully clean** (no open P0/P1 in I/J), **every game fully played through, every notification type **zero open P0P2 and Passes D + E + L + P fully clean** (no open P0/P1 in I/J), **every game fully played through,
verified or explicitly `not implemented→Future.md`, all join-game navigation paths and all back-stack checks every notification type verified or explicitly `not implemented→Future.md`, chat (L) + the couple-shared premium gate
verified**, **and `qa/entrypoint_smoke.sh` GREEN on both emulators (0 FAIL — every entry-point cold-start opens and (A) + settings-take-effect (M) + content/language (P: no typos/off-voice/non-inclusive copy, question bank on-guide)
stays)**. Then stop (P3s optional). Don't re-open a clean pass within the same round. verified, all join-game navigation paths and all back-stack checks verified**, **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) ## Re-QA loop (until flawless)
After the fix phase, re-run Pass AJ (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full After the fix phase, re-run Passes AN + 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 P0P2 and Passes D+E fully clean. round yields zero P0P2 and Passes D+E fully clean.
- **Prune on confirmation (Report hygiene):** the moment a re-QA round re-verifies a `Fixed` issue, **delete its row** - **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 from `ClaudeReport.md` (move its ID to the compact `Resolved & confirmed (archived — detail in git)` line) and

View File

@ -1,5 +1,9 @@
# Claude QA Report — Full-App QA (living report) # Claude QA Report — Full-App QA (living report)
> **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 PM8 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). **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**); N (daily-Q + reveal both-answered gate render); smoke **6/6 GREEN both emulators**. 2 P3 brand-asset backlogs still open. **0 FATAL.**
>
> **Verdict (2026-06-27): R14 = full fresh AJ ClaudeQAPlan run — 0 open P0P2, 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 AJ — FLAWLESS (0 open P0P3).** 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 AJ 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 (2026-06-27): R13 = fixed the entire open backlog + full fresh AJ — FLAWLESS (0 open P0P3).** 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 AJ 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 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.
@ -14,6 +18,8 @@
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current) ## Run-state (current)
- **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.** Uncommitted (user commits): functions (`quietHours.ts` + 4 senders), `firestore.rules`, client (`FirestoreUserDataSource`/`UserRepository(+Impl)`/`NotificationSettingsScreen`). **Functions + rules DEPLOYED to prod (standing auth).** NEXT (R16): confirm M-001 holds → prune; close remaining N/L sub-items (failed-send/offline retry, delete-msg, outcomes loop) + the 2 P3 brand backlogs.
- **R14 (2026-06-27) — full fresh AJ ClaudeQAPlan run (pure QA, no code changes) — FLAWLESS, 0 open P0P3, 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 AJ — FLAWLESS (0 open P0P3).** 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). **AJ:** 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`. - **R13 (2026-06-27) — backlog fix pass + full fresh AJ — FLAWLESS (0 open P0P3).** 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). **AJ:** 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`. - **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 P0P2.** 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.` `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 P0P2.** 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.`
@ -41,35 +47,26 @@
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | 0 | 0 | | P1 | 0 | 0 |
| P2 | **0** | **1** (C-DARK-UI-001) | | P2 | **0** | **1** (M-001 quiet hours) |
| P3 | **0** | **4** (J-OBS, C-ART-EDGE-002, C-DARK-UI-002, C-DARK-UI-003) | | P3 | **2** (BRAND-DARK-COVERAGE, BRAND-ICON-CUSTOM) | **0** |
_R13: A-201 (P1) confirmed live → pruned to the archived line. The 5 fixes above are verified-live this round; they live one confirmation round, then prune._ _R15: found + FIXED **M-001** (P2 quiet hours — fixed + verified live, pending 1 confirmation round). **0 open P0P2.** 2 P3
brand-asset backlogs still open (every image needs a dark variant; every icon must be custom) — full asset lists in
`ClaudeBrandingReview.md`._
## Issues — ad hoc dark-mode UI/brand review (2026-06-27) ## Issues — open (brand-asset backlogs, P3)
> Dedicated emulator: `CloserCodexQA` / `emulator-5558`; fresh paired users created through the real invite flow; system dark mode forced. This pass focused on brand fit, image integration, dark-mode button/text visibility, route integrity, and logs after navigation/game launch. **Clean surfaces:** profile creation, invite, paired Home, Settings ("Connected with Codex Dark"), Notifications, Messages, and Paywall primary content/buttons are readable and on-brand in dark mode. **Known C-ART-EDGE-002 reproduced:** Today's direct-call hero art still appears as a bright pasted light tile on dark, already tracked below. > Surfaced by the 2026-06-27 brand standards audit (new Pass H/Pass C mandates). Both are **brand-quality defects**
> (the app functions): light-only art shows on dark; generic icons aren't on-brand. Asset lists + prompts to make live
> in `ClaudeBrandingReview.md` (Image theme-variant coverage · Icon/glyph audit).
| ID | Sev | Area | Description | Evidence / repro | Suggested fix | Status | | ID | Sev | Area | Description | Suggested fix | Status |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|
| C-DARK-UI-001 | P2 | This or That / dark active gameplay | **Dark-mode gameplay still looks off-brand and weakly legible.** The diagonal line + two-circle backdrop cuts through the question and reads like a forgotten placeholder, especially in dark mode. A/B option labels and option text are too dim on the dark cards; mood-picker duration chips are also low-contrast. This is the one screen where dark button/choice text does not meet the rest of the app's standard. | `emulator-5558`: Play → This or That → choose mood/count → active question. Screenshot: `/tmp/closer-dark-20-this-or-that-game.png`; setup chip contrast: `/tmp/closer-dark-19-this-or-that-mood.png`. Logcat after launch: 0 app FATAL/ANR/force-finish. | Replace the placeholder-like line/circle motif with branded game art or a subtle surface treatment; use theme tokens with high-contrast option text for dark; verify A/B cards, selected/pressed/disabled states, and all mood/count chips in both themes. | **Fixed + verified live R13 (working tree)**`ThisOrThatScreen.kt`: `ChoicePromptBackdrop` redrawn as a soft theme-aware glow + two faint paired-card silhouettes low in the frame (no line/diagram, never crosses the prompt); `OptionCard` A/B now map to `colorScheme.primary`/`secondary` with high-contrast `onSurface` body text + visible accent borders + rich filled selected state; `VersusBadge`/progress/pills/mood number-circle/`TotLengthChips` all theme-aware. Verified both themes (5554 dark / 5556 light): backdrop subtle, options/chips legible, 0 FATAL. Added light+dark previews. | | M-001 | P2 | Settings / notifications | **Quiet hours did not suppress backgrounded/killed partner pushes.** "Quiet hours — 10 PM8 AM, no notifications" was stored **local-only** (DataStore); partner pushes carry a `notification` block the OS shows directly when the recipient is backgrounded/killed, and the only client check (`PartnerNotificationManager.isInQuietHours`) runs **foreground-only** (`AppMessagingService.onMessageReceived`). So the "no notifications" promise was broken for the main case. Repro: Sam QH ON @22:28 CST, backgrounded → QA chat → "QA sent a message" posted to Sam's shade. | Client mirrors window+tz to `users/{uid}`; Cloud Functions (`onMessageWritten`/`onAnswerWritten`/`onAnswerRevealed`/`onGameSessionUpdate`) suppress via fail-open `notifications/quietHours.ts:recipientInQuietHours()`; `firestore.rules` user-doc allowlist extended for `quietHours*`+`timezone`. | **Fixed — verified live R15** (fn log suppress vs notify; deployed prod). Pending 1 confirm. |
| C-DARK-UI-002 | P3 | Paired Home / first-launch check-in modal | **Slider label and value collide in dark mode.** The second check-in row reads visually like `communicate?5` because the label runs into the numeric value. Button text ("Submit", "Skip for now") is readable, but the modal needs spacing/layout polish. | `emulator-5558`: first paired Home launch opened the quick check-in modal. Screenshot: `/tmp/closer-dark-16-paired-home.png`. | Give slider rows a stable two-column layout or wrap/value-align the score independently; verify at default and larger font scales. | **Fixed R13 (working tree)**`OutcomeCheckInDialog.kt` `OutcomeSlider`: label given `Modifier.weight(1f)` and the row switched to `spacedBy(12.dp)`, so the numeric value keeps its own column and can never collide with a long label. Verified-by-construction (the one-time baseline-check-in dialog is gated `outcomeBaselineShownAt!=0` on these installs; the change is a deterministic layout fix). | | 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)** |
| C-DARK-UI-003 | P3 | Insets / bottom navigation and gesture area | **Several dark-mode scroll surfaces render useful content too close to or under bottom nav/gesture areas.** Play clips the lower "Desire Sync" card behind bottom nav; unpaired Home's bottom CTA is too tight to the nav area; Paywall's "Already subscribed? / Restore" card starts under the gesture area. Text is mostly readable, but it looks unfinished and can hide tap targets. | Screenshots: `/tmp/closer-dark-18-play.png`, `/tmp/closer-dark-07-post-profile.png`, `/tmp/closer-dark-23-paywall.png`. | Audit scaffold/content padding for bottom nav + system gesture insets; add consistent `navigationBarsPadding`/bottom spacer to scrollable content and modal/fullscreen paywall surfaces. | **Fixed + verified live R13 (working tree)** — added explicit bottom clearance to the three flagged scroll containers: Play `LazyColumn` `contentPadding = bottom 28.dp`; Home column `bottom 36.dp`; Paywall column `bottom 40.dp`. Verified live: Play last row (Bucket List / Past Games) clears the bottom nav; Paywall Restore/legal clears the gesture area. | | BRAND-ICON-CUSTOM | P3 | Icons / brand | **~60 distinct generic Material icons** across ~201 call sites (generic hearts `Favorite`/`FavoriteBorder`, `Person`, `Lock`, `Star`, `PlayArrow`, `ArrowBack`, …) — these are placeholders, not the Closer brand. | Replace each with a bespoke `glyph_*` in the house style (`ImageVector.vectorResource` + `Icon(tint)`), highest-traffic first; ship bar = **0 generic Material icons**. Backlog table in `ClaudeBrandingReview.md`. | **Open (P3)** |
## Issues — R12 (0 open P0P2 = FLAWLESS · A-201 P1 fixed+verified-live pending 1 confirm · 2 open P3 [J-OBS, C-ART-EDGE-002])
> R12 was a FRESH FULL AJ run. Found A-201 (P1, Date Match premium bypass) — **fixed + verified live this round** (gated
> via `CouplePremiumChecker`). C-DARKART-001 (P2) + C-ART-EDGE-001 (P3), fixed in R11, held through R12's visual sweep and
> are **pruned** to the archived-ID line below (detail in git / working tree). New P3 **C-ART-EDGE-002** (direct-call hero
> hard edges) is deferred polish. Remaining open = 2 non-blocking P3s. **A-201 fix + R11 art fixes are in the working tree
> (user commits).**
| ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---|
| A-201 | P1 | Premium / Date Match bypass (Pass A, R12) | **Premium date ideas are NOT gated — free users can view, swipe, like and match them with no paywall.** `DateIdea.isPremium` is documented as "requires an active premium entitlement," but `DateMatchRepositoryImpl.getDateIdeas()` returns `DateIdeaSeed.all` (premium ideas included, **no entitlement filter**), `DateMatchViewModel` loads them with no `CouplePremiumChecker`, and `DateMatchScreen` only renders a cosmetic `PremiumBadge()` — no lock overlay, no paywall on like/super-like. So the premium tier for Date Match is unenforced. **Escaped prior Pass A rounds** (which asserted "all gates use CouplePremiumChecker" — Date Match has NO gate). (Plan buckets "premium bypass" as P0; filed P1 as it's a content-tier subset, no security/data impact.) | 5554 (QA **free**): Play → Date Match → reject through deck → reach a **★ Premium** idea ("night camping getaway") → Like (heart) → **no paywall**, swipe accepted, deck advanced to the next premium idea ("Zipline canopy tour"); 0 FATAL, no `PERMISSION_DENIED`. | Gate premium date ideas through `CouplePremiumChecker`: either filter `isPremium` ideas out of a free couple's deck, OR intercept like/super-like on a premium idea → route to Paywall when neither partner is premium (couple-shared). Mirror the Desire Sync / Question-pack gate. | **Confirmed live R13 → PRUNED** (added to the archived-ID line). R13 re-verify: free QA → Date Match → Love a ★Premium idea ("Overnight camping getaway") → **Paywall**, deck did **not** advance (returned to the same premium card on back); 0 FATAL. Fix is in the working tree (`DateMatchViewModel`/`DateMatchScreen`, committed `2cd0af6`-era + this confirm). |
| C-ART-EDGE-002 | P3 | Art / hard edges on direct-call heroes (Pass C, R12) | **Hero illustrations rendered via direct `painterResource` (not the shared `BrandIllustration`) still show hard edges on dark theme** — the R11 C-ART-EDGE-001 feather fix only covered `BrandIllustration`/`EmptyState`. The Today "Weekend Side Quest" daily-question hero (light/pink art) renders as a **bright rounded-rect block with a hard bottom edge on the dark screen**. These direct-call heroes have **no `-night` variant** either. Likely same class: paywall couple art, onboarding, Home tonight-prompt/partner-activation, spin-wheel hero, pack art (all direct `painterResource(illustration_*)`). Matches the user's "all images should fade into the screen" request (only partially satisfied by C-ART-EDGE-001). | 5554 (dark): Today tab → daily question → "Weekend Side Quest" hero shows a hard bright edge against the dark card. | Route these heroes through `BrandIllustration` (gains feather + theme-variant), OR apply the same `featherEdges()` treatment at each call site; consider `tile`/`hero` variants. Verify each direct `painterResource(R.drawable.illustration_*)` site listed in the R12 grep. | **Fixed + verified live R13 (working tree)** — routed the **8 opaque** (RGB, no-alpha) hero tiles through `BrandIllustration(tile=true)`: daily_question (the repro), couple_paywall, couple_subscription, couple_onboarding, partner_activation, tonight_partner_prompt, couple_invite, together_empty. Confirmed via `identify` that spin_wheel / streak_milestone / reveal_celebration are transparent-or-celebration and left as-is (they float). Verified live: Today "Weekend Side Quest" hero now feathers into the dark surface (no hard bright block); paywall hero blends. |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Fixed + verified live R13 (working tree)**`ChatComposer` media buttons (40→48dp default), voice play/pause (36→48), recording-cancel (44→48), and the failed-message retry/dismiss (28→48) in `ChatComponents.kt` + `ConversationScreen.kt`. Verified live: composer clickables now measure 126px = 48dp on both axes; layout clean (premium-lock badges intact). |
## Resolved & confirmed (archived — full detail in git history) ## Resolved & confirmed (archived — full detail in git history)
A-001 · A-003 · **A-201** · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · **C-DARKART-001** · C-DS-001 · **C-ART-EDGE-001** · **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** — all fixed and re-verified (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**.) 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** — all fixed and re-verified (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 AJ 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**.)
## Security cornerstone — clean (Pass D, deep dive, Round 7) ## Security cornerstone — clean (Pass D, deep dive, Round 7)
- **D1 at-rest:** chat text + `lastMessagePreview` + all 4 game-answer collections (ToT / How Well / Desire Sync / Wheel, both users) + Memory Lane capsules + date-swipe actions = `enc:v1:`. No plaintext content; only metadata in clear. - **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.
@ -112,6 +109,7 @@ also set an `ACTION_VIEW` + `closer://` **data Uri** on the notification intent:
path (no Uri) loads results; **warm tap from Settings now routes** (was the stuck case). path (no Uri) loads results; **warm tap from Settings now routes** (was the stuck case).
## Round history (one line each) ## Round history (one line each)
- **R14 (2026-06-27) — full fresh AJ 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 AJ, FLAWLESS (0 open P0P3).** 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). AJ: 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). - **R13 (2026-06-27) — open-backlog fix pass + full fresh AJ, FLAWLESS (0 open P0P3).** 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). AJ: 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 AJ run + fix phase, FLAWLESS (0 open P0P2).** Found **A-201 (P1): Date Match - **R12 (2026-06-27) — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2).** Found **A-201 (P1): Date Match
premium ideas ungated** (free users could like/match ★Premium ideas — `getDateIdeas()=all`, no checker, badge only; premium ideas ungated** (free users could like/match ★Premium ideas — `getDateIdeas()=all`, no checker, badge only;
@ -154,4 +152,4 @@ also set an `ACTION_VIEW` + `closer://` **data Uri** on the notification intent:
## Operational constants ## 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. - **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. - **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; `users/{uid}` update rule allows arbitrary non-`hasPremium` fields (tighten to a field allowlist). - **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.)_

View File

@ -27,6 +27,13 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works
Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny
non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore
would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle. would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle.
- **Biometric app-lock re-locks on cold-start/process-death but maybe not plain background→resume.** R15 code review:
`MainActivity` gates `AppNavigation` behind `BiometricLockScreen` when `biometricLoginEnabled` and `sessionVerified`
is false; `sessionVerified` is a `remember{}` that resets on Activity recreation (cold-start, process death) — so the
lock re-arms there — but a plain background→foreground without recreation keeps `sessionVerified = true`, so it may not
re-prompt. Architecturally sound (no compose-tree bypass; content isn't composed until unlocked), but consider
re-locking on `ON_STOP`/timeout so a picked-up unlocked phone re-prompts. *Prompted by:* R15 Pass M code audit (not
live-tested — emulator has no enrolled biometric).
> Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here. > Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here.

View File

@ -164,6 +164,32 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
/**
* Mirror the user's quiet-hours window to their user doc so Cloud Functions can suppress
* partner-action pushes server-side (M-001 the OS shows a `notification` block directly when
* the recipient is backgrounded, so client-side suppression alone can't keep the "no
* notifications" promise). Window is stored as minutes-from-midnight + the device timezone id.
*/
suspend fun updateQuietHours(
uid: String,
enabled: Boolean,
startMinutes: Int,
endMinutes: Int,
timezone: String
): Unit = suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf(
"quietHoursEnabled" to enabled,
"quietHoursStartMinutes" to startMinutes,
"quietHoursEndMinutes" to endMinutes,
"timezone" to timezone
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun deleteUserData(uid: String): Unit = suspend fun deleteUserData(uid: String): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
userRef(uid).delete() userRef(uid).delete()

View File

@ -42,6 +42,14 @@ class UserRepositoryImpl @Inject constructor(
override suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean) = override suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean) =
dataSource.updateNotificationPrefs(uid, partnerAnswered, chatMessage) dataSource.updateNotificationPrefs(uid, partnerAnswered, chatMessage)
override suspend fun updateQuietHours(
uid: String,
enabled: Boolean,
startMinutes: Int,
endMinutes: Int,
timezone: String
) = dataSource.updateQuietHours(uid, enabled, startMinutes, endMinutes, timezone)
override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid) override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid)
override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid) override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid)

View File

@ -15,6 +15,7 @@ interface UserRepository {
suspend fun storeFcmToken(uid: String, token: String) suspend fun storeFcmToken(uid: String, token: String)
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata) suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)
suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean) suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean)
suspend fun updateQuietHours(uid: String, enabled: Boolean, startMinutes: Int, endMinutes: Int, timezone: String)
suspend fun clearCoupleId(uid: String) suspend fun clearCoupleId(uid: String)
suspend fun deleteUserData(uid: String) suspend fun deleteUserData(uid: String)
} }

View File

@ -9,9 +9,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.TimeZone
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -103,6 +105,14 @@ class NotificationSettingsViewModel @Inject constructor(
fun toggleQuietHours(on: Boolean) = viewModelScope.launch { fun toggleQuietHours(on: Boolean) = viewModelScope.launch {
settingsRepository.setQuietHours(on) settingsRepository.setQuietHours(on)
syncQuietHours()
}
init {
// Backfill the recipient-side quiet-hours window/timezone to Firestore so the server can
// honor it for backgrounded/killed delivery (M-001) — covers users who enabled quiet hours
// before this build, the next time they open Notification settings.
syncQuietHours()
} }
private fun syncNotifPrefs(partnerAnswered: Boolean, chatMessage: Boolean) { private fun syncNotifPrefs(partnerAnswered: Boolean, chatMessage: Boolean) {
@ -111,6 +121,23 @@ class NotificationSettingsViewModel @Inject constructor(
runCatching { userRepository.updateNotificationPrefs(uid, partnerAnswered, chatMessage) } runCatching { userRepository.updateNotificationPrefs(uid, partnerAnswered, chatMessage) }
} }
} }
/** Mirror the local quiet-hours window + device timezone to the user doc (M-001). */
private fun syncQuietHours() {
val uid = authRepository.currentUserId ?: return
viewModelScope.launch {
runCatching {
val qh = settingsRepository.settings.first().quietHours
userRepository.updateQuietHours(
uid = uid,
enabled = qh.enabled,
startMinutes = qh.startHour * 60 + qh.startMinute,
endMinutes = qh.endHour * 60 + qh.endMinute,
timezone = TimeZone.getDefault().id
)
}
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View File

@ -1144,6 +1144,11 @@ SCRIPTS.md
These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now. These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now.
### M-001 — quiet hours must be enforced SERVER-SIDE (a `notification` block bypasses client code when backgrounded)
**Symptom (R15)**: "Quiet hours — 10 PM8 AM, no notifications" did nothing for the case it exists for. With quiet hours ON and the recipient backgrounded/killed, partner chats/answers still posted to the shade. **Root cause**: quiet hours was **local-only** (`SettingsDataStore`, never written to Firestore) and the only check, `PartnerNotificationManager.isInQuietHours`, runs inside `AppMessagingService.onMessageReceived` — which FCM invokes **only in the foreground**. Partner pushes carry a `notification` block, so when the app is backgrounded/killed the **OS renders it directly** and no app code runs → the window is never consulted. The Settings copy was therefore a false promise for the primary scenario.
**Fix (R15)**: enforce server-side. (1) Client mirrors the window to the recipient's user doc — `FirestoreUserDataSource.updateQuietHours()` writes `quietHoursEnabled` + `quietHoursStartMinutes` + `quietHoursEndMinutes` + `timezone` (`TimeZone.getDefault().id`); `NotificationSettingsViewModel` syncs on toggle **and** on init (backfill). (2) `functions/src/notifications/quietHours.ts:recipientInQuietHours(userData)` computes the recipient's local now via `Intl.DateTimeFormat({timeZone})`, handles the midnight-crossing window, and is **FAIL-OPEN** (any missing/malformed field → returns false → deliver). (3) The four partner-action senders skip when it returns true: `onMessageWritten`, `onAnswerWritten`, `onAnswerRevealed`, `onGameSessionUpdate`. (4) `firestore.rules` user-doc update allowlist extended for the four new fields.
**Re-introduction risk**: (a) Client-side suppression of a server `notification`-block push only ever works foreground — any "don't notify when X" rule (quiet hours, snooze, DND) must be enforced where the push is **sent** (Cloud Functions), or sent as data-only (unreliable when killed). (b) **The `users/{uid}` update rule is a field allowlist** (`firestore.rules` ~L198, `hasOnly([...])`) — a new client-written user-doc field is silently `PERMISSION_DENIED` until added there *and* to `FirestoreUserDataSource`. (c) Keep the helper fail-open so a bug can only under-suppress (deliver), never wrongly drop a notification. (d) Scheduled/promotional senders (`reengagement`) already had their own quiet-hours check — the gap was the real-time partner-action path.
### C-DARK-UI-001 — game surfaces must use theme tokens, not fixed palette darks ### C-DARK-UI-001 — game surfaces must use theme tokens, not fixed palette darks
**Symptom**: This-or-That active gameplay was off-brand and weakly legible in dark mode — option body text and mood/duration chips used fixed `CloserPalette.PurpleDeep`/`PinkAccentDeep` (dark values) on a dark surface, and `ChoicePromptBackdrop` drew a diagonal line + two circles that read like a technical diagram crossing the prompt. **Symptom**: This-or-That active gameplay was off-brand and weakly legible in dark mode — option body text and mood/duration chips used fixed `CloserPalette.PurpleDeep`/`PinkAccentDeep` (dark values) on a dark surface, and `ChoicePromptBackdrop` drew a diagonal line + two circles that read like a technical diagram crossing the prompt.
**Fix (R13)**: `ThisOrThatScreen.kt` — A/B options map to `MaterialTheme.colorScheme.primary`/`secondary` (light lavender/pink in dark, rich purple/magenta in light) with high-contrast `onSurface` body text + visible accent `BorderStroke` + a filled `onPrimary`/`onSecondary` selected state; `VersusBadge`, progress, the `N/total`+title pills, the mood number-circle, and `TotLengthChips` all read from `colorScheme`; `ChoicePromptBackdrop` redrawn as a soft glow + two faint paired-card silhouettes (low alpha, never crossing the prompt). Light+dark previews added. **Fix (R13)**: `ThisOrThatScreen.kt` — A/B options map to `MaterialTheme.colorScheme.primary`/`secondary` (light lavender/pink in dark, rich purple/magenta in light) with high-contrast `onSurface` body text + visible accent `BorderStroke` + a filled `onPrimary`/`onSecondary` selected state; `VersusBadge`, progress, the `N/total`+title pills, the mood number-circle, and `TotLengthChips` all read from `colorScheme`; `ChoicePromptBackdrop` redrawn as a soft glow + two faint paired-card silhouettes (low alpha, never crossing the prompt). Light+dark previews added.

View File

@ -198,7 +198,9 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([ && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId', 'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId',
'plan', 'createdAt', 'lastActiveAt', 'fcmToken', 'plan', 'createdAt', 'lastActiveAt', 'fcmToken',
'notifPartnerAnswered', 'notifChatMessage' 'notifPartnerAnswered', 'notifChatMessage',
// M-001: quiet-hours window mirrored for server-side push suppression.
'quietHoursEnabled', 'quietHoursStartMinutes', 'quietHoursEndMinutes', 'timezone'
]); ]);
// Entitlements written server-side only (RevenueCat webhook via Admin SDK). // Entitlements written server-side only (RevenueCat webhook via Admin SDK).

View File

@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.onGamePartFinished = exports.onGameSessionUpdate = void 0; exports.onGamePartFinished = exports.onGameSessionUpdate = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
/** /**
* Firestore trigger that notifies partners when a game session is created or completed. * Firestore trigger that notifies partners when a game session is created or completed.
* *
@ -81,6 +82,8 @@ exports.onGameSessionUpdate = functions.firestore
const partnerBName = (_f = (_e = userB.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Partner B'; const partnerBName = (_f = (_e = userB.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Partner B';
const avatarA = (_g = userA.data()) === null || _g === void 0 ? void 0 : _g.photoUrl; const avatarA = (_g = userA.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
const avatarB = (_h = userB.data()) === null || _h === void 0 ? void 0 : _h.photoUrl; const avatarB = (_h = userB.data()) === null || _h === void 0 ? void 0 : _h.photoUrl;
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
const dataFor = (uid) => (uid === partnerA ? userA.data() : userB.data());
const currentData = (_j = change.after.data()) !== null && _j !== void 0 ? _j : {}; const currentData = (_j = change.after.data()) !== null && _j !== void 0 ? _j : {};
if (!change.after.exists) if (!change.after.exists)
return; // deletion — nothing to notify return; // deletion — nothing to notify
@ -109,7 +112,12 @@ exports.onGameSessionUpdate = functions.firestore
const recipientId = startedBy === partnerA ? partnerB : partnerA; const recipientId = startedBy === partnerA ? partnerB : partnerA;
const starterName = startedBy === partnerA ? partnerAName : partnerBName; const starterName = startedBy === partnerA ? partnerAName : partnerBName;
const starterAvatar = startedBy === partnerA ? avatarA : avatarB; const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId); if ((0, quietHours_1.recipientInQuietHours)(dataFor(recipientId))) {
console.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`);
}
else {
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId);
}
} }
return; return;
} }
@ -125,9 +133,19 @@ exports.onGameSessionUpdate = functions.firestore
}); });
if (claimed) { if (claimed) {
const gt = (_l = currentData.gameType) !== null && _l !== void 0 ? _l : 'wheel'; const gt = (_l = currentData.gameType) !== null && _l !== void 0 ? _l : 'wheel';
// Notify BOTH partners, each naming the OTHER. // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours.
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId); if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerA))) {
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId); console.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`);
}
else {
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId);
}
if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerB))) {
console.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`);
}
else {
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId);
}
} }
return; return;
} }

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,51 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.recipientInQuietHours = recipientInQuietHours;
/**
* Quiet-hours suppression for partner-action pushes.
*
* The Settings UI promises "10 PM 8 AM, no notifications". The client stores the window in local
* DataStore AND mirrors it to the recipient's `users/{uid}` doc (quietHoursEnabled / *StartMinutes /
* *EndMinutes / timezone). Because a partner push carries a `notification` block, the OS shows it
* directly when the recipient app is backgrounded/killed so the only place the promise can be kept
* is server-side, here, before the push is sent (M-001).
*
* FAIL-OPEN by design: if quiet hours is not explicitly enabled, or any field (window/timezone) is
* missing or malformed, we return `false` (do NOT suppress). A bug here can therefore only ever fall
* back to today's behavior (notification delivered) it can never wrongly drop a notification, and
* existing installs keep delivering exactly as before until the client backfills the fields.
*/
function recipientInQuietHours(userData, now = new Date()) {
var _a, _b;
if (!userData || userData.quietHoursEnabled !== true)
return false;
const start = userData.quietHoursStartMinutes;
const end = userData.quietHoursEndMinutes;
const tz = userData.timezone;
if (typeof start !== 'number' || typeof end !== 'number' || typeof tz !== 'string' || !tz) {
return false;
}
let nowMinutes;
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(now);
const hour = Number((_a = parts.find((p) => p.type === 'hour')) === null || _a === void 0 ? void 0 : _a.value) % 24;
const minute = Number((_b = parts.find((p) => p.type === 'minute')) === null || _b === void 0 ? void 0 : _b.value);
if (Number.isNaN(hour) || Number.isNaN(minute))
return false;
nowMinutes = hour * 60 + minute;
}
catch (_c) {
// Unknown/invalid timezone id → fail open.
return false;
}
// Window may cross midnight (e.g. 22:00 → 08:00).
return start <= end
? nowMinutes >= start && nowMinutes <= end
: nowMinutes >= start || nowMinutes <= end;
}
//# sourceMappingURL=quietHours.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"quietHours.js","sourceRoot":"","sources":["../../src/notifications/quietHours.ts"],"names":[],"mappings":";;AAcA,sDAkCC;AAhDD;;;;;;;;;;;;;GAaG;AACH,SAAgB,qBAAqB,CACnC,QAAoD,EACpD,MAAY,IAAI,IAAI,EAAE;;IAEtB,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,iBAAiB,KAAK,IAAI;QAAE,OAAO,KAAK,CAAA;IAElE,MAAM,KAAK,GAAG,QAAQ,CAAC,sBAAsB,CAAA;IAC7C,MAAM,GAAG,GAAG,QAAQ,CAAC,oBAAoB,CAAA;IACzC,MAAM,EAAE,GAAG,QAAQ,CAAC,QAAQ,CAAA;IAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,CAAC,EAAE,EAAE,CAAC;QAC1F,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,UAAkB,CAAA;IACtB,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;YAC7C,QAAQ,EAAE,EAAE;YACZ,IAAI,EAAE,SAAS;YACf,MAAM,EAAE,SAAS;YACjB,MAAM,EAAE,KAAK;SACd,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAA;QACrB,MAAM,IAAI,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,0CAAE,KAAK,CAAC,GAAG,EAAE,CAAA;QACrE,MAAM,MAAM,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,0CAAE,KAAK,CAAC,CAAA;QACpE,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;YAAE,OAAO,KAAK,CAAA;QAC5D,UAAU,GAAG,IAAI,GAAG,EAAE,GAAG,MAAM,CAAA;IACjC,CAAC;IAAC,WAAM,CAAC;QACP,2CAA2C;QAC3C,OAAO,KAAK,CAAA;IACd,CAAC;IAED,kDAAkD;IAClD,OAAO,KAAK,IAAI,GAAG;QACjB,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG;QAC1C,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG,CAAA;AAC9C,CAAC"}

View File

@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.onAnswerRevealed = void 0; exports.onAnswerRevealed = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
/** /**
* Firestore trigger: when one partner OPENS (reveals) the shared answers i.e. their own * Firestore trigger: when one partner OPENS (reveals) the shared answers i.e. their own
* daily answer doc flips isRevealed false true notify the other partner that they've * daily answer doc flips isRevealed false true notify the other partner that they've
@ -93,6 +94,11 @@ exports.onAnswerRevealed = functions.firestore
console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`); console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`);
return; return;
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) {
console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`);
return;
}
const questionId = typeof after.questionId === 'string' ? after.questionId : ''; const questionId = typeof after.questionId === 'string' ? after.questionId : '';
const revealerDoc = await db.collection('users').doc(userId).get(); const revealerDoc = await db.collection('users').doc(userId).get();
const revealerName = ((_e = revealerDoc.data()) === null || _e === void 0 ? void 0 : _e.displayName) || 'Your partner'; const revealerName = ((_e = revealerDoc.data()) === null || _e === void 0 ? void 0 : _e.displayName) || 'Your partner';

View File

@ -1 +1 @@
{"version":3,"file":"onAnswerRevealed.js","sourceRoot":"","sources":["../../src/questions/onAnswerRevealed.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;GAMG;AACU,QAAA,gBAAgB,GAAG,SAAS,CAAC,SAAS;KAChD,QAAQ,CAAC,2DAA2D,CAAC;KACrE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;;IAClC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAI1C,CAAA;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAsC,CAAA;IACvE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAsC,CAAA;IAErE,mDAAmD;IACnD,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI;QAAE,OAAM;IAEnE,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,OAAO,CAAC,IAAI,CAAC,6BAA6B,QAAQ,YAAY,CAAC,CAAA;QAC/D,OAAM;IACR,CAAC;IACD,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,+BAA+B,MAAM,oBAAoB,QAAQ,EAAE,CAAC,CAAA;QACjF,OAAM;IACR,CAAC;IACD,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,MAAM,CAAC,CAAA;IACvD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,4CAA4C,QAAQ,EAAE,CAAC,CAAA;QACpE,OAAM;IACR,CAAC;IAED,kEAAkE;IAClE,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,IAAI,cAAc,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,WAAW,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;QACnD,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IACzF,CAAC;IACD,MAAM,aAAa,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IAC/F,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QACjC,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IACF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,GAAG,CAAC,gDAAgD,SAAS,EAAE,CAAC,CAAA;QACxE,OAAM;IACR,CAAC;IAED,kEAAkE;IAClE,IAAI,CAAA,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,oBAAoB,MAAK,KAAK,EAAE,CAAC;QAC1D,OAAO,CAAC,GAAG,CAAC,8BAA8B,SAAS,yCAAyC,CAAC,CAAA;QAC7F,OAAM;IACR,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,KAAK,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAA;IAC/E,MAAM,WAAW,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAA;IAClE,MAAM,YAAY,GAAG,CAAC,MAAA,WAAW,CAAC,IAAI,EAAE,0CAAE,WAAkC,KAAI,cAAc,CAAA;IAC9F,MAAM,cAAc,GAAG,MAAA,WAAW,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAEnD,MAAM,OAAO,GAAqC;QAChD,YAAY,EAAE;YACZ,KAAK,EAAE,GAAG,YAAY,sBAAsB;YAC5C,IAAI,EAAE,iCAAiC;SACxC;QACD,IAAI,kBACF,IAAI,EAAE,uBAAuB,EAC7B,SAAS,EAAE,QAAQ,EACnB,WAAW,EAAE,UAAU,EACvB,IAAI,IACD,CAAC,OAAO,cAAc,KAAK,QAAQ,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC;YACjE,CAAC,CAAC,EAAE,iBAAiB,EAAE,cAAc,EAAE;YACvC,CAAC,CAAC,EAAE,CAAC,CACR;KACF,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,gCAClB,OAAO,KACV,KAAK,EACL,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,GAClC,CAAC,CAC9B,CACF,CAAA;IACD,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QAC3B,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IACjF,CAAC,CAAC,CAAA;IACF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,CAAC,KAAK,CAAC,+CAA+C,EAAE,QAAQ,CAAC,CAAA;IAEjG,OAAO,CAAC,GAAG,CAAC,+BAA+B,SAAS,SAAS,MAAM,kBAAkB,QAAQ,EAAE,CAAC,CAAA;AAClG,CAAC,CAAC,CAAA"} {"version":3,"file":"onAnswerRevealed.js","sourceRoot":"","sources":["../../src/questions/onAnswerRevealed.ts"],"names":[],"mappings":"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"}

View File

@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.onAnswerWritten = void 0; exports.onAnswerWritten = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
/** /**
* Firestore trigger that sends an FCM notification to the other partner when * Firestore trigger that sends an FCM notification to the other partner when
* one partner writes an answer under * one partner writes an answer under
@ -101,6 +102,11 @@ exports.onAnswerWritten = functions.firestore
console.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`); console.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`);
return; return;
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) {
console.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`);
return;
}
const answerData = snap.data(); const answerData = snap.data();
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''; const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '';
// Sender (the partner who just answered) avatar — used as the notification large icon. // Sender (the partner who just answered) avatar — used as the notification large icon.

View File

@ -1 +1 @@
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"} {"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,4DAAmE;AAEnE;;;;;;;;GAQG;AACU,QAAA,eAAe,GAAG,SAAS,CAAC,SAAS;KAC/C,QAAQ,CAAC,2DAA2D,CAAC;KACrE,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;;IAChC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAI1C,CAAA;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,OAAO,CAAC,IAAI,CAAC,4BAA4B,QAAQ,YAAY,CAAC,CAAA;QAC9D,OAAM;IACR,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAE7D,gFAAgF;IAChF,kFAAkF;IAClF,uFAAuF;IACvF,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,4BAA4B,MAAM,8BAA8B,QAAQ,EAAE,CAAC,CAAA;QACxF,OAAM;IACR,CAAC;IAED,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,MAAM,CAAC,CAAA;IACvD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,iDAAiD,QAAQ,EAAE,CAAC,CAAA;QACzE,OAAM;IACR,CAAC;IAED,yEAAyE;IACzE,kFAAkF;IAClF,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,IAAI,cAAc,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,WAAW,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;QACnD,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC9D,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC1B,CAAC;IACH,CAAC;IAED,MAAM,aAAa,GAAG,MAAM,EAAE;SAC3B,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,WAAW,CAAC;SACvB,GAAG,EAAE,CAAA;IACR,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QACjC,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAChB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,EAAE,CAAC,CAAA;QACvE,OAAM;IACR,CAAC;IAED,+EAA+E;IAC/E,MAAM,YAAY,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,oBAAoB,CAAA;IAChE,IAAI,YAAY,KAAK,KAAK,EAAE,CAAC;QAC3B,OAAO,CAAC,GAAG,CAAC,6BAA6B,SAAS,yCAAyC,CAAC,CAAA;QAC5F,OAAM;IACR,CAAC;IAED,2FAA2F;IAC3F,IAAI,IAAA,kCAAqB,EAAC,cAAc,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,OAAO,CAAC,GAAG,CAAC,6BAA6B,SAAS,kCAAkC,CAAC,CAAA;QACrF,OAAM;IACR,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAsC,CAAA;IAClE,MAAM,UAAU,GAAG,OAAO,UAAU,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAA;IAEzF,uFAAuF;IACvF,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,YAAY,GAAG,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAE/C,0FAA0F;IAC1F,wFAAwF;IACxF,MAAM,iBAAiB,GAAG,MAAM,EAAE;SAC/B,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;SACnC,UAAU,CAAC,gBAAgB,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC;SACtC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IAC7C,MAAM,YAAY,GAAG,iBAAiB,CAAC,MAAM,CAAA;IAE7C,MAAM,OAAO,GAAqC;QAChD,YAAY,EAAE,YAAY;YACxB,CAAC,CAAC;gBACE,KAAK,EAAE,6BAA6B;gBACpC,IAAI,EAAE,wDAAwD;aAC/D;YACH,CAAC,CAAC;gBACE,KAAK,EAAE,6BAA6B;gBACpC,IAAI,EAAE,4CAA4C;aACnD;QACL,IAAI,kBACF,IAAI,EAAE,kBAAkB,EACxB,SAAS,EAAE,QAAQ,EACnB,WAAW,EAAE,UAAU,EACvB,IAAI,IACD,CAAC,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;YAC7D,CAAC,CAAC,EAAE,iBAAiB,EAAE,YAAY,EAAE;YACrC,CAAC,CAAC,EAAE,CAAC,CACR;KACF,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,gCAClB,OAAO,KACV,KAAK,EACL,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,GAClC,CAAC,CAC9B,CACF,CAAA;IAED,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE;QACpC,IAAI,MAAM,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YACjC,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAC7D,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,KAAK,CAAC,8CAA8C,EAAE,QAAQ,CAAC,CAAA;IACzE,CAAC;IAED,0EAA0E;IAC1E,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC;QAClD,cAAc,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KAC7D,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,iDAAiD,EAAE,CAAC,CAAC,CAAC,CAAA;IAEnF,OAAO,CAAC,GAAG,CACT,sCAAsC,SAAS,eAAe,QAAQ,aAAa,UAAU,EAAE,CAChG,CAAA;AACH,CAAC,CAAC,CAAA"}

View File

@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.onMessageWritten = void 0; exports.onMessageWritten = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
/** /**
* Firestore trigger that notifies the other partner when a chat message is * Firestore trigger that notifies the other partner when a chat message is
* sent in a conversation (the couple chat or a per-question discussion). * sent in a conversation (the couple chat or a per-question discussion).
@ -74,6 +75,11 @@ exports.onMessageWritten = functions.firestore
console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`); console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`);
return; return;
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) {
console.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`);
return;
}
const tokens = []; const tokens = [];
if (partnerUserDoc.exists) { if (partnerUserDoc.exists) {
const legacyToken = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.fcmToken; const legacyToken = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.fcmToken;

View File

@ -1 +1 @@
{"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"} {"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"}

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/** /**
* Firestore trigger that notifies partners when a game session is created or completed. * Firestore trigger that notifies partners when a game session is created or completed.
@ -51,6 +52,8 @@ export const onGameSessionUpdate = functions.firestore
const partnerBName = userB.data()?.displayName ?? 'Partner B' const partnerBName = userB.data()?.displayName ?? 'Partner B'
const avatarA = userA.data()?.photoUrl as string | undefined const avatarA = userA.data()?.photoUrl as string | undefined
const avatarB = userB.data()?.photoUrl as string | undefined const avatarB = userB.data()?.photoUrl as string | undefined
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.data())
const currentData = change.after.data() ?? {} const currentData = change.after.data() ?? {}
if (!change.after.exists) return // deletion — nothing to notify if (!change.after.exists) return // deletion — nothing to notify
@ -80,11 +83,15 @@ export const onGameSessionUpdate = functions.firestore
const recipientId = startedBy === partnerA ? partnerB : partnerA const recipientId = startedBy === partnerA ? partnerB : partnerA
const starterName = startedBy === partnerA ? partnerAName : partnerBName const starterName = startedBy === partnerA ? partnerAName : partnerBName
const starterAvatar = startedBy === partnerA ? avatarA : avatarB const starterAvatar = startedBy === partnerA ? avatarA : avatarB
await notifyPartner( if (recipientInQuietHours(dataFor(recipientId))) {
db, messaging, recipientId, starterName, gameType, console.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`)
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, } else {
starterAvatar, sessionId await notifyPartner(
) db, messaging, recipientId, starterName, gameType,
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
starterAvatar, sessionId
)
}
} }
return return
} }
@ -100,17 +107,25 @@ export const onGameSessionUpdate = functions.firestore
}) })
if (claimed) { if (claimed) {
const gt = currentData.gameType ?? 'wheel' const gt = currentData.gameType ?? 'wheel'
// Notify BOTH partners, each naming the OTHER. // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours.
await notifyPartner( if (recipientInQuietHours(dataFor(partnerA))) {
db, messaging, partnerA, partnerBName, gt, console.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`)
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, } else {
avatarB, sessionId await notifyPartner(
) db, messaging, partnerA, partnerBName, gt,
await notifyPartner( 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
db, messaging, partnerB, partnerAName, gt, avatarB, sessionId
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, )
avatarA, sessionId }
) if (recipientInQuietHours(dataFor(partnerB))) {
console.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`)
} else {
await notifyPartner(
db, messaging, partnerB, partnerAName, gt,
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
avatarA, sessionId
)
}
} }
return return
} }

View File

@ -0,0 +1,49 @@
/**
* Quiet-hours suppression for partner-action pushes.
*
* The Settings UI promises "10 PM 8 AM, no notifications". The client stores the window in local
* DataStore AND mirrors it to the recipient's `users/{uid}` doc (quietHoursEnabled / *StartMinutes /
* *EndMinutes / timezone). Because a partner push carries a `notification` block, the OS shows it
* directly when the recipient app is backgrounded/killed so the only place the promise can be kept
* is server-side, here, before the push is sent (M-001).
*
* FAIL-OPEN by design: if quiet hours is not explicitly enabled, or any field (window/timezone) is
* missing or malformed, we return `false` (do NOT suppress). A bug here can therefore only ever fall
* back to today's behavior (notification delivered) it can never wrongly drop a notification, and
* existing installs keep delivering exactly as before until the client backfills the fields.
*/
export function recipientInQuietHours(
userData: FirebaseFirestore.DocumentData | undefined,
now: Date = new Date()
): boolean {
if (!userData || userData.quietHoursEnabled !== true) return false
const start = userData.quietHoursStartMinutes
const end = userData.quietHoursEndMinutes
const tz = userData.timezone
if (typeof start !== 'number' || typeof end !== 'number' || typeof tz !== 'string' || !tz) {
return false
}
let nowMinutes: number
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(now)
const hour = Number(parts.find((p) => p.type === 'hour')?.value) % 24
const minute = Number(parts.find((p) => p.type === 'minute')?.value)
if (Number.isNaN(hour) || Number.isNaN(minute)) return false
nowMinutes = hour * 60 + minute
} catch {
// Unknown/invalid timezone id → fail open.
return false
}
// Window may cross midnight (e.g. 22:00 → 08:00).
return start <= end
? nowMinutes >= start && nowMinutes <= end
: nowMinutes >= start || nowMinutes <= end
}

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/** /**
* Firestore trigger: when one partner OPENS (reveals) the shared answers i.e. their own * Firestore trigger: when one partner OPENS (reveals) the shared answers i.e. their own
@ -64,6 +65,12 @@ export const onAnswerRevealed = functions.firestore
return return
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) {
console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`)
return
}
const questionId = typeof after.questionId === 'string' ? after.questionId : '' const questionId = typeof after.questionId === 'string' ? after.questionId : ''
const revealerDoc = await db.collection('users').doc(userId).get() const revealerDoc = await db.collection('users').doc(userId).get()
const revealerName = (revealerDoc.data()?.displayName as string | undefined) || 'Your partner' const revealerName = (revealerDoc.data()?.displayName as string | undefined) || 'Your partner'

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/** /**
* Firestore trigger that sends an FCM notification to the other partner when * Firestore trigger that sends an FCM notification to the other partner when
@ -78,6 +79,12 @@ export const onAnswerWritten = functions.firestore
return return
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) {
console.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`)
return
}
const answerData = snap.data() as Partial<Record<string, unknown>> const answerData = snap.data() as Partial<Record<string, unknown>>
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '' const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/** /**
* Firestore trigger that notifies the other partner when a chat message is * Firestore trigger that notifies the other partner when a chat message is
@ -48,6 +49,12 @@ export const onMessageWritten = functions.firestore
return return
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) {
console.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`)
return
}
const tokens: string[] = [] const tokens: string[] = []
if (partnerUserDoc.exists) { if (partnerUserDoc.exists) {
const legacyToken = partnerUserDoc.data()?.fcmToken const legacyToken = partnerUserDoc.data()?.fcmToken