fix(outcomes): restore Your Progress read — scope query to allowed dayKeys + coerce Long scores (I-001, I-002)

I-001: getOutcomes() did a bare collection list .get() on couples/{cid}/outcomes,
which firestore.rules denies (reads allowed only for dayKey in day_0/30/60/90) ->
always PERMISSION_DENIED, swallowed to emptyList(). Now scopes the query with
whereIn(FieldPath.documentId(), OUTCOME_DAY_KEYS) so it satisfies the rule.

I-002 (found while fixing I-001): toOutcomeScores() cast values to Map<String,Int>,
but Firestore returns integer fields as Long on Android -> ClassCastException ->
scores dropped (same shape submitOutcomeCallable writes, so the real path was broken
too). Now coerces (value as? Number)?.toInt().

Verified live: 0 outcomes PERMISSION_DENIED after relaunch; seeded a day_0 baseline
(int64) -> "Your Progress" shows "Baseline recorded" (was "No baseline yet"). Seed
removed, couple baseline restored (0 outcomes, 0 active sessions). Both pending one
re-QA confirmation round before pruning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-25 23:58:37 -05:00
parent 35d36e6851
commit ab29f6b12f
3 changed files with 30 additions and 13 deletions

View File

@ -91,7 +91,7 @@ Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` →
- **Caching / lazy-load:** LazyColumn/Row/Grid in 17 files; Coil (AsyncImage) in 11; Room DAOs cache static question/category data locally — all in place, no load-all anti-patterns seen. - **Caching / lazy-load:** LazyColumn/Row/Grid in 17 files; Coil (AsyncImage) in 11; Room DAOs cache static question/category data locally — all in place, no load-all anti-patterns seen.
- **Leak check:** conversation open/close ×6 → ViewRootImpl=1, Activities=1, Views +2, PSS bounded after trim → no window/Activity/listener leak. - **Leak check:** conversation open/close ×6 → ViewRootImpl=1, Activities=1, Views +2, PSS bounded after trim → no window/Activity/listener leak.
- **Redundant reads:** precise per-read counts need an instrumented/Perfetto build (Firestore success reads aren't in adb logcat); no failing-read spam **except I-001**; no leaked listeners. - **Redundant reads:** precise per-read counts need an instrumented/Perfetto build (Firestore success reads aren't in adb logcat); no failing-read spam **except I-001**; no leaked listeners.
- **Finding: I-001 (P1)** — `getOutcomes()` bare-list query is rules-denied → "Your Progress"/outcomes silently broken (see ClaudeReport.md). - **Finding: I-001 (P1) — FIXED+VERIFIED** `getOutcomes()` bare-list query was rules-denied → fixed with `whereIn(documentId, dayKeys)`; 0 PERMISSION_DENIED after. **I-002 (P1) — FIXED+VERIFIED** (found fixing I-001): scores stored as int64 → read as Long → `Map<String,Int>` cast CCE → swallowed; fixed via `Number.toInt()`. Live: seeded day_0 → "Your Progress" shows "Baseline recorded". Both pending Round-9 confirm.
## Pass J — Accessibility (R8, emulator-5554) ## Pass J — Accessibility (R8, emulator-5554)
- **Font scaling (font_scale 2.0, worst case):** Home, Paywall, Settings all **reflow + scroll, no clipped/hidden buttons** — meets the acceptance bar. Minor: long subtitles/email ellipsize, bottom-nav labels wrap ("Mess ages"). Restored to 1.0. ✅ - **Font scaling (font_scale 2.0, worst case):** Home, Paywall, Settings all **reflow + scroll, no clipped/hidden buttons** — meets the acceptance bar. Minor: long subtitles/email ellipsize, bottom-nav labels wrap ("Mess ages"). Restored to 1.0. ✅

View File

@ -1,12 +1,12 @@
# Claude QA Report — Full-App QA (living report) # Claude QA Report — Full-App QA (living report)
> **Verdict (2026-06-25, R8): 1 open P1 — I-001 ("Your Progress" outcomes read is rules-denied → feature silently broken). All else clean; security cornerstone clean.** > **Verdict (2026-06-25, R8): 0 open P0P2 (1 P3 J-OBS, non-blocking). I-001 + I-002 (outcomes read) fixed + verified live. Security cornerstone clean. At the flawless bar.**
> >
> This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned > This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current) ## Run-state (current)
`Round 8 (re-QA + Passes I/J) — IN PROGRESS | 1 open P1 (I-001) + 1 P3 (J-OBS) | Passes I+J done | NEXT ACTION: fix phase — I-001 (build+verify), then re-QA confirm.` `Round 8 — fix phase DONE | 0 open P0P2 (1 P3 J-OBS) | I-001+I-002 fixed+verified live | NEXT ACTION: optional deferred C/E/F coverage; Round 9 re-confirm + prune I-001/I-002.`
- **Build:** client HEAD `23dd6a7`, Cloud Functions deployed. - **Build:** client HEAD `23dd6a7`, Cloud Functions deployed.
- **Devices / accounts:** emulator-5554 = QA (`Y05AKO2IlTPMa0JQW1BiNIM0uzK2`) · emulator-5556 = Sam (`imDjjO…`) · paired, coupleId `Xal3Kw3gjSdn0niERYKJ`, both free (baseline restored). - **Devices / accounts:** emulator-5554 = QA (`Y05AKO2IlTPMa0JQW1BiNIM0uzK2`) · emulator-5556 = Sam (`imDjjO…`) · paired, coupleId `Xal3Kw3gjSdn0niERYKJ`, both free (baseline restored).
- **Docs:** Playbook `ClaudeQAPlan.md` · Coverage `ClaudeQACoverage.md` · Ideas `Future.md` `## QA` · Branding `ClaudeBrandingReview.md`. - **Docs:** Playbook `ClaudeQAPlan.md` · Coverage `ClaudeQACoverage.md` · Ideas `Future.md` `## QA` · Branding `ClaudeBrandingReview.md`.
@ -15,15 +15,20 @@
| Severity | Open | Fixed (pending 1 confirm) | | Severity | Open | Fixed (pending 1 confirm) |
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | **1** | 0 | | P1 | 0 | 2 (I-001, I-002) |
| P2 | 0 | 0 | | P2 | 0 | 0 |
| P3 | **1** | 0 | | P3 | **1** | 0 |
## Open issues ## Open issues
| ID | Sev | Area | Description | Repro | Suggested fix | Status | | ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| I-001 | **P1** | Outcomes / "Your Progress" read | `FirestoreOutcomeDataSource.getOutcomes()` (line 45-53) issues a **bare collection list** `.get()` on `couples/{cid}/outcomes`, but the rule (firestore.rules:658) only allows reading specific dayKey docs (`day_0/30/60/90`) and **denies list queries → always `PERMISSION_DENIED`**. `OutcomeRepositoryImpl.getOutcomes` (26-29) swallows it (records non-fatal → returns `emptyList()`). Net: recorded check-ins **never display** in Your Progress; Home/Settings reminder logic re-prompts for completed days; crashReporter spammed each load. Found via Pass I (perf) efficiency lens — masked from A/B/C because this couple has 0 outcomes + the failure is swallowed. | Open app → logcat: `Listen/Get for Query(couples/{cid}/outcomes …) failed: PERMISSION_DENIED` (live-confirmed R8). | Constrain the query to satisfy the rule: `.whereIn(FieldPath.documentId(), listOf("day_0","day_30","day_60","day_90")).get()` (or 4 parallel `getOutcome` gets). No rules change needed. | **Open** | | J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open (P3, non-blocking)** |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open** |
## Fixed this round — pending one confirmation round (then prune)
| ID | Sev | Area | Fix | Status |
|---|---|---|---|---|
| I-001 | P1 | Outcomes read — query rules-denied | `getOutcomes()` now scopes the query with `.whereIn(FieldPath.documentId(), OUTCOME_DAY_KEYS)` (the 4 allowed dayKeys) so it satisfies firestore.rules:658 instead of issuing a denied bare-list. | **Fixed + verified live:** 0 `outcomes` PERMISSION_DENIED after relaunch (was firing every load). |
| I-002 | P1 | Outcomes read — score parse (found fixing I-001) | `toOutcomeScores()` now coerces `(value as? Number)?.toInt()` instead of casting to `Map<String,Int>` — Firestore returns integer fields as **Long** on Android → the hard Int cast threw CCE → scores swallowed to `emptyList()`. Same shape `submitOutcomeCallable` writes, so it broke the real path too. | **Fixed + verified live:** seeded a day_0 baseline (int64, == real callable shape) → "Your Progress" now shows **"Baseline recorded"** (was "No baseline yet"). Seed removed, baseline restored. |
## Resolved & confirmed (archived — full detail in git history) ## Resolved & confirmed (archived — full detail in git history)
A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS · F-RACE-001 — all fixed and re-verified (commits in history; **F-RACE-001** fixed `23dd6a7`, re-confirmed live R8: race → 1 session, loser joins same set). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.) A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS · F-RACE-001 — all fixed and re-verified (commits in history; **F-RACE-001** fixed `23dd6a7`, re-confirmed live R8: race → 1 session, loser joins same set). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
@ -35,7 +40,7 @@ A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-
- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads. - **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
## Round history (one line each) ## Round history (one line each)
- **R8** (in progress) — F-RACE-001 re-confirmed live (race → 1 session; loser joins winner's same-set session via "Join the game") + pruned; running Passes I/J. - **R8** — F-RACE-001 re-confirmed + pruned; Passes I (perf) + J (a11y) run; found+fixed+verified **I-001 & I-002** (outcomes read: query rules-denied + Long/Int parse CCE → "Your Progress" was silently dead). 0 open P0P2.
- **R7** — multi-angle security/concurrency deep dive → cornerstone fully clean; F-RACE-001 found + fixed + verified. 0 new open. - **R7** — multi-angle security/concurrency deep dive → cornerstone fully clean; F-RACE-001 found + fixed + verified. 0 new open.
- **R6** — branding drop + Future.md backlog regression (white-keyhole icons/loader/splash, inclusive gender, copy, rate-limit split, results-push suppression, paywall retry/offline) → 0 new open. - **R6** — branding drop + Future.md backlog regression (white-keyhole icons/loader/splash, inclusive gender, copy, rate-limit split, results-push suppression, paywall retry/offline) → 0 new open.
- **R5** — Cloud Functions deployed (E-OBS channel fix, E-003 results routing) + new Pass G (account creation / fake-account abuse) clean → 0 open. - **R5** — Cloud Functions deployed (E-OBS channel fix, E-003 results routing) + new Pass G (account creation / fake-account abuse) clean → 0 open.

View File

@ -1,9 +1,11 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.domain.model.Outcome import app.closer.domain.model.Outcome
import app.closer.domain.model.OutcomeDay
import app.closer.domain.model.OutcomeDayKey import app.closer.domain.model.OutcomeDayKey
import app.closer.domain.model.OutcomeScores import app.closer.domain.model.OutcomeScores
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctions
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -18,6 +20,11 @@ class FirestoreOutcomeDataSource @Inject constructor(
private val db: FirebaseFirestore, private val db: FirebaseFirestore,
private val functions: FirebaseFunctions private val functions: FirebaseFunctions
) { ) {
private companion object {
// The only outcome doc ids the security rules allow reading.
val OUTCOME_DAY_KEYS: List<OutcomeDayKey> = OutcomeDay.entries.map { it.key }
}
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit = suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
functions.getHttpsCallable("submitOutcomeCallable") functions.getHttpsCallable("submitOutcomeCallable")
@ -43,10 +50,14 @@ class FirestoreOutcomeDataSource @Inject constructor(
} }
suspend fun getOutcomes(coupleId: String): List<Outcome> { suspend fun getOutcomes(coupleId: String): List<Outcome> {
// Security rules permit reading only the fixed dayKey docs and DENY an
// unconstrained list query, so the query must be scoped to those ids by
// document id — otherwise every read fails PERMISSION_DENIED (I-001).
val snapshot = db val snapshot = db
.collection(FirestoreCollections.COUPLES) .collection(FirestoreCollections.COUPLES)
.document(coupleId) .document(coupleId)
.collection(FirestoreCollections.OUTCOMES) .collection(FirestoreCollections.OUTCOMES)
.whereIn(FieldPath.documentId(), OUTCOME_DAY_KEYS)
.get() .get()
.await() .await()
return snapshot.documents.mapNotNull { it.toOutcome() } return snapshot.documents.mapNotNull { it.toOutcome() }
@ -73,13 +84,14 @@ class FirestoreOutcomeDataSource @Inject constructor(
) )
} }
@Suppress("UNCHECKED_CAST")
private fun Map<*, *>.toOutcomeScores(): OutcomeScores? { private fun Map<*, *>.toOutcomeScores(): OutcomeScores? {
val map = this as? Map<String, Int> ?: return null // Firestore returns integer fields as Long on Android, so coerce via Number
val connection = map["connection"] ?: return null // rather than casting to Int (a hard Int cast threw CCE → scores dropped, I-002).
val communication = map["communication"] ?: return null fun score(key: String): Int? = (this[key] as? Number)?.toInt()
val intimacy = map["intimacy"] ?: return null val connection = score("connection") ?: return null
val happiness = map["happiness"] ?: return null val communication = score("communication") ?: return null
val intimacy = score("intimacy") ?: return null
val happiness = score("happiness") ?: return null
return runCatching { OutcomeScores(connection, communication, intimacy, happiness) } return runCatching { OutcomeScores(connection, communication, intimacy, happiness) }
.getOrNull() .getOrNull()
} }