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

Closer answer history + Closer play hub + Closer date planning screen Closer settings screen +

+ +

Closer login screen

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