chore: working tree changes — QA docs, app tweaks, Cloud Functions updates

This commit is contained in:
null 2026-06-27 13:31:09 -05:00
parent 9c84c36443
commit 2cd0af65a8
14 changed files with 542 additions and 33 deletions

View File

@ -70,6 +70,19 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
blocker — don't halt the whole run for it.
## Methodology (every pass)
- **EVIDENCE OVER ASSUMPTION — read the logs, never assume, always verify (the #1 rule).** Every conclusion —
`pass`, `fail`, `fixed`, "it works", "the notification didn't open" — must be backed by **observed evidence**, never
by what the UI *appears* to do or by reasoning about the code. Concretely:
- **Read `logcat` on EVERY action, not only when something looks wrong.** `logcat -c` before a tap/flow, then after,
scan for `FATAL EXCEPTION`/ANR/`PERMISSION_DENIED`/exceptions. **Absence of a visible symptom ≠ success** — a screen
that "looks fine" can be masking a swallowed exception, a denied read, or a crash on another device.
- **Verify with ground truth, not appearance:** confirm persisted state via **admin reads** (Firestore), confirm
delivery via `notification_queue`/`dumpsys notification`, confirm routing via the landed screen + back stack,
confirm encryption via the raw stored bytes. "Looked right" is not verified.
- **Don't theorize a root cause — reproduce it and read the stack.** If behavior is "didn't work / closed / flashed",
pull the crash log FIRST (this session's bug was misdiagnosed by reasoning until the live stack named the splash NPE).
- **Don't trust a synthetic pass** (`am start`, admin write, direct call) for launch/notification/permission paths —
verify through the **real** channel (see Reproduction fidelity). A green that didn't exercise the user's path is not green.
- Devices: **5554 (QA)**, **5556 (Sam)**, paired; one **fresh throwaway account** for pre-pairing flows.
- Drive via adb tap/swipe; resolve coords from `uiautomator dump` bounds; downscale screenshots to read;
scan `logcat` for `FATAL EXCEPTION`/ANR on each screen.
@ -94,7 +107,13 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
- **Device/OS matrix:** don't certify on one emulator only — cover **minSdk + targetSdk**, a **small** and a **large**
screen, and at least one **physical device** (App Check / Play Integrity behave differently on emulators).
- **Automate the regression smoke:** capture the smoke checklist as a runnable script (adb/Maestro) so every round
re-checks it cheaply instead of by hand.
re-checks it cheaply instead of by hand. **Built:** `qa/entrypoint_smoke.sh <serial> <recipient_uid>` (+ helper
`qa/qa_push.js`) — the cold-start / entry-point launch-integrity smoke. It launches via the launcher AND sends a
**real** push to a killed (`am kill`) app and **taps the actual OS notification** for each type, asserting the app
**opens and STAYS** (process alive, 0 FATAL, off the launcher). This is the smoke that catches the "opens-and-closes"
splash-crash class that `am start` can't. Run it **every round and after any commit touching MainActivity / splash /
theme / manifest / nav / notifications**. `FAIL` = an app crash (real bug); `BLOCK` = push not delivered (flaky
emulator FCM — rerun, not a bug).
- **Test-data hygiene:** keep known test accounts; clean up artifacts (stray messages/reactions/sessions) between
rounds so they don't masquerade as bugs.
- **Evidence standard:** every filed bug must be reproducible from text alone: build/commit, device, account, theme,
@ -104,6 +123,19 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
- **Flake policy:** if something fails once and then passes, do not dismiss it. Repeat from a clean state, vary timing
(rapid tap / slow network / background-resume), inspect logs, and file it as intermittent if it cannot be made fully
deterministic. Intermittent routing, notification, encryption, duplicate-write, or crash behavior is still a bug.
- **Reproduction fidelity (how we catch DEEP bugs) — the test harness must exercise the SAME path as the user.** A
synthetic shortcut (`am start` extras, admin writes, calling a function directly, `am force-stop`) can **pass while the
real path crashes** — the splash-handover NPE only fires on a real notification cold-start, and `am force-stop` can't
even receive FCM. So for launch / notification / permission / IPC / deep-link behavior, reproduce through the **real OS
mechanism** (real push tapped from the shade, real launcher cold-start, real permission dialog). Record **which angle**
proved it in `ClaudeQACoverage.md`; "synthetic/UI-shortcut only" is **not** a pass for these paths.
- **Symptom→inspection reflexes (apply before theorizing a root cause):** (1) "opens-and-closes / flashes / silently
fails" ⇒ it's a **crash until the stack says otherwise**`logcat -c` then capture `FATAL EXCEPTION` from the live
repro **before** proposing a cause (don't fix by reasoning, like the routing red-herring on this very bug). (2)
**Many features break at once ⇒ inspect the SHARED code path** (launch/`onCreate`/splash/auth/key-load), not each
feature. (3) "worked before, broken now" ⇒ `git blame`/`git log -L` the failing line to the introducing commit. (4)
Treat cosmetic/branding/theme/manifest/splash commits as **capable of deep crashes** — re-run the cold-start +
notification smoke after them.
## Living discovery ritual (before each round, and whenever reality disagrees with the docs)
The app is allowed to grow; the QA plan must keep up. Before a pass or chunk, quickly inventory the current code/app
@ -136,7 +168,12 @@ from as many **independent angles** as apply — not just the in-app happy path:
- **Admin inspection (ground truth)** — read the RAW stored docs/objects (admin bypasses rules) to assert what is
actually persisted: ciphertext only, no plaintext, no raw keys/invite-seeds, no private content in pushes.
- **Concurrency / race** — two partners (or two rapid taps) hit the same thing at once.
- **Killed / cold state** — force-stop, then deliver + tap a notification; cold-start straight onto a deep link.
- **Killed / cold state** — kill with **`am kill <pkg>`**, NOT `am force-stop`: a force-stopped app is in Android's
*stopped* state and is **excluded from FCM broadcasts** (`GCM broadcast …result=CANCELLED`), so the push never
arrives and you get a false "no notification". Then deliver a **real** push and **tap the actual OS notification**
(one at a time — clear the shade first; tapping a *grouped summary* launches with no extras and falsely lands on
Home). `am start … --es type …` is **not** equivalent to a real notification tap (different launch path — see the
crash-triage note in Pass E). Also cold-start straight onto a deep link.
- **Malformed / abusive input** — oversized, empty, rapid-fire, injection-ish, forged FCM payloads, replayed/expired
tokens & invite codes.
- **Offline / flaky** — drop network mid-action → graceful failure, recover on reconnect.
@ -170,6 +207,20 @@ State lives in **files**, not memory:
relevant pass before ending the chunk. Also add the matching row/cell to `ClaudeQACoverage.md` if it needs recurring
verification. Do this even after the immediate bug is filed/fixed so the lesson or newly discovered surface is not
lost to memory or git history.
- **Learn from every ESCAPED or DEEP bug — MANDATORY retrospective (do this automatically, not only when asked).**
Any bug that (a) **escaped a prior round**, (b) needed **non-obvious diagnosis** (a crash, an "opens-and-closes",
a "didn't work", an intermittent, a wrong-root-cause first guess), or (c) **recurred** triggers a short retrospective
the moment it's fixed — the fix is **not complete** until all four are done:
1. **Add the guard that would have caught it** — a new `qa/` smoke check, a coverage row, or a concrete pass step
(e.g. the cold-start bug → `qa/entrypoint_smoke.sh`). If an existing smoke missed it, extend the smoke.
2. **Record the generalizable inspection lesson** in the relevant pass of this doc AND in `memory/` (how to *find*
this class next time — the reflex, not just the fix).
3. **Name the missing state/angle/entry-point** that let it hide and add it to the multi-angle / state matrices so it's
exercised every round (e.g. "real notification tap on an `am kill`'d app", not just `am start`).
4. **Note any wrong turn in diagnosis** so the misstep isn't repeated (e.g. "synthetic test passed while the real
path crashed → don't fix by reasoning; reproduce via the real channel + read the stack").
This is how the plan self-improves between rounds — treat the human pointing out a missed bug as a signal the plan had
a gap, and close the gap here, not just the bug.
- **Commit cadence**: commit `ClaudeReport.md` + `ClaudeQACoverage.md` after each pass and each chunk.
- **Chunking**: run small chunks (Pass C one screen-group; Pass A one feature), checkpoint after each.
- **Session-start ritual**: (1) read run-state header + both MD files; (2) `adb devices` shows **both** emulators
@ -233,7 +284,7 @@ Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSync
`ui/dates/{DateMatch,DateMatches}Screen`, `ui/memorylane/MemoryLaneScreen`, `ui/challenges/ConnectionChallengesScreen`.
Also: **any VM/screen calling `EntitlementChecker.isPremium()` directly** (grep for it) is a candidate gate.
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through)
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through ALL different play stayles of the game)
Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match.
- **PLAY AS THE USER (mandatory mindset for this pass):** drive every game **the way a real user would** — reach it
through the actual in-app navigation a person would tap (Play hub → the game's card → its buttons), **not** via
@ -258,6 +309,13 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
(after the natural paths) deep-link/crafted intent + cold-start from a push. A game isn't complete unless **both**
partners can **start, join, resume, finish, reopen results, and recover from a stale/ended session** — with no
duplicate sessions, wrong routes, stuck waiting screens, broken back nav, or premium-gate mistakes.
- **FIRST-FINISHER → WAITING-PARTNER NOTIFICATION (mandatory state — async games):** explicitly exercise the asymmetric
state where **one partner finishes their part and the OTHER is idle/away**. The waiting partner MUST get a "your turn
to play" nudge (`partner_completed_part` via `onGamePartFinished`) the moment the first finishes — async games
(this_or_that / wheel / how_well / desire_sync) only flip to `completed` (→ `partner_finished_game`) once BOTH answer,
so without the first-finish nudge the waiting partner is told nothing. Verify the **idle partner** (on Home, or
backgrounded/killed) actually receives + can tap into the game. (This state was missed for a long time precisely
because QA always played both sides through; "one finishes, the other never played" is its own required angle.)
- **VARY THE STYLE OF PLAY (don't just repeat the happy path):** across runs, deliberately exercise *different* ways a
real couple would play each game, because different inputs hit different code paths:
- **Different DEPTHS and QUESTION COUNTS — cover the matrix, don't settle for one combo:** play each game across
@ -294,11 +352,35 @@ Account); Paywall; Your Progress/Activity; Recovery.
empty-state image, pack art, celebration asset, and dark/light variant in context. It should feel intentionally
integrated with the page hierarchy, copy, spacing, and action area — not like a forgotten placeholder dropped into
an empty slot. Check crop, scale, padding, alignment, corner radius, background/tile treatment, theme variant,
loading/fallback state, and whether the image competes with or clarifies the primary task. If it is broken,
clipped, low-contrast, off-brand, stale, or placeholder-looking, file a bug in `ClaudeReport.md`; if the screen
**edge treatment**, loading/fallback state, and whether the image competes with or clarifies the primary task. If it is
broken, clipped, low-contrast, off-brand, stale, or placeholder-looking, file a bug in `ClaudeReport.md`; if the screen
works but would benefit from new/better art, log the prompt need in `ClaudeBrandingReview.md`.
- **SOFT EDGES — art must fade into the screen, not show a hard tile edge (mandatory):** every displayed illustration
should **blend/feather softly into the background**, not sit as a hard-edged rounded rectangle/card with a visible
boundary or border line. Inspect each illustration's edges against the screen on **both themes** — a crisp tile edge,
outline/border, or a pale block floating on the surface is a finding (C-ART-EDGE-001). (Current state: `BrandIllustration`
hard-`clip`s to `RoundedCornerShape` + a hairline `border`, and `EmptyState` renders raw, so the art's near-white tile
shows a hard edge.) Fix pattern: feather the edges to transparent (radial/linear fade mask via
`Modifier.graphicsLayer{compositingStrategy=Offscreen}` + `drawWithContent` `BlendMode.DstIn` gradient), or a vignette
matching the surface, or ship transparent-edged art — applied in the shared `BrandIllustration`/`EmptyState` helpers so
it's consistent everywhere.
- **Probe:** `ui/theme/Theme.kt` hardcoded brand colors + chat's custom `closerBackgroundBrush` — verify dark mode
truly adapts; grep screens for hardcoded `Color(0x...)`.
- **THEME-VARIANT ART must follow the IN-APP theme, not just the system (mandatory — RUN THE DECOUPLED STATE):** the app
has its own theme toggle (Settings → Appearance → Light/Dark/Device) that swaps Compose colors but does **not** change
the Android config `uiMode`, while `-night` drawables (`drawable-night-nodpi/`) and `painterResource` resolve off the
**system** `uiMode`. So art can mismatch the UI when the two disagree. **Test the decoupled state explicitly, every
round:** force system light then set the app to **Dark**, and force system dark then set the app to **Light**, and on
every screen that has a dark art variant confirm the illustration matches the **in-app** theme (no bright/light tile on
a dark screen, no dark tile on a light screen). Commands:
`adb -s <serial> shell cmd uimode night no` (system light) / `… night yes` (system dark); then toggle the in-app theme
in Appearance. Screens with `-night` variants to check: Security (privacy_recovery), Memory Lane, Bucket List, Answer
History, Date Match (empty + success), Connection Challenges header, Pairing success, Messages empty, Past Games,
Quiet-hours, Account-deletion, + any new `illustration_*` added to `drawable-night-nodpi/`. **Restore `cmd uimode night
auto` after.** Light art on a dark screen (or vice-versa) when the in-app theme is switched = bug (P2 theme-not-adapting;
see C-DARKART-001). Fix pattern: drive the resource `uiMode` from the in-app theme (e.g. a themed `Resources` in the
shared `BrandIllustration` helper, or `AppCompatDelegate.setDefaultNightMode`/config override) so `painterResource`
picks `-night` per the app's own setting.
- **States, not just happy path:** empty / loading / error / not-paired / locked-premium / signed-out /
stale-or-deleted-target / populated-with-many where they exist; many need data setup (seeding is user-gated) — note
unreachable states in coverage rather than skipping silently.
@ -429,6 +511,25 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
restarts, or silently fails, classify whether it was wrong routing, missing extras, stale data, permission denial, or
a crash. Any notification tap that crashes (example class: tapping a game notification to open **Spin the Wheel**)
is a filed bug with stack trace + exact payload/session/game type, not a vague "didn't open" note.
- **Test the REAL launch path, not a synthetic one.** `adb am start … --es type=…` does **not** reproduce a real
notification tap: the OS notification tap launches the activity through the **SysUILaunch splash handover**
(`reportSplashscreenViewShown` → `handOverSplashScreenView`), which `am start` skips. A whole bug class
(e.g. the **splash-exit `provider.iconView` NPE** — the handover delivers a splash view with **no icon**,
`SplashScreenView: Icon: view: null`, on notification cold-starts only) crashes onCreate → "Force finishing
activity" → the app **opens-and-closes**, yet `am start` AND the normal launcher icon both pass. Verdict: for
cold-start/notification routing, a synthetic-intent pass is **not** a pass — confirm with a real push tapped from
the shade on an `am kill`'d app.
- **"Opens and closes / flashes / returns to launcher" ⇒ assume a crash; pull the stack FIRST.** `logcat -c`
before the tap, then grep `FATAL EXCEPTION|AndroidRuntime|Force finishing|getIconView`. A real repro + the stack
trace beats code-reasoning every time (this bug was misdiagnosed as deep-link routing until the live stack named
`MainActivity.kt` + `SplashScreenViewProvider.getIconView`). Confirm crashes reach **Crashlytics** so field cold-start
crashes surface.
- **Many notification types "broken" at once ⇒ suspect the SHARED entry path (splash/`onCreate`/launch), not each
handler.** When chat AND every game's results push all fail identically, the bug is in what they share (the
cold-start path), not per-type routing. Re-run a **cold-start smoke after ANY change to** `MainActivity` / splash /
theme / manifest / launchMode / branding-"loading state" commits — these cosmetic-looking changes broke the launch.
- **For "worked before, broken now": `git blame` / `git log -L` the crashing line/function** to pin the introducing
commit, then re-test that exact path on it.
- **Both-client × app-state matrix (per type):** QA→Sam and Sam→QA, each in **foreground / background / killed
(cold-start)**, plus **already on the target screen**, **on a different screen**, **logged out**, **unpaired**, with
a **stale/expired/completed/deleted target**, and **both users opening around the same time**. Not a `pass` unless it
@ -459,6 +560,9 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
`not implemented→Future.md` (don't count as pass):
`chat_message`(onMessageWritten → partner → conversation; foreground→chat-head bubble) ·
`partner_started_game`/`partner_finished_game`(onGameSessionUpdate → partner → game/join · results/reveal) ·
`partner_completed_part`(**onGamePartFinished** → waiting partner → game; fired when the FIRST player finishes an
async game so the partner is told "your turn" — async games complete only when BOTH answer, so without this the
waiting partner got nothing between first-finish and both-finish) ·
`join_game`/`game_invite` & `partner_joined_game` (if present → partner/starter → join screen · waiting-room update) ·
`partner_answered`(onAnswerWritten → partner → reveal) ·
`game_abandoned`/`game_ended` (if present → partner → safe ended state, not a stuck session) ·
@ -509,6 +613,13 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
- **Lifecycle / process death:** background mid-flow + return; force-kill the app and relaunch (Android may kill the
process) — state/auth/draft restore sanely; deep-link/notification after process death still loads (verified for
chat — extend to all). Rotation/config-change doesn't lose Compose state. Low-memory.
- **Cold-start launch integrity from EVERY entry point (Pass F OWNS this — it's the shared path no other pass owned, and
where the splash-crash hid):** the app must **open AND stay** (no crash, no "opens-and-closes", lands off the launcher)
when cold-started from: the **launcher icon**, **each notification type tapped from a killed (`am kill`) app**, a
**deep link**, and any widget/quick-action. This is the `MainActivity`/splash/`onCreate`/auth-bootstrap path; a crash
here (e.g. splash-exit `iconView` NPE) breaks **all** notifications at once. **Run `qa/entrypoint_smoke.sh` here every
round and after any MainActivity/splash/theme/manifest/nav/notification change.** Reproduce via the REAL push tapped
from the shade (not `am start`); "opens-and-closes" ⇒ pull the FATAL stack (see Pass E crash-triage).
- **Network resilience:** offline / flaky / airplane mid-action across answers, games, dates (not just chat media) —
graceful failure + retry/queue, no crash, no silent data loss, recovery on reconnect.
- **Idempotency / rapid input:** double-tap send/submit, rapid nav, double-start, double-join, repeated paywall-unlock
@ -534,6 +645,10 @@ written as ready-to-paste ChatGPT image-generation prompts** — the user genera
image supports the screen's job, aligns with the surrounding typography/actions, has enough breathing room, and uses
the right light/dark treatment. Art that looks generic, unfinished, randomly placed, or visually disconnected is a
finding even if the bitmap itself is technically valid.
- **Soft edges (art melts into the surface):** illustrations should **fade/feather into the screen background**, not read
as a hard-edged tile/card with a crisp boundary or outline. Confirm edge treatment on both themes; a hard tile edge is
a finding (C-ART-EDGE-001). Generated art should carry **transparent/feathered edges** (no baked-in rounded-rect block);
if rendered, the shared helper should fade the edges to the surface. Record the desired edge treatment in each prompt.
- **First, lock the house style (do this once per round, refresh if the art evolved):** read `docs/brand/visual-identity.md`
+ `docs/brand/asset-system.md` AND open 23 existing illustrations (`illustration_couple_onboarding`,
`illustration_reveal_celebration`, `pack_art_*`) to capture the *actual* look. New screens/features since the last
@ -655,6 +770,12 @@ Optimize every QA doc for a reader who has **5 seconds** to find the current sta
device/theme) + regression smoke (launch/no-crash, send text, inbox loads, a game opens, **content still ciphertext
in Firestore**) → flip its row to **Fixed** + **commit** (one per issue/cluster) → next. Don't start the next until
the current is verified.
- **Real-path verification gate (do NOT mark Fixed without it):** verify the fix through the **same path the user hits**,
not a synthetic shortcut. A crash/launch/notification fix is only "Fixed" once reproduced-then-cleared via the REAL
channel (real push tapped from the shade on an `am kill`'d app; real launcher cold-start) — `am start`/`am force-stop`
passes don't count. For any cold-start/notification/launch fix, the gate is **`qa/entrypoint_smoke.sh` green**. (This
session's miss: a routing "fix" was declared on `am start` evidence while the real bug was a splash crash on the FCM
cold-start. Don't repeat it.)
- **Couple-shared premium fix**: replace direct `isPremium()` gates with
`CouplePremiumChecker.coupleHasPremium(partnerId)` in every gated VM/screen (partner-entitlement read rule deployed).
**High regression risk** — re-verify each feature in BOTH self-premium and free states.
@ -665,7 +786,8 @@ Optimize every QA doc for a reader who has **5 seconds** to find the current sta
`blocked→id`; a **round** is done when all passes (AJ) are done; **flawless** = one full round with **zero open P0P2
and Passes D + E fully clean** (no open P0/P1 in I/J), **every game fully played through, every notification type
verified or explicitly `not implemented→Future.md`, all join-game navigation paths and all back-stack checks
verified**. Then stop (P3s optional). Don't re-open a clean pass within the same round.
verified**, **and `qa/entrypoint_smoke.sh` GREEN on both emulators (0 FAIL — every entry-point cold-start opens and
stays)**. Then stop (P3s optional). Don't re-open a clean pass within the same round.
## Re-QA loop (until flawless)
After the fix phase, re-run Pass AJ (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full

View File

@ -29,10 +29,10 @@
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | 0 |
| P2 | **0** | **5** |
| P3 | **1** | 0 |
| P2 | **1** | **5** |
| P3 | **2** | 0 |
## Issues — R10 (5×P2 fixed + verified live this round, pending 1 confirm; J-OBS P3 open)
## Issues — R10 (1 open P2 [C-DARKART-001] · 5×P2 fixed pending 1 confirm · 2 open P3 [J-OBS, C-ART-EDGE-001])
> Each P2 below was found in R10's report-only passes, then fixed + verified live in the R10 fix phase (build
> succeeded, both emulators reinstalled, 0 FATAL, content still `enc:v1:` at rest). Per hygiene they survive **one**
> confirmation round, then prune. **Fix commits are in the working tree (user commits).** Quick fix summary:
@ -42,6 +42,8 @@
| ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---|
| C-ART-EDGE-001 | P3 | Art / edge treatment (Pass C+H, R10) | **Displayed illustrations have hard edges instead of fading into the screen.** `BrandIllustration` (`BrandIllustration.kt:35-39`) hard-`clip`s art to `RoundedCornerShape(28.dp)` + a hairline `border`, and `EmptyState` (`EmptyState.kt:43`) renders raw `painterResource` — so the art's near-white/tile background reads as a crisp rounded-rectangle card boundary on the screen (especially visible on dark theme) instead of blending in. Affects every tiled illustration app-wide, both themes. | Any art screen (e.g. Security padlock, Memory Lane, empty states): the illustration shows a hard tile edge/outline rather than feathering into the background. | Feather the edges to transparent in the shared `BrandIllustration`/`EmptyState` helpers (radial/linear fade mask via `graphicsLayer{compositingStrategy=Offscreen}` + `drawWithContent` `BlendMode.DstIn` gradient), or a vignette matching the surface; OR ship transparent/feathered-edge art. Apply once in the shared helpers for consistency. | **Open (P3)** |
| C-DARKART-001 | P2 | Theme / dark-mode art (Pass C, R10) | **Dark-mode illustrations don't follow the IN-APP theme switch — only the system dark mode.** The in-app toggle (Settings → Appearance → Dark) swaps Compose colors via `CloserTheme(darkTheme=…)` but there's **no** `AppCompatDelegate.setDefaultNightMode`/config `uiMode` override, so `painterResource` + the `drawable-night-nodpi/` variants resolve off the **system** `uiMode`. Result: a user who switches the app to Dark while their phone is in light mode gets **dark UI + light illustrations** (bright tile clashing on the dark screen). Affects all 12 `-night` illustrations. | 5554: `cmd uimode night no` (system light) → Settings → Appearance → **Dark** → Security shows the **light** padlock tile on a dark screen. With `cmd uimode night yes` the **dark** aubergine variant correctly appears → proves the art follows system, not the app. | Drive the resource `uiMode` from the in-app theme: a themed `Resources` in the shared `BrandIllustration` helper (load the bitmap from a `createConfigurationContext` with `UI_MODE_NIGHT_*` set from the app theme), OR `AppCompatDelegate.setDefaultNightMode` / `applyOverrideConfiguration`. Verify every `-night` screen after. | **Open (P2)** |
| C-NAV-003 | P2 | Nav / duplicate header (Pass C, R10) | **Two stacked app bars (double back arrow) on Wheel History / Past Games** — regression of the C-CC-001 class. `WHEEL_HISTORY` + `GAME_HISTORY` are in `shellBackRoutes` (AppNavigation.kt:615-616) so the shell draws a "Wheel History" back bar, but `GameHistoryScreen` (`WheelHistoryScreen.kt:69-72`) renders its **own** `Scaffold`+`TopAppBar("Past Games")` → two title bars + two back arrows stacked. A grep of every `shellBackRoutes` screen shows exactly two that own a `TopAppBar`: GameHistory (verified live) and **PartnerHome** (`PartnerHomeScreen.kt:228`, `PARTNER_HOME` in shellBackRoutes:594, reachable via Home StreakCard `onPartner`) — same defect, code-confirmed. | 5554: Play → Spin the Wheel → History → screen shows "← Wheel History" bar over "← Past Games" bar over "Past games" content. | Remove `WHEEL_HISTORY`, `GAME_HISTORY`, `PARTNER_HOME` from `shellBackRoutes` (the screens own their headers) — exactly the fix applied to CONNECTION_CHALLENGES for C-CC-001 (see comment at AppNavigation.kt:609). | **Fixed + verified live R10 (working tree; user commits)** |
| C-PW-001 | P2 | Paywall / dark-mode contrast (Pass C, R10) | **Paywall "What's included" benefit pills are near-invisible in dark theme.** `BenefitPill` (`PaywallScreen.kt:246`) draws a hardcoded **light** background `CloserPalette.PurpleMist` but sets text `color = MaterialTheme.colorScheme.onSurface`, which is near-white on dark → light-on-light, text barely legible (the `PurpleDeep` checkmark stays visible). Light theme is fine. Same class as the fixed C-DS-001. | 5554 (dark): Play → Desire Sync (both free) → Paywall → "What's included" list rows show white pills with unreadable labels (confirmed via crop; text present in hierarchy). 5556 (light): same rows legible. | Give `BenefitPill` text a fixed dark brand color (e.g. `CloserPalette.PurpleDeep` / `0xFF56306F`, matching the checkmark) since the pill background is always light, OR make the pill background theme-adaptive. | **Fixed + verified live R10 (working tree; user commits)** |
| C-NAV-002 | P2 | Nav / back-stack (Pass C, R10) | **Finishing Spin-the-Wheel → results → system BACK re-enters the completed Wheel Session play screen** (shows "10/10, Finish/Skip/End session"), then BACK again → Wheel hub. The session→complete nav uses plain `navigateRoute` (`else → navController.navigate`, no `popUpTo`), so the finished play screen stays on the back stack. This is the "WATCH — wheel back-stack" item flagged in B; now deliberately reproduced on 5556 (not an automation artifact). | 5556: play a wheel to completion → auto-lands on Complete/results → BACK → lands inside the finished Wheel Session screen. | When navigating WHEEL_SESSION→WHEEL_COMPLETE, `popUpTo(WHEEL_SESSION){inclusive=true}` (or pop in WheelSessionScreen on navigateTo) so BACK from results returns to the wheel hub/Play, not the finished play screen. | **Fixed + verified live R10 (working tree; user commits)** |
@ -58,7 +60,50 @@ A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-
- **D4 keys:** couple key phrase-wrapped (argon2id); recovery phrase server-blind; `encryptedRecoveryPhrase` wiped on acceptance; plaintext `inviteCode` not exploitable (invite readable only by inviter; no code-encrypted secret persisted).
- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
## ⛔ "All notifications broken / app opens-and-closes" — ROOT CAUSE = splash crash (FIXED R10)
**The actual cause was NOT routing — it was a crash in the splash-screen exit animation on notification cold-starts.**
`MainActivity.onCreate` (added in **`95cad84`, 2026-06-25**) set `splashScreen.setOnExitAnimationListener { provider -> provider.iconView.animate()… }`.
On a **notification / PendingIntent cold-start** the OS hands the splash view over **without an icon** (`SplashScreenView: Icon: view: null`),
and `provider.iconView` throws an internal `NullPointerException` (`SplashScreenViewProvider$ViewImpl31.getIconView`) →
`onCreate` crashes → "Force finishing activity" → **the app opened and immediately closed on EVERY notification tap**
(chat, game-start, results — all of them, because they share the cold-start path). This is why it looked like "all
notifications broke again." Normal launcher cold-starts were fine (icon present), which masked it.
- **Why my earlier `am start` tests missed it:** shell `am start` uses a different splash transfer than the FCM
PendingIntent handover (the SysUILaunch remote transition), so it didn't hit the null-icon handover. Also `am
force-stop` can't receive FCM at all (stopped-package broadcast exclusion) — must use `am kill` to test killed-app push.
- **Fix (R10, working tree):** `MainActivity` wraps the icon scale in `runCatching` (best-effort) and the view fade in
`runCatching { … }.onFailure { provider.remove() }` so the splash is **always** removed and onCreate **never** crashes.
- **Verified live:** real FCM notification → killed (`am kill`) Closer2 → tapped the OS notification → cold-start logs
`Icon: view: null` then `remove starting view`, **0 FATAL, process stays alive, lands on Home** (was the crash).
Normal launcher cold-start still animates + works.
## Notification deep-link routing — SINGLE mechanism (do NOT reintroduce a second one)
**Invariant:** an app-posted notification carries the resolved route in **one** place — the `app_route` **extra**
and routing is `MainActivity.deepLinkRouteFromIntent``pendingDeepLink``AppNavigation` `navigateRoute`. Do **not**
also set an `ACTION_VIEW` + `closer://` **data Uri** on the notification intent: for routes that have a `navDeepLink`
(conversation / answer_reveal / daily_question / question_thread / home / play) the NavController auto-handles that Uri
**in addition** to `pendingDeepLink` → a race/duplicate nav. That dual path is what kept re-breaking notifications.
- **Why it broke "again" (root cause, traced via git):** `aaab768`/`1b9d8cf`/`b9b1560` built routing on the
`closer://` **data Uri** (NavController auto-handle) + a `pendingDeepLink` gated on **`currentRoute == HOME`**;
then `38fdc6d` added the `app_route` extra **on top** without removing the data Uri → two mechanisms for the same
tap. The HOME-only gate also meant a **warm** tap from any non-Home tab set `pendingDeepLink` but never consumed it.
- **Fix (R10, working tree):** `PartnerNotificationManager.showNotification` no longer sets `ACTION_VIEW`/data Uri —
`app_route` extra only. `AppNavigation` pendingDeepLink gate broadened from `== HOME` to `!in entryRoutes` (fires once
past onboarding, on any main screen). **Verified live (0 FATAL):** killed-app tap → chat opens the conversation; all
4 game **results** pushes (`partner_finished_game`) load the real per-session results (wheel "Here's how you each
answered" · This-or-That "5/5 in sync" · How Well "Perfect read 5/5" · Desire Sync "5 shared desires"); app_route-only
path (no Uri) loads results; **warm tap from Settings now routes** (was the stuck case).
## Round history (one line each)
- **E-GAME-003 (2026-06-27) — FIXED+VERIFIED+DEPLOYED: async-game first-finisher left the waiting partner un-notified.**
Async games (this_or_that/wheel/how_well/desire_sync) write answers to `couples/{c}/{gameType}/{sessionId}` and the
session only flips to `completed` when BOTH answer — so `onGameSessionUpdate` (watches the session doc) never fired on
a single finish, and the waiting partner got nothing ("Closer2 finished a game but the partner was never notified").
Fix = new Cloud Function **`onGamePartFinished`** (trigger on the answer doc; on exactly-1 answer, idempotently claim
`partFinishNotifiedAt` on the session + send `partner_completed_part` "X finished their part — your turn to play!").
Verified live: QA finished ToT part → session `partFinishNotifiedAt=true`, Sam queue got 1 `partner_completed_part`,
posted on Sam's device, tap → opened ToT, 0 FATAL. Deployed (`onGamePartFinished` created, `onGameSessionUpdate`
updated). Funcs source uncommitted (user commits).
- **R10 (2026-06-26) — FULL ClaudeQAPlan run AJ + fix phase.** Found 5 P2 in report-only passes, fixed + verified all live: C-HOME-001 (Home dup pending card), C-NAV-002 (wheel results→BACK re-entered finished session), C-NAV-003 (duplicate app bar on Wheel History/PartnerHome), C-PW-001 (dark paywall pills light-on-light), C-SEC-001 (Security read wrong recovery-phrase store → accepter couldn't view phrase; E2EE recovery itself sound). E-GAME-002 confirmed live (startNotifiedAt set + partner_started_game→right partner + foreground banner + Join→joined active ToT) → pruned. D1D7 security clean (non-member denied all raw-API reads/writes, no self-grant, secure-subdoc gate correct, argon2id+AAD=coupleId). Concurrency double-start→1 session. Perf jank 5.53% / a11y font-2.0 reflows — no regression. Build OK, both emulators reinstalled, 0 FATAL, content still `enc:v1:`. App fixes in working tree (user commits).
- **Notif→game fix + dark art + QA sweep (2026-06-26, uncommitted).** **E-GAME-001 (P1, FIXED+VERIFIED):** game notifications "led nowhere" — backgrounded/warm taps landed on Home (MainActivity was standard launch mode → `onNewIntent` never delivered the tap's extras → `pendingDeepLink` unset), and even when routed, the game screen showed *setup* instead of joining (one-shot `getActiveSessionForCouple` raced the post-push Firestore sync → returned stale-empty). Fixes: `AndroidManifest` `MainActivity launchMode=singleTop` + `QuestionSessionRepositoryImpl.getActiveSessionForCouple` now SERVER-first (cache fallback). **Verified live:** Sam backgrounded → taps partner_started_game → lands IN the active This-or-That (1/10), joined, no duplicate session; back-stack sane (game→back→Home→back→exit, C-NAV-001 holds). Generic across game types (shared routing + getActiveSession). **Dark-theme art:** 12 `_dark` variants → `drawable-night-nodpi/` (light names) so dark mode auto-swaps; verified live (Security shows the aubergine variant on dark; light unchanged). **QA sweep:** tabs both themes, deep-link back-stack, all 12 illustrations both themes — **0 FATAL**, baseline intact.
- **Brand art drop (2026-06-26) — wired + QA-swept, 0 issues.** All 11 generated illustrations (A1A12, source gitignored) wired into their screens via shared `EmptyState` + new `BrandIllustration` helper (commits `077a408`→`5868d06`). **Complete both-theme sweep:** in-context dark **and** light verified for Bucket List (A6), Quiet hours (A9), Security (A11), Delete account (A12) — all render as crisp rounded tiles, on-brand, no clipping/contrast issue; A1 (transparent), A3 (banner) + the empty-only states (A2/A4/A5/A8/A10, unreachable on the baseline couple) verified via the debug Art-preview gallery on both themes + the proven shared tile. **0 FATAL/ANR** both devices; baseline intact (0 sessions/outcomes). Process catch: 5556 was on a stale build mid-sweep → reinstalled current, both now on `768f511`. Details in `ClaudeBrandingReview.md`.

View File

@ -66,17 +66,27 @@ class MainActivity : AppCompatActivity() {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
// Gentle scale-up + fade exit, handing off to the animated in-app logo loader.
// IMPORTANT: every guard here is mandatory. `provider.iconView` throws an internal NPE inside
// androidx core-splashscreen when the OS hands the splash view over WITHOUT an icon — which is
// exactly what happens on a notification / PendingIntent cold-start. Unguarded, that NPE crashed
// onCreate → "Force finishing activity" → the app "opened and closed" on EVERY notification tap
// (looked like all notifications were broken). Make the icon scale best-effort and ALWAYS remove
// the splash so the app proceeds even if the animation can't run.
splashScreen.setOnExitAnimationListener { provider ->
runCatching {
provider.iconView.animate()
.scaleX(1.15f)
.scaleY(1.15f)
.setDuration(300L)
.start()
}
runCatching {
provider.view.animate()
.alpha(0f)
.setDuration(300L)
.withEndAction { provider.remove() }
.start()
}.onFailure { provider.remove() }
}
maybeRequestNotificationPermission()
registerFcmToken()

View File

@ -165,7 +165,11 @@ fun AppNavigation(
// Wait until the user has actually settled on Home (authenticated + onboarding finished).
// Navigating during the onboarding→home transition races its popUpTo, which discards the
// destination — the symptom of "the app opens but the message never loads".
if (currentRoute == AppRoute.HOME) {
// Fire once the user is past the entry flow (authenticated, on the main graph) — not ONLY on
// HOME. Gating on HOME alone meant a notification tapped while the app sat on any other tab
// or a deep screen (warm start) set pendingDeepLink but never consumed it — "tap does
// nothing". entryRoutes stay excluded so we don't race the onboarding→Home popUpTo.
if (currentRoute != null && currentRoute !in entryRoutes) {
kotlinx.coroutines.delay(350)
navigateRoute(link)
onDeepLinkConsumed()

View File

@ -126,13 +126,13 @@ class PartnerNotificationManager @Inject constructor(
) {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return
val deepLinkUri = Uri.parse("${DEEP_LINK_SCHEME}://$DEEP_LINK_HOST/$route")
// ONE deterministic deep-link path for every notification: carry the already-resolved app
// route as an extra and let MainActivity.deepLinkRouteFromIntent → pendingDeepLink navigate.
// We intentionally DO NOT also set an ACTION_VIEW `closer://` data Uri here: for routes that
// have a navDeepLink (conversation / answer_reveal / daily_question / …) the NavController
// would ALSO auto-handle that Uri, racing/duplicating our pendingDeepLink navigation. That
// dual path is what kept re-breaking notification routing — one mechanism for every route.
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkUri
// Also carry the resolved route so MainActivity can navigate without relying on a
// per-route navDeepLink registration (game/challenge/date/capsule routes aren't
// registered as Uri deep links). See MainActivity.deepLinkRouteFromIntent.
putExtra("app_route", route)
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onGameSessionUpdate = void 0;
exports.onGamePartFinished = exports.onGameSessionUpdate = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
@ -132,6 +132,63 @@ exports.onGameSessionUpdate = functions.firestore
return;
}
});
/**
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game.
*
* Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to
* couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to
* 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game).
* Between first-finish and both-finish the waiting partner got NOTHING they never learned it was
* their turn (the symptom: "X finished a game but the partner was never notified"). The
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
*
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
*/
const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync'];
exports.onGamePartFinished = functions.firestore
.document('couples/{coupleId}/{gameType}/{sessionId}')
.onWrite(async (change, context) => {
var _a, _b, _c, _d, _e, _f, _g;
const { coupleId, gameType, sessionId } = context.params;
if (!ASYNC_GAME_COLLECTIONS.includes(gameType))
return; // ignore messages/reactions/etc.
if (!change.after.exists)
return;
const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {});
const answerUids = Object.keys(answers);
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
// partner_finished_game instead.
if (answerUids.length !== 1)
return;
const finisherUid = answerUids[0];
const db = admin.firestore();
const messaging = admin.messaging();
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d)
return false;
if (d.status === 'completed' || d.partFinishNotifiedAt)
return false;
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (!claimed)
return;
const coupleDoc = await db.collection('couples').doc(coupleId).get();
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const recipient = userIds.find((u) => u !== finisherUid);
if (!recipient)
return;
const finisher = await db.collection('users').doc(finisherUid).get();
const finisherName = (_f = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Your partner';
const finisherAvatar = (_g = finisher.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, finisherAvatar, sessionId);
});
/**
* Send notification to partner via FCM and write to notification_queue.
*/
@ -139,6 +196,8 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no
var _a;
const title = notificationType === 'partner_finished_game'
? `${partnerName} finished the game`
: notificationType === 'partner_completed_part'
? `${partnerName} finished their part`
: `${partnerName} is playing`;
const notificationPayload = {
type: notificationType,

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.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.syncEntitlement = exports.revenueCatWebhook = void 0;
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.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
@ -86,6 +86,7 @@ var onUserDelete_1 = require("./users/onUserDelete");
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
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; } });
// 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,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,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B,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,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"}

View File

@ -116,6 +116,66 @@ export const onGameSessionUpdate = functions.firestore
}
})
/**
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game.
*
* Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to
* couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to
* 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game).
* Between first-finish and both-finish the waiting partner got NOTHING they never learned it was
* their turn (the symptom: "X finished a game but the partner was never notified"). The
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
*
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
*/
const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync']
export const onGamePartFinished = functions.firestore
.document('couples/{coupleId}/{gameType}/{sessionId}')
.onWrite(async (change, context) => {
const { coupleId, gameType, sessionId } =
context.params as { coupleId: string; gameType: string; sessionId: string }
if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc.
if (!change.after.exists) return
const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
const answerUids = Object.keys(answers)
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
// partner_finished_game instead.
if (answerUids.length !== 1) return
const finisherUid = answerUids[0]
const db = admin.firestore()
const messaging = admin.messaging()
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
if (!fresh.exists || !d) return false
if (d.status === 'completed' || d.partFinishNotifiedAt) return false
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
return true
})
if (!claimed) return
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const recipient = userIds.find((u) => u !== finisherUid)
if (!recipient) return
const finisher = await db.collection('users').doc(finisherUid).get()
const finisherName = (finisher.data()?.displayName as string) ?? 'Your partner'
const finisherAvatar = finisher.data()?.photoUrl as string | undefined
await notifyPartner(
db, messaging, recipient, finisherName, gameType,
'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId,
finisherAvatar, sessionId
)
})
/**
* Send notification to partner via FCM and write to notification_queue.
*/
@ -134,6 +194,8 @@ async function notifyPartner(
const title =
notificationType === 'partner_finished_game'
? `${partnerName} finished the game`
: notificationType === 'partner_completed_part'
? `${partnerName} finished their part`
: `${partnerName} is playing`
const notificationPayload = {
type: notificationType,

View File

@ -36,7 +36,7 @@ export { createInviteCallable } from './couples/createInviteCallable'
export { submitOutcomeCallable } from './couples/submitOutcomeCallable'
export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate } from './games/onGameSessionUpdate'
export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate'
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
// was removed to shrink attack surface. Deployment can be verified via

35
qa/README.md Normal file
View File

@ -0,0 +1,35 @@
# qa/ — re-runnable QA smokes (autonomous)
Durable, committed QA tooling so each round re-checks the fragile paths cheaply instead of by hand.
See `ClaudeQAPlan.md` (Pass E crash-triage, Pass F cold-start ownership, Definition of Done) for how these fit the run.
## entrypoint_smoke.sh — cold-start / entry-point launch integrity
The smoke that catches the **"app opens and immediately closes"** class (e.g. the splash-exit `iconView` NPE on a
notification cold-start). That class breaks **every** notification at once (shared cold-start path) and is **invisible to
`adb am start`** — only a REAL push tapped from the shade on a genuinely killed app reproduces it.
```bash
# both emulators; run after ANY change to MainActivity / splash / theme / manifest / nav / notifications, and every round
bash qa/entrypoint_smoke.sh emulator-5554 Y05AKO2IlTPMa0JQW1BiNIM0uzK2
bash qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2
```
Asserts each entry (launcher icon · each notification type tapped from a killed app · …) **opens AND stays** (process
alive, 0 FATAL, off the launcher). `PASS`/`FAIL`/`BLOCK`:
- **FAIL** = a real app crash (the bug). Pull the stack: `adb -s <serial> logcat -d | grep -E "FATAL EXCEPTION|getIconView"`.
- **BLOCK** = the push didn't reach the killed app (flaky emulator FCM / FcmRetry) — environmental, just rerun.
- Runtime ~37 min (FCM retries); run in the background and read the matrix. Exit 0 only if 0 FAIL.
## qa_push.js — send a real push (helper)
Sends a faithful `notification`+`data` FCM to a user's token so a killed app shows it and tapping cold-starts via the
real OS handover. **Use `am kill` (NOT `am force-stop`)** to kill — force-stopped apps are excluded from FCM.
```bash
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> partner_started_game game_type=this_or_that
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversation_id=main
```
## Test couple (current emulators)
- coupleId `Xal3Kw3gjSdn0niERYKJ`
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
- Admin SA JSON: `closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json` (gitignored; override path via `SA_JSON`).

100
qa/entrypoint_smoke.sh Executable file
View File

@ -0,0 +1,100 @@
#!/usr/bin/env bash
# qa/entrypoint_smoke.sh — Cold-start / entry-point launch-integrity smoke.
#
# WHY THIS EXISTS: a whole bug class makes the app "open and immediately close" on a REAL notification
# cold-start (e.g. the splash-exit `provider.iconView` NPE — the OS hands over a splash with no icon on
# the notification/PendingIntent launch path). It breaks EVERY notification at once (shared cold-start
# path) yet is invisible to `adb am start` (different launch path) and to the normal launcher icon.
# The only reliable catch is a real push, delivered to a genuinely killed app (`am kill`, NOT
# `am force-stop` — force-stopped apps are excluded from FCM), tapped from the shade.
#
# For each entry the app must: OPEN, STAY UP (process alive, 0 FATAL), and land OFF the launcher.
# Emulator FCM-to-killed-app is flaky (FcmRetry); undeliverable cases are reported BLOCKED, not FAIL —
# only an actual open-and-close / crash is a FAIL.
#
# Usage: qa/entrypoint_smoke.sh <serial> [recipient_uid]
# qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2
# Env overrides: PKG, SA_JSON, COUPLE_ID.
# Runtime: ~37 min (flaky emulator FCM adds retries). Run in the background and read the matrix.
# Exit 0 only if zero FAIL (BLOCK = undeliverable push, environmental — rerun, not an app bug).
set -u
SERIAL="${1:-emulator-5554}"
RUID="${2:-}"
PKG="${PKG:-closer.app}"
HERE="$(cd "$(dirname "$0")" && pwd)"
export NODE_PATH="${NODE_PATH:-$HERE/../functions/node_modules}"
PASS=0; FAIL=0; BLOCKED=0
adbs() { adb -s "$SERIAL" "$@"; }
alive() { adbs shell pidof "$PKG" 2>/dev/null | tr -d '\r'; }
crashed() { adbs logcat -d -t 1500 2>/dev/null | grep -E "FATAL EXCEPTION|getIconView|Force finishing.*$PKG" | grep -v AppsFilter | head -3; }
on_launcher() { adbs shell dumpsys activity activities 2>/dev/null | grep -m1 "ResumedActivity" | grep -qi "launcher"; }
ok() { echo " PASS $1"; PASS=$((PASS+1)); }
bad() { echo " FAIL $1 [$2]"; FAIL=$((FAIL+1)); }
blkd() { echo " BLOCK $1 [$2]"; BLOCKED=$((BLOCKED+1)); }
verify() { # <label> — the real check: app opened and STAYED (no crash/close)
local c a; c="$(crashed)"; a="$(alive)"
if [ -n "$a" ] && [ -z "$c" ] && ! on_launcher; then ok "$1"
else bad "$1" "alive='${a:-none}' crash='${c:-none}' onLauncher=$(on_launcher && echo yes || echo no)"; fi
}
settle_fcm() { adbs shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1; sleep 15; adbs shell input keyevent KEYCODE_HOME >/dev/null 2>&1; sleep 1; }
# find the QA-SMOKE:<type> notification in the shade; echoes "x y" if present
find_notif() { # <type>
adbs shell cmd statusbar expand-notifications >/dev/null 2>&1; sleep 2
adbs shell uiautomator dump /sdcard/sm.xml >/dev/null 2>&1
adbs pull /sdcard/sm.xml "/tmp/sm_${SERIAL}.xml" >/dev/null 2>&1
python3 - "/tmp/sm_${SERIAL}.xml" "$1" <<'PY'
import re,sys
xml=open(sys.argv[1],encoding="utf-8",errors="ignore").read(); want="QA-SMOKE:"+sys.argv[2]
for n in re.findall(r'<node[^>]*>',xml):
t=re.search(r'text="([^"]*)"',n)
if t and want in t.group(1):
b=re.search(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',n)
if b: print((int(b.group(1))+int(b.group(3)))//2,(int(b.group(2))+int(b.group(4)))//2); break
PY
}
echo "== entrypoint smoke :: $SERIAL =="
settle_fcm # clear 'stopped' flag, register token, give FCM time to connect
# 1) Launcher icon cold-start.
adbs shell am kill "$PKG" >/dev/null 2>&1; sleep 2
adbs logcat -c 2>/dev/null
adbs shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1
sleep 6
verify "launcher cold-start"
# 2) Notification cold-starts (real push -> killed app -> tap).
if [ -n "$RUID" ]; then
for CASE in \
"partner_started_game|game_type=this_or_that" \
"partner_completed_part|game_type=this_or_that" \
"partner_finished_game|game_type=wheel" \
"chat_message|conversation_id=main" \
"partner_answered|" ; do
TYPE="${CASE%%|*}"; EXTRAS="${CASE#*|}"
XY=""; ATTEMPT=0
while [ -z "$XY" ] && [ "$ATTEMPT" -lt 2 ]; do
ATTEMPT=$((ATTEMPT+1))
[ "$ATTEMPT" -eq 2 ] && settle_fcm # 2nd try: relaunch to kick the FCM transport
adbs shell am kill "$PKG" >/dev/null 2>&1; sleep 2
adbs logcat -c 2>/dev/null
OUT="$(node "$HERE/qa_push.js" "$RUID" "$TYPE" $EXTRAS 2>&1)"
if ! echo "$OUT" | grep -q "^SENT"; then bad "notif:$TYPE (send)" "$OUT"; break; fi
# poll for delivery up to ~24s
for _ in 1 2 3 4 5 6; do sleep 4; XY="$(find_notif "$TYPE")"; [ -n "$XY" ] && break; adbs shell cmd statusbar collapse >/dev/null 2>&1; done
done
if echo "${OUT:-}" | grep -q "^SENT" && [ -z "$XY" ]; then blkd "notif:$TYPE" "not delivered (flaky emulator FCM); rerun"; continue; fi
[ -z "$XY" ] && continue
adbs shell input tap $XY >/dev/null 2>&1
sleep 8
verify "notif:$TYPE tap -> opens & stays"
done
else
echo " (notification cases skipped — pass a recipient_uid to run them)"
fi
echo "== result: ${PASS} passed, ${FAIL} failed, ${BLOCKED} blocked =="
[ "$FAIL" -eq 0 ]

71
qa/qa_push.js Normal file
View File

@ -0,0 +1,71 @@
// qa/qa_push.js — send a REAL notification+data FCM to a user's token (faithful to the app's own
// pushes) so a KILLED app shows it in the shade and tapping it cold-starts via the real OS splash
// handover. This is the ONE path that reproduces the "opens-and-closes" crash class (e.g. the
// splash-exit iconView NPE) — `adb am start --es …` does NOT (different launch path), and
// `am force-stop` can't even receive FCM. See ClaudeQAPlan.md (Pass E crash-triage / reproduction
// fidelity) and memory/project_notifications.md.
//
// Usage: NODE_PATH=functions/node_modules node qa/qa_push.js <uid> <type> [key=value ...]
// node qa/qa_push.js <uid> partner_started_game game_type=this_or_that
// node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
// node qa/qa_push.js <uid> chat_message conversation_id=main
// node qa/qa_push.js <uid> partner_answered
// Env overrides: SA_JSON, COUPLE_ID.
const admin = require('firebase-admin')
const path = require('path')
const SA = process.env.SA_JSON ||
path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
const [uid, type, ...rest] = process.argv.slice(2)
if (!uid || !type) {
console.error('usage: qa_push.js <uid> <type> [key=value ...]')
process.exit(2)
}
const extras = {}
for (const kv of rest) {
const i = kv.indexOf('=')
if (i > 0) extras[kv.slice(0, i)] = kv.slice(i + 1)
}
// Channel must exist in the app (NotificationChannelSetup). game_* → game_activity, else partner_activity.
const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
;(async () => {
// A results-ready push needs a completed session id; auto-resolve one if not supplied.
if (type === 'partner_finished_game' && !extras.game_session_id && extras.game_type) {
const q = await db.collection('couples').doc(COUPLE).collection(extras.game_type).get()
let pick = null
q.forEach((d) => {
const a = d.data().answers || {}
if (Object.keys(a).length >= 2 && !pick) pick = d.id
})
if (pick) extras.game_session_id = pick
}
const tk = await db.collection('users').doc(uid).collection('fcmTokens').get()
const token = tk.docs[0] && tk.docs[0].data().token
if (!token) {
console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)')
process.exit(3)
}
const data = { type, couple_id: COUPLE, ...extras }
// Distinct body so the smoke can find + tap exactly this notification (not a grouped summary).
const marker = 'QA-SMOKE:' + type
const res = await admin.messaging().send({
token,
notification: { title: 'Closer', body: marker },
android: { priority: 'high', notification: { channelId } },
data,
})
console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res)
process.exit(0)
})().catch((e) => {
console.error('ERR ' + e.message)
process.exit(1)
})