chore: working tree changes — QA docs, app tweaks, Cloud Functions updates
This commit is contained in:
parent
9c84c36443
commit
2cd0af65a8
134
ClaudeQAPlan.md
134
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 <serial> <recipient_uid>` (+ helper
|
||||
`qa/qa_push.js`) — the cold-start / entry-point launch-integrity smoke. It launches via the launcher AND sends a
|
||||
**real** push to a killed (`am kill`) app and **taps the actual OS notification** for each type, asserting the app
|
||||
**opens and STAYS** (process alive, 0 FATAL, off the launcher). This is the smoke that catches the "opens-and-closes"
|
||||
splash-crash class that `am start` can't. Run it **every round and after any commit touching MainActivity / splash /
|
||||
theme / manifest / nav / notifications**. `FAIL` = an app crash (real bug); `BLOCK` = push not delivered (flaky
|
||||
emulator FCM — rerun, not a bug).
|
||||
- **Test-data hygiene:** keep known test accounts; clean up artifacts (stray messages/reactions/sessions) between
|
||||
rounds so they don't masquerade as bugs.
|
||||
- **Evidence standard:** every filed bug must be reproducible from text alone: build/commit, device, account, theme,
|
||||
|
|
@ -104,6 +123,19 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
|
|||
- **Flake policy:** if something fails once and then passes, do not dismiss it. Repeat from a clean state, vary timing
|
||||
(rapid tap / slow network / background-resume), inspect logs, and file it as intermittent if it cannot be made fully
|
||||
deterministic. Intermittent routing, notification, encryption, duplicate-write, or crash behavior is still a bug.
|
||||
- **Reproduction fidelity (how we catch DEEP bugs) — the test harness must exercise the SAME path as the user.** A
|
||||
synthetic shortcut (`am start` extras, admin writes, calling a function directly, `am force-stop`) can **pass while the
|
||||
real path crashes** — the splash-handover NPE only fires on a real notification cold-start, and `am force-stop` can't
|
||||
even receive FCM. So for launch / notification / permission / IPC / deep-link behavior, reproduce through the **real OS
|
||||
mechanism** (real push tapped from the shade, real launcher cold-start, real permission dialog). Record **which angle**
|
||||
proved it in `ClaudeQACoverage.md`; "synthetic/UI-shortcut only" is **not** a pass for these paths.
|
||||
- **Symptom→inspection reflexes (apply before theorizing a root cause):** (1) "opens-and-closes / flashes / silently
|
||||
fails" ⇒ it's a **crash until the stack says otherwise** — `logcat -c` then capture `FATAL EXCEPTION` from the live
|
||||
repro **before** proposing a cause (don't fix by reasoning, like the routing red-herring on this very bug). (2)
|
||||
**Many features break at once ⇒ inspect the SHARED code path** (launch/`onCreate`/splash/auth/key-load), not each
|
||||
feature. (3) "worked before, broken now" ⇒ `git blame`/`git log -L` the failing line to the introducing commit. (4)
|
||||
Treat cosmetic/branding/theme/manifest/splash commits as **capable of deep crashes** — re-run the cold-start +
|
||||
notification smoke after them.
|
||||
|
||||
## Living discovery ritual (before each round, and whenever reality disagrees with the docs)
|
||||
The app is allowed to grow; the QA plan must keep up. Before a pass or chunk, quickly inventory the current code/app
|
||||
|
|
@ -136,7 +168,12 @@ from as many **independent angles** as apply — not just the in-app happy path:
|
|||
- **Admin inspection (ground truth)** — read the RAW stored docs/objects (admin bypasses rules) to assert what is
|
||||
actually persisted: ciphertext only, no plaintext, no raw keys/invite-seeds, no private content in pushes.
|
||||
- **Concurrency / race** — two partners (or two rapid taps) hit the same thing at once.
|
||||
- **Killed / cold state** — force-stop, then deliver + tap a notification; cold-start straight onto a deep link.
|
||||
- **Killed / cold state** — kill with **`am kill <pkg>`**, NOT `am force-stop`: a force-stopped app is in Android's
|
||||
*stopped* state and is **excluded from FCM broadcasts** (`GCM broadcast …result=CANCELLED`), so the push never
|
||||
arrives and you get a false "no notification". Then deliver a **real** push and **tap the actual OS notification**
|
||||
(one at a time — clear the shade first; tapping a *grouped summary* launches with no extras and falsely lands on
|
||||
Home). `am start … --es type …` is **not** equivalent to a real notification tap (different launch path — see the
|
||||
crash-triage note in Pass E). Also cold-start straight onto a deep link.
|
||||
- **Malformed / abusive input** — oversized, empty, rapid-fire, injection-ish, forged FCM payloads, replayed/expired
|
||||
tokens & invite codes.
|
||||
- **Offline / flaky** — drop network mid-action → graceful failure, recover on reconnect.
|
||||
|
|
@ -170,6 +207,20 @@ State lives in **files**, not memory:
|
|||
relevant pass before ending the chunk. Also add the matching row/cell to `ClaudeQACoverage.md` if it needs recurring
|
||||
verification. Do this even after the immediate bug is filed/fixed so the lesson or newly discovered surface is not
|
||||
lost to memory or git history.
|
||||
- **Learn from every ESCAPED or DEEP bug — MANDATORY retrospective (do this automatically, not only when asked).**
|
||||
Any bug that (a) **escaped a prior round**, (b) needed **non-obvious diagnosis** (a crash, an "opens-and-closes",
|
||||
a "didn't work", an intermittent, a wrong-root-cause first guess), or (c) **recurred** triggers a short retrospective
|
||||
the moment it's fixed — the fix is **not complete** until all four are done:
|
||||
1. **Add the guard that would have caught it** — a new `qa/` smoke check, a coverage row, or a concrete pass step
|
||||
(e.g. the cold-start bug → `qa/entrypoint_smoke.sh`). If an existing smoke missed it, extend the smoke.
|
||||
2. **Record the generalizable inspection lesson** in the relevant pass of this doc AND in `memory/` (how to *find*
|
||||
this class next time — the reflex, not just the fix).
|
||||
3. **Name the missing state/angle/entry-point** that let it hide and add it to the multi-angle / state matrices so it's
|
||||
exercised every round (e.g. "real notification tap on an `am kill`'d app", not just `am start`).
|
||||
4. **Note any wrong turn in diagnosis** so the misstep isn't repeated (e.g. "synthetic test passed while the real
|
||||
path crashed → don't fix by reasoning; reproduce via the real channel + read the stack").
|
||||
This is how the plan self-improves between rounds — treat the human pointing out a missed bug as a signal the plan had
|
||||
a gap, and close the gap here, not just the bug.
|
||||
- **Commit cadence**: commit `ClaudeReport.md` + `ClaudeQACoverage.md` after each pass and each chunk.
|
||||
- **Chunking**: run small chunks (Pass C one screen-group; Pass A one feature), checkpoint after each.
|
||||
- **Session-start ritual**: (1) read run-state header + both MD files; (2) `adb devices` shows **both** emulators
|
||||
|
|
@ -233,7 +284,7 @@ Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSync
|
|||
`ui/dates/{DateMatch,DateMatches}Screen`, `ui/memorylane/MemoryLaneScreen`, `ui/challenges/ConnectionChallengesScreen`.
|
||||
Also: **any VM/screen calling `EntitlementChecker.isPremium()` directly** (grep for it) is a candidate gate.
|
||||
|
||||
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through)
|
||||
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through ALL different play stayles of the game)
|
||||
Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match.
|
||||
- **PLAY AS THE USER (mandatory mindset for this pass):** drive every game **the way a real user would** — reach it
|
||||
through the actual in-app navigation a person would tap (Play hub → the game's card → its buttons), **not** via
|
||||
|
|
@ -258,6 +309,13 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
|
|||
(after the natural paths) deep-link/crafted intent + cold-start from a push. A game isn't complete unless **both**
|
||||
partners can **start, join, resume, finish, reopen results, and recover from a stale/ended session** — with no
|
||||
duplicate sessions, wrong routes, stuck waiting screens, broken back nav, or premium-gate mistakes.
|
||||
- **FIRST-FINISHER → WAITING-PARTNER NOTIFICATION (mandatory state — async games):** explicitly exercise the asymmetric
|
||||
state where **one partner finishes their part and the OTHER is idle/away**. The waiting partner MUST get a "your turn
|
||||
to play" nudge (`partner_completed_part` via `onGamePartFinished`) the moment the first finishes — async games
|
||||
(this_or_that / wheel / how_well / desire_sync) only flip to `completed` (→ `partner_finished_game`) once BOTH answer,
|
||||
so without the first-finish nudge the waiting partner is told nothing. Verify the **idle partner** (on Home, or
|
||||
backgrounded/killed) actually receives + can tap into the game. (This state was missed for a long time precisely
|
||||
because QA always played both sides through; "one finishes, the other never played" is its own required angle.)
|
||||
- **VARY THE STYLE OF PLAY (don't just repeat the happy path):** across runs, deliberately exercise *different* ways a
|
||||
real couple would play each game, because different inputs hit different code paths:
|
||||
- **Different DEPTHS and QUESTION COUNTS — cover the matrix, don't settle for one combo:** play each game across
|
||||
|
|
@ -294,11 +352,35 @@ Account); Paywall; Your Progress/Activity; Recovery.
|
|||
empty-state image, pack art, celebration asset, and dark/light variant in context. It should feel intentionally
|
||||
integrated with the page hierarchy, copy, spacing, and action area — not like a forgotten placeholder dropped into
|
||||
an empty slot. Check crop, scale, padding, alignment, corner radius, background/tile treatment, theme variant,
|
||||
loading/fallback state, and whether the image competes with or clarifies the primary task. If it is broken,
|
||||
clipped, low-contrast, off-brand, stale, or placeholder-looking, file a bug in `ClaudeReport.md`; if the screen
|
||||
**edge treatment**, loading/fallback state, and whether the image competes with or clarifies the primary task. If it is
|
||||
broken, clipped, low-contrast, off-brand, stale, or placeholder-looking, file a bug in `ClaudeReport.md`; if the screen
|
||||
works but would benefit from new/better art, log the prompt need in `ClaudeBrandingReview.md`.
|
||||
- **SOFT EDGES — art must fade into the screen, not show a hard tile edge (mandatory):** every displayed illustration
|
||||
should **blend/feather softly into the background**, not sit as a hard-edged rounded rectangle/card with a visible
|
||||
boundary or border line. Inspect each illustration's edges against the screen on **both themes** — a crisp tile edge,
|
||||
outline/border, or a pale block floating on the surface is a finding (C-ART-EDGE-001). (Current state: `BrandIllustration`
|
||||
hard-`clip`s to `RoundedCornerShape` + a hairline `border`, and `EmptyState` renders raw, so the art's near-white tile
|
||||
shows a hard edge.) Fix pattern: feather the edges to transparent (radial/linear fade mask via
|
||||
`Modifier.graphicsLayer{compositingStrategy=Offscreen}` + `drawWithContent` `BlendMode.DstIn` gradient), or a vignette
|
||||
matching the surface, or ship transparent-edged art — applied in the shared `BrandIllustration`/`EmptyState` helpers so
|
||||
it's consistent everywhere.
|
||||
- **Probe:** `ui/theme/Theme.kt` hardcoded brand colors + chat's custom `closerBackgroundBrush` — verify dark mode
|
||||
truly adapts; grep screens for hardcoded `Color(0x...)`.
|
||||
- **THEME-VARIANT ART must follow the IN-APP theme, not just the system (mandatory — RUN THE DECOUPLED STATE):** the app
|
||||
has its own theme toggle (Settings → Appearance → Light/Dark/Device) that swaps Compose colors but does **not** change
|
||||
the Android config `uiMode`, while `-night` drawables (`drawable-night-nodpi/`) and `painterResource` resolve off the
|
||||
**system** `uiMode`. So art can mismatch the UI when the two disagree. **Test the decoupled state explicitly, every
|
||||
round:** force system light then set the app to **Dark**, and force system dark then set the app to **Light**, and on
|
||||
every screen that has a dark art variant confirm the illustration matches the **in-app** theme (no bright/light tile on
|
||||
a dark screen, no dark tile on a light screen). Commands:
|
||||
`adb -s <serial> shell cmd uimode night no` (system light) / `… night yes` (system dark); then toggle the in-app theme
|
||||
in Appearance. Screens with `-night` variants to check: Security (privacy_recovery), Memory Lane, Bucket List, Answer
|
||||
History, Date Match (empty + success), Connection Challenges header, Pairing success, Messages empty, Past Games,
|
||||
Quiet-hours, Account-deletion, + any new `illustration_*` added to `drawable-night-nodpi/`. **Restore `cmd uimode night
|
||||
auto` after.** Light art on a dark screen (or vice-versa) when the in-app theme is switched = bug (P2 theme-not-adapting;
|
||||
see C-DARKART-001). Fix pattern: drive the resource `uiMode` from the in-app theme (e.g. a themed `Resources` in the
|
||||
shared `BrandIllustration` helper, or `AppCompatDelegate.setDefaultNightMode`/config override) so `painterResource`
|
||||
picks `-night` per the app's own setting.
|
||||
- **States, not just happy path:** empty / loading / error / not-paired / locked-premium / signed-out /
|
||||
stale-or-deleted-target / populated-with-many where they exist; many need data setup (seeding is user-gated) — note
|
||||
unreachable states in coverage rather than skipping silently.
|
||||
|
|
@ -429,6 +511,25 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
|
|||
restarts, or silently fails, classify whether it was wrong routing, missing extras, stale data, permission denial, or
|
||||
a crash. Any notification tap that crashes (example class: tapping a game notification to open **Spin the Wheel**)
|
||||
is a filed bug with stack trace + exact payload/session/game type, not a vague "didn't open" note.
|
||||
- **Test the REAL launch path, not a synthetic one.** `adb am start … --es type=…` does **not** reproduce a real
|
||||
notification tap: the OS notification tap launches the activity through the **SysUILaunch splash handover**
|
||||
(`reportSplashscreenViewShown` → `handOverSplashScreenView`), which `am start` skips. A whole bug class
|
||||
(e.g. the **splash-exit `provider.iconView` NPE** — the handover delivers a splash view with **no icon**,
|
||||
`SplashScreenView: Icon: view: null`, on notification cold-starts only) crashes onCreate → "Force finishing
|
||||
activity" → the app **opens-and-closes**, yet `am start` AND the normal launcher icon both pass. Verdict: for
|
||||
cold-start/notification routing, a synthetic-intent pass is **not** a pass — confirm with a real push tapped from
|
||||
the shade on an `am kill`'d app.
|
||||
- **"Opens and closes / flashes / returns to launcher" ⇒ assume a crash; pull the stack FIRST.** `logcat -c`
|
||||
before the tap, then grep `FATAL EXCEPTION|AndroidRuntime|Force finishing|getIconView`. A real repro + the stack
|
||||
trace beats code-reasoning every time (this bug was misdiagnosed as deep-link routing until the live stack named
|
||||
`MainActivity.kt` + `SplashScreenViewProvider.getIconView`). Confirm crashes reach **Crashlytics** so field cold-start
|
||||
crashes surface.
|
||||
- **Many notification types "broken" at once ⇒ suspect the SHARED entry path (splash/`onCreate`/launch), not each
|
||||
handler.** When chat AND every game's results push all fail identically, the bug is in what they share (the
|
||||
cold-start path), not per-type routing. Re-run a **cold-start smoke after ANY change to** `MainActivity` / splash /
|
||||
theme / manifest / launchMode / branding-"loading state" commits — these cosmetic-looking changes broke the launch.
|
||||
- **For "worked before, broken now": `git blame` / `git log -L` the crashing line/function** to pin the introducing
|
||||
commit, then re-test that exact path on it.
|
||||
- **Both-client × app-state matrix (per type):** QA→Sam and Sam→QA, each in **foreground / background / killed
|
||||
(cold-start)**, plus **already on the target screen**, **on a different screen**, **logged out**, **unpaired**, with
|
||||
a **stale/expired/completed/deleted target**, and **both users opening around the same time**. Not a `pass` unless it
|
||||
|
|
@ -459,6 +560,9 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
|
|||
`not implemented→Future.md` (don't count as pass):
|
||||
`chat_message`(onMessageWritten → partner → conversation; foreground→chat-head bubble) ·
|
||||
`partner_started_game`/`partner_finished_game`(onGameSessionUpdate → partner → game/join · results/reveal) ·
|
||||
`partner_completed_part`(**onGamePartFinished** → waiting partner → game; fired when the FIRST player finishes an
|
||||
async game so the partner is told "your turn" — async games complete only when BOTH answer, so without this the
|
||||
waiting partner got nothing between first-finish and both-finish) ·
|
||||
`join_game`/`game_invite` & `partner_joined_game` (if present → partner/starter → join screen · waiting-room update) ·
|
||||
`partner_answered`(onAnswerWritten → partner → reveal) ·
|
||||
`game_abandoned`/`game_ended` (if present → partner → safe ended state, not a stuck session) ·
|
||||
|
|
@ -509,6 +613,13 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
|
|||
- **Lifecycle / process death:** background mid-flow + return; force-kill the app and relaunch (Android may kill the
|
||||
process) — state/auth/draft restore sanely; deep-link/notification after process death still loads (verified for
|
||||
chat — extend to all). Rotation/config-change doesn't lose Compose state. Low-memory.
|
||||
- **Cold-start launch integrity from EVERY entry point (Pass F OWNS this — it's the shared path no other pass owned, and
|
||||
where the splash-crash hid):** the app must **open AND stay** (no crash, no "opens-and-closes", lands off the launcher)
|
||||
when cold-started from: the **launcher icon**, **each notification type tapped from a killed (`am kill`) app**, a
|
||||
**deep link**, and any widget/quick-action. This is the `MainActivity`/splash/`onCreate`/auth-bootstrap path; a crash
|
||||
here (e.g. splash-exit `iconView` NPE) breaks **all** notifications at once. **Run `qa/entrypoint_smoke.sh` here every
|
||||
round and after any MainActivity/splash/theme/manifest/nav/notification change.** Reproduce via the REAL push tapped
|
||||
from the shade (not `am start`); "opens-and-closes" ⇒ pull the FATAL stack (see Pass E crash-triage).
|
||||
- **Network resilience:** offline / flaky / airplane mid-action across answers, games, dates (not just chat media) —
|
||||
graceful failure + retry/queue, no crash, no silent data loss, recovery on reconnect.
|
||||
- **Idempotency / rapid input:** double-tap send/submit, rapid nav, double-start, double-join, repeated paywall-unlock
|
||||
|
|
@ -534,6 +645,10 @@ written as ready-to-paste ChatGPT image-generation prompts** — the user genera
|
|||
image supports the screen's job, aligns with the surrounding typography/actions, has enough breathing room, and uses
|
||||
the right light/dark treatment. Art that looks generic, unfinished, randomly placed, or visually disconnected is a
|
||||
finding even if the bitmap itself is technically valid.
|
||||
- **Soft edges (art melts into the surface):** illustrations should **fade/feather into the screen background**, not read
|
||||
as a hard-edged tile/card with a crisp boundary or outline. Confirm edge treatment on both themes; a hard tile edge is
|
||||
a finding (C-ART-EDGE-001). Generated art should carry **transparent/feathered edges** (no baked-in rounded-rect block);
|
||||
if rendered, the shared helper should fade the edges to the surface. Record the desired edge treatment in each prompt.
|
||||
- **First, lock the house style (do this once per round, refresh if the art evolved):** read `docs/brand/visual-identity.md`
|
||||
+ `docs/brand/asset-system.md` AND open 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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -66,17 +66,27 @@ class MainActivity : AppCompatActivity() {
|
|||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
// Gentle scale-up + fade exit, handing off to the animated in-app logo loader.
|
||||
// IMPORTANT: every guard here is mandatory. `provider.iconView` throws an internal NPE inside
|
||||
// androidx core-splashscreen when the OS hands the splash view over WITHOUT an icon — which is
|
||||
// exactly what happens on a notification / PendingIntent cold-start. Unguarded, that NPE crashed
|
||||
// onCreate → "Force finishing activity" → the app "opened and closed" on EVERY notification tap
|
||||
// (looked like all notifications were broken). Make the icon scale best-effort and ALWAYS remove
|
||||
// the splash so the app proceeds even if the animation can't run.
|
||||
splashScreen.setOnExitAnimationListener { provider ->
|
||||
runCatching {
|
||||
provider.iconView.animate()
|
||||
.scaleX(1.15f)
|
||||
.scaleY(1.15f)
|
||||
.setDuration(300L)
|
||||
.start()
|
||||
}
|
||||
runCatching {
|
||||
provider.view.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(300L)
|
||||
.withEndAction { provider.remove() }
|
||||
.start()
|
||||
}.onFailure { provider.remove() }
|
||||
}
|
||||
maybeRequestNotificationPermission()
|
||||
registerFcmToken()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.onGameSessionUpdate = void 0;
|
||||
exports.onGamePartFinished = exports.onGameSessionUpdate = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
/**
|
||||
|
|
@ -132,6 +132,63 @@ exports.onGameSessionUpdate = functions.firestore
|
|||
return;
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game.
|
||||
*
|
||||
* Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to
|
||||
* couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to
|
||||
* 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game).
|
||||
* Between first-finish and both-finish the waiting partner got NOTHING — they never learned it was
|
||||
* their turn (the symptom: "X finished a game but the partner was never notified"). The
|
||||
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
|
||||
*
|
||||
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
|
||||
*/
|
||||
const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync'];
|
||||
exports.onGamePartFinished = functions.firestore
|
||||
.document('couples/{coupleId}/{gameType}/{sessionId}')
|
||||
.onWrite(async (change, context) => {
|
||||
var _a, _b, _c, _d, _e, _f, _g;
|
||||
const { coupleId, gameType, sessionId } = context.params;
|
||||
if (!ASYNC_GAME_COLLECTIONS.includes(gameType))
|
||||
return; // ignore messages/reactions/etc.
|
||||
if (!change.after.exists)
|
||||
return;
|
||||
const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {});
|
||||
const answerUids = Object.keys(answers);
|
||||
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
|
||||
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
|
||||
// partner_finished_game instead.
|
||||
if (answerUids.length !== 1)
|
||||
return;
|
||||
const finisherUid = answerUids[0];
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
|
||||
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
|
||||
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
|
||||
const claimed = await db.runTransaction(async (tx) => {
|
||||
const fresh = await tx.get(sessionRef);
|
||||
const d = fresh.data();
|
||||
if (!fresh.exists || !d)
|
||||
return false;
|
||||
if (d.status === 'completed' || d.partFinishNotifiedAt)
|
||||
return false;
|
||||
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
|
||||
return true;
|
||||
});
|
||||
if (!claimed)
|
||||
return;
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get();
|
||||
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
|
||||
const recipient = userIds.find((u) => u !== finisherUid);
|
||||
if (!recipient)
|
||||
return;
|
||||
const finisher = await db.collection('users').doc(finisherUid).get();
|
||||
const finisherName = (_f = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Your partner';
|
||||
const finisherAvatar = (_g = finisher.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
|
||||
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, finisherAvatar, sessionId);
|
||||
});
|
||||
/**
|
||||
* Send notification to partner via FCM and write to notification_queue.
|
||||
*/
|
||||
|
|
@ -139,6 +196,8 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no
|
|||
var _a;
|
||||
const title = notificationType === 'partner_finished_game'
|
||||
? `${partnerName} finished the game`
|
||||
: notificationType === 'partner_completed_part'
|
||||
? `${partnerName} finished their part`
|
||||
: `${partnerName} is playing`;
|
||||
const notificationPayload = {
|
||||
type: notificationType,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -116,6 +116,66 @@ export const onGameSessionUpdate = functions.firestore
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game.
|
||||
*
|
||||
* Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to
|
||||
* couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to
|
||||
* 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game).
|
||||
* Between first-finish and both-finish the waiting partner got NOTHING — they never learned it was
|
||||
* their turn (the symptom: "X finished a game but the partner was never notified"). The
|
||||
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
|
||||
*
|
||||
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
|
||||
*/
|
||||
const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync']
|
||||
export const onGamePartFinished = functions.firestore
|
||||
.document('couples/{coupleId}/{gameType}/{sessionId}')
|
||||
.onWrite(async (change, context) => {
|
||||
const { coupleId, gameType, sessionId } =
|
||||
context.params as { coupleId: string; gameType: string; sessionId: string }
|
||||
if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc.
|
||||
if (!change.after.exists) return
|
||||
|
||||
const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
|
||||
const answerUids = Object.keys(answers)
|
||||
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
|
||||
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
|
||||
// partner_finished_game instead.
|
||||
if (answerUids.length !== 1) return
|
||||
const finisherUid = answerUids[0]
|
||||
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
|
||||
|
||||
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
|
||||
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
|
||||
const claimed = await db.runTransaction(async (tx) => {
|
||||
const fresh = await tx.get(sessionRef)
|
||||
const d = fresh.data()
|
||||
if (!fresh.exists || !d) return false
|
||||
if (d.status === 'completed' || d.partFinishNotifiedAt) return false
|
||||
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
|
||||
return true
|
||||
})
|
||||
if (!claimed) return
|
||||
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
const recipient = userIds.find((u) => u !== finisherUid)
|
||||
if (!recipient) return
|
||||
const finisher = await db.collection('users').doc(finisherUid).get()
|
||||
const finisherName = (finisher.data()?.displayName as string) ?? 'Your partner'
|
||||
const finisherAvatar = finisher.data()?.photoUrl as string | undefined
|
||||
|
||||
await notifyPartner(
|
||||
db, messaging, recipient, finisherName, gameType,
|
||||
'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId,
|
||||
finisherAvatar, sessionId
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Send notification to partner via FCM and write to notification_queue.
|
||||
*/
|
||||
|
|
@ -134,6 +194,8 @@ async function notifyPartner(
|
|||
const title =
|
||||
notificationType === 'partner_finished_game'
|
||||
? `${partnerName} finished the game`
|
||||
: notificationType === 'partner_completed_part'
|
||||
? `${partnerName} finished their part`
|
||||
: `${partnerName} is playing`
|
||||
const notificationPayload = {
|
||||
type: notificationType,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ~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 <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`).
|
||||
|
|
@ -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: ~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() { # <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 ]
|
||||
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue