From a6d30625850b0edc5ccb7b804e99321de0d7cd3c Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 19:05:59 -0500 Subject: [PATCH] fix(daily-question): add reconcileLocalAnswerFromFirestore to heal Room/Firestore desync --- .../closer/ui/questions/LocalAnswerMapping.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt b/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt index d03ee5e0..c44c53ec 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt @@ -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)