feat(games): partner game-session push orchestration — in-app notification banner, Firestore rules, Cloud Function, QA docs

This commit is contained in:
null 2026-06-28 22:24:46 -05:00
parent 0aaec3c10f
commit f51a55743c
25 changed files with 444 additions and 52 deletions

View File

@ -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 `<bitmap>` `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 `<bitmap>` `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 **KO** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **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 "<Name> 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.)*

View File

@ -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** "<Name> 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.

View File

@ -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 "<Name> 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)

View File

@ -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))
}
)

View File

@ -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"]
)
}

View File

@ -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<String>()
?: emptyList(),
joinedByUsers = (doc.get("joinedByUsers") as? List<*>)?.filterIsInstance<String>()
?: emptyList()
)
@ -264,6 +268,31 @@ class QuestionSessionRepositoryImpl @Inject constructor(
}.await()
}
override suspend fun markUserJoined(
sessionId: String,
coupleId: String,
userId: String
): Result<Unit> = 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<String>)
?.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<Unit> = 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<String>() ?: emptyList(),
joinedByUsers = (doc.get("joinedByUsers") as? List<*>)
?.filterIsInstance<String>() ?: emptyList()
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()

View File

@ -12,5 +12,8 @@ data class QuestionSession(
val isPremium: Boolean = false,
val status: String = "active",
val gameType: String = "wheel",
val completedByUsers: List<String> = emptyList()
val completedByUsers: List<String> = 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<String> = emptyList()
)

View File

@ -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<Unit>
// 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<Unit>
// Force-complete the active session (escape hatch for stuck/abandoned games).
suspend fun abandonSession(coupleId: String): Result<Unit>

View File

@ -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<Unit> {
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).

View File

@ -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() {

View File

@ -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").

View File

@ -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<IncomingGamePrompt?>(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)
)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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'));

View File

@ -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 }
: {})),
};

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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"}
{"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"}

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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 } : {}),