chore: update README, screenshots, answer data source cleanup (batch v1.0.21)

This commit is contained in:
null 2026-06-20 02:01:42 -05:00
parent 9c1fbf60a0
commit 38aedab962
14 changed files with 42 additions and 43 deletions

View File

@ -15,7 +15,12 @@ The product is built as a native Kotlin app with Jetpack Compose and Firebase-ba
<p>
<img src="docs/screenshots/06-answer-history.png" alt="Closer answer history" width="180" />
<img src="docs/screenshots/08-play.png" alt="Closer play hub" width="180" />
<img src="docs/screenshots/09-date-builder.png" alt="Closer date planning screen" width="180" />
<img src="docs/screenshots/07-settings.png" alt="Closer settings screen" width="180" />
</p>
<p>
<img src="docs/screenshots/02-login.png" alt="Closer login screen" width="180" />
</p>

View File

@ -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.

View File

@ -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<String> {

View File

@ -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)
}
}

View File

@ -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)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 KiB

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB