Compare commits

...

8 Commits

8 changed files with 243 additions and 4 deletions

2
.gitignore vendored
View File

@ -88,3 +88,5 @@ scratchpad/
SECURITY.md
Future.md
docs/strategy/positioning-vs-paired.md
ClaudeQAPlan.md
ClaudeReport.md

View File

@ -968,7 +968,10 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
- **Install/update/migration lifecycle:** fresh install, update over an existing signed-in install, app data retained,
Room/DataStore/SharedPreferences migrations, notification channel migration, cached encryption/key material,
pending deep links/notifications across update, and version-skew between partners if one device updates first. No
sign-out loops, stale build routing, lost local state, broken permissions, or migration crashes.
sign-out loops, stale build routing, lost local state, broken permissions, or migration crashes. **When local state
is lost but Firestore is intact (fresh device / cleared data), already-answered content must reconcile back rather
than re-prompt** — see the **R23-DQ-001** daily-question reconcile check in Pass N (a re-answer offered against an
immutable `secure/payload` is silent data loss).
- **Crash reporting:** confirm crashes/ANRs are actually captured (Crashlytics) so field issues surface.
### Pass H — Branding & artwork (every screen: could it carry more of the brand? where would art help?)
@ -1213,6 +1216,13 @@ The non-game interactive surfaces that have no functional home (Pass B is games
**Discuss** thread (Pass L) → **Answer History****streak** increment + milestone celebration (`streak_milestone`)
→ reveal `isRevealed` retry (the `onAnswerRevealed` push). Verify the premium daily-question fallback
(`DailyQuestionResolver` per-user) does **not** desync the couple's shared daily Q.
- **R23-DQ-001 — fresh-device / cleared-local-DB reconcile (data-loss guard):** after answering, simulate the
Room↔Firestore desync (new device, reinstall with data cleared, or a wiped local answer store) so Firestore holds
the answer but local Room/prefs don't. Home must **not** show a stale "your turn", and opening the daily question
must show the **submitted/reveal** state (NOT an editable re-answer form) — because the `secure/payload` doc is
immutable (`allow update:false`), so a re-answer would be silently rejected and a *changed* pick lost. The guard is
`reconcileLocalAnswerFromFirestore` (Room-first; rebuilds from the read-gated couple-key payload) wired into
`DailyQuestionViewModel` + `HomeViewModel`; covered by `ReconcileLocalAnswerTest`.
- **Relationship check-ins / Your Progress (outcomes):** baseline check-in (gated to show once), 30/60/90-day
follow-ups, slider inputs persist (`submitOutcomeCallable`), the progress view renders patterns/milestones,
`scheduledOutcomesReminder` fires, "No baseline yet" → check-in dialog (C-DARK-UI-002 area). Submit + Skip both work.

File diff suppressed because one or more lines are too long

View File

@ -197,7 +197,8 @@ class HomeViewModel @Inject constructor(
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver,
private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource,
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource,
private val answerDataSource: FirestoreAnswerDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -267,6 +268,27 @@ class HomeViewModel @Inject constructor(
}
}
// Heal a Room/Firestore desync for today's daily answer (fresh device / cleared DB):
// if Firestore already holds this user's answer but Room doesn't, write it back so Home
// stops showing a stale "your turn" and never offers a re-answer that the immutable
// secure/payload rule would reject (R23). Room-first → a no-op once Room is correct;
// the answers observer then recomputes the card.
val dailyAnswerCoupleId = couple?.id
if (uid != null && dailyAnswerCoupleId != null && dailyQuestion != null) {
launch {
runCatching {
app.closer.ui.questions.reconcileLocalAnswerFromFirestore(
question = dailyQuestion,
coupleId = dailyAnswerCoupleId,
date = FirestoreAnswerDataSource.todayLocalDateString(),
userId = uid,
firestore = answerDataSource,
localAnswers = localAnswerRepository
)
}.onFailure { Log.w(TAG, "daily answer reconcile failed", it) }
}
}
// Outcome check-in due-state calculation
val appSettings = settingsRepository.settings.first()
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt

View File

@ -75,7 +75,26 @@ class DailyQuestionViewModel @Inject constructor(
val today = resolved.date
val coupleId = resolved.coupleId
val question = resolved.question
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
// Room-first, but if Room has none while Firestore already holds this user's answer
// (fresh device / cleared DB), reconcile it back so we don't offer a re-answer that
// the immutable secure/payload rule would silently reject — R23 data-loss guard.
val userId = authRepository.currentUserId
val answer = question?.let { q ->
if (coupleId != null && userId != null) {
runCatching {
reconcileLocalAnswerFromFirestore(
question = q,
coupleId = coupleId,
date = today,
userId = userId,
firestore = firestoreAnswerDataSource,
localAnswers = localAnswerRepository
)
}.getOrNull()
} else {
localAnswerRepository.getAnswer(q.id)
}
}
val partnerHasAnswered = coupleId?.let {
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
} ?: false

View File

@ -1,9 +1,64 @@
package app.closer.ui.questions
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.LocalAnswerRepository
/**
* Reconcile a missing local answer with Firestore. When Room has no answer for [question] but the
* couple's Firestore record shows this [userId] already answered [date]'s question, rebuild the
* answer from the read-gated couple-key payload (the owner can always read their own) and persist
* it back to Room so subsequent loads are correct.
*
* This closes the R23 data-loss class: on a fresh device / after the local DB is cleared, Room is
* empty while Firestore still holds the answer, so the screen would otherwise offer a re-answer
* but the `secure/payload` doc is immutable (`allow update: if false`), so the re-write is silently
* rejected and a *changed* answer is lost. Returning the prior answer keeps the screen in its
* submitted/reveal state instead of an editable form. Returns null only when there is genuinely no
* prior answer (the normal first-answer path).
*
* The decrypted content is persisted to Room only when it actually decrypts, so a transient
* key-unavailable read never poisons Room with an empty answer (a later load can still recover it).
*
* Room-first: when Room already holds the answer it is returned immediately (a single local read,
* no network), so this is safe to call on every load from any surface.
*/
suspend fun reconcileLocalAnswerFromFirestore(
question: Question,
coupleId: String,
date: String,
userId: String,
firestore: FirestoreAnswerDataSource,
localAnswers: LocalAnswerRepository
): LocalAnswer? {
localAnswers.getAnswer(question.id)?.let { return it }
val meta = runCatching { firestore.getAnswerForUser(coupleId, userId, date) }.getOrNull() ?: return null
// The daily assignment can rotate; only heal when the stored answer is for THIS question.
if (meta.questionId.isNotBlank() && meta.questionId != question.id) return null
val decoded = runCatching { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) }.getOrNull()
val ids = decoded?.selectedOptionIds ?: emptyList()
val healed = LocalAnswer(
questionId = question.id,
questionText = question.text,
category = question.category,
answerType = meta.answerType.ifBlank { question.type },
writtenText = decoded?.writtenText,
selectedOptionIds = ids,
selectedOptionTexts = selectedOptionTexts(question, ids),
scaleValue = decoded?.scaleValue,
createdAt = meta.createdAt,
updatedAt = meta.updatedAt,
isRevealed = meta.isRevealed,
schemaVersion = 2,
isSealed = false,
answerDate = date
)
if (decoded != null) runCatching { localAnswers.saveAnswer(healed) }
return healed
}
fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState {
answer ?: return copy(submitted = false, isRevealed = false)

View File

@ -0,0 +1,125 @@
package app.closer.ui.questions
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.data.remote.FirestoreAnswerDataSource.DecodedAnswer
import app.closer.domain.model.ChoiceAnswerConfig
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.repository.LocalAnswerRepository
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit coverage for the R23 data-loss guard: [reconcileLocalAnswerFromFirestore] must heal a
* Room/Firestore desync (fresh device / cleared local DB) so the daily question is never offered
* for a silently-rejected re-answer.
*/
class ReconcileLocalAnswerTest {
private val coupleId = "couple1"
private val date = "2026-06-30"
private val userId = "userA"
private val choiceQ = Question(
id = "q1", text = "Snack mission?", category = "fun", type = "single_choice",
answerConfig = ChoiceAnswerConfigImpl(
config = ChoiceAnswerConfig(
options = listOf(ChoiceOption("sweet", "Sweet"), ChoiceOption("salty", "Salty"))
)
)
)
private fun meta(questionId: String = "q1", revealed: Boolean = false) = LocalAnswer(
questionId = questionId,
questionText = "",
category = "",
answerType = "single_choice",
isRevealed = revealed,
answerDate = date
)
@Test
fun `room-first - returns the local answer without touching Firestore`() = runTest {
val local = mockk<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
val existing = meta().copy(selectedOptionIds = listOf("sweet"))
coEvery { local.getAnswer("q1") } returns existing
val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local)
assertEquals(existing, result)
coVerify(exactly = 0) { firestore.getAnswerForUser(any(), any(), any()) }
}
@Test
fun `room empty but Firestore has it - rebuilds, persists to Room, maps option texts`() = runTest {
val local = mockk<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
coEvery { local.getAnswer("q1") } returns null
coEvery { local.saveAnswer(any()) } just Runs
coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns meta(revealed = true)
coEvery { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) } returns
DecodedAnswer(writtenText = null, selectedOptionIds = listOf("sweet"), scaleValue = null)
val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local)
assertEquals("q1", result?.questionId)
assertEquals(listOf("sweet"), result?.selectedOptionIds)
assertEquals(listOf("Sweet"), result?.selectedOptionTexts) // mapped from the question config
assertTrue(result?.isRevealed == true)
coVerify(exactly = 1) { local.saveAnswer(match { it.selectedOptionIds == listOf("sweet") }) }
}
@Test
fun `no prior answer anywhere - returns null (the normal first-answer path)`() = runTest {
val local = mockk<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
coEvery { local.getAnswer("q1") } returns null
coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns null
val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local)
assertNull(result)
coVerify(exactly = 0) { local.saveAnswer(any()) }
}
@Test
fun `assignment rotated - Firestore answer is for a different question, do not heal`() = runTest {
val local = mockk<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
coEvery { local.getAnswer("q1") } returns null
coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns meta(questionId = "OTHER")
val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local)
assertNull(result)
coVerify(exactly = 0) { local.saveAnswer(any()) }
}
@Test
fun `metadata present but content undecryptable - reports answered, does NOT poison Room`() = runTest {
val local = mockk<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
coEvery { local.getAnswer("q1") } returns null
coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns meta()
coEvery { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) } returns null // key unavailable
val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local)
// Still treated as answered (so the screen won't offer a re-answer)...
assertEquals("q1", result?.questionId)
assertTrue(result?.selectedOptionIds?.isEmpty() == true)
// ...but not persisted, so a later load with the key available can recover the real content.
coVerify(exactly = 0) { local.saveAnswer(any()) }
}
}

View File

@ -1163,6 +1163,12 @@ SCRIPTS.md
These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now.
### R23-DQ-001 — sourcing "already answered?" from local Room only → silent re-answer data loss against the immutable `secure/payload`
**Symptom (R23)**: on a device whose local answer store was empty while Firestore still held the user's daily answer (fresh device / reinstall with cleared data / wiped prefs), Home showed a stale **"your turn"** and the daily-question screen offered an **editable re-answer form**. Submitting logged `Write failed at couples/{id}/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` (swallowed). If the user picked a *different* answer it was **silently lost** — the `secure/payload` doc is immutable (`allow update: if false`), so the overwrite is denied and the reveal keeps the *old* content while the UI claimed "saved".
**Root cause**: `DailyQuestionViewModel.loadDailyQuestion` and `HomeViewModel` derived answered-state from **local Room/prefs only** (`localAnswerRepository.getAnswer` / `observeAnswers``answeredQuestionIds`), with no fallback to Firestore. Room and Firestore can legitimately diverge (Auth + Firestore persist across a reinstall; the local answer store does not), so the app offered an action the rules forbid.
**Fix (R23)**: `reconcileLocalAnswerFromFirestore` ([`ui/questions/LocalAnswerMapping.kt`](../app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt)) — **Room-first** (returns the local answer immediately when present, so the common path is unchanged and network-free); otherwise reads the answer metadata + decrypts the owner's own read-gated couple-key payload, rebuilds the `LocalAnswer` (mapping option-texts), and **writes it back to Room** (only when it actually decrypts, so a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Covered by `ReconcileLocalAnswerTest` (5 branches). `QuestionDetailViewModel` (pack questions) is **local-only** and not affected.
**Re-introduction risk**: any UI that decides whether to offer a **submit-once / immutable** write (daily answers, date reflections, anything with a read-gated `secure/payload` whose `allow update:false`) must treat **Firestore as authoritative for existence**, not local cache — a local-only check re-offers the action after the local DB is lost and the immutable rule rejects the re-write silently. The date feature already does this (its `hasReflected` reads Firestore). When adding a new private→reveal collection, reconcile from Firestore on load and verify the fresh-device path (Pass N **R23-DQ-001** check), since `pm clear` (the faithful repro) is blocked in our emulator setup (App Check token).
### B-ABANDON-001 — never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys
**Symptom (R20)**: tapping **Quit** on any game (This or That / How Well / Wheel) navigated away but **left the session `active` server-side** — stranded, blocking new games — with no user-visible error. Logcat showed `Write failed at couples/{id}/sessions/{sid}: PERMISSION_DENIED``ThisOrThatViewModel: quit-abandon no-op`. The failure was swallowed by `runCatching{…}.onFailure{ Log.d }`. This had been mistaken for a test-data cleanup nuisance for several rounds ("Quit doesn't cancel server-side") before being root-caused.
**Root cause**: `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) completed the session by calling `saveSession(activeSession.copy(status="completed", completedAt=now))`. `saveSession` does `doc.set(data)` where `data` is a **fixed 13-field map** — so it **overwrites the whole document and drops any field not in that map**, including the server-only notification flags the Cloud Function wrote via Admin SDK (`startNotifiedAt`, `joinNotifiedAt`, `partFinishNotifiedAt`, `finishNotifiedAt`). The session-update rule's `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status','completedAt','completedByUsers','joinedByUsers'])` counts a **removed** key as affected → those dropped flags fail `hasOnly` → the entire write is denied. (The normal both-finished completion path was unaffected because `markUserComplete` uses a **targeted `tx.update(docRef, {completedByUsers, status?, completedAt?})`**, never `saveSession`.)