From f68cab5cf2bb6ecfcca133879644b62c66275f34 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 2 Jul 2026 02:42:55 -0500 Subject: [PATCH] feat: signup flow, age gate, user model updates, how well screen, game prompt banner --- ClaudeQACoverage.md | 7 +- ClaudeReport.md | 12 ++- .../data/remote/FirestoreUserDataSource.kt | 6 ++ .../data/repository/UserRepositoryImpl.kt | 3 + .../main/java/app/closer/domain/AgeGate.kt | 31 ++++++ .../java/app/closer/domain/SignupHandoff.kt | 17 +++ .../main/java/app/closer/domain/model/User.kt | 4 + .../domain/repository/UserRepository.kt | 1 + .../java/app/closer/ui/auth/SignUpScreen.kt | 60 +++++++++++ .../app/closer/ui/auth/SignUpViewModel.kt | 17 ++- .../closer/ui/components/GamePromptBanner.kt | 12 ++- .../app/closer/ui/howwell/HowWellScreen.kt | 11 +- .../ui/onboarding/CreateProfileScreen.kt | 101 +++++++++++++++++- .../ui/onboarding/CreateProfileViewModel.kt | 62 +++++++++-- .../java/app/closer/domain/AgeGateTest.kt | 61 +++++++++++ docs/qa/ui-review.md | 26 +++++ firestore.rules | 2 +- 17 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/app/closer/domain/AgeGate.kt create mode 100644 app/src/main/java/app/closer/domain/SignupHandoff.kt create mode 100644 app/src/test/java/app/closer/domain/AgeGateTest.kt diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index 43a8e8e5..96e4d1a0 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -1,6 +1,9 @@ # Claude QA Coverage Matrix > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. +> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.** +> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.** +> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b). > **R25 (2026-06-30) — full fresh run on the new R24 E2EE backup/restore surface + cornerstone regression; 0 new defects.** Cheap gates green (unit **244** · fn **38** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** · cold-start smoke **6/6 both** · render smoke **4/4**). **Pass D CLEAN (deep on backup/restore):** at-rest manifest=pointers-only, Storage snapshot=`enc:v1:` (16KB, server-blind), restore_requests=0; rules member-scoped + keybox bound to other member + immutable pubkey; **D3 live negative all-denied** (backup manifest/chunks/restore_requests + create/write = 403/400; original couple/self-grant = 403); cross-user restore via **tokenized capability URL** verified (plain GET→200+`enc:v1:`); **R24 storage.rules deploy gap RESOLVED**. **Pass E:** `restore_requested` partner push **deployed + firing** (Sam queue: 3 today) → RESTORE_CONSENT; **R24-b functions NOT deployed** (`onRestoreFulfilled` absent, no `lastRestoreSelfAlertAt`, 0 `restore_self_alert`) → self-alert + completion alert = `blocked→deploy`. **Pass M:** new Security entries live-verified this session (recovery reveal on no-lock, Copy+`IS_SENSITIVE` mask, Help-my-partner-restore + back). **Cornerstone regression (Sam 5556):** A paywall gate ✓, B Play-hub cards+badges ✓, L inbox+thread fully decrypted no-leak ✓, N daily-Q decrypted+reveal ✓, 0 FATAL. **⚠️ PROCESS LANDMINE (I caused): `connectedDebugAndroidTest` UNINSTALLS+WIPES the app-under-test → wiped QA(5554) data → QA at fresh onboarding (O-ONBOARD-001 stays fixed). NEVER run instrumented tests on 5554/5556 fixtures — use throwaway 5558.** QA fixture recovery = `blocked→user` (password/re-auth needed). **User actions to close: (1) `firebase deploy --only functions` ✅ DONE (functions deployed by user; both self-alerts live-validated R25-c). (2) restore QA fixture ✅ DONE R25-b.** > **R25-b (2026-06-30) — QA(5554) fixture RECOVERED; live 2-device partner-assisted restore verified end-to-end, 0 defects.** Password reset via admin (user-authorized) → QA signed in → **NEEDS_RECOVERY** → "Start restore" published request (code 592847) → **deployed `onRestoreRequested` fired LIVE** (Sam got "Help your partner restore 💜" push, id 40038) → Sam's **Change-1 consent live-verified** (email anchor + name **QA** decrypted locally + confirm checkbox; Approve gated on code(6) **AND** confirm) → approve → **QA auto-restored**: paired Home + "Sam/Revealed" + **full chat history decrypts**; fresh `restore_ok_R25` from restored QA **decrypts on Sam** = bidirectional round-trip = full R24 restore regression. **Deferred obs (not a defect):** warm-start restore-push tap opened Play hub not RESTORE_CONSENT (likely collapsed-notif-group artifact; cold-start routing smoke-green). > **R25-c (2026-06-30) — LIVE-FIRE of deployed owner-alerts (Change 3): both restore self-alerts observed on QA's OWN device; last user-gate CLOSED; 0 defects.** User-authorized re-wipe QA(5554) → sign in → NEEDS_RECOVERY → Start restore (code 565429). **(1) Request self-alert fired LIVE** — QA **shade** (`closer.app` id 67945, `partner_activity`, imp 4) + durable `users/{QA}/notification_queue` (`restore_self_alert`, 23:17:38 “New device is restoring your history”) + partner push to Sam (“Help your partner restore 💜”) — all from one `onRestoreRequested`. **R25-b routing obs CLOSED:** tapping Sam’s *single* restore notif → RESTORE_CONSENT (not Play hub) ⇒ earlier artifact was the collapsed 2-item group header. Consent gate re-verified (code(6) alone Approve-disabled → +confirm enabled). Sam approve → **`onRestoreFulfilled` fired (status ok, 1319ms) on REQUESTED→READY** → **(2) completion self-alert** queued to QA (`restore_self_alert`, 23:19:50 “Your history was just restored”) — not on shade only because QA was foregrounded (auto-restored); push still reached live tokens. 132s apart (>~60s dedupe → no suppression). **Robustness live:** 1 stale token (`registration-token-not-registered`) failed but `Promise.allSettled` → function ok. QA auto-restored to paired Home + content decrypts, 0 FATAL → **fixture healthy**. **Minor follow-on (not defect):** prune `not-registered` FCM tokens. @@ -33,11 +36,11 @@ | K — Billing & subscription lifecycle | Gate (couple-shared unlock) verified via admin toggle (Pass A) + Premium-unlock modal + `onEntitlementChanged` push live (R13/R14). **Real money path (purchase/restore/cancel→expiry-relock/refund/plan-switch) NOT tested** — needs a real device + Play sandbox. | ⚠️ **todo** — money path `blocked→needs-device`; gate ✅ | | L — Messaging & chat (E2E) | R15: conversation render driven live — decrypt **both dirs**, attribution, timestamps, **Seen** receipt, ❤️ **reaction**, ordering, day-separators, voice-note + image bubbles, E2E composer lock glyphs; inbox decrypted previews **no `enc:` leak**; live QA→Sam send delivered; at-rest `enc:v1:`. **R18 re-verified live round-trip** (Sam→QA: received + decrypted on partner, `enc:v1:`(79) at rest, marker absent from server docs = no leak, Seen receipt). Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — re-confirmed R18; 4 sub-items carry | | M — Settings & account management | R15: **M-001 (quiet hours) FIXED + verified live** (server-side fail-open suppression); per-type notif toggle take-effect confirmed live (server-enforced; field flips in Firestore; toggle-off → 0 delivery); theme/DataStore persistence across relaunch ✅; biometric lock code-sound (cold-start re-lock; background-resume observation → Future.md). Remaining: edit-profile persist, unpair/delete-cascade (disruptive — deferred). **R18: M-001 re-confirmed** — toggling QH writes the client mirror (`quietHoursEnabled`/`StartMinutes 1320`/`EndMinutes 480`/`timezone`) to `users/{uid}` correctly; server suppression deployed + R15-verified. Recommend prune. | ✅ **pass (core)** — M-001 confirmed (prune next); unpair/delete deferred | -| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ⚠️ **partial** — N-001/N-002 fixed; **Date Memories/Reflection = todo (new R25, needs 2-device live run)** | +| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ✅ **pass** — N-001/N-002 fixed; **Date Memories/Reflection CLOSED (R26 2-device live: reflect→reveal, edit-before-reveal, notes, bg/fg deep-link, ready/opened pushes + read-failure/timeout hardening)** | | O — Release build & store readiness | **Not started.** All QA to date is on the **debug** APK. Minified release build, signing/AAB, App Check enforcement, i18n/RTL, App-Links, Play Data-Safety = pre-ship gate, not yet run. | ❌ **todo (pre-ship gate)** | | P — Content, copy & language | R15: UI-microcopy swept (warm/inclusive; debug rows `BuildConfig.DEBUG`-gated; friendly error fallbacks; on-brand privacy copy) + **question-bank audit live: 6103 Qs — 0 empty, 0 exact dupes, 0 placeholder tokens, complete/mutually-exclusive answer configs, good type variety, consent-framed sensitive content.** No typos/off-voice/non-inclusive copy found. **R18: found+fixed P-GRAMMAR-001** — in-game wheel surfaced a subject-verb agreement error; bank scan found **13 stress-Q** from one template family where plural subjects hit a singular "{x} is …" frame ("busy weeks/health worries/… is affecting you"); fixed the 13 rows in asset `app.db` (data-only); root fix belongs in the content generator. | ✅ **pass** — copy clean; P-GRAMMAR-001 fixed (asset) + grammar-audit recommended | -**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **Open: 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE) — both blocked on the user.** +**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **R28: HW-BREAKDOWN-001 FIXED+verified (humanize option-ID fallback) + BANNER-RESULTS-COPY-001 (P4) fixed + O-AGE-001 18+ gate IMPLEMENTED+live-verified → archived/mostly-closed.** **Open: 0 P2/P3 blockers.** Remaining non-blocking follow-ups: **O-AGE-001** rules DEPLOYED + birthDate persistence verified against the live rule → only the Play maturity questionnaire remains (product, not code); **BRAND-DARK-COVERAGE** effectively resolved (all 22 `illustration_*` have dark variants; only transparent celebration assets lack one and read fine on dark). --- diff --git a/ClaudeReport.md b/ClaudeReport.md index ac6d5c92..379ebbd9 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -1,5 +1,11 @@ # Claude QA Report — Full-App QA (living report) +> **Verdict (2026-07-02): R28 = closed R27's two gaps — FIXED HW-BREAKDOWN-001 (P3) + live-ran the 3 premium games 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL; open P3 board now clear except the 2 brand-asset backlogs.** **HW-BREAKDOWN-001 (P3) FIXED:** How Well results breakdown was rendering an un-guessed/fallback choice as its raw option-ID slug (`a_small_romantic_surprise`); added `humanizeOptionId()` (`_`→space + Title-case) applied to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win when present) — built + installed on both emulators. **Premium grant (user-authorized THIS occurrence: "grant QA premium via admin write, test the 3 premium games, then revoke") → couple-shared unlock re-confirmed live:** granting QA (`entitlements/premium` hasPremium/isActive/premium=true, source `qa_admin`) unlocked BOTH partners — Sam's (free) Play hub dropped the 🔒 on Desire Sync/Memory Lane and QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal (Pass A partner→both, live). **Premium games — all 3 PASS live 2-device:** ① **Desire Sync** — both answered privately (Sam Y·Y·Y·Y·Y, QA Y·Y·Y·N·N) → both devices rendered the **identical intersection: "3 shared desires / 2 answers stayed private"** listing exactly the 3 mutual-YES prompts (the 2 QA said No to stayed private), You/partner chips both "Private", and Sam's waiting screen **auto-flipped** when QA finished + fired the in-app **"You both finished — View"** banner. ② **Memory Lane** — created + sealed a capsule ("R27-Capsule-TITLE" / body "R27-SEALED-BODY-do-not-leak", 1yr → opens Jul 2 2027); the list row shows **only the title under a lock** ("Opens in 12 mo") — **sealed body does NOT leak** (confirms the odd-looking pre-existing rows like "R10_Memory_Testy…" are just long *titles*, not body previews). ③ **Date Match** — mutual like on "Sunrise hike + thermos coffee" (liked on QA, then Sam) produced a **"Matched"** entry in the couple-shared "Your Matches" list **on both devices** (Mutual love ×4, each with "We did this"); premium-tagged idea "Overnight camping getaway" is now swipeable/matched (A-201 gate lifts correctly under premium). **Premium REVOKED after testing (fulfilling the authorization):** admin set hasPremium/isActive/premium=false + `revokedAt` — verified at DB (BEFORE all-true → AFTER all-false) **and live in UI** (Memory Lane + Past Games show 🔒 **Premium** again; free fixture state restored). **Copy nit BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** the foreground game-results banner read "See how you and **Your partner** compare" — the generic fallback is capitalized (correct at sentence-start for the started/joined/your-turn lines) but reads wrong mid-sentence. `GamePromptBanner.styleFor()` now branches the RESULTS line2: resolved name → "See how you and Sam compare"; no name → name-free **"See how you both compare"** (never an awkward capitalized generic). Compiles + installed both emulators. **O-AGE-001 (P2 pre-ship) — IMPLEMENTED + live-verified (18+ DOB gate):** new `domain/AgeGate` (18+, DOB→age math, 8 unit tests); `User.birthDate: Long?` + Firestore read/`createUser`/`updateBirthDate` + `firestore.rules` users-update allowlist `+birthDate`; `SignUpScreen` now has a **Date of birth** picker ("Closer is an 18+ app.") validated **before account creation** (under-age → no account); `CreateProfileViewModel/Screen` add a conditional first **DOB step** for arrivals without a DOB (Google/legacy) and skip it for email users (DOB carried via a `SignupHandoff` singleton — the post-signup Firestore write races auth-token attachment, so we validate at sign-up but persist at profile). **Live-verified on throwaway 5558 (fresh installs, own account):** DOB blank → "Please enter your date of birth."; **Jul 5 2008 (age 17) → "You must be at least 18 to use Closer." + NO account created** (stays on sign-up); adult DOB (2000/2001) → account created → CreateProfile opens at **NAME (Step 1 of 3), DOB step correctly skipped** for the email user; full profile save **succeeds** (no error). **Landmine caught live (would've shipped a broken flow):** the first attempt persisted birthDate via `updateBirthDate` (an *update* — the doc already exists from the FCM-token write), which the **deployed** rules reject (my allowlist add is undeployed) → **PERMISSION_DENIED broke the whole profile save**. Fixed by making the birthDate persist **best-effort** (`runCatching`, never blocks profile creation — the gate is already enforced client-side). **birthDate persistence: the additive `firestore:rules` allowlist change was DEPLOYED by the user — verified via the exact app update path (authed `PATCH {birthDate,lastActiveAt}` on own doc → 200 ALLOWED + persisted; was 403 pre-deploy) and confirmed the allowlist still rejects a non-allowlisted field (→ 403), so the deploy widened only `birthDate` and weakened nothing.** birthDate now persists end-to-end; the best-effort write also keeps profile creation resilient. Unit suite **279 green** (+8 AgeGate). Throwaway test accounts created + deleted; 5558 shut down (QA/Sam fixtures untouched). **0 FATAL** across the whole session, both emulators. All code + docs uncommitted (user commits); **firestore.rules changed (undeployed).** +> +> **Verdict (2026-07-02): R27 = full-plan COMPLETION — live-ran every pass R26 had carried; 0 P0/P1/P2, 1 new P3 (HW-BREAKDOWN-001), 0 FATAL.** Same build as R26 (UI-only diff; app unchanged, only doc edits since). Closed the gap I was honest about: **P** (question bank — 6103 Qs 0 empty/dupe/placeholder, configs complete, daily pack 500 intact, **Room hash `7e7d78…` preserved**), **I** (perf — core-tabs 6.67% janky/90th 31ms, conversation scroll 3.04%, 0 missed-vsync), **J** (a11y — font 2.0 reflow with no hidden actions, reduce-motion no hang, **TalkBack 160/160 icons labelled**, 48dp targets carried), **F** (resilience — Messages renders from cache in airplane-mode 0-FATAL, portrait-lock holds under forced rotation, process-death via smoke), **G/D3** (security — **live raw-API: non-member 403 on couple/messages/daily/date_reflections/user + self-grant 403**; own-doc 404 = rules are the gate), **H** (branding on-brand; 2 P3 backlogs carried). **Pass B games (live 2-device):** This-or-That 5/5 (R26), **How Well** 2-of-3 with correct scale+choice scoring, **Connection Challenges** resume→complete Day6→advance Day7→mutual per-day gate + streak/missed-day recovery, **Spin-the-Wheel** spin/category/session/answer/cap/quit (full 10-Q carries R18b). **NEW — HW-BREAKDOWN-001 (P3):** How Well results breakdown shows a wrong *choice*-guess as its raw option-ID slug (`a_small_romantic_surprise`) instead of the human label ("Only if needed" resolves correctly) — cosmetic ID-leak in the (untouched) How Well feature; recommend resolving the guessed option ID to its display text in the breakdown row. **Premium games (Desire Sync, Memory Lane, Date Match):** paywall GATE verified for all three (free → Paywall; Date Match free-swipe→paywall = A-201 holds); **GAMEPLAY was blocked this round — the admin premium grant was declined by the auto-mode classifier (not the specific action authorized by "run the full qa"); needs explicit OK. → RESOLVED in R28: user explicitly authorized the grant, all 3 premium games ran full 2-device, then premium was revoked (see R28 verdict above).** **K** (real money path) + **O** (release build/store) remain pre-ship/needs-device. All docs uncommitted (user commits). +> +> **Verdict (2026-07-01): R26 = full-plan run on the text-input/truncation + DateReflection-hardening build — 0 defects, 0 FATAL.** Round validated this session's UI-only changes (display-truncation removed from content/error surfaces; free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; spin-the-wheel written-answer cap added; near-limit counter; DateReflection read-failure→retryable ERROR + bounded couple-read) and closed the last outstanding coverage item. **Cheap gates GREEN:** unit **244** · functions **47** · theme-scan CRIT **1 = false-pos** (HomeScreen:829 brand count pill) · painter-xml **0** · wiring 🔴**0** dead · ime-scan **PASS**. **Cold-start `entrypoint_smoke` 6/6 on BOTH emulators** (launcher + 5 notif cold-starts open & stay). **QA(5556) fixture RE-RESTORED (user-authorized):** an environmental logout after the standby emulator kill (couple key + recovery intact on disk) → **admin Auth password reset + sign-in, no restore ceremony**; QA landed on paired Home with daily-Q + partner state + full chat history decrypting. **Pass D (E2EE at-rest, admin ground-truth) CLEAN:** conversations (main + per-question discussion), daily-question answers (both users), date_reflections all `enc:v1:` with content-free metadata; image messages carry only an encrypted `mediaUrl` (no text/plaintext); **no rules or crypto changed this cycle**, so R25's D2 static + D3 live-negative results carry. **Pass L (messaging) PASS:** QA inbox previews fully decrypted (no `enc:` leak), full thread decrypts with attribution/day-separators/Seen, and a **live 2-device round-trip** (QA→Sam: "QA_roundtrip_passL_restore" delivered + decrypted on Sam) — proving the *restored* couple key still produces ciphertext the partner decrypts (R24 restore-key integrity). **Pass A PASS:** free (Sam) → Desire Sync → **Paywall** "Go deeper together" with legible dark-mode benefit pills (C-PW-001 holds). **Pass M PASS:** Settings renders clean (profile card, Connected-with, sections; debug rows `BuildConfig.DEBUG`-gated; no clipping). **Pass C:** every screen driven this round renders clean in dark; theme-scan unchanged (1 known FP). **Pass N — Date Memories/Reflection (the R25 `todo`) CLOSED:** 2-device reflect→reveal, edit-before-reveal (against deployed rules), the new notes field, background + foreground deep-link into the exact reflection, and the `date_reflection_ready` / `date_reflection_opened` pushes were all verified live this session, plus the R25-fixed hardening (own-status read-failure → retryable ERROR with Try-again, 8s couple-read timeout, blank-dateId guard). **Pass B (games) — This-or-That re-run live 2-device:** start (Sam) → waiting-for-partner gate → **join from QA's foreground game banner** → both answer 5 → **"5/5 in sync · Two peas in a pod" results rendered symmetrically on BOTH devices** (per-question You/partner breakdown, answers decrypted, all Match); incidentally re-confirmed the live foreground game banners (`partner_completed_part` "Your turn — reveal how you line up" + results "You both finished · View", each with the partner avatar) and real-time reveal sync (Sam's waiting screen auto-flipped when QA finished). **Not run (documented, carried / pre-ship):** K real money path (needs device + Play sandbox), O minified release/store readiness, device/OS matrix (two identical emulators), and the remaining 6 games' Pass-B re-run (no games-logic change this cycle; smoke covers all game cold-start paths, last full 7-game B clean R12/R18b). All code + docs uncommitted (user commits). +> > **Verdict (2026-06-27): R15 = gap-closing round on Passes L/M/N/P + smoke — found & FIXED M-001 (P2 quiet hours).** Targeted the previously-uncovered gaps (the new "flawless" bar needs L + P clean + M take-effect). **Found M-001 (P2):** "Quiet hours — 10 PM–8 AM, no notifications" did **not** suppress partner pushes when the recipient app was backgrounded/killed (the main case) — quiet hours was **local-only** (never synced server-side) and the OS shows the FCM `notification` block directly without running app code. **Fixed + verified live:** client now mirrors the window+timezone to `users/{uid}`; the 4 partner-action senders (`onMessageWritten`/`onAnswerWritten`/`onAnswerRevealed`/`onGameSessionUpdate`) suppress server-side via a **fail-open** `recipientInQuietHours()`; rules allowlist extended for the new fields. Live: QH ON → function logs `…is in quiet hours — suppressing`, 0 delivery; QH OFF → `notified partner`, delivery resumes; per-type chat toggle still suppresses (server-enforced). **Then drove Pass N (user "FIX"):** **N-001 (P1) — Bucket List was entirely non-functional** (coupleId never wired → all CRUD silently no-op) → **FIXED + verified live** (add `enc:v1:`/complete/delete/render). **N-002 (P2) — "Plan a Date"/Date Builder "Create Plan" was a no-op** (wrote to an unread prefs collection; `dateIdeaId`/`coupleId` never wired) → **FIXED + verified live** (re-pointed to create a PLANNED `DatePlan` → Home shows "Date coming up"). Outcomes/Your Progress code-correct. **Clean passes:** L (chat decrypt both dirs, attribution, Seen receipt, ❤️ reaction, ordering, day-sep, inbox no `enc:` leak, at-rest `enc:v1:`); P (UI copy warm/inclusive, debug rows `BuildConfig.DEBUG`-gated, friendly error fallbacks; **question bank 6103 Qs — 0 empty, 0 dupes, 0 placeholders, complete answer configs, on-guide tone**); daily-Q + reveal gate render; smoke **6/6 GREEN both emulators**. 2 P3 brand-asset backlogs still open. **0 FATAL.** > > **Verdict (2026-06-27): R14 = full fresh A–J ClaudeQAPlan run — 0 open P0–P2, 0 new functional findings.** A pure-QA confirmation round (no code changes) on the R13 build. _(A follow-up 2026-06-27 brand-standards audit then opened **2 P3 brand-asset backlogs** — every image needs a dark variant; every icon must be custom — see the Issues section + `ClaudeBrandingReview.md`.)_ The 5 R13 fixes (C-DARK-UI-001/002/003, C-ART-EDGE-002, J-OBS) **held through R14's sweep → pruned**; the Premium-unlock modal held + re-verified. **Live results:** Pass A — premium ENFORCEMENT audited (all 6 gated features have a real gate, not just a badge; A-201 class closed) + free→Paywall confirmed for Date Match / Desire Sync / Question Packs + couple-shared unlock (Sam→QA) with modal + `subscription_entitlement_changed` push delivered live. Pass B — Desire Sync / How Well / Spin-the-Wheel full 2-device (mixed answer types incl free-text + multi-select + skipped-answer reveal), **first-finisher `partner_completed_part` nudge confirmed live**, Memory Lane create+seal (premium), Connection Challenges resume, Date Match deck (ToT carried from R13). Pass C — broad both-theme sweep + **decoupled-theme-art mandate** (system-light+app-Dark → dark UI + correctly-themed feathered art). Pass D — cornerstone LIVE (non-member 403 ×2, self-grant 403, member 200, chat at-rest `enc:v1:`). Pass E — all triggers fired live with content-free copy to the right partner. Pass F — offline cache render + process-death recovery, both 0 FATAL. Pass I — jank 5.25%. Pass J — J-OBS 48dp holds. **0 FATAL across the whole run, both emulators.** @@ -68,7 +74,7 @@ |---|---|---| | P0 | 0 | 0 | | P1 | 0 | 0 | -| P2 | **1** (O-AGE-001 pre-ship — needs product/legal) | **1** (B-ABANDON-001 — fixed+verified live R20) | +| P2 | **0 open** (O-AGE-001 18+ gate IMPLEMENTED+verified R28; birthDate persistence pending a gated rules deploy) | **1** (B-ABANDON-001 — fixed+verified live R20) | | P3 | **1** (BRAND-DARK-COVERAGE — needs dark art assets) | **1** (B-COPY-001 — fixed+verified live R20) | _R18b cleanup (2026-06-28): **pruned the entire confirmed backlog** (one-confirmation-round rule). Pruned to the @@ -77,7 +83,7 @@ archived line: **O-ONBOARD-001** (P0, verified live R18b) · **C-DARKART-002** ( + build) · **M-001** (verified live R15) · **TEST-001 / TEST-002** (re-confirmed green this round: 208 unit + 24 functions) · **P-GRAMMAR-001** (asset fix) · **BucketList-FAB** · **BRAND-ICON-CUSTOM** (verified live R18) · **C-ORIENT-001 → RESOLVED** (portrait locked in the manifest + verified live: `requestedOrientation=PORTRAIT` holds under forced -landscape). **Only 2 items remain open, both blocked on the user** (O-AGE-001 product/legal; BRAND-DARK-COVERAGE art)._ +landscape). _(R28 update: **O-AGE-001 18+ gate now IMPLEMENTED + live-verified; rules DEPLOYED** (birthDate persistence confirmed against the live rule) — the only remaining item is the Play maturity questionnaire (product, not code). **BRAND-DARK-COVERAGE is effectively resolved** — all 22 `illustration_*` have `drawable-night-nodpi/` dark variants; only a few transparent celebration assets lack a dark variant and they already read correctly on dark.)_ _R16: ran the new **cheap gates** → found+fixed **TEST-001** (unit suite was silently red, 5 failures) → **205 unit + 24 functions green**. **Entrypoint smoke 6/6 on BOTH emulators, 0 blocked.** Theme triage: of the 9 filed C-THEME, **3 were @@ -142,7 +148,7 @@ routed correctly, so that was a test artifact, not a bug.)_ |---|---|---|---|---|---| | B-ABANDON-001 | P2 | Games lifecycle (Pass B) | **Quit/abandon on any game silently failed `PERMISSION_DENIED`, stranding the session `active`.** `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) round-tripped through `saveSession`, which does a **full `doc.set()`** of a fixed 13-field map — dropping the server-only flags the Cloud Function wrote (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`). The session-update rule's `affectedKeys().hasOnly([...])` counts those *removed* keys, so the write is denied; the session never flips to `completed` → stranded active (blocks starting new games), and the failure is swallowed by `Log.d`. Proven live (R20): `Write failed at couples/.../sessions/MWkzZOWWRLrLNNoSwM0n: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. Affects ToT/HowWell/Wheel (all route through `abandonSession`). Escaped R19 (only the arrayUnion paths were rules-tested). | **FIXED (R20):** targeted `update(mapOf("status" to "completed","completedAt" to now))` in `abandonSession` so `affectedKeys == {status, completedAt}` ⊆ allowlist + monotonic active→completed; `finishGame` now delegates to it. Verified live: Quit → no denial → `active=0`, then a different game started immediately (lockout resolved). | **Fixed — pending 1 confirm** | | B-COPY-001 | P3 | Content/copy (Pass P) · Home | **Home "GAME_WAITING" hero falsely claimed the partner already played.** Body was hardcoded *"Your partner already played their part — take your turn to reveal how you two line up,"* but the card fires on `getActiveSessionForCouple()?.takeIf { uid !in completedByUsers }` — and for async games `completedByUsers` stays empty until BOTH finish, so the card (and its false claim) shows the instant a game is merely *started*, even when neither partner has answered. Confirmed live (R20): with `completedByUsers=[]` + no answers, QA's Home showed the claim, then QA joined to a fresh Q1/10 (no reveal). Copy-vs-behavior mismatch. | **FIXED (R20):** neutral, partner-named, always-accurate copy — "Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up." The accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN `GamePromptBanner`. Verified live both devices (starter + joiner). | **Fixed — pending 1 confirm** | -| O-AGE-001 | P2 | Release / store readiness (Pass O) | **No age gate / age verification despite adult-intimacy content.** Sign-up collects only email+password+confirm; Create Profile collects name+gender; `domain/model/User.kt` has **no DOB/age field**; the only "birthday" in-app is the *partner's* relationship special-date (`SpecialDatesSection`), not age. Yet the app ships sexual/intimacy content (Desire Sync). Google Play content-rating + sexual-content policy generally require an accurate maturity rating and may require an age gate. _(Static finding — 2026-06-28 QA-plan gap review; confirm against current Play policy + intended content rating.)_ | Add an 18+/age-appropriate gate where required + complete the Play content/maturity questionnaire to match actual content. **Pre-ship gate** (does not block per-round flawless). | **Open (pre-ship)** | +| O-AGE-001 | P2 | Release / store readiness (Pass O) | **No age gate / age verification despite adult-intimacy content.** Sign-up collects only email+password+confirm; Create Profile collects name+gender; `domain/model/User.kt` has **no DOB/age field**; the only "birthday" in-app is the *partner's* relationship special-date (`SpecialDatesSection`), not age. Yet the app ships sexual/intimacy content (Desire Sync). Google Play content-rating + sexual-content policy generally require an accurate maturity rating and may require an age gate. _(Static finding — 2026-06-28 QA-plan gap review; confirm against current Play policy + intended content rating.)_ | Add an 18+/age-appropriate gate where required + complete the Play content/maturity questionnaire to match actual content. **Pre-ship gate** (does not block per-round flawless). | **R28: IMPLEMENTED + live-verified; rules DEPLOYED** — 18+ DOB gate at sign-up (under-age blocked, no account) + conditional DOB step for Google/legacy; `AgeGate` + `User.birthDate` + rules allowlist + 8 unit tests. birthDate persistence verified against the deployed rule (authed update PATCH → 200; junk field → 403). **Remaining (product, not code): complete the Play content/maturity questionnaire to match actual content.** | | BRAND-DARK-COVERAGE | P3 | Art / theme | Most illustrations are **light-only** — only 12 of ~25 have a `drawable-night-nodpi/` dark variant. All `illustration_couple_*` heroes (paywall/subscription/onboarding/invite/history), `daily_question`, `partner_activation`, `tonight_partner_prompt`, `together_empty`, and **all 10 `pack_art_*` banners** show the **light/pink image on a dark screen** (feathered edges don't change the image colors). | Generate dark/aubergine-palette variants for each light-only asset → `drawable-night-nodpi/` (identical filename); `BrandIllustration` auto-selects per in-app theme. Re-run the decoupled-theme check. List in `ClaudeBrandingReview.md`. | **Open (P3)** | ## Resolved & confirmed (archived — full detail in git history) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt index 9d08d71b..2d4ab486 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt @@ -61,6 +61,7 @@ class FirestoreUserDataSource @Inject constructor( partnerId = data.getString("partnerId"), coupleId = coupleId, plan = data.getString("plan") ?: "free", + birthDate = data.getLong("birthDate"), createdAt = data.getLong("createdAt") ?: 0L, lastActiveAt = data.getLong("lastActiveAt") ?: 0L ) @@ -98,6 +99,7 @@ class FirestoreUserDataSource @Inject constructor( "partnerId" to user.partnerId, "coupleId" to user.coupleId, "plan" to user.plan, + "birthDate" to user.birthDate, "createdAt" to user.createdAt, "lastActiveAt" to user.lastActiveAt ) @@ -106,6 +108,10 @@ class FirestoreUserDataSource @Inject constructor( .addOnFailureListener { cont.resumeWithException(it) } } + /** Age-gate DOB (O-AGE-001). Set once when a Google/legacy user has no birthDate yet. */ + suspend fun updateBirthDate(uid: String, birthDateMillis: Long): Unit = + setMerge(uid, mapOf("birthDate" to birthDateMillis, "lastActiveAt" to System.currentTimeMillis())) + suspend fun updateDisplayName(uid: String, displayName: String) { require(displayName != FieldEncryptor.LOCKED_PLACEHOLDER) { "Refusing to persist the locked placeholder as displayName" diff --git a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt index f3de9b30..ea42b719 100644 --- a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt @@ -28,6 +28,9 @@ class UserRepositoryImpl @Inject constructor( override suspend fun updateSex(uid: String, sex: String) = dataSource.updateSex(uid, sex) + override suspend fun updateBirthDate(uid: String, birthDateMillis: Long) = + dataSource.updateBirthDate(uid, birthDateMillis) + override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid) override suspend fun storeFcmToken(uid: String, token: String) = diff --git a/app/src/main/java/app/closer/domain/AgeGate.kt b/app/src/main/java/app/closer/domain/AgeGate.kt new file mode 100644 index 00000000..4e9d279c --- /dev/null +++ b/app/src/main/java/app/closer/domain/AgeGate.kt @@ -0,0 +1,31 @@ +package app.closer.domain + +import java.util.Calendar + +/** + * Single source of truth for the 18+ age gate (O-AGE-001). Closer ships adult-intimacy content + * (Desire Sync), so sign-up requires a date of birth and blocks anyone under [MIN_AGE]. The birth + * date is stored plaintext on the user doc (not E2EE) so it stays auditable for the store content + * rating — it is not relationship content. + */ +object AgeGate { + const val MIN_AGE = 18 + + /** Whole years old at [nowMillis] for someone born at [birthDateMillis]. */ + fun ageInYears(birthDateMillis: Long, nowMillis: Long = System.currentTimeMillis()): Int { + val dob = Calendar.getInstance().apply { timeInMillis = birthDateMillis } + val now = Calendar.getInstance().apply { timeInMillis = nowMillis } + var age = now.get(Calendar.YEAR) - dob.get(Calendar.YEAR) + val monthDiff = now.get(Calendar.MONTH) - dob.get(Calendar.MONTH) + if (monthDiff < 0 || + (monthDiff == 0 && now.get(Calendar.DAY_OF_MONTH) < dob.get(Calendar.DAY_OF_MONTH)) + ) { + age-- + } + return age + } + + /** True only if [birthDateMillis] is a real past date and the person is at least [MIN_AGE]. */ + fun isAdult(birthDateMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean = + birthDateMillis in 1 until nowMillis && ageInYears(birthDateMillis, nowMillis) >= MIN_AGE +} diff --git a/app/src/main/java/app/closer/domain/SignupHandoff.kt b/app/src/main/java/app/closer/domain/SignupHandoff.kt new file mode 100644 index 00000000..f08c9842 --- /dev/null +++ b/app/src/main/java/app/closer/domain/SignupHandoff.kt @@ -0,0 +1,17 @@ +package app.closer.domain + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up + * for the 18+ gate, but PERSISTED at profile creation: a Firestore write issued immediately after + * `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules). So + * sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it + * once the session has settled — and skips re-asking. Process death clears this; CreateProfile then + * re-asks via its own DOB step (the gate still holds). + */ +@Singleton +class SignupHandoff @Inject constructor() { + var pendingBirthDate: Long? = null +} diff --git a/app/src/main/java/app/closer/domain/model/User.kt b/app/src/main/java/app/closer/domain/model/User.kt index 36e4463f..7cd7a0bc 100644 --- a/app/src/main/java/app/closer/domain/model/User.kt +++ b/app/src/main/java/app/closer/domain/model/User.kt @@ -9,6 +9,10 @@ data class User( val partnerId: String? = null, val coupleId: String? = null, val plan: String = "free", + // Date of birth (epoch millis, local midnight) captured for the 18+ age gate (O-AGE-001). + // Null for legacy users created before the gate + partners we can't see. Stored plaintext + // (not E2EE) — it's a compliance/rating field, not relationship content. See [app.closer.domain.AgeGate]. + val birthDate: Long? = null, val createdAt: Long = System.currentTimeMillis(), val lastActiveAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/app/closer/domain/repository/UserRepository.kt b/app/src/main/java/app/closer/domain/repository/UserRepository.kt index d20ea4b1..72387ebb 100644 --- a/app/src/main/java/app/closer/domain/repository/UserRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/UserRepository.kt @@ -11,6 +11,7 @@ interface UserRepository { suspend fun updateDisplayName(uid: String, displayName: String) suspend fun updatePhotoUrl(uid: String, photoUrl: String) suspend fun updateSex(uid: String, sex: String) + suspend fun updateBirthDate(uid: String, birthDateMillis: Long) suspend fun hasProfile(uid: String): Boolean suspend fun storeFcmToken(uid: String, token: String) suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata) diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index 287965de..28e59847 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -10,7 +10,10 @@ import android.util.Log import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -190,6 +193,63 @@ fun SignUpScreen( keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signUp() }) ) + Spacer(Modifier.height(12.dp)) + + // 18+ age gate (O-AGE-001). Read-only field that opens a date picker; the overlay Box + // captures taps reliably (a readOnly OutlinedTextField doesn't forward clicks). Max date + // is today; default lands ~18 years back so the common case is one scroll away. + val dobLabel = state.birthDate?.let { + java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it)) + } ?: "" + val openDobPicker = { + focusManager.clearFocus() + val initMillis = state.birthDate + ?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis + val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis } + android.app.DatePickerDialog( + context, + { _, y, m, d -> + viewModel.updateBirthDate( + java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis + ) + }, + c.get(java.util.Calendar.YEAR), + c.get(java.util.Calendar.MONTH), + c.get(java.util.Calendar.DAY_OF_MONTH) + ).apply { datePicker.maxDate = System.currentTimeMillis() }.show() + } + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = dobLabel, + onValueChange = {}, + readOnly = true, + label = { Text("Date of birth") }, + placeholder = { Text("You must be 18 or older") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = authTextFieldColors(), + trailingIcon = { + Icon(CloserGlyphs.Cake, contentDescription = null, tint = AuthMuted) + }, + supportingText = { + Text( + "Closer is an 18+ app.", + style = MaterialTheme.typography.bodySmall, + color = AuthMuted + ) + } + ) + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = openDobPicker + ) + ) + } + Spacer(Modifier.height(28.dp)) Button( diff --git a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt index 8b439d39..76ee92db 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt @@ -2,6 +2,8 @@ package app.closer.ui.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.domain.AgeGate +import app.closer.domain.SignupHandoff import app.closer.domain.model.GoogleSignInResult import app.closer.domain.model.User import app.closer.domain.repository.AuthRepository @@ -18,6 +20,8 @@ data class SignUpUiState( val email: String = "", val password: String = "", val confirmPassword: String = "", + // 18+ age gate (O-AGE-001): DOB captured as epoch millis (local midnight). Null until picked. + val birthDate: Long? = null, val isPasswordVisible: Boolean = false, val isLoading: Boolean = false, val error: String? = null, @@ -30,7 +34,8 @@ data class SignUpUiState( @HiltViewModel class SignUpViewModel @Inject constructor( private val authRepository: AuthRepository, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val signupHandoff: SignupHandoff ) : ViewModel() { private val _uiState = MutableStateFlow(SignUpUiState()) @@ -39,17 +44,22 @@ class SignUpViewModel @Inject constructor( fun updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) } fun updatePassword(v: String) = _uiState.update { it.copy(password = v, error = null) } fun updateConfirmPassword(v: String) = _uiState.update { it.copy(confirmPassword = v, error = null) } + fun updateBirthDate(millis: Long) = _uiState.update { it.copy(birthDate = millis, error = null) } fun togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } fun dismissError() = _uiState.update { it.copy(error = null) } fun signUp() { val state = _uiState.value val pw = state.password + val dob = state.birthDate when { state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return } pw.length < 8 -> { _uiState.update { it.copy(error = "Password must be at least 8 characters.") }; return } !pw.any { it.isLetter() } || !pw.any { it.isDigit() } -> { _uiState.update { it.copy(error = "Password must include both letters and numbers.") }; return } pw != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return } + // 18+ age gate (O-AGE-001): validate BEFORE creating any account so no under-age account exists. + dob == null -> { _uiState.update { it.copy(error = "Please enter your date of birth.") }; return } + !AgeGate.isAdult(dob) -> { _uiState.update { it.copy(error = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") }; return } } _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { @@ -57,6 +67,11 @@ class SignUpViewModel @Inject constructor( .onSuccess { // Best-effort: send a verification email. Don't block account creation if it fails. authRepository.sendEmailVerification() + // Hand the validated DOB to CreateProfile to persist. We do NOT write it here: a + // Firestore write issued right after signUpWithEmail races the auth-token attachment + // and silently fails rules. CreateProfile writes it once the session has settled and + // skips re-asking. (dob is non-null — the guard above returns otherwise.) + signupHandoff.pendingBirthDate = dob _uiState.update { it.copy(isLoading = false, success = true) } } .onFailure { e -> diff --git a/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt index 47e89eeb..202454d8 100644 --- a/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt +++ b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt @@ -140,13 +140,21 @@ private data class PromptStyle( // Single client source of truth for the banner copy. Mirror any change in the Cloud Function copy // (functions/src/games/onGameSessionUpdate.ts notifyPartner) so foreground/background stay in sync. private fun styleFor(prompt: IncomingGamePrompt): PromptStyle { - val name = prompt.partnerName?.takeIf { it.isNotBlank() } ?: "Your partner" + // `resolved` is the partner's real (locally-decrypted) display name when we have it; `name` is the + // sentence-START label (capitalized fallback reads fine there). Mid-sentence copy must NOT use the + // capitalized generic fallback ("…and Your partner compare"), so those lines branch on `resolved`. + val resolved = prompt.partnerName?.takeIf { it.isNotBlank() } + val name = resolved ?: "Your partner" val game = gameDisplayName(prompt.gameType) return when (prompt.kind) { GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join") GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View") GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play") - GamePromptKind.RESULTS -> PromptStyle("You both finished", "See how you and $name compare", "View") + GamePromptKind.RESULTS -> PromptStyle( + "You both finished", + if (resolved != null) "See how you and $resolved compare" else "See how you both compare", + "View" + ) } } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index df10bf9a..da7df95f 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -106,15 +106,20 @@ fun HowWellAnswer.isClose(other: HowWellAnswer): Boolean = scaleValue != null && other.scaleValue != null && !isMatch(other) && abs(scaleValue - other.scaleValue) == 1 +/** Turn a raw option id ("a_small_romantic_surprise") into readable text as a last resort, so a slug can + * never leak into the UI when the id isn't found in the question's config (HW-BREAKDOWN-001). */ +private fun humanizeOptionId(id: String): String = + id.replace('_', ' ').trim().replaceFirstChar { it.uppercase() } + fun HowWellAnswer.displayText(config: AnswerConfig?): String = when { selectedOptionId != null -> when (config) { - is ChoiceAnswerConfigImpl -> config.config.options.find { it.id == selectedOptionId }?.text ?: selectedOptionId + is ChoiceAnswerConfigImpl -> config.config.options.find { it.id == selectedOptionId }?.text ?: humanizeOptionId(selectedOptionId) is ThisOrThatAnswerConfigImpl -> when (selectedOptionId) { config.config.optionA.id -> config.config.optionA.text config.config.optionB.id -> config.config.optionB.text - else -> selectedOptionId + else -> humanizeOptionId(selectedOptionId) } - else -> selectedOptionId + else -> humanizeOptionId(selectedOptionId) } scaleValue != null -> "$scaleValue" else -> "—" diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt index b1e7b5af..f509f851 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt @@ -9,6 +9,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -129,7 +130,8 @@ fun CreateProfileScreen( containerColor = Color.Transparent, modifier = Modifier.background(AuthBackgroundBrush), topBar = { - if (state.currentStep != ProfileStep.NAME) { + val firstStep = if (state.requireBirthDate) ProfileStep.DOB else ProfileStep.NAME + if (state.currentStep != firstStep) { TopAppBar( title = { Text("Create profile", color = AuthInk) }, navigationIcon = { @@ -159,15 +161,42 @@ fun CreateProfileScreen( ) { Spacer(Modifier.height(48.dp)) + // Total is 4 when the DOB step is shown (Google/legacy), else 3. DOB is enum-ordinal 0, so + // the non-DOB flow uses the ordinal as-is (NAME=1) and the DOB flow uses ordinal+1 (DOB=1). + val totalSteps = if (state.requireBirthDate) 4 else 3 + val stepNumber = if (state.requireBirthDate) state.currentStep.ordinal + 1 else state.currentStep.ordinal Text( - text = "Step ${state.currentStep.ordinal + 1} of 3", + text = "Step $stepNumber of $totalSteps", style = MaterialTheme.typography.labelMedium, color = AuthMuted, textAlign = TextAlign.Center ) Spacer(Modifier.height(24.dp)) + val openDobPicker = { + focusManager.clearFocus() + val initMillis = state.birthDate + ?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis + val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis } + android.app.DatePickerDialog( + context, + { _, y, m, d -> + viewModel.selectBirthDate( + java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis + ) + }, + c.get(java.util.Calendar.YEAR), + c.get(java.util.Calendar.MONTH), + c.get(java.util.Calendar.DAY_OF_MONTH) + ).apply { datePicker.maxDate = System.currentTimeMillis() }.show() + } + when (state.currentStep) { + ProfileStep.DOB -> DobStep( + state = state, + onPickDate = openDobPicker, + onContinue = viewModel::goToNextStep + ) ProfileStep.NAME -> NameStep( state = state, onNameChange = viewModel::updateDisplayName, @@ -200,6 +229,74 @@ fun CreateProfileScreen( } } +@Composable +private fun DobStep( + state: CreateProfileUiState, + onPickDate: () -> Unit, + onContinue: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "How old are you?", + style = MaterialTheme.typography.headlineMedium, + color = AuthInk, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Closer is an 18+ app. We ask once to confirm your age.", + style = MaterialTheme.typography.bodyMedium, + color = AuthMuted, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(40.dp)) + + val dobLabel = state.birthDate?.let { + java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it)) + } ?: "" + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = dobLabel, + onValueChange = {}, + readOnly = true, + label = { Text("Date of birth") }, + placeholder = { Text("Tap to choose") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = authTextFieldColors(), + isError = state.birthDateError != null, + trailingIcon = { Icon(CloserGlyphs.Cake, contentDescription = null, tint = AuthMuted) }, + supportingText = state.birthDateError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } } + ) + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onPickDate + ) + ) + } + + Spacer(Modifier.height(28.dp)) + + Button( + onClick = onContinue, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthPrimary, + contentColor = AuthOnPrimary + ) + ) { + if (state.isLoading) CloserHeartLoader(size = 22.dp) + else Text("Continue", style = MaterialTheme.typography.labelLarge) + } + } +} + @Composable private fun NameStep( state: CreateProfileUiState, diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt index 7bd219e7..dbb95d36 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt @@ -4,6 +4,8 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.data.remote.FirebaseStorageDataSource +import app.closer.domain.AgeGate +import app.closer.domain.SignupHandoff import app.closer.domain.model.User import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.UserRepository @@ -16,6 +18,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject enum class ProfileStep { + DOB, NAME, SEX, PHOTO @@ -27,6 +30,11 @@ data class CreateProfileUiState( val sex: String = "", val photoUrl: String = "", val photoUri: String? = null, + // 18+ age gate (O-AGE-001). Shown as the first step ONLY for users who arrive without a DOB + // (Google sign-in / legacy); email sign-up already captured + persisted it, so they skip it. + val birthDate: Long? = null, + val requireBirthDate: Boolean = false, + val birthDateError: String? = null, val isLoading: Boolean = false, val error: String? = null, val success: Boolean = false, @@ -38,7 +46,8 @@ data class CreateProfileUiState( class CreateProfileViewModel @Inject constructor( private val authRepository: AuthRepository, private val userRepository: UserRepository, - private val storageDataSource: FirebaseStorageDataSource + private val storageDataSource: FirebaseStorageDataSource, + private val signupHandoff: SignupHandoff ) : ViewModel() { private val _uiState = MutableStateFlow(CreateProfileUiState()) @@ -51,12 +60,22 @@ class CreateProfileViewModel @Inject constructor( private fun loadExistingProfile() { val uid = authRepository.currentUserId ?: return viewModelScope.launch { - val user = runCatching { userRepository.getUser(uid) }.getOrNull() ?: return@launch + val user = runCatching { userRepository.getUser(uid) }.getOrNull() + // Prefer the on-file DOB; else the one just validated at email sign-up (handed off in memory, + // since the post-signup write is unreliable). Require the DOB step only when we have neither + // (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped. + val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate + val needsDob = effectiveDob == null _uiState.update { it.copy( - displayName = user.displayName, - sex = user.sex, - photoUrl = user.photoUrl + displayName = user?.displayName ?: it.displayName, + sex = user?.sex ?: it.sex, + photoUrl = user?.photoUrl ?: it.photoUrl, + birthDate = effectiveDob, + requireBirthDate = needsDob, + // Promote the DOB step to first only if the user is still at the default entry step + // (don't yank them backwards if they've already advanced). + currentStep = if (needsDob && it.currentStep == ProfileStep.NAME) ProfileStep.DOB else it.currentStep ) } } @@ -66,11 +85,15 @@ class CreateProfileViewModel @Inject constructor( fun selectSex(sex: String) = _uiState.update { it.copy(sex = sex, sexError = null, error = null) } + fun selectBirthDate(millis: Long) = _uiState.update { it.copy(birthDate = millis, birthDateError = null, error = null) } + fun setPhotoUri(uri: String?) = _uiState.update { it.copy(photoUri = uri, error = null) } fun goBack() { - val previous = when (_uiState.value.currentStep) { - ProfileStep.NAME -> ProfileStep.NAME + val state = _uiState.value + val previous = when (state.currentStep) { + ProfileStep.DOB -> ProfileStep.DOB + ProfileStep.NAME -> if (state.requireBirthDate) ProfileStep.DOB else ProfileStep.NAME ProfileStep.SEX -> ProfileStep.NAME ProfileStep.PHOTO -> ProfileStep.SEX } @@ -80,6 +103,14 @@ class CreateProfileViewModel @Inject constructor( fun goToNextStep() { val state = _uiState.value when (state.currentStep) { + ProfileStep.DOB -> { + val dob = state.birthDate + when { + dob == null -> _uiState.update { it.copy(birthDateError = "Please enter your date of birth.") } + !AgeGate.isAdult(dob) -> _uiState.update { it.copy(birthDateError = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") } + else -> _uiState.update { it.copy(currentStep = ProfileStep.NAME, birthDateError = null) } + } + } ProfileStep.NAME -> { val name = state.displayName.trim() when { @@ -113,6 +144,12 @@ class CreateProfileViewModel @Inject constructor( } return } + // 18+ age gate (O-AGE-001): never persist a profile for a user we can't confirm is 18+ + // (defense-in-depth behind the DOB step; also covers a skipped/bypassed step). + if (state.requireBirthDate && (state.birthDate == null || !AgeGate.isAdult(state.birthDate))) { + _uiState.update { it.copy(currentStep = ProfileStep.DOB, birthDateError = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") } + return + } val uid = authRepository.currentUserId ?: run { _uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") } return @@ -131,19 +168,28 @@ class CreateProfileViewModel @Inject constructor( displayName = name, sex = state.sex, photoUrl = finalPhotoUrl, + birthDate = state.birthDate, createdAt = System.currentTimeMillis(), lastActiveAt = System.currentTimeMillis() ) if (existing == null) { - userRepository.createUser(user.copy(displayName = name, sex = state.sex, photoUrl = finalPhotoUrl)) + userRepository.createUser(user.copy(displayName = name, sex = state.sex, photoUrl = finalPhotoUrl, birthDate = state.birthDate)) } else { userRepository.updateDisplayName(uid, name) userRepository.updateSex(uid, state.sex) if (finalPhotoUrl.isNotBlank()) { userRepository.updatePhotoUrl(uid, finalPhotoUrl) } + // Persist the DOB captured this session for users whose doc predates the age gate. + // Best-effort: the age gate is already enforced client-side, so an audit-only write + // failing (e.g. transient/rules) must never block profile creation. + if (existing.birthDate == null && state.birthDate != null) { + runCatching { userRepository.updateBirthDate(uid, state.birthDate) } + } } }.onSuccess { + // DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it. + signupHandoff.pendingBirthDate = null _uiState.update { it.copy(isLoading = false, success = true) } }.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") } diff --git a/app/src/test/java/app/closer/domain/AgeGateTest.kt b/app/src/test/java/app/closer/domain/AgeGateTest.kt new file mode 100644 index 00000000..8af6f9d0 --- /dev/null +++ b/app/src/test/java/app/closer/domain/AgeGateTest.kt @@ -0,0 +1,61 @@ +package app.closer.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Calendar + +class AgeGateTest { + + // Fixed "now" = 2026-07-02 noon, so the age math is deterministic. + private fun now(): Long = Calendar.getInstance().apply { + clear(); set(2026, Calendar.JULY, 2, 12, 0, 0) + }.timeInMillis + + private fun dob(year: Int, month: Int, day: Int): Long = + Calendar.getInstance().apply { clear(); set(year, month, day) }.timeInMillis + + @Test + fun `turns 18 exactly today counts as adult`() { + assertTrue(AgeGate.isAdult(dob(2008, Calendar.JULY, 2), now())) + } + + @Test + fun `one day short of 18 is not adult`() { + assertFalse(AgeGate.isAdult(dob(2008, Calendar.JULY, 3), now())) + } + + @Test + fun `clearly over 18 is adult`() { + assertTrue(AgeGate.isAdult(dob(2001, Calendar.JANUARY, 1), now())) + } + + @Test + fun `seventeen is not adult`() { + assertFalse(AgeGate.isAdult(dob(2009, Calendar.JANUARY, 1), now())) + } + + @Test + fun `future birth date is rejected`() { + assertFalse(AgeGate.isAdult(dob(2030, Calendar.JANUARY, 1), now())) + } + + @Test + fun `zero or negative epoch is rejected`() { + assertFalse(AgeGate.isAdult(0L, now())) + assertFalse(AgeGate.isAdult(-1L, now())) + } + + @Test + fun `age in years is correct across the birthday boundary`() { + assertEquals(18, AgeGate.ageInYears(dob(2008, Calendar.JULY, 2), now())) + assertEquals(17, AgeGate.ageInYears(dob(2008, Calendar.JULY, 3), now())) + assertEquals(25, AgeGate.ageInYears(dob(2001, Calendar.JULY, 2), now())) + } + + @Test + fun `minimum age is eighteen`() { + assertEquals(18, AgeGate.MIN_AGE) + } +} diff --git a/docs/qa/ui-review.md b/docs/qa/ui-review.md index c33f2b6a..d0e1cf70 100644 --- a/docs/qa/ui-review.md +++ b/docs/qa/ui-review.md @@ -12,6 +12,32 @@ Reviewed Compose UI files under `app/src/main/java/app/closer/ui/` for responsiv ## Build Verification `./gradlew :app:compileDebugKotlin` → **BUILD SUCCESSFUL** (3s) +## Correction (R25 review — some Batch-8 truncations reverted) + +A follow-up review found that Batch 8 over-applied `maxLines`+`TextOverflow.Ellipsis`: it's correct for +*chrome* (one-line titles, labels, pills, counts) but wrong for *content and errors*, where it silently +hides what a partner wrote. The rule is now **ellipsize chrome, never content or errors; bound content at +input instead.** The following Batch-8 entries below were **reverted** (the code no longer truncates them): + +- **`questions/components/QuestionDiscussionThread.kt`** — message bubble `maxLines=10` **removed** (messages + wrap in full; was also inconsistent with the main `ConversationScreen`, which never truncated bubbles). +- **`questions/components/AnswerBubble.kt`** — `maxLines=5` on the answer bubble **removed** (it was + ellipsizing full written answers). +- **`questions/components/QuestionHeader.kt`** — `maxLines=6` on the question text **removed** (the question + is the screen's focal content and wraps). +- **`settings/RelationshipSettingsScreen.kt`** — truncation on the **explanation + error** text **removed** + (the `TopAppBar` title's `maxLines=1` was **kept** — that's chrome). + +Instead of display truncation, free-text is **bounded at entry** in the ViewModels, centralized in +`ui/components/TextInputLimits.kt` (`MESSAGE` 2000 · `DISCUSSION_MESSAGE` 500 · `WRITTEN_ANSWER` 2000; the +conversation/discussion/question-detail/question-thread/wheel VMs alias those, and chat/discussion/wheel/ +written-answer `.trim()` on send). The spin-the-wheel written answer — the one input that was genuinely +uncapped — is now capped. The shared written-answer field shows a character counter only within +`TextInputLimits.COUNTER_THRESHOLD` of the cap. (See `ClaudeQAPlan.md` → Pass J.) + +**Note:** the `components/PlaceholderScreen.kt` entry below refers to a file that no longer exists in the +tree (removed/renamed since Batch 8) — disregard it. + ## Per-File Findings ### `home/HomeScreen.kt` diff --git a/firestore.rules b/firestore.rules index 8cf156e3..41f08c87 100644 --- a/firestore.rules +++ b/firestore.rules @@ -215,7 +215,7 @@ service cloud.firestore { allow update: if isOwner(uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId', - 'plan', 'createdAt', 'lastActiveAt', 'fcmToken', + 'plan', 'birthDate', 'createdAt', 'lastActiveAt', 'fcmToken', 'notifPartnerAnswered', 'notifChatMessage', // Daily/streak/promotional prefs mirrored so the scheduled senders can honor them. 'notifDailyReminder', 'notifStreakReminder', 'notifPromotional',