chore: update README, screenshots, answer data source cleanup (batch v1.0.21)
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,34 +101,11 @@ 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
|
||||
)
|
||||
|
||||
/** 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)
|
||||
.set(data)
|
||||
.update(mapOf("answerKeyReleased" to true, "updatedAt" to com.google.firebase.firestore.FieldValue.serverTimestamp()))
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 580 KiB After Width: | Height: | Size: 585 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 444 KiB After Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 225 KiB |