From f51a55743c7f7f6f7e5d5e314756a671a1f3ee9a Mon Sep 17 00:00:00 2001 From: null Date: Sun, 28 Jun 2026 22:24:46 -0500 Subject: [PATCH] =?UTF-8?q?feat(games):=20partner=20game-session=20push=20?= =?UTF-8?q?orchestration=20=E2=80=94=20in-app=20notification=20banner,=20F?= =?UTF-8?q?irestore=20rules,=20Cloud=20Function,=20QA=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ClaudeQACoverage.md | 13 +- ClaudeReport.md | 1 + Future.md | 10 +- .../closer/core/navigation/AppNavigation.kt | 21 ++- .../core/notifications/AppMessagingService.kt | 24 ++- .../QuestionSessionRepositoryImpl.kt | 35 +++- .../closer/domain/model/QuestionSession.kt | 5 +- .../repository/QuestionSessionRepository.kt | 4 + .../domain/usecase/GameSessionManager.kt | 11 ++ .../notifications/GamePromptController.kt | 21 ++- .../PartnerNotificationManager.kt | 8 + .../closer/ui/components/GamePromptBanner.kt | 41 +++-- .../closer/ui/desiresync/DesireSyncScreen.kt | 5 + .../app/closer/ui/howwell/HowWellScreen.kt | 5 + .../closer/ui/thisorthat/ThisOrThatScreen.kt | 5 + .../closer/ui/wheel/WheelSessionViewModel.kt | 7 + .../PartnerNotificationTypeTest.kt | 16 ++ firestore.rules | 7 +- functions/dist/games/onGameSessionUpdate.js | 46 ++++- .../dist/games/onGameSessionUpdate.js.map | 2 +- functions/dist/index.js | 4 +- functions/dist/index.js.map | 2 +- .../dist/releaseKey/wrapReleaseKeyCallable.js | 160 ++++++++++++++++++ .../releaseKey/wrapReleaseKeyCallable.js.map | 1 + functions/src/games/onGameSessionUpdate.ts | 42 ++++- 25 files changed, 444 insertions(+), 52 deletions(-) create mode 100644 functions/dist/releaseKey/wrapReleaseKeyCallable.js create mode 100644 functions/dist/releaseKey/wrapReleaseKeyCallable.js.map diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index 8ea65be5..f59a1208 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -1,7 +1,7 @@ # Claude QA Coverage Matrix > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. -> Build = **R18 working tree** (uncommitted: `MainActivity.kt`, `MemoryCapsuleGenerator.kt`+test, asset `app.db` grammar fix, docs); debug APK rebuilt+installed both emulators. Position + verdict: see `ClaudeReport.md` R18 run-state. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 3 open P3 (BRAND-DARK-COVERAGE, BRAND-ICON-CUSTOM, C-ORIENT-001); fixed-pending-1-confirm: O-ONBOARD-001, C-DARKART-002, M-001, TEST-002, P-GRAMMAR-001, BucketList-FAB.** 0 FATAL. +> Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); 209 unit + 24 functions tests green; debug APK rebuilt+installed both emulators. **⚠ `functions/` + `firestore.rules` must be deployed by the user** for the join push to fire live. Position + verdict: see `ClaudeReport.md` R18b run-state. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** > > **Scope expanded (plan review):** the playbook now has first-class passes **K–O** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **Device/OS matrix = `blocked→needs-device` (pre-ship):** all per-round QA runs on two **identical** emulators (5554/5556, same API + screen) — minSdk/targetSdk · small/large screen · ≥1 physical device are NOT covered; don't claim "device matrix ✓". > @@ -15,10 +15,10 @@ | Pass | Coverage | Status | |---|---|---| | A — Couple-shared premium | R18: re-verified live (Sam free) — Desire Sync + premium **Boundaries** pack → Paywall; Mixed pack free prompt opens (gate not over-broad); Free filter graceful empty. R13 A-201 (Date Match) holds. | ✅ pass (multi-surface gate re-confirmed R18) | -| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10) | ✅ pass (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | -| C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. | ✅ theme-scan CRIT 0 · C-DARKART-002 fixed (pending 1 confirm) · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.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). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). | ✅ pass · finish-gate verified live (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | +| C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. R18b: **C-ORIENT-001 RESOLVED** — `MainActivity` locked to portrait (no landscape design; verified `requestedOrientation=PORTRAIT` holds under forced rotation). C-DARKART-002 confirmed + pruned. | ✅ theme-scan CRIT 0 · C-DARKART-002 + C-ORIENT-001 resolved · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.md` | | D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds | -| E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · Doze/battery = needs-device | +| E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. **R18b: NEW `partner_joined_game` (joiner avatar) + standardized durable in-app game banner** — all foreground game pushes route through the themed banner (started/joined transient, your-turn/results persistent + tappable; foreground OS dupe suppressed, background OS unchanged); verified live per kind + Pass-E smoke 6/6 both emulators (join push pending functions+rules deploy). | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · banner standardized · Doze/battery = needs-device | | F — Resilience | R12: concurrency (F-RACE-001 atomic-start code + R8 live) · process-death (smoke `am kill`×5 → push → cold-start recovered each) · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred | | G — Account creation / fake-account | R10: abuse live via D3 (non-member denied, no self-grant) + invite rules; happy/validation R5-clean (unchanged) | ✅ pass | | H — Branding & artwork | R13: ToT redesign + Premium-unlock modal done. **2026-06-27 brand audit opened 2 backlogs.** | ⚠️ **BRAND-DARK-COVERAGE** (light-only illustrations need dark variants) + **BRAND-ICON-CUSTOM** (~60 generic Material icons → bespoke `glyph_*`) — full asset lists in `ClaudeBrandingReview.md` | @@ -31,7 +31,7 @@ | 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. **R15: 9 open P2 theme defects (C-THEME-001..009); 3 fixed pending 1 confirm (M-001 quiet hours, N-001 Bucket List, N-002 Date Builder); 2 open P3 brand backlogs.** +**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.** --- @@ -63,6 +63,8 @@ All 7 played one complete time through on both devices via the real in-app path; Note: exit each game via "Back to Play" between games so the session closes (B-001 auto-completion fix verified). F-RACE-001 (simultaneous start) fixed — see Pass F. +**R18b — FINISH-GATE (every question must be answered before a game can finish):** Grounding found only **Spin the Wheel** let a player finish with blanks (explicit Skip; `Next` advanced when empty; `End session` submitted the rest as "Skipped"; it's the only game with text boxes). Per user decision ("Hybrid"): Wheel now uses an index-keyed nullable answer store + an **`attemptFinish()` gate** — skip/blank is allowed mid-play, but Finish bounces to the first unanswered prompt with an a11y "N left to finish" banner and submits only when none are blank (enforces non-empty text + ≥1 choice; scale always has a value). The other three already require a pick to advance (ToT/Desire Sync auto-advance on tap; How Well's Continue is disabled until selected) — verified by code + Pass-B observation. **Live (both emulators):** full 2-player Spin-the-Wheel (all 10, mixed written/choice) → completed reveal with **no "Skipped"**; gate bounce + persistent "N left" banner + walk-forward confirmed; then a full 2-player This-or-That ("5/5 in sync"). 3 new `WheelSessionViewModelTest` cases (gaps→bounce/no-submit; all-answered→submit no "Skipped"; completion-walk). 0 FATAL. + ## Pass C — Visual (light + dark), all ~50 routes ~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back). - **Theme-scan execution tracking (MANDATORY):** every Pass C round must record the counts exactly as @@ -102,6 +104,7 @@ Full live two-device run (games + messages): - **Payload privacy (P0) — code audit of every sender + at-rest D1:** `onMessageWritten`, `onGameSessionUpdate` (+ part-finished), `onAnswerWritten`, `onAnswerRevealed`, `createDateMatch`, `onCoupleLeave` — each `data` block carries only routing IDs (type, couple_id, conversation/question/game/session id) + optional **public** avatar URL; titles use only the partner's display name; bodies are static. **No message/answer/date/swipe content, no keys/invite codes/recovery phrases.** At-rest cross-check: latest 6 `conversations/main/messages` all `enc:v1:` (server-blind source). Cross-checks D6. - **Real `onMessageWritten` live re-drive NOT re-run this round** (UI-automation thrash on the composer send button) → carried from **R18 live** (exact copy "Sam sent a message"/"Tap to read and reply", no content) + this round's payload code audit + at-rest D1. - **Doze / battery-optimization / App-Standby delivery → `blocked → needs-device`** (emulators never enter these states; the #1 real-world "notifications don't work" cause). Run on a physical device before any store push (`dumpsys deviceidle force-idle`, app Optimized→Restricted). +- **R18b — `partner_joined_game` + standardized durable in-app game banner (FEATURE):** new push when the non-starter opens an active session → the **starter** gets " joined your game" with the **joiner's avatar** (server-visible join via `joinedByUsers`; one-time `joinNotifiedAt`; `onGameSessionUpdate` branch). All four foreground game pushes now route through the themed `GamePromptBanner` with the partner's avatar + `sender_name`: **started/joined transient (~9s), your-turn/results persistent until tapped**; foreground OS duplicate suppressed; **background OS notification unchanged** (already shows the avatar large-icon — the purple banner is in-app only). **Live (5554):** all four kinds rendered correctly with avatar+name; **RESULTS still shown at 15s vs STARTED auto-dismissed by 12s**; 0 foreground OS dupes; 0 FATAL. **Pass-E cold-start smoke 6/6 on both emulators** (shared path regression-clean). `PartnerNotificationTypeTest` covers the new type's mapping + routing. **`blocked→deploy`: the join push only fires once `functions/` + `firestore.rules` are deployed** (the `joinedByUsers` client write is rule-gated; best-effort + swallowed until then). The banner standardization for already-deployed types works immediately. ## Pass F — Resilience / lifecycle / concurrency / time - **Concurrency race:** F-RACE-001 (P1) fixed + **re-confirmed live (R8):** simultaneous mood-tap on both devices → **1 session** (was 2); race-loser landed on WaitingForPartner → **"Join the game"** → joined the winner's session at the **same Q1** (shared reveal preserved). Archived. *(Minor pre-existing note: loser can alternatively land on Play hub; not seen this run.)* diff --git a/ClaudeReport.md b/ClaudeReport.md index 7bac15b2..a71b2c56 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,6 +18,7 @@ > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. ## Run-state (current) +- **R18b (2026-06-28) — FEATURE: "partner joined your game" push + standardized in-app game banner (user: "notification that your paired partner joined the game … w/ their icon … make that theme the standard").** Decision (discussed): **"Standardize, keep durable."** **New `partner_joined_game`:** the non-starter opening an active session writes their uid to `joinedByUsers` (new field; client `markUserJoined` mirrors `markUserComplete`; `firestore.rules` session allowlist += `joinedByUsers`, server-only `joinNotifiedAt` deliberately excluded); `onGameSessionUpdate` adds a branch that claims a one-time `joinNotifiedAt` and notifies the **starter** " joined your game" with the **joiner's avatar**. Wired the join-write into all 4 games' non-starter paths (ToT/DesireSync/HowWell `joinSession` + Wheel `load()` resume), best-effort + off the critical path (guarded to never fire for the starter). **Standardized banner:** generalized `GamePromptController`/`GamePromptBanner` with a `kind` (STARTED/JOINED/YOUR_TURN/RESULTS) — per-kind copy/action/avatar; **STARTED/JOINED transient (~9s), YOUR_TURN/RESULTS persistent** (stay until tapped/dismissed). `AppMessagingService` now routes all 4 game types to the banner in the **foreground** (suppressing the foreground OS duplicate; background OS unchanged — already shows the avatar large-icon); `AppNavigation` routes the banner action by kind (RESULTS→per-session results, else→the game). Added `sender_name` to the game push `data` so the banner names the partner. **Build + 209 Android unit + 24 functions tests green** (added a `PartnerNotificationTypeTest` case for the new type's mapping + routing). **Live (5554, foreground, real avatar+name):** STARTED "Sam started a game"+Join (avatar loaded), **JOINED "Sam joined your game"+View**, YOUR_TURN "Sam finished their part / Your turn"+Play, RESULTS "Sam finished / See your results"+View — **RESULTS still showing at 15s (persistent) while STARTED auto-dismissed by 12s (transient)**; **0 QA-SMOKE entries in the shade (no foreground OS dupe); 0 FATAL.** **Pass-E regression smoke 6/6 on BOTH emulators** (shared cold-start path clean). **⚠ DEPLOY REQUIRED (user):** the `partner_joined_game` push only fires once `functions/` + `firestore.rules` are deployed to `closer-app-22014` (I can't deploy; prior prod writes were classifier-denied) — until then the `joinedByUsers` client write is denied by the live rules (best-effort, swallowed, no crash). The banner-standardization for already-deployed types (started/your-turn/results) works immediately. Uncommitted (user commits): `QuestionSession.kt`, `QuestionSessionRepository.kt`(+Impl), `GameSessionManager.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `WheelSessionViewModel.kt`, `GamePromptController.kt`, `GamePromptBanner.kt`, `AppMessagingService.kt`, `PartnerNotificationManager.kt`, `AppNavigation.kt`, `PartnerNotificationTypeTest.kt`, `functions/src/games/onGameSessionUpdate.ts`, `firestore.rules`, `Future.md`, `ClaudeReport.md`. - **R18b (2026-06-28) — cleanup + backlog prune (user: "clean up and work on what you can from ClaudeReport.md and Future.md").** **C-ORIENT-001 (P3) → FIXED + verified live:** added `android:screenOrientation="portrait"` to `MainActivity` (no landscape design exists — 0 `*-land` resource dirs); under forced device landscape the activity holds `requestedOrientation=SCREEN_ORIENTATION_PORTRAIT` and renders upright. **Re-confirmed the test gate:** 208 Android unit + 24 functions tests green (re-validates TEST-001/TEST-002 + no regression from the Wheel + manifest changes). **Live-confirmed the last unverified theme fixes in dark (5554):** Date Match heart "View matches" button + match-count badge (C-THEME-008/009) render on `primaryContainer`/`error` (no light-on-light); C-THEME-004/005 confirmed via theme-scan CRITICAL=0 + same-batch sibling live-confirm + build (direct view blocked by a residual active session). **Pruned the entire confirmed backlog** to the archived line (11 IDs + C-ORIENT-001) per the one-confirmation-round rule — **open issues now just 2, both blocked on the user: O-AGE-001 (P2, product/legal age gate) + BRAND-DARK-COVERAGE (P3, needs dark art assets).** Triaged the rest of Future.md as user/device-blocked (release config needs real version+legal URLs+RC key; biometric re-lock-on-background is a UX call + untestable without an enrolled biometric; App Check excluded in dev; proactive-notif/instrumented-smoke/screenshot-diff/skeletons/help-surface are larger features). **Residual test-data:** one active This-or-That session (Sam) created during reinstalls — couldn't clear cleanly (Quit/End-their-game don't cancel server-side; admin write denied; pm-clear forbidden). Uncommitted (user commits): `AndroidManifest.xml`, `ClaudeReport.md`. - **R18b (2026-06-28) — FEATURE: games must be fully answered before finishing (user: "if a user skips a question it makes the user go back and answer it before the game is over … for all games").** Grounding found the four Play-hub games use different models and **only Spin the Wheel** let a player finish with blanks (explicit Skip, `Next` advanced when blank, `End session` submitted the rest as "Skipped", and it's the only game with text boxes); **This or That / Desire Sync / How Well already require a pick to advance** (verified by code — `select()` needs an option / `commitAnswer` guard + `enabled = hasSelection`). Per user decision **"Hybrid"**: implement skip-then-must-complete on the Wheel; leave the other three (no forced skip affordance). **Wheel change (`WheelSessionViewModel.kt` + `WheelSessionScreen.kt`):** answers are now an index-keyed nullable list; `skip()`/blank-`next()` leave a slot `null`; the new **`attemptFinish()` gate** submits only when no slot is `null`, else bounces to the first unanswered prompt and shows a "N questions left — answer them to finish" banner (a11y `liveRegion`); `End session`→`Finish now` (gated); enforces non-empty text + ≥1 choice via the existing `hasValidSelection()`. Category-picker copy updated. **Verified:** build + **205→ unit tests green incl. 3 new `WheelSessionViewModelTest`** (gaps→bounce/no-submit; all-answered→submit with no "Skipped"; completion-walk). **Live (emulator-5554, fresh wheel):** `Finish now` with all blank → banner "10 questions left" + stayed on Q1 (no submit); answered Q1 + `Finish` → jumped to Q2, banner "9 questions left" — gate + banner + walk-forward + text-box enforcement all confirmed; 0 FATAL. **Full end-to-end (both emulators):** played a complete Spin the Wheel on QA **and** Sam (all 10 prompts, mixed written/choice) → session completed → reveal shows both players' real answers with **no "Skipped"**; then played a complete **This or That** on both (5/5 "in sync" reveal) — confirming a second game is fully playable once the wheel is done and ToT requires a pick to advance (no skip). Ended at **0 active sessions** (clean). How Well / Desire Sync left unverified-live (Desire Sync is premium→paywall; How Well unchanged) — both verified by code (require a selection to advance). Adjacent checks (no change): multi_choice has no `minSelections` (≥1 is correct); Daily Question already gated (`canSubmit`); reveal renders legacy "Skipped" as `display ?: "—"` (no crash). **Test-data notes:** cleared a stale stuck wheel session via the in-app reveal→`markUserComplete` path (admin write to flip it was **classifier-denied**, not worked around); live testing then created one new active wheel session (net-neutral) which blocked live-opening the other 3 games — those are verified by code (unchanged) + observed during Pass E. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `CategoryPickerScreen.kt`, `WheelSessionViewModelTest.kt`, `ClaudeReport.md`. - **R18b (2026-06-28) — Pass E full live re-run (user: "run ClaudeQAPLan pass E") — ✅ CLEAN, 0 P0/P1, 0 FATAL.** Both emulators online (5554=QA, 5556=Sam, paired `Xal3Kw3gjSdn0niERYKJ`, both free), fresh FCM tokens (1 each). **Cold-start crash-triage smoke 6/6 on BOTH** (`qa/entrypoint_smoke.sh`: launcher + 5 push types `am kill`→real push→shade-tap→opens&stays, 0 fail/0 blocked) — the shared splash/onCreate path is clean. **Routing (background→tap, landed-screen verified):** 7 types received on Sam + 3 on QA (both-client) — chat→exact conversation, partner_answered & daily_question→Today, started_game & completed_part(tot)→game screen, finished_game(wheel)→per-session results (completed→results, not a dead active session), date_match→Your Matches; every tap correct destination + app alive + 0 FATAL. **Foreground:** partner_started_game→in-app banner (Join/dismiss) ✅; chat_message→draggable chat-head bubble ✅ (verified via real open→back→Home→send + distinct conv id; `conversation_id=main` suppression on a process-death-restored back stack is **by-design read-suppression** via `ActiveThreadMonitor`, clears on normal back-nav — not a defect). **Malformed/stale (all graceful, 0 FATAL):** unknown type→no nav/no crash; chat w/o conversation_id→Messages inbox; started_game w/o game_type→Play hub; finished_game w/ deleted session→graceful waiting state w/ escape. **Payload privacy (P0) clean** — code audit of all 6 senders (`onMessageWritten`/`onGameSessionUpdate`(+part-finished)/`onAnswerWritten`/`onAnswerRevealed`/`createDateMatch`/`onCoupleLeave`): `data` carries only routing IDs + optional public avatar URL, titles use display name only, bodies static; **no message/answer/date/swipe content, no keys/codes/phrases**; at-rest D1 cross-check — latest 6 `conversations/main/messages` all `enc:v1:`. **NOT re-run this round:** real in-app `onMessageWritten` send (UI-automation thrash on the composer send button) → carried from R18 live (exact copy, no content) + this round's code audit + at-rest D1; **Doze/battery/App-Standby = `blocked→needs-device`** (emulators can't enter those states — run on a physical device before store push). **No app-code changes** (pure QA round); touched `ClaudeReport.md` + `ClaudeQACoverage.md` + `ClaudeQAPlan.md` (added a Pass-E guard: `qa_push.js` reproduces the *push* but bypasses the Cloud Function *trigger*, so assertion #1 "trigger fires" needs ≥1 real in-app action per round) (user commits). Confirmed Navigation Compose **restores the back stack across process death** (launcher cold-start lands on the last sub-screen) — expected Android behavior, and the source of the bubble-suppression artifact above. NEXT: real-trigger live re-drive when convenient; physical-device Doze gate; continue other passes. diff --git a/Future.md b/Future.md index beb225c8..781b2d5c 100644 --- a/Future.md +++ b/Future.md @@ -37,10 +37,12 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works illustration; no export-data row exists). Full map in `ClaudeBrandingReview.md`. _(This-or-That backdrop redesign is Codex C-DARK-UI-001.)_ -- **Minor proactive-notification gaps (low priority).** No push when a partner *joins* your active game - (`partner_joined_game`) or *ends/abandons* one (`game_ended`/`game_abandoned`) — the other partner sees it - in-session / on WaitingForPartner, so nothing's broken, just less proactive. *Prompted by:* Pass E (R8) inventory — - these speculative types aren't implemented. +- **Minor proactive-notification gaps (low priority).** ~~No push when a partner *joins* your active game + (`partner_joined_game`)~~ → **BUILT (2026-06-28, pending deploy):** the non-starter opening an active session + writes `joinedByUsers` (rule-gated), `onGameSessionUpdate` notifies the starter " joined your game" with the + joiner's avatar (one-time `joinNotifiedAt`); foreground shows the standardized in-app banner. **Still open:** no push + when a partner *ends/abandons* a game (`game_ended`/`game_abandoned`) — the other partner sees it in-session / + on WaitingForPartner, so nothing's broken, just less proactive. *Prompted by:* Pass E (R8) inventory. - **Clarify Connection Challenges day-progress when partners are out of step.** If one partner catches up a *missed* day ("Pick it back up") while the other doesn't, the two devices show different **"Day N of 7"** (seen R10: QA Day 4 vs Sam Day 3) even though the 🔥 streak stays in sync on both. Not broken (plausibly individual-pace-through-the-series by design), but two people in the same shared challenge seeing different day numbers is confusing — consider a shared "you're on Day N together" framing or a clearer caught-up/ahead indicator. *Prompted by:* Pass B (R10) Connection Challenges playthrough. ### Security hardening (defense-in-depth — not vulnerabilities; rules already hold) diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index b71a77b7..095c37e0 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -573,15 +573,18 @@ fun AppNavigation( // (background delivery uses the OS notification). Tap "Join" → the game route, which // auto-joins the couple's active session on open. app.closer.ui.components.GamePromptBanner( - onJoin = { gameType, sessionId -> - val route = app.closer.notifications.PartnerNotificationType.PARTNER_STARTED_GAME - .routeFor( - app.closer.notifications.PartnerNotificationPayload( - gameType = gameType, - gameSessionId = sessionId - ) - ) - navigateRoute(route) + onAction = { kind, gameType, sessionId -> + val payload = app.closer.notifications.PartnerNotificationPayload( + gameType = gameType, + gameSessionId = sessionId + ) + // Results open the per-session results/replay; start/join/your-turn open the live game. + val type = if (kind == app.closer.notifications.GamePromptKind.RESULTS) { + app.closer.notifications.PartnerNotificationType.GAME_RESULTS_READY + } else { + app.closer.notifications.PartnerNotificationType.PARTNER_STARTED_GAME + } + navigateRoute(type.routeFor(payload)) } ) diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index 3f939537..33678cf8 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -71,17 +71,31 @@ class AppMessagingService : FirebaseMessagingService() { return } - // Partner started a game while we're foregrounded → surface the prominent in-app banner - // instead of the OS notification (mirrors chat). Skip if we're already viewing that game live. - if (type == "partner_started_game") { + // Game pushes while foregrounded → surface the prominent in-app banner (the standard themed + // surface) instead of an OS notification. started/joined are transient presence nudges; + // your-turn/results are durable (the banner persists until tapped). Suppress the in-game + // nudges (started/your-turn) if we're already live on that exact session; presence (joined) + // and the results payoff always show. + val gameKind = when (type) { + "partner_started_game" -> app.closer.notifications.GamePromptKind.STARTED + "partner_joined_game" -> app.closer.notifications.GamePromptKind.JOINED + "partner_completed_part" -> app.closer.notifications.GamePromptKind.YOUR_TURN + "partner_finished_game" -> app.closer.notifications.GamePromptKind.RESULTS + else -> null + } + if (gameKind != null) { val sessionId = message.data["game_session_id"] val alreadyViewing = sessionId != null && sessionId == activeGameSessionMonitor.activeSessionId - if (!alreadyViewing) { + val suppress = alreadyViewing && + (gameKind == app.closer.notifications.GamePromptKind.STARTED || + gameKind == app.closer.notifications.GamePromptKind.YOUR_TURN) + if (!suppress) { gamePromptController.show( coupleId = coupleId, gameType = message.data["game_type"] ?: "", + kind = gameKind, gameSessionId = sessionId, - starterName = message.data["starter_name"], + partnerName = message.data["sender_name"], avatarUrl = message.data["sender_avatar_url"] ) } diff --git a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt index c4985bf2..93a1555e 100644 --- a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt @@ -46,7 +46,8 @@ class QuestionSessionRepositoryImpl @Inject constructor( "isPremium" to session.isPremium, "status" to session.status, "gameType" to session.gameType, - "completedByUsers" to session.completedByUsers + "completedByUsers" to session.completedByUsers, + "joinedByUsers" to session.joinedByUsers ) doc.set(data).await() // Return the (possibly auto-generated) document id so callers can observe the @@ -90,7 +91,8 @@ class QuestionSessionRepositoryImpl @Inject constructor( "isPremium" to session.isPremium, "status" to session.status, "gameType" to session.gameType, - "completedByUsers" to session.completedByUsers + "completedByUsers" to session.completedByUsers, + "joinedByUsers" to session.joinedByUsers ) tx.set(newRef, data) // Re-point the per-couple active-session lock to the new session. @@ -122,6 +124,8 @@ class QuestionSessionRepositoryImpl @Inject constructor( status = doc.getString("status") ?: "active", gameType = doc.getString("gameType") ?: GameType.WHEEL, completedByUsers = (doc.get("completedByUsers") as? List<*>)?.filterIsInstance() + ?: emptyList(), + joinedByUsers = (doc.get("joinedByUsers") as? List<*>)?.filterIsInstance() ?: emptyList() ) @@ -264,6 +268,31 @@ class QuestionSessionRepositoryImpl @Inject constructor( }.await() } + override suspend fun markUserJoined( + sessionId: String, + coupleId: String, + userId: String + ): Result = runCatching { + val docRef = firestore.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.SESSIONS) + .document(sessionId) + + firestore.runTransaction { tx -> + val snap = tx.get(docRef) + // Only the NON-starter "joins"; the starter opening their own session is a no-op. + val startedBy = snap.getString("startedByUserId") ?: "" + if (userId == startedBy) return@runTransaction + @Suppress("UNCHECKED_CAST") + val joinedBy = (snap.get("joinedByUsers") as? List) + ?.toMutableList() ?: mutableListOf() + if (userId in joinedBy) return@runTransaction // already recorded — don't re-fire + joinedBy.add(userId) + // Never changes status; the server function claims its own one-time joinNotifiedAt flag. + tx.update(docRef, mapOf("joinedByUsers" to joinedBy)) + }.await() + } + override suspend fun abandonSession(coupleId: String): Result = runCatching { val activeSession = getActiveSessionForCouple(coupleId) ?: return@runCatching saveSession( @@ -298,6 +327,8 @@ class QuestionSessionRepositoryImpl @Inject constructor( status = doc.getString("status") ?: "completed", gameType = doc.getString("gameType") ?: GameType.WHEEL, completedByUsers = (doc.get("completedByUsers") as? List<*>) + ?.filterIsInstance() ?: emptyList(), + joinedByUsers = (doc.get("joinedByUsers") as? List<*>) ?.filterIsInstance() ?: emptyList() ) }.onFailure { crashReporter.recordException(it) }.getOrNull() diff --git a/app/src/main/java/app/closer/domain/model/QuestionSession.kt b/app/src/main/java/app/closer/domain/model/QuestionSession.kt index 8e58f4ce..2bfecb8c 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionSession.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionSession.kt @@ -12,5 +12,8 @@ data class QuestionSession( val isPremium: Boolean = false, val status: String = "active", val gameType: String = "wheel", - val completedByUsers: List = emptyList() + val completedByUsers: List = emptyList(), + /** Members other than the starter who have opened/joined this active session. Drives the + * `partner_joined_game` notification to the starter. */ + val joinedByUsers: List = emptyList() ) diff --git a/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt b/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt index 31a7d736..972c0304 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt @@ -32,6 +32,10 @@ interface QuestionSessionRepository { // Per-user completion: marks one user done; auto-completes when both users are recorded. suspend fun markUserComplete(sessionId: String, coupleId: String, userId: String): Result + // Records that a non-starter has opened/joined the active session (drives partner_joined_game). + // No-op for the starter; never changes status. + suspend fun markUserJoined(sessionId: String, coupleId: String, userId: String): Result + // Force-complete the active session (escape hatch for stuck/abandoned games). suspend fun abandonSession(coupleId: String): Result diff --git a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt index 047d3e26..0ee04328 100644 --- a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -173,6 +173,17 @@ class GameSessionManager @Inject constructor( return sessionRepository.markUserComplete(sessionId, coupleId, userId) } + /** + * Record that the current user has opened/joined the active session (drives the + * `partner_joined_game` push to the starter). No-op for the starter; never changes status. + * Best-effort — a failure here must never block joining the game. + */ + suspend fun markUserJoined(sessionId: String, coupleId: String): Result { + val userId = authRepository.currentUserId + ?: return Result.failure(Exception("Not signed in")) + return sessionRepository.markUserJoined(sessionId, coupleId, userId) + } + /** * Force-finish the couple's active session without requiring both users to mark done. * Use when one partner wants to release the lock (e.g. partner never played). diff --git a/app/src/main/java/app/closer/notifications/GamePromptController.kt b/app/src/main/java/app/closer/notifications/GamePromptController.kt index eecd5ece..93bf4e3e 100644 --- a/app/src/main/java/app/closer/notifications/GamePromptController.kt +++ b/app/src/main/java/app/closer/notifications/GamePromptController.kt @@ -7,15 +7,23 @@ import javax.inject.Inject import javax.inject.Singleton /** - * State for the in-app "partner started a game" banner. Shown over the app's own UI when a game - * start arrives while the app is FOREGROUNDED (background delivery uses the OS notification). Mirrors - * [MessageBubbleController] — the foreground FCM path posts here, the root UI observes [prompt]. + * What kind of in-app game banner to show. Drives the banner's copy, action label, tap-route, and + * whether it auto-dismisses. STARTED/JOINED are transient presence nudges; YOUR_TURN/RESULTS are + * durable (persist until tapped/dismissed) because they're easy-to-miss, act-on-it moments. + */ +enum class GamePromptKind { STARTED, JOINED, YOUR_TURN, RESULTS } + +/** + * State for the in-app game banner. Shown over the app's own UI when a game push arrives while the + * app is FOREGROUNDED (background delivery uses the OS notification). Mirrors [MessageBubbleController] + * — the foreground FCM path posts here, the root UI observes [prompt]. */ data class IncomingGamePrompt( val coupleId: String, val gameType: String, + val kind: GamePromptKind = GamePromptKind.STARTED, val gameSessionId: String? = null, - val starterName: String? = null, + val partnerName: String? = null, val avatarUrl: String? = null ) @@ -27,12 +35,13 @@ class GamePromptController @Inject constructor() { fun show( coupleId: String, gameType: String, + kind: GamePromptKind, gameSessionId: String?, - starterName: String?, + partnerName: String?, avatarUrl: String? ) { if (coupleId.isBlank() || gameType.isBlank()) return - _prompt.value = IncomingGamePrompt(coupleId, gameType, gameSessionId, starterName, avatarUrl) + _prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl) } fun dismiss() { diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index 84e31060..bc9f3dad 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -205,6 +205,12 @@ enum class PartnerNotificationType( channelId = NotificationChannelSetup.CHANNEL_GAMES, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), + PARTNER_JOINED_GAME( + title = "Your partner joined your game.", + body = "They're in — tap to play together.", + channelId = NotificationChannelSetup.CHANNEL_GAMES, + rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER + ), PARTNER_COMPLETED_PART( title = "Your partner finished their part.", body = "Open yours when you're ready.", @@ -300,6 +306,7 @@ enum class PartnerNotificationType( // session on open), not the generic Play hub — the push says "Tap to join". Falls back to the // hub only if the server didn't send game_type. E-003. PARTNER_STARTED_GAME -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY + PARTNER_JOINED_GAME -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY PARTNER_COMPLETED_PART -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY // Results-ready means the session is COMPLETED, so the plain game route would show "start a // new game" (getActiveSession returns only active sessions). Deep link to the per-session @@ -330,6 +337,7 @@ enum class PartnerNotificationType( "partner_opened_answer" -> PARTNER_OPENED_ANSWER "reveal_ready" -> REVEAL_READY "partner_started_game" -> PARTNER_STARTED_GAME + "partner_joined_game" -> PARTNER_JOINED_GAME "partner_completed_part" -> PARTNER_COMPLETED_PART // Server (onGameSessionUpdate) emits this type once BOTH partners finish — the reveal // is ready, so this maps to the results-ready copy (not "open yours when ready"). 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 d2b892b2..ce00f68f 100644 --- a/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt +++ b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt @@ -43,6 +43,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import app.closer.domain.model.GameType import app.closer.notifications.GamePromptController +import app.closer.notifications.GamePromptKind import app.closer.notifications.IncomingGamePrompt import app.closer.ui.theme.CloserPalette import coil.compose.AsyncImage @@ -74,7 +75,7 @@ private fun gameDisplayName(gameType: String): String = when (gameType) { */ @Composable fun GamePromptBanner( - onJoin: (gameType: String, gameSessionId: String?) -> Unit, + onAction: (kind: GamePromptKind, gameType: String, gameSessionId: String?) -> Unit, viewModel: GamePromptViewModel = hiltViewModel() ) { val prompt by viewModel.prompt.collectAsState() @@ -83,9 +84,11 @@ fun GamePromptBanner( var last by remember { mutableStateOf(null) } LaunchedEffect(prompt) { if (prompt != null) last = prompt } - // Auto-dismiss a few seconds after it appears (it's a transient nudge, not a persistent card). + // Presence nudges (started/joined) auto-dismiss; act-on-it banners (your turn / results) persist + // until tapped or dismissed so they're never missed. LaunchedEffect(prompt) { - if (prompt != null) { + val p = prompt + if (p != null && !styleFor(p).persistent) { delay(9000) viewModel.dismiss() } @@ -101,20 +104,38 @@ fun GamePromptBanner( val p = last ?: return@AnimatedVisibility GamePromptCard( prompt = p, - onJoin = { onJoin(p.gameType, p.gameSessionId); viewModel.dismiss() }, + onAction = { onAction(p.kind, p.gameType, p.gameSessionId); viewModel.dismiss() }, onDismiss = { viewModel.dismiss() } ) } } } +private data class PromptStyle( + val line1: String, + val line2: String, + val action: String, + val persistent: Boolean +) + +private fun styleFor(prompt: IncomingGamePrompt): PromptStyle { + val name = prompt.partnerName?.takeIf { it.isNotBlank() } ?: "Your partner" + val game = gameDisplayName(prompt.gameType) + return when (prompt.kind) { + GamePromptKind.STARTED -> PromptStyle("$name started a game", game, "Join", persistent = false) + GamePromptKind.JOINED -> PromptStyle("$name joined your game", game, "View", persistent = false) + GamePromptKind.YOUR_TURN -> PromptStyle("$name finished their part", "Your turn", "Play", persistent = true) + GamePromptKind.RESULTS -> PromptStyle("$name finished", "See your results", "View", persistent = true) + } +} + @Composable private fun GamePromptCard( prompt: IncomingGamePrompt, - onJoin: () -> Unit, + onAction: () -> Unit, onDismiss: () -> Unit ) { - val name = prompt.starterName?.takeIf { it.isNotBlank() } ?: "Your partner" + val style = styleFor(prompt) Row( modifier = Modifier .statusBarsPadding() @@ -160,19 +181,19 @@ private fun GamePromptCard( Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { Text( - text = "$name started a game", + text = style.line1, style = MaterialTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.85f) ) Text( - text = gameDisplayName(prompt.gameType), + text = style.line2, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), color = Color.White ) } Button( - onClick = onJoin, + onClick = onAction, colors = ButtonDefaults.buttonColors( containerColor = Color.White, contentColor = CloserPalette.PurpleDeep @@ -181,7 +202,7 @@ private fun GamePromptCard( ) { Icon(CloserGlyphs.Play, contentDescription = null, modifier = Modifier.size(18.dp)) Text( - text = "Join", + text = style.action, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 4.dp) ) diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index f24376d0..b48060af 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -230,6 +230,11 @@ class DesireSyncViewModel @Inject constructor( // Re-opened after answering? Skip straight to WAITING instead of re-prompting. val uid = userId val cId = coupleId + // Tell the starter "your partner joined" (best-effort, off the critical path; idempotent + + // no-op for the starter server-side). + if (cId != null) viewModelScope.launch { + runCatching { gameSessionManager.markUserJoined(existingSessionId, cId) } + } val alreadyAnswered = uid != null && cId != null && runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true } .getOrDefault(false) 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 e52d83ee..f4cdcc0f 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -269,6 +269,11 @@ class HowWellViewModel @Inject constructor( if (questions.isEmpty()) return fail("Couldn't load this round's questions.") // Re-opened after answering? Skip straight to WAITING instead of re-prompting. val cId = coupleId + // Tell the starter "your partner joined" (best-effort, off the critical path; idempotent + + // no-op for the starter server-side). + if (cId != null && uid != startedBy) viewModelScope.launch { + runCatching { gameSessionManager.markUserJoined(existingSessionId, cId) } + } val alreadyAnswered = cId != null && runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true } .getOrDefault(false) diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 4e4b6ae2..dbbe9f91 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -276,6 +276,11 @@ class ThisOrThatViewModel @Inject constructor( // straight to WAITING; observeReveal upgrades to the reveal once both are in. val uid = userId val cId = coupleId + // Tell the starter "your partner joined" (best-effort, off the critical path; idempotent + + // no-op for the starter server-side). + if (cId != null) viewModelScope.launch { + runCatching { gameSessionManager.markUserJoined(existingSessionId, cId) } + } val alreadyAnswered = uid != null && cId != null && runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true } .getOrDefault(false) diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index 8921f04f..5a009746 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -100,6 +100,13 @@ class WheelSessionViewModel @Inject constructor( } val session = gameSessionManager.getActiveSession(couple.id) + // Tell the starter "your partner joined" the first time the non-starter opens the + // session (best-effort, off the critical path; idempotent + no-op for the starter). + if (session != null && uid != session.startedByUserId) { + viewModelScope.launch { + runCatching { gameSessionManager.markUserJoined(sessionId, couple.id) } + } + } // Load the exact spun set from its fixed id list so both partners answer the // identical wheel, in the same order, regardless of who spun. val questionIds = session?.questionIds diff --git a/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt b/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt index 2b273cfb..09870751 100644 --- a/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt +++ b/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt @@ -42,6 +42,22 @@ class PartnerNotificationTypeTest { fun `game notifications deep link to play hub`() { assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_STARTED_GAME.routeFor(PartnerNotificationPayload())) assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_COMPLETED_PART.routeFor(PartnerNotificationPayload())) + assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_JOINED_GAME.routeFor(PartnerNotificationPayload())) + } + + @Test + fun `partner joined game is mapped and deep links into the active game`() { + assertEquals( + PartnerNotificationType.PARTNER_JOINED_GAME, + PartnerNotificationType.fromRemoteType("partner_joined_game") + ) + // With a known game type it opens that game (mirrors started/your-turn), not the generic hub. + assertEquals( + AppRoute.THIS_OR_THAT, + PartnerNotificationType.PARTNER_JOINED_GAME.routeFor( + PartnerNotificationPayload(gameType = "this_or_that") + ) + ) } @Test diff --git a/firestore.rules b/firestore.rules index ce2f38d3..d0d6e61e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -353,9 +353,12 @@ service cloud.firestore { allow update: if isCouplesMember(coupleId) // startedByUserId is immutable for direct client writes. && request.resource.data.startedByUserId == resource.data.startedByUserId - // Only session progress/completion fields may change. + // Only session progress/completion/join fields may change. (`joinedByUsers` records the + // non-starter opening the session → drives the partner_joined_game push. The server-only + // `joinNotifiedAt`/`startNotifiedAt`/`finishNotifiedAt` flags are intentionally NOT here, + // so clients can't spoof a "notified" claim — the Cloud Function writes those via Admin.) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['status', 'completedAt', 'completedByUsers']) + .hasOnly(['status', 'completedAt', 'completedByUsers', 'joinedByUsers']) // status is monotonic: stay the same, or transition active → completed (never revert). && (request.resource.data.status == resource.data.status || (resource.data.status == 'active' && request.resource.data.status == 'completed')); diff --git a/functions/dist/games/onGameSessionUpdate.js b/functions/dist/games/onGameSessionUpdate.js index 9714c763..b07ea9be 100644 --- a/functions/dist/games/onGameSessionUpdate.js +++ b/functions/dist/games/onGameSessionUpdate.js @@ -46,7 +46,7 @@ const quietHours_1 = require("../notifications/quietHours"); exports.onGameSessionUpdate = functions.firestore .document('couples/{coupleId}/sessions/{sessionId}') .onWrite(async (change, context) => { - var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; const { coupleId, sessionId } = context.params; // The per-couple active-session lock lives at sessions/_active — it is a pointer, not a // game session, so it must never produce a partner notification. @@ -121,6 +121,39 @@ exports.onGameSessionUpdate = functions.firestore } return; } + // ── Partner joined an active session ───────────────────────────────── + // The non-starter opening the session writes their uid into `joinedByUsers` (client, rule-gated). + // Notify the STARTER once, with the joiner's name + avatar. One-time via `joinNotifiedAt` + // (server-only flag, claimed in a transaction — same pattern as start/finishNotifiedAt). + if (status === 'active' && !currentData.joinNotifiedAt && Array.isArray(currentData.joinedByUsers)) { + const startedBy = currentData.startedByUserId; + const joiner = currentData.joinedByUsers.find((u) => u && u !== startedBy); + if (joiner) { + const claimed = await db.runTransaction(async (tx) => { + const fresh = await tx.get(sessionRef); + const d = fresh.data(); + if (!fresh.exists || !d || d.status !== 'active' || d.joinNotifiedAt) + return false; + const j = Array.isArray(d.joinedByUsers) ? d.joinedByUsers : []; + if (!j.some((u) => u && u !== d.startedByUserId)) + return false; + tx.update(sessionRef, { joinNotifiedAt: admin.firestore.FieldValue.serverTimestamp() }); + return true; + }); + if (claimed) { + const gameType = (_l = currentData.gameType) !== null && _l !== void 0 ? _l : 'wheel'; + const joinerName = joiner === partnerA ? partnerAName : partnerBName; + const joinerAvatar = joiner === partnerA ? avatarA : avatarB; + if ((0, quietHours_1.recipientInQuietHours)(dataFor(startedBy))) { + console.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`); + } + else { + await notifyPartner(db, messaging, startedBy, joinerName, gameType, 'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId, joinerAvatar, sessionId); + } + } + } + return; + } // ── Session completed (reveal ready for both) ──────────────────────── if (status === 'completed' && !currentData.finishNotifiedAt) { const claimed = await db.runTransaction(async (tx) => { @@ -132,7 +165,7 @@ exports.onGameSessionUpdate = functions.firestore return true; }); if (claimed) { - const gt = (_l = currentData.gameType) !== null && _l !== void 0 ? _l : 'wheel'; + const gt = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel'; // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours. if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerA))) { console.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`); @@ -216,7 +249,9 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no ? `${partnerName} finished the game` : notificationType === 'partner_completed_part' ? `${partnerName} finished their part` - : `${partnerName} is playing`; + : notificationType === 'partner_joined_game' + ? `${partnerName} joined your game` + : `${partnerName} is playing`; const notificationPayload = { type: notificationType, title, @@ -262,7 +297,10 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no // Put backgrounded notifications on the Games channel instead of the FCM fallback channel, // so importance/sound and the per-category toggle apply. E-OBS. android: { notification: { channelId: 'game_activity' } }, - data: Object.assign(Object.assign({ type: notificationPayload.type, couple_id: coupleId, game_type: gameType }, (sessionId ? { game_session_id: sessionId } : {})), (senderAvatarUrl && senderAvatarUrl.length > 0 + data: Object.assign(Object.assign({ type: notificationPayload.type, couple_id: coupleId, game_type: gameType, + // The acting partner's display name (public; also in the title) so the in-app foreground + // banner can name them instead of a generic "Your partner". + sender_name: partnerName }, (sessionId ? { game_session_id: sessionId } : {})), (senderAvatarUrl && senderAvatarUrl.length > 0 ? { sender_avatar_url: senderAvatarUrl } : {})), }; diff --git a/functions/dist/games/onGameSessionUpdate.js.map b/functions/dist/games/onGameSessionUpdate.js.map index 222f7b54..02f19560 100644 --- a/functions/dist/games/onGameSessionUpdate.js.map +++ b/functions/dist/games/onGameSessionUpdate.js.map @@ -1 +1 @@ -{"version":3,"file":"onGameSessionUpdate.js","sourceRoot":"","sources":["../../src/games/onGameSessionUpdate.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onGameSessionUpdate.js","sourceRoot":"","sources":["../../src/games/onGameSessionUpdate.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index b5df7054..71e490fb 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const admin = __importStar(require("firebase-admin")); // Initialize the Admin SDK once for every function in this codebase. // Handlers call admin.firestore()/messaging() lazily at invocation time, so a @@ -89,6 +89,8 @@ Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } }); Object.defineProperty(exports, "onGamePartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGamePartFinished; } }); +var wrapReleaseKeyCallable_1 = require("./releaseKey/wrapReleaseKeyCallable"); +Object.defineProperty(exports, "wrapReleaseKeyCallable", { enumerable: true, get: function () { return wrapReleaseKeyCallable_1.wrapReleaseKeyCallable; } }); // NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint // was removed to shrink attack surface. Deployment can be verified via // `firebase functions:list`. If an uptime probe is ever needed, re-add it behind diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 83ad2fbb..396962c6 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/dist/releaseKey/wrapReleaseKeyCallable.js b/functions/dist/releaseKey/wrapReleaseKeyCallable.js new file mode 100644 index 00000000..a29da85d --- /dev/null +++ b/functions/dist/releaseKey/wrapReleaseKeyCallable.js @@ -0,0 +1,160 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.wrapReleaseKeyCallable = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const DEFAULT_AAD = 'closer_release_key'; +exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) => { + var _a, _b, _c, _d, _e; + const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); + } + if (!context.app) { + throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); + } + const recipientUserId = data === null || data === void 0 ? void 0 : data.recipientUserId; + const oneTimeKeyB64 = data === null || data === void 0 ? void 0 : data.oneTimeKey; + if (!recipientUserId || typeof recipientUserId !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'recipientUserId is required.'); + } + if (!oneTimeKeyB64 || typeof oneTimeKeyB64 !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is required.'); + } + // Validate the oneTimeKey is well-formed base64 of a 32-byte AES-256 key. + let oneTimeKey; + try { + oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64'); + } + catch (_f) { + throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is not valid base64.'); + } + if (oneTimeKey.length !== 32) { + throw new functions.https.HttpsError('invalid-argument', `oneTimeKey must be 32 bytes; got ${oneTimeKey.length}.`); + } + const db = admin.firestore(); + // Caller must be in a couple and the recipient must be their partner. + const callerDoc = await db.collection('users').doc(callerId).get(); + const coupleId = (_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId; + if (!coupleId) { + throw new functions.https.HttpsError('failed-precondition', 'Caller is not paired.'); + } + const coupleDoc = await db.collection('couples').doc(coupleId).get(); + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.'); + } + const coupleUserIds = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds; + if (!coupleUserIds || !coupleUserIds.includes(callerId)) { + throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.'); + } + if (!coupleUserIds.includes(recipientUserId)) { + throw new functions.https.HttpsError('permission-denied', 'recipientUserId is not the caller\'s partner.'); + } + if (recipientUserId === callerId) { + throw new functions.https.HttpsError('permission-denied', 'Cannot wrap a release key for yourself.'); + } + // Read recipient's public key. Android stores it at users/{uid}/devices/primary. + const deviceDoc = await db.collection('users').doc(recipientUserId).collection('devices').doc('primary').get(); + if (!deviceDoc.exists) { + throw new functions.https.HttpsError('failed-precondition', 'Recipient has not registered a release-key device. Ask them to open the app first.'); + } + const publicKeyB64 = (_d = deviceDoc.data()) === null || _d === void 0 ? void 0 : _d.publicKey; + if (!publicKeyB64 || typeof publicKeyB64 !== 'string') { + throw new functions.https.HttpsError('failed-precondition', 'Recipient device is missing a public key.'); + } + // The actual Tink wrap requires the Tink runtime, which is available on the server via + // the Node.js tink-crypto package. We import it lazily so missing Tink is a clear runtime + // error rather than a cold-start crash. + let tinkAead; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const tink = require('@tink-crypto/tink-crypto'); + tinkAead = tink.aead; + } + catch (_g) { + throw new functions.https.HttpsError('internal', 'Tink crypto library is not available on the server.'); + } + // Decode the recipient's public keyset and create a HybridEncrypt primitive. + let hybridEncrypt; + try { + const publicKeyset = Buffer.from(publicKeyB64.replace(/^pub:v1:/, ''), 'base64url'); + const publicHandle = tinkAead.cleartextKeysetHandle.read(tinkAead.jsonKeysetReader.withBytes(publicKeyset)); + hybridEncrypt = publicHandle.getPrimitive(tinkAead.hybrid.HybridEncrypt); + } + catch (err) { + console.warn(`[wrapReleaseKeyCallable] public key parse failed for ${recipientUserId}:`, err); + throw new functions.https.HttpsError('failed-precondition', 'Recipient public key could not be parsed.'); + } + // Wrap the one-time key. + const aad = (_e = data === null || data === void 0 ? void 0 : data.aad) !== null && _e !== void 0 ? _e : DEFAULT_AAD; + let ciphertext; + try { + ciphertext = Buffer.from(hybridEncrypt.encrypt(oneTimeKey, Buffer.from(aad, 'utf-8'))); + } + catch (err) { + console.warn(`[wrapReleaseKeyCallable] wrap failed for ${recipientUserId}:`, err); + throw new functions.https.HttpsError('internal', 'Failed to wrap release key.'); + } + // Build the keybox:v1: envelope. The response also exposes the raw components so iOS + // can log them for debugging without learning the Tink-internal layout. + const keyboxB64 = ciphertext.toString('base64url'); + const auditFields = { + action: 'wrap_release_key', + actor: callerId, + recipient: recipientUserId, + coupleId, + aad, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + }; + // Best-effort audit log. Do not fail the wrap if logging fails. + try { + await db.collection('users').doc(callerId).collection('notification_queue').add(Object.assign(Object.assign({}, auditFields), { type: 'wrap_release_key', read: true })); + } + catch (err) { + console.warn(`[wrapReleaseKeyCallable] audit log failed for ${callerId}:`, err); + } + // Tink's ECIES ciphertext is a single opaque blob; the ephemeral public key and MAC are + // embedded inside it. We expose placeholder fields for iOS diagnostics. + const response = { + keybox: `keybox:v1:${keyboxB64}`, + ephemeralPublicKey: '', + ciphertext: keyboxB64, + mac: '', + }; + console.log(`[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}`); + return response; +}); +//# sourceMappingURL=wrapReleaseKeyCallable.js.map \ No newline at end of file diff --git a/functions/dist/releaseKey/wrapReleaseKeyCallable.js.map b/functions/dist/releaseKey/wrapReleaseKeyCallable.js.map new file mode 100644 index 00000000..bd374ed8 --- /dev/null +++ b/functions/dist/releaseKey/wrapReleaseKeyCallable.js.map @@ -0,0 +1 @@ +{"version":3,"file":"wrapReleaseKeyCallable.js","sourceRoot":"","sources":["../../src/releaseKey/wrapReleaseKeyCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts index 1a456517..e1af0f3c 100644 --- a/functions/src/games/onGameSessionUpdate.ts +++ b/functions/src/games/onGameSessionUpdate.ts @@ -96,6 +96,41 @@ export const onGameSessionUpdate = functions.firestore return } + // ── Partner joined an active session ───────────────────────────────── + // The non-starter opening the session writes their uid into `joinedByUsers` (client, rule-gated). + // Notify the STARTER once, with the joiner's name + avatar. One-time via `joinNotifiedAt` + // (server-only flag, claimed in a transaction — same pattern as start/finishNotifiedAt). + if (status === 'active' && !currentData.joinNotifiedAt && Array.isArray(currentData.joinedByUsers)) { + const startedBy = currentData.startedByUserId + const joiner = (currentData.joinedByUsers as string[]).find((u) => u && u !== startedBy) + if (joiner) { + const claimed = await db.runTransaction(async (tx) => { + const fresh = await tx.get(sessionRef) + const d = fresh.data() + if (!fresh.exists || !d || d.status !== 'active' || d.joinNotifiedAt) return false + const j = Array.isArray(d.joinedByUsers) ? (d.joinedByUsers as string[]) : [] + if (!j.some((u) => u && u !== d.startedByUserId)) return false + tx.update(sessionRef, { joinNotifiedAt: admin.firestore.FieldValue.serverTimestamp() }) + return true + }) + if (claimed) { + const gameType = currentData.gameType ?? 'wheel' + const joinerName = joiner === partnerA ? partnerAName : partnerBName + const joinerAvatar = joiner === partnerA ? avatarA : avatarB + if (recipientInQuietHours(dataFor(startedBy))) { + console.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`) + } else { + await notifyPartner( + db, messaging, startedBy, joinerName, gameType, + 'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId, + joinerAvatar, sessionId + ) + } + } + } + return + } + // ── Session completed (reveal ready for both) ──────────────────────── if (status === 'completed' && !currentData.finishNotifiedAt) { const claimed = await db.runTransaction(async (tx) => { @@ -211,7 +246,9 @@ async function notifyPartner( ? `${partnerName} finished the game` : notificationType === 'partner_completed_part' ? `${partnerName} finished their part` - : `${partnerName} is playing` + : notificationType === 'partner_joined_game' + ? `${partnerName} joined your game` + : `${partnerName} is playing` const notificationPayload = { type: notificationType, title, @@ -270,6 +307,9 @@ async function notifyPartner( type: notificationPayload.type, couple_id: coupleId, game_type: gameType, + // The acting partner's display name (public; also in the title) so the in-app foreground + // banner can name them instead of a generic "Your partner". + sender_name: partnerName, // Lets the client deep link a results-ready push to the per-session results/replay screen // (a completed session isn't returned by getActiveSession). E-003 results-ready. ...(sessionId ? { game_session_id: sessionId } : {}),