From 2cd0af65a849a701c9fd39a960c63728438d8cd2 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 27 Jun 2026 13:31:09 -0500 Subject: [PATCH] =?UTF-8?q?chore:=20working=20tree=20changes=20=E2=80=94?= =?UTF-8?q?=20QA=20docs,=20app=20tweaks,=20Cloud=20Functions=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ClaudeQAPlan.md | 134 +++++++++++++++++- ClaudeReport.md | 51 ++++++- app/src/main/java/app/closer/MainActivity.kt | 30 ++-- .../closer/core/navigation/AppNavigation.kt | 6 +- .../PartnerNotificationManager.kt | 12 +- functions/dist/games/onGameSessionUpdate.js | 63 +++++++- .../dist/games/onGameSessionUpdate.js.map | 2 +- functions/dist/index.js | 3 +- functions/dist/index.js.map | 2 +- functions/src/games/onGameSessionUpdate.ts | 64 ++++++++- functions/src/index.ts | 2 +- qa/README.md | 35 +++++ qa/entrypoint_smoke.sh | 100 +++++++++++++ qa/qa_push.js | 71 ++++++++++ 14 files changed, 542 insertions(+), 33 deletions(-) create mode 100644 qa/README.md create mode 100755 qa/entrypoint_smoke.sh create mode 100644 qa/qa_push.js diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index 0e2d685f..84c9dcd9 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -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 ` (+ 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 `**, 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 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 2–3 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 (A–J) are done; **flawless** = one full round with **zero open P0–P2 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 A–J (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full diff --git a/ClaudeReport.md b/ClaudeReport.md index e2474c3e..56cf5c73 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -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 A–J + 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. D1–D7 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 (A1–A12, 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`. diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index b69a2679..f48f56db 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -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 -> - provider.iconView.animate() - .scaleX(1.15f) - .scaleY(1.15f) - .setDuration(300L) - .start() - provider.view.animate() - .alpha(0f) - .setDuration(300L) - .withEndAction { provider.remove() } - .start() + 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() diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index c1758e5e..b535ad3c 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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() diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index b09e0de7..effb9d2a 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -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 } diff --git a/functions/dist/games/onGameSessionUpdate.js b/functions/dist/games/onGameSessionUpdate.js index ed341078..0b565e56 100644 --- a/functions/dist/games/onGameSessionUpdate.js +++ b/functions/dist/games/onGameSessionUpdate.js @@ -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,7 +196,9 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no var _a; const title = notificationType === 'partner_finished_game' ? `${partnerName} finished the game` - : `${partnerName} is playing`; + : notificationType === 'partner_completed_part' + ? `${partnerName} finished their part` + : `${partnerName} is playing`; const notificationPayload = { type: notificationType, title, diff --git a/functions/dist/games/onGameSessionUpdate.js.map b/functions/dist/games/onGameSessionUpdate.js.map index eae6e5b9..866a2798 100644 --- a/functions/dist/games/onGameSessionUpdate.js.map +++ b/functions/dist/games/onGameSessionUpdate.js.map @@ -1 +1 @@ -{"version":3,"file":"onGameSessionUpdate.js","sourceRoot":"","sources":["../../src/games/onGameSessionUpdate.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onGameSessionUpdate.js","sourceRoot":"","sources":["../../src/games/onGameSessionUpdate.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index 537fb09f..1f2597a9 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.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 diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index b59cb251..e6abd998 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts index cdca3a1a..e0478cfc 100644 --- a/functions/src/games/onGameSessionUpdate.ts +++ b/functions/src/games/onGameSessionUpdate.ts @@ -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 + 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,7 +194,9 @@ async function notifyPartner( const title = notificationType === 'partner_finished_game' ? `${partnerName} finished the game` - : `${partnerName} is playing` + : notificationType === 'partner_completed_part' + ? `${partnerName} finished their part` + : `${partnerName} is playing` const notificationPayload = { type: notificationType, title, diff --git a/functions/src/index.ts b/functions/src/index.ts index 44154162..ecb207e0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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 diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 00000000..ce591c16 --- /dev/null +++ b/qa/README.md @@ -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 logcat -d | grep -E "FATAL EXCEPTION|getIconView"`. +- **BLOCK** = the push didn't reach the killed app (flaky emulator FCM / FcmRetry) — environmental, just rerun. +- Runtime ~3–7 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 partner_started_game game_type=this_or_that +NODE_PATH=functions/node_modules node qa/qa_push.js partner_finished_game game_type=wheel # auto-resolves a completed session +NODE_PATH=functions/node_modules node qa/qa_push.js 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`). diff --git a/qa/entrypoint_smoke.sh b/qa/entrypoint_smoke.sh new file mode 100755 index 00000000..b57ed208 --- /dev/null +++ b/qa/entrypoint_smoke.sh @@ -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 [recipient_uid] +# qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2 +# Env overrides: PKG, SA_JSON, COUPLE_ID. +# Runtime: ~3–7 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() { #