Compare commits
No commits in common. "14bfbd04c8cf77c79c241b33661bd0805fcc5d30" and "8d563d4fd4d20740a1c09a390b80516b105b8108" have entirely different histories.
14bfbd04c8
...
8d563d4fd4
|
|
@ -88,5 +88,3 @@ scratchpad/
|
||||||
SECURITY.md
|
SECURITY.md
|
||||||
Future.md
|
Future.md
|
||||||
docs/strategy/positioning-vs-paired.md
|
docs/strategy/positioning-vs-paired.md
|
||||||
ClaudeQAPlan.md
|
|
||||||
ClaudeReport.md
|
|
||||||
|
|
|
||||||
|
|
@ -968,10 +968,7 @@ 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,
|
- **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,
|
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
|
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. **When local state
|
sign-out loops, stale build routing, lost local state, broken permissions, or migration crashes.
|
||||||
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.
|
- **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?)
|
### Pass H — Branding & artwork (every screen: could it carry more of the brand? where would art help?)
|
||||||
|
|
@ -1216,13 +1213,6 @@ 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`)
|
**Discuss** thread (Pass L) → **Answer History** → **streak** increment + milestone celebration (`streak_milestone`)
|
||||||
→ reveal `isRevealed` retry (the `onAnswerRevealed` push). Verify the premium daily-question fallback
|
→ reveal `isRevealed` retry (the `onAnswerRevealed` push). Verify the premium daily-question fallback
|
||||||
(`DailyQuestionResolver` per-user) does **not** desync the couple's shared daily Q.
|
(`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
|
- **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,
|
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.
|
`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
|
|
@ -197,8 +197,7 @@ class HomeViewModel @Inject constructor(
|
||||||
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
|
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
|
||||||
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver,
|
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver,
|
||||||
private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(HomeUiState())
|
private val _uiState = MutableStateFlow(HomeUiState())
|
||||||
|
|
@ -268,27 +267,6 @@ 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
|
// Outcome check-in due-state calculation
|
||||||
val appSettings = settingsRepository.settings.first()
|
val appSettings = settingsRepository.settings.first()
|
||||||
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt
|
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt
|
||||||
|
|
|
||||||
|
|
@ -75,26 +75,7 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
val today = resolved.date
|
val today = resolved.date
|
||||||
val coupleId = resolved.coupleId
|
val coupleId = resolved.coupleId
|
||||||
val question = resolved.question
|
val question = resolved.question
|
||||||
// Room-first, but if Room has none while Firestore already holds this user's answer
|
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
||||||
// (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 {
|
val partnerHasAnswered = coupleId?.let {
|
||||||
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
||||||
} ?: false
|
} ?: false
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,9 @@
|
||||||
package app.closer.ui.questions
|
package app.closer.ui.questions
|
||||||
|
|
||||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
|
||||||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
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 {
|
fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState {
|
||||||
answer ?: return copy(submitted = false, isRevealed = false)
|
answer ?: return copy(submitted = false, isRevealed = false)
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
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()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1163,12 +1163,6 @@ 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.
|
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
|
### 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.
|
**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`.)
|
**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`.)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue