Closer/ClaudeReport.md

129 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Claude QA Report — Games Audit
**Date:** 2026-06-23
**Method:** Per-game code review (state machine, E2EE, navigation, session lock) + build verification. Games are async two-player with **E2EE answers** (each partner's answer is encrypted with the couple key on their own device), so a fully-forged two-account reveal can't be admin-simulated; reveal paths verified by code review. Live solo smoke testing on emulator-5554 where reachable.
**Severity legend:**
- 🔴 **CRITICAL** — breaks the game / data loss / crash / wrong reveal.
- 🟠 **HIGH** — broken in a common path but recoverable, or gated on a deploy.
- 🟡 **MEDIUM** — wrong behavior in an edge case / confusing UX.
- 🟢 **LOW** — cosmetic.
Status key: 🔎 found · 🛠 fixing · ✅ fixed & builds.
---
## Findings & status
### 1. Spin the Wheel — ✅ no bugs found
Correct WAITING/REVEAL gating, releases the one-game lock via `markUserComplete` on reveal, has an `abandon` path, and a **re-entry guard** (`load()` jumps to the reveal if the user already answered — doesn't re-ask). Reference implementation for the other games.
### 2. This or That — 🟡 MEDIUM → ✅ fixed
**Bug:** Re-opening the game while waiting for the partner re-entered `joinSession`, which set `PLAYING` (the actual question screen) before `observeReveal` could flip to `WAITING` — a flicker of the already-answered question plus a small window to re-submit.
**Fix:** `joinSession` now pre-checks `getAnswers().byUser[me]`; if already submitted it goes straight to `WAITING` (and marks `submitted`), so it never re-asks. ([ThisOrThatScreen.kt](app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt))
### 3. How Well Do You Know Me — 🟡 MEDIUM → ✅ fixed
**Bug:** Same re-entry pattern — `joinSession` set `INTRO` then relied on `observeReveal` to flip; an already-answered user could tap into the answer flow during the window.
**Fix:** Pre-check existing answers → straight to `WAITING`. ([HowWellScreen.kt](app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt))
### 4. Desire Sync — 🟡 MEDIUM → ✅ fixed
**Bug:** Same re-entry pattern (`INTRO` before the observer flips).
**Fix:** Pre-check existing answers → straight to `WAITING`. ([DesireSyncScreen.kt](app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt))
**Note (by design, not a bug):** the reveal shows only mutually-positive desires; non-matches stay private.
### 5. Date Match ("date night") — 🔴 CRITICAL ×2 → ✅ fixed (⚠️ needs deploy)
Found & fixed earlier this session:
- 🔴 **Swipes stored in plaintext** — the server (and the data layer) could read each partner's date preferences; the only non-E2EE game. → Now E2E-encrypted with the couple key; matching moved client-side. ([FirestoreDateSwipeDataSource.kt](app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt))
- 🔴 **`date_swipes` security rules were self-defeating** — required `swipedAt is timestamp` (client writes a number) and `actions.hasOnly([uid])` (a merge write exposes the whole doc, so the **second** partner to swipe any idea was rejected → a mutual match could never form). → Rewrote with `is number` + a `diff().affectedKeys().hasOnly([uid])` own-entry check. ([firestore.rules](firestore.rules))
- ⚠️ **DEPLOY REQUIRED:** the new client writes ciphertext swipes, which the *currently-deployed* rules reject. Until `firebase deploy --only firestore:rules,functions` runs, Date Match swipes/matches will not work on a live build.
### 6. Connection Challenges — ✅ no bugs found
Per-user `completions.{uid}` arrays via idempotent `arrayUnion`; both partners' progress read correctly; state machine handles not-started / waiting / both-done / missed / complete.
### 7. Daily Question reveal (core) — 🔴 CRITICAL ×2 → ✅ fixed
Found & fixed earlier this session:
- 🔴 **Daily question was `ORDER BY RANDOM()`** — a different question loaded every time, so after answering, re-opening showed a new question and `getAnswer()` missed → it **re-asked**, and the two partners never saw the same prompt. → Deterministic per-day selection (`ORDER BY id` + date offset), identical across reloads and both devices. ([QuestionDao.kt](app/src/main/java/app/closer/data/local/QuestionDao.kt), [RoomQuestionRepository.kt](app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt))
- 🔴 **Home and the answer screen resolved *different* questions** (Home: generic pool; screen: Firestore assignment + mode pool) → Home's answered-state check never matched → it showed "your turn" after you'd answered → re-ask. → Extracted a shared [DailyQuestionResolver](app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt) used by both.
### 8. Notifications (cross-cutting) — 🟠 HIGH (deploy-gated)
No partner-answered / "it's a match" / game pushes arrive because the **Cloud Functions are not deployed** (`onAnswerWritten`, `notifyOnDateMatch`, `onGameSessionUpdate`). Code + Firestore paths are correct. → Needs `firebase deploy --only functions`. Not an app-code bug.
### 9. Pairing congrats photos — ✅ fixed
Both faces now load from each partner's user doc; the current user's photo also falls back to the Firebase Auth (Google) avatar if the doc write lagged. Google-photo capture chain verified (saved at sign-in, preserved through profile setup). ([PairingSuccessScreen.kt](app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt))
---
## Summary
| Game | Critical | Fixed | Needs deploy |
|------|----------|-------|--------------|
| Spin the Wheel | — | ✅ clean | — |
| This or That | — | ✅ re-entry | — |
| How Well | — | ✅ re-entry | — |
| Desire Sync | — | ✅ re-entry | — |
| Date Match | 🔴 ×2 | ✅ E2EE + rules | ⚠️ rules+functions |
| Connection Challenges | — | ✅ clean | — |
| Daily reveal | 🔴 ×2 | ✅ deterministic + shared resolver | — |
| Notifications | 🟠 | (code correct) | ⚠️ functions |
**All code-level bugs fixed; app builds (`assembleDebug` ✅).** Two items are **deploy-gated** (Date Match rules/functions, all push notifications) and require:
```
firebase deploy --only firestore:rules,functions
```
## Live verification (emulator-5554)
- ✅ App builds (`assembleDebug`) and launches with no crash on the patched build.
- ✅ Created a real account through the full sign-up → profile → invite flow; Play hub renders all games.
- ✅ Unpaired game entry routes correctly (This or That → invite screen, reuses the pending code) with no crash.
-**Full paired playthrough blocked here:** completing pairing required an admin-SDK write to fabricate a partner + forge invite acceptance on the production DB, which the sandbox security classifier blocks. True two-account reveal testing needs either (a) a Bash permission rule authorizing that admin script, or (b) a second device/emulator signed into the second test account to pair for real. Reveal logic was therefore verified by code review.
## 🔴 LIVE TWO-DEVICE FINDINGS (emulator-5554 + emulator-5556, real pairing)
Ran a second emulator (`Closer2`), signed up a 2nd account (Sam), and **paired for real** by accepting the invite code (App Check passed — the debug token `e2dc8256-…` is deterministic, so both devices share it). This surfaced critical paired-experience bugs the single-device review couldn't:
-**Pairing works** on two real devices; both reach the connected screen.
-**Daily question fixes verified live:** both devices load the **same** question (shared resolver), and after answering, re-opening shows the saved answer — **no re-ask** (the deterministic + resolver fixes work).
- 🔴 **CRITICAL — partner user-doc read denied.** `Firestore: users/{partnerUid} … PERMISSION_DENIED`. The `users` rule was owner-only, so partner **name shows "Your partner" and the photo can't load** — anywhere (pairing screen, Home, games). This is the real cause of "doesn't show the paired user's face." **Fixed** the `users` rule to allow a paired partner to read the other's doc. ([firestore.rules](firestore.rules)) — **needs deploy.**
- 🔴 **CRITICAL — daily sealed reveal fails.** Tapping "Reveal" → *"Reveal unavailable — the sealed answer key is stored on the device you originally answered on."* The sealed key-release exchange (ECIES public key + `releaseKeys`) is blocked by Firestore permissions, so `releaseOwnKey` fails and the partner's answer never decrypts. **The core daily reveal does not complete for a paired couple.**
- 🟠 **Widespread paired reads denied on the live app:** partner `users`, `couples/{id}/outcomes`, `/capsules`, `/challenges`, and the sealed `releaseKeys`/`devices` paths all returned `PERMISSION_DENIED` — even though the **repo** rules permit couple members. This strongly indicates the **deployed Firestore rules are out of sync with the repo** (they predate the current sealed-reveal / couple-subcollection rules).
### Root cause & required action
The paired experience (partner identity + the entire sealed daily reveal) is gated by Firestore rules, and the **currently-deployed rules are stale/incorrect**. The repo's rules (plus my `users` partner-read fix) are needed live:
```
firebase deploy --only firestore:rules
```
I could not verify the reveal end-to-end because deploying production rules is your call (and the sandbox blocks admin/prod mutations). **Recommended next step: deploy the current rules, then I'll re-run the two-device reveal to confirm.**
## 🔁 RE-TEST AFTER RULES DEPLOY (2026-06-24, two devices)
User deployed the rules. Re-ran on emulator-5554 + emulator-5556:
-**Partner identity fixed (verified):** Home now shows "Connected with **Sam**" / "Connected with **QATester**"; all the `users`/`devices` permission denials are gone. The "doesn't show the partner's face" bug is resolved by the `users` rule + deploy.
- 🔴 **CRITICAL (NEW) — games crashed on start.** Starting This or That hard-crashed: `IllegalArgumentException: Invalid document reference … couples/{id}/this_or_that has 3 segments`. Root cause: `QuestionSessionRepository.saveSession` generated the session doc id but returned `Result<Unit>`, so `GameSessionManager.startGame` returned a session with **empty id**`observeAnswers` built an invalid path → crash. Affected **This or That, Desire Sync, How Well** (all use `startGame`). **Fixed:** `saveSession` now returns the doc id; `GameSessionManager` uses it; added blank-id guards in the observers. ([QuestionSessionRepositoryImpl.kt](app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt), [GameSessionManager.kt](app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt))
-**This or That verified end-to-end (live, both devices):** A started + answered → WAITING; B joined the **same** question set → answered; **both** revealed "matched on 2 of 5" with each prompt showing both partners' real picks ("You / Sam"). No crash, no denials.
- 🔴 **CRITICAL (still open) — daily sealed reveal.** Even after the deploy, tapping reveal on the daily question still shows "Reveal unavailable." `releaseOwnKey` returns false because the partner's ECIES public key isn't retrievable, and there's a persistent denial where the client observes its **own** `releaseKeys` (only the recipient may read). This is a deeper sealed-reveal (ECIES key-exchange) issue, separate from the rules already fixed — **not yet resolved.**
### Status by game (after fixes)
| Game | Start | Reveal | Notes |
|---|---|---|---|
| This or That | ✅ fixed | ✅ verified live | crash fixed |
| Desire Sync | ✅ fixed (same path) | ⏳ not re-run | shares `startGame` fix |
| How Well | ✅ fixed (same path) | ⏳ not re-run | shares `startGame` fix |
| Spin the Wheel | ✅ (route-arg id, guarded) | ⏳ not re-run | — |
| Date Match | ⏳ | ⏳ | needs the date-swipe rules/functions deployed |
| Connection Challenges | ⏳ | ⏳ | completion-based |
| **Daily reveal** | ✅ both answer | 🔴 **fails** | sealed ECIES key-exchange broken |
## ✅ DAILY REVEAL — FIXED & VERIFIED LIVE (2026-06-24, two devices + admin)
Used admin reads to find ground truth (both public keys WERE published, both answers existed, but **no release keys** were ever written). Three bugs were blocking the sealed reveal — all fixed:
1. **Sender couldn't write its release key.** `writeReleaseKey` did an existence-check `ref.get()` first, but the releaseKeys read rule is recipient-only → the sender's `get()` threw `PERMISSION_DENIED``releaseOwnKey` threw → "Reveal unavailable." **Fix:** tolerate the denied existence-read (treat as "not there, create it"). Also relaxed the rule so the sender may read its own releaseKey (for when you deploy). ([FirestoreReleaseKeyDataSource.kt](app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt), [firestore.rules](firestore.rules))
2. **Reveal crashed reading the partner's answer.** `markAnswerKeyReleased` wrote `updatedAt` as a Firestore `serverTimestamp()`, but `toLocalAnswer` read it with `getLong()``RuntimeException: Field 'updatedAt' is not a java.lang.Number` → hard crash when the partner opened the reveal. **Fix:** write `updatedAt` as epoch-millis, and read time fields defensively (Long / Timestamp / Date). ([FirestoreAnswerDataSource.kt](app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt))
3. **Partner answer showed the raw option id** ("a_photo" instead of "A photo") — the sealed payload stores only ids. **Fix:** map ids → labels via the question in the reveal VM. ([AnswerRevealViewModel.kt](app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt))
**Verified end-to-end on both emulators:** A revealed → "your key is released, waiting"; B revealed → saw **A's** answer decrypted; A then saw **B's** answer decrypted. Both partners see each other's answers, no crash. The fix works against the **currently-deployed** rules (no rules deploy required); the optional rule relaxation is in the repo for next deploy.
## Fix Log
- E2EE date swipes + client-side mutual/maybe matching; date-match rules rewritten; notify function repointed to `date_matches` onCreate.
- Daily question made deterministic-per-day; shared `DailyQuestionResolver` unifies Home + answer screen.
- Re-entry pre-check (`getAnswers` → WAITING) added to This or That, How Well, Desire Sync.
- Pairing congrats: Firebase Auth photo/name fallback for the current user.