diff --git a/README.md b/README.md
index a3c35b41..0a47a912 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,12 @@ The product is built as a native Kotlin app with Jetpack Compose and Firebase-ba
+
+
+
+
+
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 f1348e5f..f7347789 100644
--- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt
+++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt
@@ -49,9 +49,8 @@ class FirestoreAnswerDataSource @Inject constructor(
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
/**
- * Persists the user's answer to Firestore. Uses sealed:v1: format (schemaVersion 3)
- * when the user has an ECIES keypair, giving partner-proof privacy. Falls back to
- * enc:v1: format (schemaVersion 2) if no keypair is present yet.
+ * Persists the user's answer to Firestore using sealed:v1: format (schemaVersion 3).
+ * The private key is created on first call if it does not exist yet.
*/
suspend fun saveAnswer(
coupleId: String,
@@ -59,14 +58,7 @@ class FirestoreAnswerDataSource @Inject constructor(
userId: String,
answer: LocalAnswer,
date: String = todayLocalDateString()
- ): Unit {
- val privateKey = userKeyManager.loadPrivateKey()
- if (privateKey != null) {
- saveAnswerSealed(coupleId, questionId, userId, answer, date)
- } else {
- saveAnswerEncrypted(coupleId, questionId, userId, answer, date)
- }
- }
+ ): Unit = saveAnswerSealed(coupleId, questionId, userId, answer, date)
private suspend fun saveAnswerSealed(
coupleId: String,
@@ -109,37 +101,14 @@ class FirestoreAnswerDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) }
}
- private suspend fun saveAnswerEncrypted(
- coupleId: String,
- questionId: String,
- userId: String,
- answer: LocalAnswer,
- date: String
- ): Unit = suspendCancellableCoroutine { cont ->
- val aead = encryptionManager.requireAead(coupleId)
- val data = mapOf(
- "userId" to userId,
- "questionId" to questionId,
- "answerType" to answer.answerType,
- "writtenText" to fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId),
- "selectedOptionIds" to if (answer.selectedOptionIds.isNotEmpty())
- listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
- else answer.selectedOptionIds,
- "scaleValue" to if (answer.scaleValue != null)
- fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
- else answer.scaleValue,
- "schemaVersion" to 2,
- "answerDate" to date,
- "createdAt" to answer.createdAt,
- "updatedAt" to answer.updatedAt,
- "isRevealed" to answer.isRevealed
- )
-
- answerRef(coupleId, date, userId)
- .set(data)
- .addOnSuccessListener { cont.resume(Unit) }
- .addOnFailureListener { cont.resumeWithException(it) }
- }
+ /** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
+ suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String): Unit =
+ suspendCancellableCoroutine { cont ->
+ answerRef(coupleId, date, userId)
+ .update(mapOf("answerKeyReleased" to true, "updatedAt" to com.google.firebase.firestore.FieldValue.serverTimestamp()))
+ .addOnSuccessListener { cont.resume(Unit) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
/**
* Fetches a partner's answer for the current local date.
diff --git a/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt b/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt
index 36116720..8eefa0eb 100644
--- a/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt
+++ b/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt
@@ -95,7 +95,10 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor(
scaleValue = if (has("scaleValue") && !isNull("scaleValue")) optInt("scaleValue") else null,
createdAt = optLong("createdAt", System.currentTimeMillis()),
updatedAt = optLong("updatedAt", System.currentTimeMillis()),
- isRevealed = optBoolean("isRevealed", false)
+ isRevealed = optBoolean("isRevealed", false),
+ schemaVersion = optInt("schemaVersion", 2),
+ isSealed = optBoolean("isSealed", false),
+ answerDate = optString("answerDate", "")
)
}
@@ -112,6 +115,9 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor(
.put("createdAt", createdAt)
.put("updatedAt", updatedAt)
.put("isRevealed", isRevealed)
+ .put("schemaVersion", schemaVersion)
+ .put("isSealed", isSealed)
+ .put("answerDate", answerDate)
}
private fun JSONObject.optStringList(key: String): List {
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 0d402db1..a839aea8 100644
--- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt
+++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt
@@ -130,6 +130,9 @@ class AnswerRevealViewModel @Inject constructor(
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
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).
+ if (!answer.isSealed) return SealedRevealPhase.WAITING_FOR_PARTNER
val pendingKey = "daily_${answer.answerDate}_${questionId}"
if (!pendingAnswerKeyStore.hasPendingKey(pendingKey)) return SealedRevealPhase.LOST_LOCAL_KEY
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
@@ -210,6 +213,19 @@ class AnswerRevealViewModel @Inject constructor(
return@launch
}
+ // Update local answer to isSealed=false so cold restarts enter WAITING_FOR_PARTNER,
+ // not LOST_LOCAL_KEY (the pending key was just removed from the store on release).
+ val ownAnswer = _uiState.value.answer
+ if (ownAnswer != null) {
+ val updated = ownAnswer.copy(isSealed = false, updatedAt = System.currentTimeMillis())
+ localAnswerRepository.saveAnswer(updated)
+ _uiState.update { it.copy(answer = updated) }
+ }
+ // Mirror to Firestore so the partner's reveal screen can verify our key was released.
+ runCatching {
+ firestoreAnswerDataSource.markAnswerKeyReleased(coupleId, date, userId)
+ }.onFailure { crashReporter.recordException(it) }
+
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
}
}
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 1c71276e..8436cf2e 100644
--- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt
+++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt
@@ -183,6 +183,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))
}.onFailure {
crashReporter.recordException(it)
}
diff --git a/docs/screenshots/01-onboarding.png b/docs/screenshots/01-onboarding.png
index 9cf6ff47..0a0b61b4 100644
Binary files a/docs/screenshots/01-onboarding.png and b/docs/screenshots/01-onboarding.png differ
diff --git a/docs/screenshots/02-login.png b/docs/screenshots/02-login.png
index a2d8d8ef..d5534b80 100644
Binary files a/docs/screenshots/02-login.png and b/docs/screenshots/02-login.png differ
diff --git a/docs/screenshots/03-home.png b/docs/screenshots/03-home.png
index 1c7690de..2bedab53 100644
Binary files a/docs/screenshots/03-home.png and b/docs/screenshots/03-home.png differ
diff --git a/docs/screenshots/04-daily-question.png b/docs/screenshots/04-daily-question.png
index 39548992..d6f84a3e 100644
Binary files a/docs/screenshots/04-daily-question.png and b/docs/screenshots/04-daily-question.png differ
diff --git a/docs/screenshots/05-question-packs.png b/docs/screenshots/05-question-packs.png
index 9309b62e..4466625b 100644
Binary files a/docs/screenshots/05-question-packs.png and b/docs/screenshots/05-question-packs.png differ
diff --git a/docs/screenshots/06-answer-history.png b/docs/screenshots/06-answer-history.png
index f7c5966e..d0b15e65 100644
Binary files a/docs/screenshots/06-answer-history.png and b/docs/screenshots/06-answer-history.png differ
diff --git a/docs/screenshots/07-settings.png b/docs/screenshots/07-settings.png
index b61014e5..6a4f691e 100644
Binary files a/docs/screenshots/07-settings.png and b/docs/screenshots/07-settings.png differ
diff --git a/docs/screenshots/08-play.png b/docs/screenshots/08-play.png
new file mode 100644
index 00000000..5446a721
Binary files /dev/null and b/docs/screenshots/08-play.png differ
diff --git a/docs/screenshots/09-date-builder.png b/docs/screenshots/09-date-builder.png
new file mode 100644
index 00000000..9d547b96
Binary files /dev/null and b/docs/screenshots/09-date-builder.png differ