From df32229f3b2f46352951c54b0b05c9f592783d61 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 26 Jun 2026 12:40:49 -0500 Subject: [PATCH] =?UTF-8?q?feat(answers):=20replace=20sealed-key=20exchang?= =?UTF-8?q?e=20with=20couple-key=20encryption=20(schemaVersion=202)=20?= =?UTF-8?q?=E2=80=94=20reveal=20on=20both-answered,=20no=20key=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/FirestoreAnswerDataSource.kt | 104 +++++++++++++++++- .../ui/answers/AnswerRevealViewModel.kt | 87 ++++++++++++++- .../ui/questions/DailyQuestionViewModel.kt | 6 +- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt index 7ad490c2..a8ac7d8e 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.suspendCancellableCoroutine import org.json.JSONArray +import org.json.JSONObject import java.time.Clock import java.time.Instant import java.time.LocalDate @@ -62,7 +63,108 @@ class FirestoreAnswerDataSource @Inject constructor( userId: String, answer: LocalAnswer, date: String = todayLocalDateString() - ): Unit = saveAnswerSealed(coupleId, questionId, userId, answer, date) + ): Unit = saveAnswerCoupleKey(coupleId, questionId, userId, answer, date) + + /** Decoded answer payload (mirror of the sealed AnswerPayload, decrypted with the couple key). */ + data class DecodedAnswer( + val writtenText: String?, + val selectedOptionIds: List, + val scaleValue: Int? + ) + + /** + * Daily answers are encrypted with the shared couple key (enc:v1:, schemaVersion 2). Both + * partners already hold the couple key, so reveal decrypts reliably the moment both have + * answered — privacy ("not until both answer") is enforced by the Firestore read rule, not a + * per-answer key handshake. (Replaced the fragile per-device sealed key exchange.) + */ + private suspend fun saveAnswerCoupleKey( + coupleId: String, + questionId: String, + userId: String, + answer: LocalAnswer, + date: String + ) { + val aead = encryptionManager.aeadFor(coupleId) + ?: throw IllegalStateException("Couple key unavailable for $coupleId") + val encryptedPayload = fieldEncryptor.encrypt( + encodeAnswerPayload(answer.writtenText, answer.selectedOptionIds, answer.scaleValue), + aead, coupleId + ) + // Metadata doc (readable by both → drives the partner's "your turn" indicator); content-free. + val metadata = mapOf( + "userId" to userId, + "questionId" to questionId, + "answerType" to answer.answerType, + "schemaVersion" to 2, + "answerDate" to date, + "createdAt" to answer.createdAt, + "updatedAt" to answer.updatedAt, + "isRevealed" to false + ) + // Encrypted content lives in the read-gated `secure` subdoc (no peek before both answer). + suspendCancellableCoroutine { cont -> + securePayloadRef(coupleId, date, userId) + .set(mapOf("encryptedPayload" to encryptedPayload)) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + suspendCancellableCoroutine { cont -> + answerRef(coupleId, date, userId) + .set(metadata) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + } + + private fun securePayloadRef(coupleId: String, date: String, userId: String) = + answerRef(coupleId, date, userId).collection("secure").document("payload") + + /** Reads + decrypts a partner's couple-key answer content from the read-gated `secure` subdoc. */ + suspend fun decryptCoupleKeyAnswerFor(coupleId: String, date: String, userId: String): DecodedAnswer? { + val encryptedPayload = runCatching { + suspendCancellableCoroutine { cont -> + securePayloadRef(coupleId, date, userId) + .get() + .addOnSuccessListener { snap -> cont.resume(snap.getString("encryptedPayload")) } + .addOnFailureListener { cont.resumeWithException(it) } + } + }.getOrNull() + return decryptCoupleKeyAnswer(coupleId, encryptedPayload) + } + + /** Marks the caller's own answer revealed in Firestore — drives the partner-opened push. */ + suspend fun markRevealed(coupleId: String, date: String, userId: String): Unit = + suspendCancellableCoroutine { cont -> + answerRef(coupleId, date, userId) + .update(mapOf("isRevealed" to true, "updatedAt" to System.currentTimeMillis())) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + /** Decrypts a couple-key (enc:v1:) answer payload. Returns null if the key/payload is unavailable. */ + fun decryptCoupleKeyAnswer(coupleId: String, encryptedPayload: String?): DecodedAnswer? { + if (encryptedPayload == null) return null + val aead = encryptionManager.aeadFor(coupleId) ?: return null + val json = fieldEncryptor.decrypt(encryptedPayload, aead, coupleId) ?: return null + return runCatching { + val o = JSONObject(json) + val arr = o.optJSONArray("selectedOptionIds") + val ids = if (arr == null) emptyList() else (0 until arr.length()).map { arr.getString(it) } + DecodedAnswer( + writtenText = if (o.isNull("writtenText")) null else o.optString("writtenText"), + selectedOptionIds = ids, + scaleValue = if (o.isNull("scaleValue")) null else o.optInt("scaleValue") + ) + }.getOrNull() + } + + private fun encodeAnswerPayload(writtenText: String?, ids: List, scale: Int?): String = + JSONObject().apply { + put("writtenText", writtenText ?: JSONObject.NULL) + put("selectedOptionIds", JSONArray(ids)) + put("scaleValue", scale ?: JSONObject.NULL) + }.toString() private suspend fun saveAnswerSealed( coupleId: String, diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index 45e560dc..ed1fde9d 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -125,6 +125,31 @@ class AnswerRevealViewModel @Inject constructor( followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category) ) + // Couple-key answers: if this answer is ALREADY revealed (e.g. re-opening the + // screen after a previous reveal), decrypt the partner's content now — the + // partner-answer fetch above only carries metadata (content is in the gated subdoc). + if (answer?.schemaVersion == 2 && answer.isRevealed && + partnerAnswer != null && coupleId != null && partnerId != null + ) { + val decoded = firestoreAnswerDataSource.decryptCoupleKeyAnswerFor(coupleId, effectiveDate(answer), partnerId) + val partnerTexts = question + ?.let { app.closer.ui.questions.selectedOptionTexts(it, decoded?.selectedOptionIds ?: emptyList()) } + ?: emptyList() + _uiState.update { + it.copy( + partnerAnswer = partnerAnswer.copy( + writtenText = decoded?.writtenText, + selectedOptionIds = decoded?.selectedOptionIds ?: emptyList(), + selectedOptionTexts = partnerTexts, + scaleValue = decoded?.scaleValue, + isSealed = false, + isRevealed = true + ), + sealedRevealPhase = SealedRevealPhase.REVEALED + ) + } + } + // Live-watch the partner's answer doc so the reveal completes on this device // the moment they release their key — no manual refresh needed. if (coupleId != null && partnerId != null) { @@ -141,7 +166,17 @@ class AnswerRevealViewModel @Inject constructor( } private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase { - if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE + if (answer == null) return SealedRevealPhase.NONE + // Couple-key answers (schemaVersion 2): reveal the instant BOTH have answered — no per-answer + // key handshake. Privacy ("not until both answer") is enforced by the Firestore read rule. + if (answer.schemaVersion == 2) { + return when { + answer.isRevealed -> SealedRevealPhase.REVEALED + partnerAnswer != null -> SealedRevealPhase.BOTH_ANSWERED + else -> SealedRevealPhase.ANSWER_SEALED // waiting for the partner to answer + } + } + if (answer.schemaVersion != 3) return SealedRevealPhase.NONE if (answer.isRevealed) return SealedRevealPhase.REVEALED // isSealed == false means our key was already released; we are waiting for the partner's key. // Only check the local key store while the key is still pending (isSealed == true). @@ -230,12 +265,60 @@ class AnswerRevealViewModel @Inject constructor( coupleIdHash = state.coupleId?.coupleLoreHash() )) if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) { - performSealedReveal(state) + if (state.answer?.schemaVersion == 2) performCoupleKeyReveal(state) + else performSealedReveal(state) } else { performLegacyReveal() } } + /** + * Couple-key reveal (schemaVersion 2): both partners have answered, so the partner's payload is + * already readable + decryptable with the shared couple key — show it immediately, no waiting on + * the partner to also reveal. Also flags our own answer revealed in Firestore so the partner gets + * the "opened your answers" push. + */ + private fun performCoupleKeyReveal(state: AnswerRevealUiState) { + val coupleId = state.coupleId ?: return + val partnerId = state.partnerId ?: return + val userId = authRepository.currentUserId ?: return + val date = effectiveDate(state.answer) + viewModelScope.launch { + // Content lives in the read-gated `secure` subdoc; we can read it now that both answered. + val decoded = firestoreAnswerDataSource.decryptCoupleKeyAnswerFor(coupleId, date, partnerId) + val partnerTexts = state.question + ?.let { app.closer.ui.questions.selectedOptionTexts(it, decoded?.selectedOptionIds ?: emptyList()) } + ?: emptyList() + val decryptedPartner = state.partnerAnswer?.copy( + writtenText = decoded?.writtenText, + selectedOptionIds = decoded?.selectedOptionIds ?: emptyList(), + selectedOptionTexts = partnerTexts, + scaleValue = decoded?.scaleValue, + isSealed = false, + isRevealed = true + ) + + localAnswerRepository.markRevealed(questionId) + runCatching { firestoreAnswerDataSource.markRevealed(coupleId, date, userId) } + .onFailure { crashReporter.recordException(it) } + + val ownAnswer = localAnswerRepository.getAnswer(questionId) + val category = ownAnswer?.category ?: state.question?.category ?: "" + retentionAnalytics.track(RetentionEvent.RevealCompleted( + categoryId = category, + coupleIdHash = state.coupleId?.coupleLoreHash() + )) + _uiState.update { + it.copy( + answer = ownAnswer, + partnerAnswer = decryptedPartner, + sealedRevealPhase = SealedRevealPhase.REVEALED, + followUpOptions = generateFollowUpOptions(ownAnswer, decryptedPartner, category) + ) + } + } + } + private fun performSealedReveal(state: AnswerRevealUiState) { val coupleId = state.coupleId ?: return val partnerId = state.partnerId ?: return diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index 14f28f5f..d73a1119 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -200,9 +200,9 @@ class DailyQuestionViewModel @Inject constructor( if (dailyQuestionDate.isNullOrBlank()) return runCatching { firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate) - // Mirror the sealed schema fields into local storage so the reveal screen can - // enter the sealed reveal state machine on subsequent launches. - localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 3, isSealed = true, answerDate = dailyQuestionDate)) + // Mirror the couple-key schema (v2) into local storage so the reveal screen resolves the + // right path on subsequent launches: reveal once BOTH have answered, no key handshake. + localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 2, isSealed = false, answerDate = dailyQuestionDate)) }.onFailure { crashReporter.recordException(it) }