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>
|
<p>
|
||||||
<img src="docs/screenshots/06-answer-history.png" alt="Closer answer history" width="180" />
|
<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" />
|
<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" />
|
<img src="docs/screenshots/02-login.png" alt="Closer login screen" width="180" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,8 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
|
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists the user's answer to Firestore. Uses sealed:v1: format (schemaVersion 3)
|
* Persists the user's answer to Firestore using sealed:v1: format (schemaVersion 3).
|
||||||
* when the user has an ECIES keypair, giving partner-proof privacy. Falls back to
|
* The private key is created on first call if it does not exist yet.
|
||||||
* enc:v1: format (schemaVersion 2) if no keypair is present yet.
|
|
||||||
*/
|
*/
|
||||||
suspend fun saveAnswer(
|
suspend fun saveAnswer(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
|
|
@ -59,14 +58,7 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
date: String = todayLocalDateString()
|
date: String = todayLocalDateString()
|
||||||
): Unit {
|
): Unit = saveAnswerSealed(coupleId, questionId, userId, answer, date)
|
||||||
val privateKey = userKeyManager.loadPrivateKey()
|
|
||||||
if (privateKey != null) {
|
|
||||||
saveAnswerSealed(coupleId, questionId, userId, answer, date)
|
|
||||||
} else {
|
|
||||||
saveAnswerEncrypted(coupleId, questionId, userId, answer, date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun saveAnswerSealed(
|
private suspend fun saveAnswerSealed(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
|
|
@ -109,37 +101,14 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun saveAnswerEncrypted(
|
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
|
||||||
coupleId: String,
|
suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String): Unit =
|
||||||
questionId: String,
|
suspendCancellableCoroutine { cont ->
|
||||||
userId: String,
|
answerRef(coupleId, date, userId)
|
||||||
answer: LocalAnswer,
|
.update(mapOf("answerKeyReleased" to true, "updatedAt" to com.google.firebase.firestore.FieldValue.serverTimestamp()))
|
||||||
date: String
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a partner's answer for the current local date.
|
* Fetches a partner's answer for the current local date.
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,10 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor(
|
||||||
scaleValue = if (has("scaleValue") && !isNull("scaleValue")) optInt("scaleValue") else null,
|
scaleValue = if (has("scaleValue") && !isNull("scaleValue")) optInt("scaleValue") else null,
|
||||||
createdAt = optLong("createdAt", System.currentTimeMillis()),
|
createdAt = optLong("createdAt", System.currentTimeMillis()),
|
||||||
updatedAt = optLong("updatedAt", 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("createdAt", createdAt)
|
||||||
.put("updatedAt", updatedAt)
|
.put("updatedAt", updatedAt)
|
||||||
.put("isRevealed", isRevealed)
|
.put("isRevealed", isRevealed)
|
||||||
|
.put("schemaVersion", schemaVersion)
|
||||||
|
.put("isSealed", isSealed)
|
||||||
|
.put("answerDate", answerDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JSONObject.optStringList(key: String): List<String> {
|
private fun JSONObject.optStringList(key: String): List<String> {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,9 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
|
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
|
||||||
if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
|
if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
|
||||||
if (answer.isRevealed) return SealedRevealPhase.REVEALED
|
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}"
|
val pendingKey = "daily_${answer.answerDate}_${questionId}"
|
||||||
if (!pendingAnswerKeyStore.hasPendingKey(pendingKey)) return SealedRevealPhase.LOST_LOCAL_KEY
|
if (!pendingAnswerKeyStore.hasPendingKey(pendingKey)) return SealedRevealPhase.LOST_LOCAL_KEY
|
||||||
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
|
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
|
||||||
|
|
@ -210,6 +213,19 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
return@launch
|
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)
|
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,9 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
if (dailyQuestionDate.isNullOrBlank()) return
|
if (dailyQuestionDate.isNullOrBlank()) return
|
||||||
runCatching {
|
runCatching {
|
||||||
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
|
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 {
|
}.onFailure {
|
||||||
crashReporter.recordException(it)
|
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 |