129 lines
14 KiB
Markdown
129 lines
14 KiB
Markdown
|
|
# 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.
|