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. blocker — don't halt the whole run for it.
## Methodology (every pass) ## 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. - 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; - Drive via adb tap/swipe; resolve coords from `uiautomator dump` bounds; downscale screenshots to read;
scan `logcat` for `FATAL EXCEPTION`/ANR on each screen. 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** - **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). 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 - **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 - **Test-data hygiene:** keep known test accounts; clean up artifacts (stray messages/reactions/sessions) between
rounds so they don't masquerade as bugs. rounds so they don't masquerade as bugs.
- **Evidence standard:** every filed bug must be reproducible from text alone: build/commit, device, account, theme, - **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 - **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 (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. 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) ## 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 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 - **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. 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. - **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 - **Malformed / abusive input** — oversized, empty, rapid-fire, injection-ish, forged FCM payloads, replayed/expired
tokens & invite codes. tokens & invite codes.
- **Offline / flaky** — drop network mid-action → graceful failure, recover on reconnect. - **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 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 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. 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. - **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. - **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 - **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`. `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. 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. 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 - **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 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** (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 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. 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 - **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: 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 - **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 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 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, 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, **edge treatment**, loading/fallback state, and whether the image competes with or clarifies the primary task. If it is
clipped, low-contrast, off-brand, stale, or placeholder-looking, file a bug in `ClaudeReport.md`; if the screen 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`. 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 - **Probe:** `ui/theme/Theme.kt` hardcoded brand colors + chat's custom `closerBackgroundBrush` — verify dark mode
truly adapts; grep screens for hardcoded `Color(0x...)`. 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 / - **States, not just happy path:** empty / loading / error / not-paired / locked-premium / signed-out /
stale-or-deleted-target / populated-with-many where they exist; many need data setup (seeding is user-gated) — note stale-or-deleted-target / populated-with-many where they exist; many need data setup (seeding is user-gated) — note
unreachable states in coverage rather than skipping silently. unreachable states in coverage rather than skipping silently.
@ -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 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**) 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. 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 - **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 (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 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): `not implemented→Future.md` (don't count as pass):
`chat_message`(onMessageWritten → partner → conversation; foreground→chat-head bubble) · `chat_message`(onMessageWritten → partner → conversation; foreground→chat-head bubble) ·
`partner_started_game`/`partner_finished_game`(onGameSessionUpdate → partner → game/join · results/reveal) · `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) · `join_game`/`game_invite` & `partner_joined_game` (if present → partner/starter → join screen · waiting-room update) ·
`partner_answered`(onAnswerWritten → partner → reveal) · `partner_answered`(onAnswerWritten → partner → reveal) ·
`game_abandoned`/`game_ended` (if present → partner → safe ended state, not a stuck session) · `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 - **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 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. 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) — - **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. 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 - **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 image supports the screen's job, aligns with the surrounding typography/actions, has enough breathing room, and uses
the right light/dark treatment. Art that looks generic, unfinished, randomly placed, or visually disconnected is a the right light/dark treatment. Art that looks generic, unfinished, randomly placed, or visually disconnected is a
finding even if the bitmap itself is technically valid. 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` - **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`, + `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 `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 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 in Firestore**) → flip its row to **Fixed** + **commit** (one per issue/cluster) → next. Don't start the next until
the current is verified. 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 - **Couple-shared premium fix**: replace direct `isPremium()` gates with
`CouplePremiumChecker.coupleHasPremium(partnerId)` in every gated VM/screen (partner-entitlement read rule deployed). `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. **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 `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 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 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) ## Re-QA loop (until flawless)
After the fix phase, re-run Pass AJ (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full After the fix phase, re-run Pass AJ (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full

View File

@ -29,10 +29,10 @@
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | 0 | 0 | | P1 | 0 | 0 |
| P2 | **0** | **5** | | P2 | **1** | **5** |
| P3 | **1** | 0 | | 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 > 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** > 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: > 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 | | 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-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-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)** | | 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). - **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. - **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) ## 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). - **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. - **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`. - **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() val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Gentle scale-up + fade exit, handing off to the animated in-app logo loader. // 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 -> splashScreen.setOnExitAnimationListener { provider ->
provider.iconView.animate() runCatching {
.scaleX(1.15f) provider.iconView.animate()
.scaleY(1.15f) .scaleX(1.15f)
.setDuration(300L) .scaleY(1.15f)
.start() .setDuration(300L)
provider.view.animate() .start()
.alpha(0f) }
.setDuration(300L) runCatching {
.withEndAction { provider.remove() } provider.view.animate()
.start() .alpha(0f)
.setDuration(300L)
.withEndAction { provider.remove() }
.start()
}.onFailure { provider.remove() }
} }
maybeRequestNotificationPermission() maybeRequestNotificationPermission()
registerFcmToken() registerFcmToken()

View File

@ -165,7 +165,11 @@ fun AppNavigation(
// Wait until the user has actually settled on Home (authenticated + onboarding finished). // Wait until the user has actually settled on Home (authenticated + onboarding finished).
// Navigating during the onboarding→home transition races its popUpTo, which discards the // Navigating during the onboarding→home transition races its popUpTo, which discards the
// destination — the symptom of "the app opens but the message never loads". // 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) kotlinx.coroutines.delay(350)
navigateRoute(link) navigateRoute(link)
onDeepLinkConsumed() onDeepLinkConsumed()

View File

@ -126,13 +126,13 @@ class PartnerNotificationManager @Inject constructor(
) { ) {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return 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 { 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) putExtra("app_route", route)
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP 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 }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onGameSessionUpdate = void 0; exports.onGamePartFinished = exports.onGameSessionUpdate = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
/** /**
@ -132,6 +132,63 @@ exports.onGameSessionUpdate = functions.firestore
return; 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. * Send notification to partner via FCM and write to notification_queue.
*/ */
@ -139,7 +196,9 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no
var _a; var _a;
const title = notificationType === 'partner_finished_game' const title = notificationType === 'partner_finished_game'
? `${partnerName} finished the game` ? `${partnerName} finished the game`
: `${partnerName} is playing`; : notificationType === 'partner_completed_part'
? `${partnerName} finished their part`
: `${partnerName} is playing`;
const notificationPayload = { const notificationPayload = {
type: notificationType, type: notificationType,
title, title,

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 }); 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")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // Initialize the Admin SDK once for every function in this codebase.
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a // 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; } }); Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.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 // NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
// was removed to shrink attack surface. Deployment can be verified via // 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 // `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. * Send notification to partner via FCM and write to notification_queue.
*/ */
@ -134,7 +194,9 @@ async function notifyPartner(
const title = const title =
notificationType === 'partner_finished_game' notificationType === 'partner_finished_game'
? `${partnerName} finished the game` ? `${partnerName} finished the game`
: `${partnerName} is playing` : notificationType === 'partner_completed_part'
? `${partnerName} finished their part`
: `${partnerName} is playing`
const notificationPayload = { const notificationPayload = {
type: notificationType, type: notificationType,
title, title,

View File

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