refactor(e2ee): remove v0/v1 migration paths, fail-closed decrypt, strict-only rules

This commit is contained in:
null 2026-06-23 17:06:23 -05:00
parent 17c7ed60b9
commit 039752d691
33 changed files with 567 additions and 970 deletions

View File

@ -45,7 +45,6 @@ import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.pairing.PairPromptScreen import app.closer.ui.pairing.PairPromptScreen
import app.closer.ui.pairing.PairingSuccessScreen import app.closer.ui.pairing.PairingSuccessScreen
import app.closer.ui.pairing.RecoveryScreen import app.closer.ui.pairing.RecoveryScreen
import app.closer.ui.pairing.EncryptionUpgradeScreen
import app.closer.ui.dates.DateMatchScreen import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen import app.closer.ui.dates.DateBuilderScreen
@ -318,20 +317,6 @@ fun AppNavigation(
} }
) )
} }
composable(route = AppRoute.ENCRYPTION_UPGRADE) {
EncryptionUpgradeScreen(
onComplete = {
navController.navigate(AppRoute.HOME) {
popUpTo(AppRoute.ENCRYPTION_UPGRADE) { inclusive = true }
}
},
onRecoveryNeeded = {
navController.navigate(AppRoute.RECOVERY) {
popUpTo(AppRoute.ENCRYPTION_UPGRADE) { inclusive = true }
}
}
)
}
// Wheel / Category Selection // Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) { composable(route = AppRoute.CATEGORY_PICKER) {

View File

@ -53,7 +53,6 @@ object AppRoute {
const val MEMORY_LANE_CAPSULE = "memory_lane_capsule/{capsuleId}" const val MEMORY_LANE_CAPSULE = "memory_lane_capsule/{capsuleId}"
const val WAITING_FOR_PARTNER = "waiting_for_partner" const val WAITING_FOR_PARTNER = "waiting_for_partner"
const val RECOVERY = "recovery" const val RECOVERY = "recovery"
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
const val YOUR_PROGRESS = "your_progress" const val YOUR_PROGRESS = "your_progress"
const val PAIRING_SUCCESS = "pairing_success/{coupleId}" const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
@ -122,7 +121,6 @@ object AppRoute {
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"), Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"), Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
Definition(RECOVERY, "Unlock Answers", "security"), Definition(RECOVERY, "Unlock Answers", "security"),
Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security"),
Definition(YOUR_PROGRESS, "Your Progress", "settings") Definition(YOUR_PROGRESS, "Your Progress", "settings")
) )

View File

@ -1,6 +1,5 @@
package app.closer.crypto package app.closer.crypto
import app.closer.crypto.EncryptionVersion.STRICT
import app.closer.domain.model.Couple import app.closer.domain.model.Couple
import com.google.crypto.tink.Aead import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeysetHandle import com.google.crypto.tink.KeysetHandle
@ -12,14 +11,10 @@ import javax.inject.Singleton
enum class EncryptionStatus { enum class EncryptionStatus {
/** Local keyset present -- ready to encrypt/decrypt. */ /** Local keyset present -- ready to encrypt/decrypt. */
UNLOCKED, UNLOCKED,
/** Found keyset in the invite slot; moved to coupleId slot automatically. */ /** Found keyset in the invite slot; moved to coupleId slot automatically (inviter's first load). */
RECONCILED_FROM_INVITE, RECONCILED_FROM_INVITE,
/** encryptionVersion == 1 but no local keyset -- prompt for recovery phrase. */ /** No local keyset on this device -- prompt for the recovery phrase. */
NEEDS_RECOVERY, NEEDS_RECOVERY
/** encryptionVersion == 0 -- this couple must create a key before writing more answers. */
NEEDS_ENCRYPTION_UPGRADE,
/** encryptionVersion == 1 with a local key -- this device must rewrite its legacy answers. */
NEEDS_CONTENT_MIGRATION
} }
class MissingCoupleKeyException(coupleId: String) : class MissingCoupleKeyException(coupleId: String) :
@ -80,30 +75,15 @@ class CoupleEncryptionManager @Inject constructor(
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId) fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
/** /**
* Called on app launch / Home load after the couple doc is resolved. * Called on app launch / Home load after the couple doc is resolved. Every couple is
* Handles inviter reconciliation (flow B') transparently. * strict-E2EE, so the only question is whether this device holds the couple key:
* present -> UNLOCKED; recoverable from the inviter's invite slot -> RECONCILED_FROM_INVITE;
* otherwise -> NEEDS_RECOVERY (prompt for the recovery phrase).
*/ */
fun checkStatus(couple: Couple): EncryptionStatus { fun checkStatus(couple: Couple): EncryptionStatus {
// v2 couples were created by Android with a strict couple key. if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
// v1 couples are mid-migration; v0 couples are plaintext (iOS MVP).
when (couple.encryptionVersion) {
EncryptionVersion.PLAINTEXT -> return EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE
EncryptionVersion.MIGRATING -> { /* fall through to keyset checks below */ }
EncryptionVersion.STRICT -> { /* fall through to keyset checks below */ }
}
if (keyStore.hasKeyset(couple.id)) {
return if (couple.encryptionVersion >= STRICT) {
EncryptionStatus.UNLOCKED
} else {
EncryptionStatus.NEEDS_CONTENT_MIGRATION
}
}
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) { if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
return if (couple.encryptionVersion >= STRICT) { return EncryptionStatus.RECONCILED_FROM_INVITE
EncryptionStatus.RECONCILED_FROM_INVITE
} else {
EncryptionStatus.NEEDS_CONTENT_MIGRATION
}
} }
return EncryptionStatus.NEEDS_RECOVERY return EncryptionStatus.NEEDS_RECOVERY
} }
@ -133,24 +113,4 @@ class CoupleEncryptionManager @Inject constructor(
} }
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId) fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
suspend fun setupLegacyCouple(coupleId: String): SetupResult = withContext(Dispatchers.Default) {
val phrase = keyManager.generateRecoveryPhrase()
val handle = keyManager.newCoupleKeyset()
val wrapped = keyManager.wrap(handle, phrase)
keyStore.storeKeyset(coupleId, handle)
keyStore.storePendingRecoveryPhrase(coupleId, phrase)
SetupResult(handle, wrapped, phrase)
}
fun pendingRecoveryPhrase(coupleId: String): String? =
keyStore.pendingRecoveryPhrase(coupleId)
fun acknowledgeRecoveryPhrase(coupleId: String) =
keyStore.clearPendingRecoveryPhrase(coupleId)
companion object {
/** Kept for backwards compatibility; prefer [EncryptionVersion.STRICT]. */
const val STRICT_ENCRYPTION_VERSION = EncryptionVersion.STRICT
}
} }

View File

@ -79,23 +79,11 @@ class CoupleKeyStore @Inject constructor(
fun deleteKeyset(coupleId: String) { fun deleteKeyset(coupleId: String) {
prefs.edit() prefs.edit()
.remove(prefKey(coupleId)) .remove(prefKey(coupleId))
.remove(pendingPhraseKey(coupleId))
.remove(recoveryPhraseKey(coupleId)) .remove(recoveryPhraseKey(coupleId))
.apply() .apply()
aeadCache.remove(coupleId) aeadCache.remove(coupleId)
} }
fun storePendingRecoveryPhrase(coupleId: String, phrase: String) {
prefs.edit().putString(pendingPhraseKey(coupleId), phrase).apply()
}
fun pendingRecoveryPhrase(coupleId: String): String? =
prefs.getString(pendingPhraseKey(coupleId), null)
fun clearPendingRecoveryPhrase(coupleId: String) {
prefs.edit().remove(pendingPhraseKey(coupleId)).apply()
}
fun aeadFor(coupleId: String): Aead? { fun aeadFor(coupleId: String): Aead? {
aeadCache[coupleId]?.let { return it } aeadCache[coupleId]?.let { return it }
val handle = loadKeyset(coupleId) ?: return null val handle = loadKeyset(coupleId) ?: return null
@ -108,7 +96,6 @@ class CoupleKeyStore @Inject constructor(
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode" private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode" private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId" private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
private fun serialize(handle: KeysetHandle): String { private fun serialize(handle: KeysetHandle): String {
val baos = ByteArrayOutputStream() val baos = ByteArrayOutputStream()

View File

@ -1,27 +1,16 @@
package app.closer.crypto package app.closer.crypto
/** /**
* Single source of truth for couple encryption versions shared by Android, * Couple encryption version stamp. The app is strict-E2EE only: every couple is
* iOS, and Cloud Functions. * created with a wrapped couple key and all answer-bearing paths require ciphertext.
* *
* v0 = legacy plaintext (no couple key, all answer paths write plaintext). * The constant is kept as a forward-compatibility marker written on couple creation
* Used by the iOS MVP because E2EE is skipped for the initial port. * (Android client + acceptInviteCallable). There are no v0 (plaintext) or v1
* v1 = legacy Tink key migration-in-progress (mixed plaintext + encrypted). * (migration) couples.
* Kept for backwards compatibility with older couples; no new couples
* should be created at v1.
* v2 = strict E2EE (all answer-bearing paths require a couple key and
* ciphertext). This is the default for all new Android couples.
*
* IMPORTANT: keep this mapping in sync with:
* - functions/src/couples/acceptInviteCallable.ts
* - iphone/ARCHITECTURE_AUDIT.md (E2EE section)
* - iphone/Closer/Services/FirestoreService.swift (couple creation TODOs)
*/ */
object EncryptionVersion { object EncryptionVersion {
const val PLAINTEXT = 0
const val MIGRATING = 1
const val STRICT = 2 const val STRICT = 2
/** Version used when creating a new couple from the Android client. */ /** Version used when creating a new couple. */
const val NEW_COUPLE_DEFAULT = STRICT const val NEW_COUPLE_DEFAULT = STRICT
} }

View File

@ -9,7 +9,12 @@ import javax.inject.Singleton
* Stateless helper that encrypts/decrypts individual Firestore field values. * Stateless helper that encrypts/decrypts individual Firestore field values.
* *
* Wire format: "enc:v1:{base64(tinkCiphertext)}" * Wire format: "enc:v1:{base64(tinkCiphertext)}"
* Plaintext values (no prefix) pass through unchanged so legacy data works. *
* Fail-closed: every couple is strict-E2EE, so all stored content is encrypted.
* A value WITHOUT the prefix is not treated as trusted plaintext [decrypt]
* returns null for it. There is no plaintext fallback. Writers must always go
* through [encrypt] (callers use requireAead so a missing key throws rather than
* writing plaintext).
* *
* AAD = coupleId bytes -- binds ciphertext to the couple and prevents * AAD = coupleId bytes -- binds ciphertext to the couple and prevents
* copy-paste of one couple's ciphertext into another couple's document. * copy-paste of one couple's ciphertext into another couple's document.
@ -34,7 +39,9 @@ class FieldEncryptor @Inject constructor() {
*/ */
fun decrypt(value: String?, aead: Aead?, coupleId: String): String? { fun decrypt(value: String?, aead: Aead?, coupleId: String): String? {
if (value == null) return null if (value == null) return null
if (!value.startsWith(PREFIX)) return value // Fail-closed: a value without the enc:v1: prefix is not trusted plaintext.
// All content is encrypted, so an unprefixed value is unexpected and rejected.
if (!value.startsWith(PREFIX)) return null
if (aead == null) return null if (aead == null) return null
return runCatching { return runCatching {
val cipher = Base64.getDecoder().decode(value.removePrefix(PREFIX)) val cipher = Base64.getDecoder().decode(value.removePrefix(PREFIX))
@ -45,7 +52,26 @@ class FieldEncryptor @Inject constructor() {
fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true
/**
* Display-safe decrypt. Returns:
* - the decrypted text for `enc:v1:` values,
* - the value unchanged for legacy plaintext,
* - [LOCKED_PLACEHOLDER] when the value IS encrypted but cannot be decrypted on
* this device (missing/wrong couple key) never the raw ciphertext.
*
* Use this anywhere a decrypted value is shown to the user. [decrypt] (which returns
* null on failure) is reserved for structured fields that are parsed, not displayed.
*/
fun decryptForDisplay(value: String?, aead: Aead?, coupleId: String): String? {
if (value == null) return null
// decrypt() returns null only when the value is encrypted but unreadable here.
return decrypt(value, aead, coupleId) ?: LOCKED_PLACEHOLDER
}
companion object { companion object {
const val PREFIX = "enc:v1:" const val PREFIX = "enc:v1:"
/** Shown in place of content that is encrypted but cannot be decrypted on this device. */
const val LOCKED_PLACEHOLDER = "🔒 Couldn't unlock on this device"
} }
} }

View File

@ -1,180 +0,0 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import com.google.crypto.tink.Aead
import com.google.firebase.firestore.DocumentReference
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
/** One-time, per-user rewrite of every historical answer-bearing field to ciphertext. */
@Singleton
class CoupleAnswerMigrationDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
suspend fun migrateUser(coupleId: String, userId: String) {
val aead = encryptionManager.requireAead(coupleId)
migrateDailyAnswers(coupleId, userId, aead)
migrateThreadContent(coupleId, userId, aead)
migrateThisOrThat(coupleId, userId, aead)
migrateDesireSync(coupleId, userId, aead)
migrateHowWell(coupleId, userId, aead)
migrateWheel(coupleId, userId, aead)
}
private fun coupleRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
private suspend fun migrateDailyAnswers(coupleId: String, userId: String, aead: Aead) {
val days = coupleRef(coupleId)
.collection(FirestoreCollections.Couples.DAILY_QUESTION)
.get().await()
for (day in days.documents) {
val ref = day.reference
.collection(FirestoreCollections.DailyQuestion.ANSWERS)
.document(userId)
migrateAnswerDocument(ref, coupleId, aead)
}
}
private suspend fun migrateThreadContent(coupleId: String, userId: String, aead: Aead) {
val threads = coupleRef(coupleId)
.collection(FirestoreCollections.Couples.QUESTION_THREADS)
.get().await()
for (thread in threads.documents) {
migrateAnswerDocument(
thread.reference.collection(FirestoreCollections.QuestionThreads.ANSWERS).document(userId),
coupleId,
aead
)
val messages = thread.reference
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
.whereEqualTo("authorUserId", userId)
.get().await()
for (message in messages.documents) {
val text = message.getString("text") ?: continue
if (!fieldEncryptor.isEncrypted(text)) {
message.reference.update(
"text",
fieldEncryptor.encrypt(text, aead, coupleId)
).await()
}
}
}
}
private suspend fun migrateAnswerDocument(ref: DocumentReference, coupleId: String, aead: Aead) {
val snapshot = ref.get().await()
if (!snapshot.exists()) return
val updates = encryptedAnswerUpdates(snapshot, coupleId, aead)
if (updates.isNotEmpty()) ref.update(updates).await()
}
private fun encryptedAnswerUpdates(
snapshot: DocumentSnapshot,
coupleId: String,
aead: Aead
): Map<String, Any> {
val updates = mutableMapOf<String, Any>()
snapshot.getString("writtenText")?.let { value ->
if (!fieldEncryptor.isEncrypted(value)) {
updates["writtenText"] = fieldEncryptor.encrypt(value, aead, coupleId)
}
}
val rawIds = (snapshot.get("selectedOptionIds") as? List<*>)?.filterIsInstance<String>()
if (!rawIds.isNullOrEmpty() && !(rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0]))) {
updates["selectedOptionIds"] = listOf(
fieldEncryptor.encrypt(JSONArray(rawIds).toString(), aead, coupleId)
)
}
snapshot.get("scaleValue")?.let { value ->
if (value !is String || !fieldEncryptor.isEncrypted(value)) {
updates["scaleValue"] = fieldEncryptor.encrypt(value.toString(), aead, coupleId)
}
}
return updates
}
private suspend fun migrateThisOrThat(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.THIS_OR_THAT
) { value ->
val answers = (value as? List<*>)?.filterIsInstance<String>() ?: return@migrateUserAnswerCollection null
fieldEncryptor.encrypt(JSONArray(answers).toString(), aead, coupleId)
}
}
private suspend fun migrateDesireSync(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.DESIRE_SYNC
) { value ->
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@migrateUserAnswerCollection null
if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) list[0]
else fieldEncryptor.encrypt(JSONArray(list).toString(), aead, coupleId)
}
}
private suspend fun migrateHowWell(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.HOW_WELL
) { value ->
val list = value as? List<*> ?: return@migrateUserAnswerCollection null
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
list[0] as String
} else {
fieldEncryptor.encrypt(JSONArray(list).toString(), aead, coupleId)
}
}
}
private suspend fun migrateWheel(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.WHEEL
) { value ->
val list = value as? List<*> ?: return@migrateUserAnswerCollection null
val normalized = list.mapNotNull { item ->
val map = item as? Map<*, *> ?: return@mapNotNull null
val rawDisplay = map["display"] as? String ?: ""
val display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
JSONObject().apply {
put("questionId", map["questionId"] as? String ?: "")
put("display", display)
}
}
fieldEncryptor.encrypt(JSONArray(normalized).toString(), aead, coupleId)
}
}
private suspend fun migrateUserAnswerCollection(
coupleId: String,
userId: String,
collection: String,
encryptLegacyValue: (Any?) -> String?
) {
val documents = coupleRef(coupleId).collection(collection).get().await()
for (document in documents.documents) {
val value = (document.get("answers") as? Map<*, *>)?.get(userId) ?: continue
if (value is String && fieldEncryptor.isEncrypted(value)) continue
val encrypted = encryptLegacyValue(value) ?: continue
document.reference.update(FieldPath.of("answers", userId), encrypted).await()
}
}
}

View File

@ -34,6 +34,7 @@ class FirestoreAnswerDataSource @Inject constructor(
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor, private val fieldEncryptor: FieldEncryptor,
private val userKeyManager: UserKeyManager, private val userKeyManager: UserKeyManager,
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val sealedAnswerEncryptor: SealedAnswerEncryptor, private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val pendingAnswerKeyStore: PendingAnswerKeyStore, private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val answerCommitment: AnswerCommitment private val answerCommitment: AnswerCommitment
@ -66,7 +67,8 @@ class FirestoreAnswerDataSource @Inject constructor(
userId: String, userId: String,
answer: LocalAnswer, answer: LocalAnswer,
date: String date: String
): Unit = suspendCancellableCoroutine { cont -> ) {
ensureUserPublicKeyPublished(userId)
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey() val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
val payload = SealedAnswerEncryptor.AnswerPayload( val payload = SealedAnswerEncryptor.AnswerPayload(
writtenText = answer.writtenText, writtenText = answer.writtenText,
@ -95,10 +97,12 @@ class FirestoreAnswerDataSource @Inject constructor(
"isRevealed" to answer.isRevealed "isRevealed" to answer.isRevealed
) )
answerRef(coupleId, date, userId) suspendCancellableCoroutine { cont ->
.set(data) answerRef(coupleId, date, userId)
.addOnSuccessListener { cont.resume(Unit) } .set(data)
.addOnFailureListener { cont.resumeWithException(it) } .addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
} }
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */ /** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
@ -125,8 +129,7 @@ class FirestoreAnswerDataSource @Inject constructor(
cont.resume(null) cont.resume(null)
return@addOnSuccessListener return@addOnSuccessListener
} }
val aead = encryptionManager.aeadFor(coupleId) cont.resume(snap.toLocalAnswer())
cont.resume(snap.toLocalAnswer(aead, coupleId))
} }
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
@ -169,64 +172,20 @@ class FirestoreAnswerDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
@Suppress("UNCHECKED_CAST") private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer( // All answers are sealed (schemaVersion 3): content lives in encryptedPayload and is
aead: com.google.crypto.tink.Aead?, // decrypted later by SealedRevealManager. Nothing is ever stored in plaintext.
coupleId: String
): LocalAnswer {
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
// schemaVersion 3: sealed:v1: — content is in encryptedPayload, not top-level fields.
// The calling code (reveal flow) is responsible for decrypting via SealedRevealManager.
if (schemaVersion == 3) {
return LocalAnswer(
questionId = getString("questionId") ?: "",
questionText = "",
category = "",
answerType = getString("answerType") ?: "written",
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false,
schemaVersion = 3,
isSealed = getBoolean("answerKeyReleased") != true,
encryptedPayload = getString("encryptedPayload"),
answerDate = getString("answerDate") ?: ""
)
}
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList()
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
if (decrypted != null) runCatching {
val arr = org.json.JSONArray(decrypted)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList()) else emptyList()
} else rawIds
val rawScale = get("scaleValue")
val scaleValue: Int? = when {
rawScale == null -> null
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
rawScale is Long -> rawScale.toInt()
rawScale is Int -> rawScale
else -> null
}
return LocalAnswer( return LocalAnswer(
questionId = getString("questionId") ?: "", questionId = getString("questionId") ?: "",
questionText = "", questionText = "",
category = "", category = "",
answerType = getString("answerType") ?: "written", answerType = getString("answerType") ?: "written",
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = selectedOptionIds,
selectedOptionTexts = emptyList(),
scaleValue = scaleValue,
createdAt = getLong("createdAt") ?: System.currentTimeMillis(), createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(), updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false, isRevealed = getBoolean("isRevealed") ?: false,
schemaVersion = schemaVersion, schemaVersion = 3,
isSealed = getBoolean("answerKeyReleased") != true,
encryptedPayload = getString("encryptedPayload"),
answerDate = getString("answerDate") ?: "" answerDate = getString("answerDate") ?: ""
) )
} }
@ -244,13 +203,15 @@ class FirestoreAnswerDataSource @Inject constructor(
modeTag: String?, modeTag: String?,
date: String date: String
): Unit = suspendCancellableCoroutine { cont -> ): Unit = suspendCancellableCoroutine { cont ->
val aead = encryptionManager.requireAead(coupleId)
val doc = mapOf( val doc = mapOf(
"questionId" to questionId, "questionId" to questionId,
"questionText" to questionText, "questionText" to questionText,
"ownAnswer" to ownAnswer, "ownAnswer" to fieldEncryptor.encrypt(ownAnswer, aead, coupleId),
"partnerAnswer" to partnerAnswer, "partnerAnswer" to fieldEncryptor.encryptNullable(partnerAnswer, aead, coupleId),
"modeTag" to modeTag, "modeTag" to modeTag,
"date" to date, "date" to date,
"schemaVersion" to 2,
"savedAt" to com.google.firebase.Timestamp.now() "savedAt" to com.google.firebase.Timestamp.now()
) )
db.collection(FirestoreCollections.COUPLES) db.collection(FirestoreCollections.COUPLES)
@ -262,6 +223,12 @@ class FirestoreAnswerDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
private suspend fun ensureUserPublicKeyPublished(userId: String) {
if (deviceKeyDataSource.getPublicKey(userId) != null) return
val privateKey = userKeyManager.getOrCreatePrivateKey()
deviceKeyDataSource.publishPublicKey(userId, userKeyManager.publicKeyB64(privateKey))
}
data class DailyQuestionAssignment( data class DailyQuestionAssignment(
val questionId: String, val questionId: String,
val date: String, val date: String,

View File

@ -1,5 +1,7 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.BucketListItem import app.closer.domain.model.BucketListItem
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
@ -22,7 +24,11 @@ import kotlin.coroutines.resumeWithException
* Categories: Adventure, Travel, Food, Learning, Romance, Intimacy, Seasonal. * Categories: Adventure, Travel, Food, Learning, Romance, Intimacy, Seasonal.
*/ */
@Singleton @Singleton
class FirestoreBucketListDataSource @Inject constructor(private val db: FirebaseFirestore) { class FirestoreBucketListDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun itemsRef(coupleId: String) = private fun itemsRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId) db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.BUCKET_LIST) .collection(FirestoreCollections.Couples.BUCKET_LIST)
@ -30,10 +36,13 @@ class FirestoreBucketListDataSource @Inject constructor(private val db: Firebase
// ─── CRUD methods ──────────────────────────────────────────────────────── // ─── CRUD methods ────────────────────────────────────────────────────────
suspend fun addItem(coupleId: String, item: BucketListItem): String { suspend fun addItem(coupleId: String, item: BucketListItem): String {
// Strict E2EE: title/description are user content and must never be stored in plaintext.
// requireAead throws if the couple key is missing rather than falling back to plaintext.
val aead = encryptionManager.requireAead(coupleId)
val doc = itemsRef(coupleId).document() val doc = itemsRef(coupleId).document()
val data = mapOf( val data = mapOf(
"title" to item.title, "title" to fieldEncryptor.encrypt(item.title, aead, coupleId),
"description" to item.description, "description" to fieldEncryptor.encrypt(item.description, aead, coupleId),
"category" to item.category, "category" to item.category,
"addedBy" to item.addedBy, "addedBy" to item.addedBy,
"addedAt" to item.addedAt, "addedAt" to item.addedAt,
@ -46,10 +55,11 @@ class FirestoreBucketListDataSource @Inject constructor(private val db: Firebase
} }
suspend fun updateItem(coupleId: String, item: BucketListItem) { suspend fun updateItem(coupleId: String, item: BucketListItem) {
val aead = encryptionManager.requireAead(coupleId)
val path = itemsRef(coupleId).document(item.id) val path = itemsRef(coupleId).document(item.id)
val data = mapOf( val data = mapOf(
"title" to item.title, "title" to fieldEncryptor.encrypt(item.title, aead, coupleId),
"description" to item.description, "description" to fieldEncryptor.encrypt(item.description, aead, coupleId),
"category" to item.category, "category" to item.category,
"completedBy" to item.completedBy, "completedBy" to item.completedBy,
"completedAt" to item.completedAt, "completedAt" to item.completedAt,
@ -143,15 +153,17 @@ class FirestoreBucketListDataSource @Inject constructor(private val db: Firebase
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toBucketListItem(coupleId: String): BucketListItem? { private fun com.google.firebase.firestore.DocumentSnapshot.toBucketListItem(coupleId: String): BucketListItem? {
val title = getString("title") ?: return null val rawTitle = getString("title") ?: return null
val addedBy = getString("addedBy") ?: return null val addedBy = getString("addedBy") ?: return null
val addedAt = (get("addedAt") as? Number)?.toLong() ?: 0L val addedAt = (get("addedAt") as? Number)?.toLong() ?: 0L
val aead = encryptionManager.aeadFor(coupleId)
return BucketListItem( return BucketListItem(
id = id, id = id,
coupleId = coupleId, coupleId = coupleId,
title = title, // decryptForDisplay shows a locked placeholder if the key is missing, never ciphertext.
description = getString("description") ?: "", title = fieldEncryptor.decryptForDisplay(rawTitle, aead, coupleId) ?: FieldEncryptor.LOCKED_PLACEHOLDER,
description = fieldEncryptor.decryptForDisplay(getString("description"), aead, coupleId) ?: "",
category = getString("category") ?: "", category = getString("category") ?: "",
addedBy = addedBy, addedBy = addedBy,
addedAt = addedAt, addedAt = addedAt,

View File

@ -54,8 +54,8 @@ class FirestoreCapsuleDataSource @Inject constructor(
id = doc.id, id = doc.id,
coupleId = coupleId, coupleId = coupleId,
authorId = doc.getString("authorId") ?: "", authorId = doc.getString("authorId") ?: "",
title = decryptField(rawTitle, coupleId) ?: "— Encrypted —", title = decryptField(rawTitle, coupleId) ?: app.closer.crypto.FieldEncryptor.LOCKED_PLACEHOLDER,
content = decryptField(rawContent, coupleId) ?: "— Encrypted —", content = decryptField(rawContent, coupleId) ?: app.closer.crypto.FieldEncryptor.LOCKED_PLACEHOLDER,
promptUsed = decryptField(rawPrompt, coupleId), promptUsed = decryptField(rawPrompt, coupleId),
unlockAt = doc.getLong("unlockAt") ?: 0L, unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L, createdAt = doc.getLong("createdAt") ?: 0L,

View File

@ -1,7 +1,7 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager import app.closer.crypto.RecoveryKeyManager
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.EncryptionVersion
import app.closer.domain.model.Couple import app.closer.domain.model.Couple
import com.google.firebase.Timestamp import com.google.firebase.Timestamp
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
@ -53,7 +53,7 @@ class FirestoreCoupleDataSource @Inject constructor(
"createdAt" to now, "createdAt" to now,
"streakCount" to 0 "streakCount" to 0
) )
data["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION data["encryptionVersion"] = EncryptionVersion.STRICT
data["wrappedCoupleKey"] = wrappedKey.cipherB64 data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64 data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params data["kdfParams"] = wrappedKey.params
@ -76,47 +76,6 @@ class FirestoreCoupleDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
/** Atomically claims a version-0 couple for client-side ciphertext migration. */
suspend fun beginEncryptionMigration(
coupleId: String,
wrappedKey: RecoveryKeyManager.WrappedKey
): Boolean = db.runTransaction { tx ->
val ref = coupleRef(coupleId)
val snapshot = tx.get(ref)
val version = (snapshot.getLong("encryptionVersion") ?: 0L).toInt()
if (version != 0) return@runTransaction false
tx.update(
ref,
mapOf(
"encryptionVersion" to 1,
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params,
"encryptionMigrationUsers" to emptyMap<String, Boolean>()
)
)
true
}.await()
/** Marks one partner's historical content migrated; version 2 requires both partners. */
suspend fun markEncryptionMigrationComplete(coupleId: String, userId: String): Boolean =
db.runTransaction { tx ->
val ref = coupleRef(coupleId)
val snapshot = tx.get(ref)
val userIds = (snapshot.get("userIds") as? List<*>)?.filterIsInstance<String>().orEmpty()
@Suppress("UNCHECKED_CAST")
val existing = snapshot.get("encryptionMigrationUsers") as? Map<String, Boolean>
?: emptyMap()
val completed = existing + (userId to true)
val allComplete = userIds.isNotEmpty() && userIds.all { completed[it] == true }
val updates = mutableMapOf<String, Any>("encryptionMigrationUsers" to completed)
if (allComplete) {
updates["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
}
tx.update(ref, updates)
allComplete
}.await()
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit = private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
userRef(uid).set( userRef(uid).set(
@ -176,9 +135,7 @@ class FirestoreCoupleDataSource @Inject constructor(
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(), encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
wrappedCoupleKey = getString("wrappedCoupleKey"), wrappedCoupleKey = getString("wrappedCoupleKey"),
kdfSalt = getString("kdfSalt"), kdfSalt = getString("kdfSalt"),
kdfParams = getString("kdfParams"), kdfParams = getString("kdfParams")
encryptionMigrationUsers = (get("encryptionMigrationUsers") as? Map<String, Boolean>)
?: emptyMap()
) )
private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) { private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) {

View File

@ -31,6 +31,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor, private val fieldEncryptor: FieldEncryptor,
private val userKeyManager: UserKeyManager, private val userKeyManager: UserKeyManager,
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val sealedAnswerEncryptor: SealedAnswerEncryptor, private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val pendingAnswerKeyStore: PendingAnswerKeyStore, private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val answerCommitment: AnswerCommitment private val answerCommitment: AnswerCommitment
@ -85,17 +86,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
// ─── Answers ───────────────────────────────────────────────────────────────── // ─── Answers ─────────────────────────────────────────────────────────────────
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) { suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
if (userKeyManager.loadPrivateKey() != null) { submitAnswerSealed(coupleId, threadId, userId, answer)
submitAnswerSealed(coupleId, threadId, userId, answer)
} else {
submitAnswerEncrypted(coupleId, threadId, userId, answer)
}
} }
// schemaVersion 3: per-answer one-time key — partner-proof before reveal. // schemaVersion 3: per-answer one-time key — partner-proof before reveal.
// threadId is used as the AAD "questionId" so thread keys are distinct from // threadId is used as the AAD "questionId" so thread keys are distinct from
// daily-question keys even when the same question appears in both contexts. // daily-question keys even when the same question appears in both contexts.
private suspend fun submitAnswerSealed(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) { private suspend fun submitAnswerSealed(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
ensureUserPublicKeyPublished(userId)
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey() val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
val payload = SealedAnswerEncryptor.AnswerPayload( val payload = SealedAnswerEncryptor.AnswerPayload(
writtenText = answer.writtenText, writtenText = answer.writtenText,
@ -127,33 +125,6 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
).voidAwait() ).voidAwait()
} }
// schemaVersion 2: shared couple key (company-proof, not partner-proof).
private suspend fun submitAnswerEncrypted(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
val now = FieldValue.serverTimestamp()
val aead = encryptionManager.requireAead(coupleId)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
.document(userId)
.set(
mapOf(
"userId" to answer.userId,
"questionId" to answer.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,
"createdAt" to now,
"updatedAt" to now
)
).voidAwait()
}
// Call after releasing the one-time key so the answer doc reflects the released state. // Call after releasing the one-time key so the answer doc reflects the released state.
// Required for correct phase detection on cold restart of the reveal screen. // Required for correct phase detection on cold restart of the reveal screen.
suspend fun markAnswerKeyReleased(coupleId: String, threadId: String, userId: String) { suspend fun markAnswerKeyReleased(coupleId: String, threadId: String, userId: String) {
@ -178,8 +149,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
.collection(FirestoreCollections.QuestionThreads.ANSWERS) .collection(FirestoreCollections.QuestionThreads.ANSWERS)
.addSnapshotListener { snap, err -> .addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId) trySend(snap.documents.mapNotNull { it.toQuestionAnswer() })
trySend(snap.documents.mapNotNull { it.toQuestionAnswer(aead, coupleId) })
} }
awaitClose { listener.remove() } awaitClose { listener.remove() }
} }
@ -281,57 +251,17 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
) )
@Suppress("UNCHECKED_CAST") private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? {
private fun DocumentSnapshot.toQuestionAnswer(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): QuestionAnswer? {
val userId = getString("userId") ?: return null val userId = getString("userId") ?: return null
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2 // All thread answers are sealed (schemaVersion 3): content lives in encryptedPayload
// and is decrypted by the reveal flow, never stored in plaintext.
// schemaVersion 3: sealed:v1: — content is in encryptedPayload.
// Decryption requires the partner's release key; the reveal flow handles it.
if (schemaVersion == 3) {
return QuestionAnswer(
userId = userId,
questionId = getString("questionId") ?: "",
answerType = getString("answerType") ?: "written",
schemaVersion = 3,
isSealed = getBoolean("answerKeyReleased") != true,
encryptedPayload = getString("encryptedPayload"),
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
)
}
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
val rawIds = (get("selectedOptionIds") as? List<String>) ?: emptyList()
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
if (decrypted != null) runCatching {
val arr = org.json.JSONArray(decrypted)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList()) else emptyList()
} else rawIds
val rawScale = get("scaleValue")
val scaleValue: Int? = when {
rawScale == null -> null
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
rawScale is Long -> rawScale.toInt()
rawScale is Int -> rawScale
else -> null
}
return QuestionAnswer( return QuestionAnswer(
userId = userId, userId = userId,
questionId = getString("questionId") ?: "", questionId = getString("questionId") ?: "",
answerType = getString("answerType") ?: "written", answerType = getString("answerType") ?: "written",
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId), schemaVersion = 3,
selectedOptionIds = selectedOptionIds, isSealed = getBoolean("answerKeyReleased") != true,
scaleValue = scaleValue, encryptedPayload = getString("encryptedPayload"),
schemaVersion = schemaVersion,
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L, createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
) )
@ -345,7 +275,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
return QuestionMessage( return QuestionMessage(
id = id, id = id,
userId = userId, userId = userId,
text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "", text = fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
) )
} }
@ -360,4 +290,10 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
) )
} }
private suspend fun ensureUserPublicKeyPublished(userId: String) {
if (deviceKeyDataSource.getPublicKey(userId) != null) return
val privateKey = userKeyManager.getOrCreatePrivateKey()
deviceKeyDataSource.publishPublicKey(userId, userKeyManager.publicKeyB64(privateKey))
}
} }

View File

@ -99,7 +99,13 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) -> val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
when (value) { when (value) {
is String -> { is String -> {
val json = fieldEncryptor.decrypt(value, aead, coupleId) ?: return@mapValues emptyList() // An encrypted blob that can't be decrypted here means the partner DID
// answer but this device lacks the key — surface a locked row per prompt
// instead of silently showing nothing (which reads as "didn't answer").
val json = fieldEncryptor.decrypt(value, aead, coupleId)
?: return@mapValues questions.map {
WheelAnswerEntry(it.id, FieldEncryptor.LOCKED_PLACEHOLDER)
}
runCatching { runCatching {
val array = JSONArray(json) val array = JSONArray(json)
(0 until array.length()).map { index -> (0 until array.length()).map { index ->
@ -111,16 +117,6 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
} }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
} }
// Version-0/1 compatibility exists only until migration completes.
is List<*> -> value.mapNotNull { item ->
(item as? Map<*, *>)?.let {
val rawDisplay = it["display"] as? String ?: ""
WheelAnswerEntry(
questionId = it["questionId"] as? String ?: "",
display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
)
}
}
else -> emptyList() else -> emptyList()
} }
} }

View File

@ -1,5 +1,7 @@
package app.closer.domain.model package app.closer.domain.model
import app.closer.crypto.EncryptionVersion
data class Couple( data class Couple(
val id: String = "", val id: String = "",
val userIds: List<String> = emptyList(), val userIds: List<String> = emptyList(),
@ -9,10 +11,9 @@ data class Couple(
val streakCount: Int = 0, val streakCount: Int = 0,
val lastAnsweredAt: Long? = null, val lastAnsweredAt: Long? = null,
val activePackId: String? = null, val activePackId: String? = null,
// E2EE: 0 = legacy plaintext, 1 = migration in progress, 2 = all answer paths strict E2EE. // Strict E2EE: every couple has a wrapped couple key. Version is stamped at 2 on creation.
val encryptionVersion: Int = 0, val encryptionVersion: Int = EncryptionVersion.STRICT,
val wrappedCoupleKey: String? = null, val wrappedCoupleKey: String? = null,
val kdfSalt: String? = null, val kdfSalt: String? = null,
val kdfParams: String? = null, val kdfParams: String? = null
val encryptionMigrationUsers: Map<String, Boolean> = emptyMap()
) )

View File

@ -5,11 +5,13 @@ import app.closer.ui.theme.closerCardColor
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@ -18,11 +20,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -49,6 +56,35 @@ internal val AuthPrimaryDeep: Color
internal val AuthOnPrimary: Color internal val AuthOnPrimary: Color
@Composable get() = MaterialTheme.colorScheme.onPrimary @Composable get() = MaterialTheme.colorScheme.onPrimary
@Composable
internal fun AuthLogoMark(
modifier: Modifier = Modifier,
size: Dp = 88.dp,
radius: Dp = 24.dp,
elevation: Dp = 18.dp
) {
val shape = RoundedCornerShape(radius)
Box(
modifier = modifier
.size(size)
.shadow(elevation = elevation, shape = shape, clip = false)
.clip(shape)
) {
Image(
painter = painterResource(R.drawable.ic_launcher_background),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Closer",
contentScale = ContentScale.Fit,
modifier = Modifier.matchParentSize().alpha(0.96f)
)
}
}
@Composable @Composable
internal fun GoogleSignInButton( internal fun GoogleSignInButton(
onClick: () -> Unit, onClick: () -> Unit,

View File

@ -93,7 +93,11 @@ fun LoginScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Spacer(Modifier.height(48.dp)) Spacer(Modifier.height(24.dp))
AuthLogoMark()
Spacer(Modifier.height(24.dp))
Text( Text(
text = "Welcome back", text = "Welcome back",

View File

@ -1,5 +1,12 @@
package app.closer.ui.auth package app.closer.ui.auth
import app.closer.R
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -38,11 +45,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import kotlinx.coroutines.launch
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -61,10 +71,15 @@ fun SignUpScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(state.success) { LaunchedEffect(state.success) {
if (state.success) onNavigate(AppRoute.CREATE_PROFILE) if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
} }
LaunchedEffect(state.googleSuccess) {
if (state.googleSuccess) onNavigate(AppRoute.ONBOARDING)
}
LaunchedEffect(state.error) { LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() } state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
} }
@ -102,6 +117,10 @@ fun SignUpScreen(
) { ) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
AuthLogoMark(size = 72.dp, radius = 20.dp, elevation = 12.dp)
Spacer(Modifier.height(20.dp))
Text( Text(
text = "Create your account", text = "Create your account",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
@ -183,6 +202,37 @@ fun SignUpScreen(
else Text("Create account", style = MaterialTheme.typography.labelLarge) else Text("Create account", style = MaterialTheme.typography.labelLarge)
} }
Spacer(Modifier.height(12.dp))
GoogleSignInButton(
enabled = !state.isLoading,
onClick = {
scope.launch {
try {
val credMgr = CredentialManager.create(context)
val option = GetSignInWithGoogleOption
.Builder(context.getString(R.string.default_web_client_id))
.build()
val request = GetCredentialRequest.Builder().addCredentialOption(option).build()
val result = credMgr.getCredential(context, request)
val credential = result.credential
if (credential is CustomCredential &&
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
) {
val idToken = GoogleIdTokenCredential.createFrom(credential.data).idToken
viewModel.signInWithGoogle(idToken)
} else {
viewModel.reportError("Google sign-up failed. Please try again.")
}
} catch (_: GetCredentialCancellationException) {
// user dismissed — do nothing
} catch (e: Exception) {
viewModel.reportError("Google sign-up failed. Please try again.")
}
}
}
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) { TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {

View File

@ -2,7 +2,10 @@ package app.closer.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.domain.model.GoogleSignInResult
import app.closer.domain.model.User
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -18,12 +21,16 @@ data class SignUpUiState(
val isPasswordVisible: Boolean = false, val isPasswordVisible: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val success: Boolean = false val success: Boolean = false,
// Google sign-up: profile already comes from Google, so route through ONBOARDING
// (which decides HOME vs CREATE_PROFILE) rather than the email CREATE_PROFILE step.
val googleSuccess: Boolean = false
) )
@HiltViewModel @HiltViewModel
class SignUpViewModel @Inject constructor( class SignUpViewModel @Inject constructor(
private val authRepository: AuthRepository private val authRepository: AuthRepository,
private val userRepository: UserRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SignUpUiState()) private val _uiState = MutableStateFlow(SignUpUiState())
@ -59,9 +66,49 @@ class SignUpViewModel @Inject constructor(
} }
} }
fun signInWithGoogle(idToken: String) {
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
authRepository.signInWithGoogle(idToken)
.onSuccess { result ->
mergeGoogleProfile(result)
_uiState.update { it.copy(isLoading = false, googleSuccess = true) }
}
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
}
}
fun reportError(message: String) = _uiState.update { it.copy(error = message) }
private suspend fun mergeGoogleProfile(result: GoogleSignInResult) {
val uid = result.uid
if (uid.isBlank()) return
val existing = runCatching { userRepository.getUser(uid) }.getOrNull()
if (existing == null) {
userRepository.createUser(
User(
id = uid,
email = result.email,
displayName = result.displayName,
photoUrl = result.photoUrl,
createdAt = System.currentTimeMillis(),
lastActiveAt = System.currentTimeMillis()
)
)
} else {
if (existing.displayName.isBlank() && result.displayName.isNotBlank()) {
userRepository.updateDisplayName(uid, result.displayName)
}
if (existing.photoUrl.isBlank() && result.photoUrl.isNotBlank()) {
userRepository.updatePhotoUrl(uid, result.photoUrl)
}
}
}
private fun friendlyError(e: Throwable): String = when { private fun friendlyError(e: Throwable): String = when {
e.message?.contains("email address is already") == true -> "An account with this email already exists." e.message?.contains("email address is already") == true -> "An account with this email already exists."
e.message?.contains("badly formatted") == true -> "Please enter a valid email address." e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
e.message?.contains("network") == true -> "Check your connection and try again."
else -> e.message ?: "Something went wrong. Please try again." else -> e.message ?: "Something went wrong. Please try again."
} }
} }

View File

@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -32,6 +33,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -164,32 +166,47 @@ private fun BucketListContent(
private fun Header( private fun Header(
onBack: () -> Unit onBack: () -> Unit
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(top = 12.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Column( IconButton(
modifier = Modifier.weight(1f) onClick = onBack,
modifier = Modifier.padding(top = 4.dp)
) { ) {
Text( Icon(
text = "Our Bucket List", imageVector = Icons.AutoMirrored.Filled.ArrowBack,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold), contentDescription = "Back",
color = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Dream dates you both want to experience",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Our Bucket List",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Dream dates you both want to experience",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
} }
} }

View File

@ -1,5 +1,6 @@
package app.closer.ui.dates package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.domain.model.BucketListItem import app.closer.domain.model.BucketListItem
@ -31,8 +32,9 @@ class BucketListViewModel @Inject constructor(
if (coupleId.isEmpty()) return if (coupleId.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
val items = repository.getItems(coupleId) runCatching { repository.getItems(coupleId) }
_uiState.update { it.copy(items = items) } .onSuccess { items -> _uiState.update { it.copy(items = items) } }
.onFailure { Log.w(TAG, "Could not load bucket list items", it) }
} }
} }
@ -54,9 +56,12 @@ class BucketListViewModel @Inject constructor(
) )
viewModelScope.launch { viewModelScope.launch {
val itemId = repository.addItem(newItem) runCatching { repository.addItem(newItem) }
val updatedItems = _uiState.value.items + newItem.copy(id = itemId) .onSuccess { itemId ->
_uiState.update { it.copy(items = updatedItems) } val updatedItems = _uiState.value.items + newItem.copy(id = itemId)
_uiState.update { it.copy(items = updatedItems) }
}
.onFailure { Log.w(TAG, "Could not add bucket list item", it) }
} }
} }
@ -66,24 +71,26 @@ class BucketListViewModel @Inject constructor(
if (coupleId.isEmpty()) return if (coupleId.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
if (item.isCompleted) { runCatching {
repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null)) if (item.isCompleted) {
_uiState.update { repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null))
it.copy( _uiState.update {
items = it.items.map { if (it.id == itemId) it.copy(isCompleted = false) else it } it.copy(
) items = it.items.map { if (it.id == itemId) it.copy(isCompleted = false) else it }
)
}
} else {
repository.completeItem(coupleId, itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "")
_uiState.update {
it.copy(
items = it.items.map {
if (it.id == itemId) it.copy(isCompleted = true, completedAt = System.currentTimeMillis())
else it
}
)
}
} }
} else { }.onFailure { Log.w(TAG, "Could not toggle bucket list item", it) }
repository.completeItem(coupleId, itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "")
_uiState.update {
it.copy(
items = it.items.map {
if (it.id == itemId) it.copy(isCompleted = true, completedAt = System.currentTimeMillis())
else it
}
)
}
}
} }
} }
@ -92,10 +99,13 @@ class BucketListViewModel @Inject constructor(
if (coupleId.isEmpty()) return if (coupleId.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
repository.deleteItem(coupleId, itemId) runCatching { repository.deleteItem(coupleId, itemId) }
_uiState.update { .onSuccess {
it.copy(items = it.items.filter { it.id != itemId }) _uiState.update {
} it.copy(items = it.items.filter { it.id != itemId })
}
}
.onFailure { Log.w(TAG, "Could not delete bucket list item", it) }
} }
} }
@ -108,6 +118,7 @@ class BucketListViewModel @Inject constructor(
} }
private companion object { private companion object {
const val TAG = "BucketListViewModel"
const val MAX_TITLE_LENGTH = 100 const val MAX_TITLE_LENGTH = 100
const val MAX_DESCRIPTION_LENGTH = 500 const val MAX_DESCRIPTION_LENGTH = 500
} }

View File

@ -22,14 +22,20 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DisplayMode import androidx.compose.material3.DisplayMode
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -38,6 +44,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -60,16 +67,38 @@ fun DateBuilderScreen(
viewModel: DateBuilderViewModel = hiltViewModel() viewModel: DateBuilderViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
DateBuilderContent( LaunchedEffect(state.saved) {
state = state, if (state.saved) {
onDateChange = viewModel::updateDate, viewModel.consumeSaved()
onTimeChange = viewModel::updateTime, onNavigate("back")
onBudgetChange = viewModel::updateBudget, }
onDurationChange = viewModel::updateDuration, }
onSave = { viewModel.savePreference() }, LaunchedEffect(state.error) {
onBack = { onNavigate("back") } state.error?.let {
) snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
Box(modifier = Modifier.fillMaxSize()) {
DateBuilderContent(
state = state,
onDateChange = viewModel::updateDate,
onTimeChange = viewModel::updateTime,
onBudgetChange = viewModel::updateBudget,
onDurationChange = viewModel::updateDuration,
onSave = { viewModel.savePreference() },
onBack = { onNavigate("back") }
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
)
}
} }
@Composable @Composable
@ -126,29 +155,44 @@ private fun DateBuilderContent(
private fun Header( private fun Header(
onBack: () -> Unit onBack: () -> Unit
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(top = 12.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( IconButton(
text = "Plan a Date", onClick = onBack,
modifier = Modifier.weight(1f), modifier = Modifier.padding(top = 4.dp)
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold), ) {
color = MaterialTheme.colorScheme.onSurface, Icon(
maxLines = 1, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
overflow = TextOverflow.Ellipsis contentDescription = "Back",
) tint = MaterialTheme.colorScheme.onSurface
Text( )
text = "Tell us what you're looking for", }
style = MaterialTheme.typography.bodyMedium, Row(
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier
maxLines = 1, .fillMaxWidth()
overflow = TextOverflow.Ellipsis .padding(top = 4.dp, bottom = 6.dp),
) horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Plan a Date",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Tell us what you're looking for",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
} }

View File

@ -1,5 +1,6 @@
package app.closer.ui.dates package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.domain.model.DatePlanPreference import app.closer.domain.model.DatePlanPreference
@ -53,15 +54,27 @@ class DateBuilderViewModel @Inject constructor(
) )
viewModelScope.launch { viewModelScope.launch {
repository.savePreference(preference) _uiState.update { it.copy(isSaving = true, error = null) }
runCatching { repository.savePreference(preference) }
.onSuccess { _uiState.update { it.copy(isSaving = false, saved = true) } }
.onFailure { e ->
Log.w(TAG, "Could not save date preference", e)
_uiState.update {
it.copy(isSaving = false, error = "Couldn't save. Check your connection and try again.")
}
}
} }
} }
fun consumeSaved() = _uiState.update { it.copy(saved = false) }
fun consumeError() = _uiState.update { it.copy(error = null) }
fun clear() { fun clear() {
_uiState.update { DateBuilderUiState() } _uiState.update { DateBuilderUiState() }
} }
private companion object { private companion object {
const val TAG = "DateBuilderViewModel"
const val MAX_TIME_LENGTH = 20 const val MAX_TIME_LENGTH = 20
const val MAX_DURATION_LENGTH = 50 const val MAX_DURATION_LENGTH = 50
} }
@ -72,5 +85,8 @@ data class DateBuilderUiState(
val scheduledDate: Long = 0L, val scheduledDate: Long = 0L,
val scheduledTime: String = "", val scheduledTime: String = "",
val budget: Int = 0, val budget: Int = 0,
val duration: String = "" val duration: String = "",
val isSaving: Boolean = false,
val saved: Boolean = false,
val error: String? = null
) )

View File

@ -106,12 +106,6 @@ fun HomeScreen(
} }
} }
LaunchedEffect(state.needsEncryptionUpgrade) {
if (state.needsEncryptionUpgrade) {
onNavigate(AppRoute.ENCRYPTION_UPGRADE)
}
}
var showBaselineDialog by remember { mutableStateOf(false) } var showBaselineDialog by remember { mutableStateOf(false) }
var showFollowUpDialog by remember { mutableStateOf(false) } var showFollowUpDialog by remember { mutableStateOf(false) }
var pendingFollowUpDay by remember { mutableStateOf<OutcomeDay?>(null) } var pendingFollowUpDay by remember { mutableStateOf<OutcomeDay?>(null) }

View File

@ -120,7 +120,6 @@ data class HomeUiState(
val secondaryActions: List<HomeAction> = emptyList(), val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false, val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false, val needsRecovery: Boolean = false,
val needsEncryptionUpgrade: Boolean = false,
val coupleId: String? = null, val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED, val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false, val hasPartnerAnsweredToday: Boolean = false,
@ -204,12 +203,6 @@ class HomeViewModel @Inject constructor(
} }
val encryptionStatus = couple?.let(encryptionManager::checkStatus) val encryptionStatus = couple?.let(encryptionManager::checkStatus)
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
val needsEncryptionUpgrade = when (encryptionStatus) {
EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE -> true
EncryptionStatus.NEEDS_CONTENT_MIGRATION ->
couple.encryptionMigrationUsers[uid] != true
else -> false
}
// Outcome check-in due-state calculation // Outcome check-in due-state calculation
val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt
@ -275,7 +268,6 @@ class HomeViewModel @Inject constructor(
coupleId = coupleId, coupleId = coupleId,
partnerLeftEvent = false, partnerLeftEvent = false,
needsRecovery = needsRecovery, needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade,
hasWaitingGame = hasWaitingGame, hasWaitingGame = hasWaitingGame,
hasActiveChallenge = hasActiveChallenge, hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan, hasUpcomingDatePlan = hasUpcomingDatePlan,
@ -503,7 +495,7 @@ class HomeViewModel @Inject constructor(
} }
val engineInput = PriorityInput( val engineInput = PriorityInput(
needsCriticalAction = needsRecovery || needsEncryptionUpgrade, needsCriticalAction = needsRecovery,
isPaired = isPaired, isPaired = isPaired,
needsEncryptionUnlock = needsRecovery, needsEncryptionUnlock = needsRecovery,
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED, revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
@ -539,13 +531,6 @@ class HomeViewModel @Inject constructor(
cta = "Start recovery", cta = "Start recovery",
target = HomeActionTarget.Settings, target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility tone = HomeActionTone.Utility
) else if (needsEncryptionUpgrade) HomeAction(
eyebrow = "Encryption update",
title = "Upgrade your answer security.",
body = "Your encryption needs a quick update so your answers stay private.",
cta = "Update encryption",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
) else null ) else null
Priority.PAIRING_NEEDED -> HomeAction( Priority.PAIRING_NEEDED -> HomeAction(

View File

@ -1,246 +0,0 @@
package app.closer.ui.pairing
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.CoupleAnswerMigrationDataSource
import app.closer.data.remote.FirestoreCoupleDataSource
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.components.StatusGlyph
import app.closer.ui.settings.SettingsBackgroundBrush
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsOnPrimary
import app.closer.ui.settings.SettingsPrimary
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class EncryptionUpgradeUiState(
val isLoading: Boolean = true,
val recoveryPhrase: String? = null,
val complete: Boolean = false,
val allPartnersComplete: Boolean = false,
val error: String? = null
)
@HiltViewModel
class EncryptionUpgradeViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val coupleDataSource: FirestoreCoupleDataSource,
private val migrationDataSource: CoupleAnswerMigrationDataSource,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(EncryptionUpgradeUiState())
val uiState: StateFlow<EncryptionUpgradeUiState> = _uiState.asStateFlow()
private var coupleId: String? = null
init {
upgrade()
}
fun upgrade() {
if (_uiState.value.isLoading && coupleId != null) return
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
runCatching {
val userId = authRepository.currentUserId ?: error("Sign in again to secure your history.")
var couple = coupleRepository.getCoupleForUser(userId)
?: error("Your couple could not be loaded.")
coupleId = couple.id
if (couple.encryptionVersion >= CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION) {
return@runCatching UpgradeResult(null, true)
}
var phrase = encryptionManager.pendingRecoveryPhrase(couple.id)
if (couple.encryptionVersion == 0) {
val wrapped = if (phrase != null && encryptionManager.isUnlocked(couple.id)) {
encryptionManager.rewrapWithNewPhrase(couple.id, phrase).getOrThrow()
} else {
val setup = encryptionManager.setupLegacyCouple(couple.id)
phrase = setup.recoveryPhrase
setup.wrapped
}
val claimed = coupleDataSource.beginEncryptionMigration(couple.id, wrapped)
if (!claimed) {
encryptionManager.deleteKeyset(couple.id)
error("Your partner started the upgrade. Ask them for the recovery phrase, then unlock this device.")
}
couple = coupleRepository.getCoupleForUser(userId)
?: error("Your couple could not be reloaded.")
}
if (!encryptionManager.isUnlocked(couple.id)) {
error("This device needs your shared recovery phrase before it can migrate answers.")
}
migrationDataSource.migrateUser(couple.id, userId)
val allComplete = coupleDataSource.markEncryptionMigrationComplete(couple.id, userId)
UpgradeResult(phrase, allComplete)
}.onSuccess { result ->
_uiState.update {
it.copy(
isLoading = false,
recoveryPhrase = result.phrase,
complete = true,
allPartnersComplete = result.allPartnersComplete
)
}
}.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, error = error.message ?: "The encryption upgrade failed. Try again.")
}
}
}
}
fun acknowledgePhrase() {
coupleId?.let(encryptionManager::acknowledgeRecoveryPhrase)
}
private data class UpgradeResult(val phrase: String?, val allPartnersComplete: Boolean)
}
@Composable
fun EncryptionUpgradeScreen(
onComplete: () -> Unit,
onRecoveryNeeded: () -> Unit,
viewModel: EncryptionUpgradeViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(SettingsBackgroundBrush)
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
StatusGlyph(
icon = Icons.Filled.Lock,
tint = SettingsPrimary,
container = SettingsPrimary.copy(alpha = 0.14f)
)
Spacer(Modifier.height(20.dp))
Text(
text = if (state.complete) "Your answers are secured" else "Securing your history",
style = MaterialTheme.typography.headlineSmall,
color = SettingsInk,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
when {
state.isLoading -> {
Text(
"This one-time upgrade encrypts answer content on this device before replacing its cloud copy.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
CloserHeartLoader()
Spacer(Modifier.height(16.dp))
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
}
state.error != null -> {
val error = state.error.orEmpty()
Text(
error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
Button(onClick = viewModel::upgrade) { Text("Try again") }
if (error.contains("recovery phrase", ignoreCase = true)) {
OutlinedButton(onClick = onRecoveryNeeded) { Text("Enter recovery phrase") }
}
}
state.recoveryPhrase != null -> {
val recoveryPhrase = state.recoveryPhrase.orEmpty()
Text(
"Save this phrase somewhere private and share it directly with your partner. Closer does not store the phrase and cannot recover it.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
SelectionContainer {
Text(
recoveryPhrase,
modifier = Modifier.fillMaxWidth().padding(16.dp),
style = MaterialTheme.typography.titleMedium,
color = SettingsInk,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { viewModel.acknowledgePhrase(); onComplete() },
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SettingsPrimary,
contentColor = SettingsOnPrimary
)
) { Text("I've saved and shared it") }
}
else -> {
Text(
if (state.allPartnersComplete)
"Both sides have migrated. New and historical answer content now uses strict end-to-end encryption."
else
"This device is ready. Your partner will finish the upgrade after unlocking with the shared recovery phrase.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(onClick = onComplete, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
}
}
}
}

View File

@ -58,14 +58,21 @@ fun PlayHubScreen(
viewModel: PlayHubViewModel = hiltViewModel() viewModel: PlayHubViewModel = hiltViewModel()
) { ) {
val hasPremium by viewModel.hasPremium.collectAsState() val hasPremium by viewModel.hasPremium.collectAsState()
PlayHubContent(onNavigate = onNavigate, hasPremium = hasPremium) val isPaired by viewModel.isPaired.collectAsState()
PlayHubContent(onNavigate = onNavigate, hasPremium = hasPremium, isPaired = isPaired)
} }
@Composable @Composable
private fun PlayHubContent( private fun PlayHubContent(
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
hasPremium: Boolean = true hasPremium: Boolean = true,
isPaired: Boolean = true
) { ) {
// Games are couple activities: an unpaired user who taps any of them is sent to
// invite their partner instead. Non-game tiles (history) use onNavigate directly.
val onPlay: (String) -> Unit = { route ->
if (isPaired) onNavigate(route) else onNavigate(AppRoute.CREATE_INVITE)
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -103,37 +110,37 @@ private fun PlayHubContent(
item { item {
FeaturedPlayCard( FeaturedPlayCard(
onClick = { onNavigate(AppRoute.SPIN_WHEEL_RANDOM) } onClick = { onPlay(AppRoute.SPIN_WHEEL_RANDOM) }
) )
} }
item { item {
ThisOrThatCard( ThisOrThatCard(
onClick = { onNavigate(AppRoute.THIS_OR_THAT) } onClick = { onPlay(AppRoute.THIS_OR_THAT) }
) )
} }
item { item {
HowWellCard( HowWellCard(
onClick = { onNavigate(AppRoute.HOW_WELL) } onClick = { onPlay(AppRoute.HOW_WELL) }
) )
} }
item { item {
DesireSyncCard( DesireSyncCard(
onClick = { onNavigate(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) } onClick = { onPlay(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
) )
} }
item { item {
ConnectionChallengesCard( ConnectionChallengesCard(
onClick = { onNavigate(AppRoute.CONNECTION_CHALLENGES) } onClick = { onPlay(AppRoute.CONNECTION_CHALLENGES) }
) )
} }
item { item {
MemoryLaneCard( MemoryLaneCard(
onClick = { onNavigate(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) } onClick = { onPlay(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) }
) )
} }
@ -148,7 +155,7 @@ private fun PlayHubContent(
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.DATE_MATCH) } onClick = { onPlay(AppRoute.DATE_MATCH) }
) )
CompactPlayCard( CompactPlayCard(
title = "Plan Date", title = "Plan Date",
@ -156,7 +163,7 @@ private fun PlayHubContent(
icon = Icons.Filled.Star, icon = Icons.Filled.Star,
tint = MaterialTheme.colorScheme.tertiary, tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.DATE_BUILDER) } onClick = { onPlay(AppRoute.DATE_BUILDER) }
) )
} }
} }
@ -172,7 +179,7 @@ private fun PlayHubContent(
icon = Icons.Filled.Done, icon = Icons.Filled.Done,
tint = MaterialTheme.colorScheme.tertiary, tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.BUCKET_LIST) } onClick = { onPlay(AppRoute.BUCKET_LIST) }
) )
CompactPlayCard( CompactPlayCard(
title = "Past Games", title = "Past Games",

View File

@ -3,16 +3,34 @@ package app.closer.ui.play
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker import app.closer.core.billing.EntitlementChecker
import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class PlayHubViewModel @Inject constructor( class PlayHubViewModel @Inject constructor(
entitlementChecker: EntitlementChecker entitlementChecker: EntitlementChecker,
private val gameSessionManager: GameSessionManager
) : ViewModel() { ) : ViewModel() {
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium() val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
// Default true so paired users never see an invite redirect flash while this loads.
private val _isPaired = MutableStateFlow(true)
val isPaired: StateFlow<Boolean> = _isPaired.asStateFlow()
init {
viewModelScope.launch {
val uid = gameSessionManager.currentUserId
val paired = uid != null &&
runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null
_isPaired.value = paired
}
}
} }

View File

@ -53,7 +53,10 @@ class CategoryPickerViewModel @Inject constructor(
private fun checkActiveSession() { private fun checkActiveSession() {
viewModelScope.launch { viewModelScope.launch {
val uid = gameSessionManager.currentUserId ?: return@launch val uid = gameSessionManager.currentUserId ?: return@launch
val couple = gameSessionManager.getCoupleForUser(uid) ?: return@launch val couple = gameSessionManager.getCoupleForUser(uid) ?: run {
_uiState.update { it.copy(navigateTo = AppRoute.CREATE_INVITE) }
return@launch
}
val active = runCatching { gameSessionManager.getActiveSession(couple.id) } val active = runCatching { gameSessionManager.getActiveSession(couple.id) }
.getOrNull() ?: return@launch .getOrNull() ?: return@launch
val target = if (active.gameType == GameType.WHEEL) { val target = if (active.gameType == GameType.WHEEL) {

View File

@ -66,7 +66,12 @@ class SpinWheelViewModel @Inject constructor(
val uid = gameSessionManager.currentUserId val uid = gameSessionManager.currentUserId
val paired = uid != null && val paired = uid != null &&
runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null
_uiState.update { it.copy(isPaired = paired) } // Games are couple activities — an unpaired user is sent to invite their partner.
if (!paired) {
_uiState.update { it.copy(isPaired = false, navigateTo = AppRoute.CREATE_INVITE) }
return@launch
}
_uiState.update { it.copy(isPaired = true) }
} }
} }
@ -160,7 +165,7 @@ class SpinWheelViewModel @Inject constructor(
}.getOrNull() }.getOrNull()
if (couple == null) { if (couple == null) {
_uiState.update { it.copy(error = "Not in a couple") } _uiState.update { it.copy(navigateTo = AppRoute.CREATE_INVITE) }
return@launch return@launch
} }

View File

@ -43,6 +43,36 @@ class FieldEncryptorTest {
assertNull(subject.decrypt(encrypted, null, "couple-a")) assertNull(subject.decrypt(encrypted, null, "couple-a"))
} }
@Test
fun `unprefixed value is not trusted as plaintext (fail closed)`() {
// No legacy plaintext exists; an unprefixed value must never pass through.
assertNull(subject.decrypt("sneaky plaintext", aead, "couple-a"))
}
@Test
fun `decryptForDisplay shows the locked placeholder for unreadable content`() {
// Encrypted but no key on this device.
val encrypted = subject.encrypt("private", aead, "couple-a")
assertEquals(FieldEncryptor.LOCKED_PLACEHOLDER, subject.decryptForDisplay(encrypted, null, "couple-a"))
// Encrypted for a different couple (wrong AAD).
assertEquals(FieldEncryptor.LOCKED_PLACEHOLDER, subject.decryptForDisplay(encrypted, aead, "couple-b"))
// Unprefixed value is treated as locked, never shown as plaintext.
assertEquals(FieldEncryptor.LOCKED_PLACEHOLDER, subject.decryptForDisplay("sneaky plaintext", aead, "couple-a"))
}
@Test
fun `decryptForDisplay round trips a valid encrypted value`() {
val encrypted = subject.encrypt("hello", aead, "couple-a")
assertEquals("hello", subject.decryptForDisplay(encrypted, aead, "couple-a"))
}
@Test
fun `decryptForDisplay passes through null`() {
assertNull(subject.decryptForDisplay(null, aead, "couple-a"))
}
private class AssociatedDataCheckingAead : Aead { private class AssociatedDataCheckingAead : Aead {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray = override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
digest(associatedData) + plaintext.reversedArray() digest(associatedData) + plaintext.reversedArray()

View File

@ -60,16 +60,6 @@ service cloud.firestore {
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1; return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
} }
function isEncryptedAnswerPayload(data) {
return (!('writtenText' in data) || data.writtenText == null || isCiphertext(data.writtenText))
&& (!('selectedOptionIds' in data)
|| (data.selectedOptionIds is list
&& (data.selectedOptionIds.size() == 0
|| (data.selectedOptionIds.size() == 1
&& isCiphertext(data.selectedOptionIds[0])))))
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
}
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal). // Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
function isSealedPayload(value) { function isSealedPayload(value) {
@ -130,42 +120,6 @@ service cloud.firestore {
.hasOnly(['answerKeyReleased', 'updatedAt']); .hasOnly(['answerKeyReleased', 'updatedAt']);
} }
function isStartingEncryptionMigration() {
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
&& request.resource.data.encryptionVersion == 1
&& request.resource.data.wrappedCoupleKey is string
&& request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string
&& request.resource.data.encryptionMigrationUsers is map
&& request.resource.data.encryptionMigrationUsers.size() == 0
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'wrappedCoupleKey', 'kdfSalt', 'kdfParams',
'encryptionVersion', 'encryptionMigrationUsers'
]);
}
function isCompletingOwnEncryptionMigration() {
let migrated = request.resource.data.encryptionMigrationUsers;
// Some version-1 couples predate the migration marker. Treat that missing
// map as empty so either partner can safely record their own completion.
let previous = ('encryptionMigrationUsers' in resource.data)
? resource.data.encryptionMigrationUsers
: {};
let changed = migrated.diff(previous).affectedKeys();
let users = resource.data.userIds;
return resource.data.encryptionVersion == 1
&& request.resource.data.encryptionVersion >= 1
&& request.resource.data.encryptionVersion <= 2
&& migrated is map
&& changed.hasOnly([request.auth.uid])
&& migrated[request.auth.uid] == true
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'encryptionVersion', 'encryptionMigrationUsers'
])
&& (request.resource.data.encryptionVersion == 1
|| (migrated[users[0]] == true && migrated[users[1]] == true));
}
function isUpdatingRecoveryWrap() { function isUpdatingRecoveryWrap() {
return request.resource.data.encryptionVersion >= 1 return request.resource.data.encryptionVersion >= 1
&& request.resource.data.wrappedCoupleKey is string && request.resource.data.wrappedCoupleKey is string
@ -301,8 +255,6 @@ service cloud.firestore {
&& ( && (
isUpdatingCoupleRhythm() isUpdatingCoupleRhythm()
|| isUpdatingRecoveryWrap() || isUpdatingRecoveryWrap()
|| isStartingEncryptionMigration()
|| isCompletingOwnEncryptionMigration()
); );
// Delete: server-only (admin SDK only). Admin SDK bypasses rules. // Delete: server-only (admin SDK only). Admin SDK bypasses rules.
@ -360,7 +312,8 @@ service cloud.firestore {
allow delete: if false; allow delete: if false;
// Answers: each user writes their own; both members can read all answers. // Answers: each user writes their own; both members can read all answers.
// Accepts schemaVersion 3 (sealed:v1: partner-proof) or schemaVersion 2 (enc:v1: couple-key). // Strict couples must use schemaVersion 3 (sealed:v1: partner-proof).
// schemaVersion 2 is accepted only for v1 migration couples.
match /answers/{userId} { match /answers/{userId} {
allow read: if isCouplesMember(coupleId); allow read: if isCouplesMember(coupleId);
allow delete: if false; allow delete: if false;
@ -368,29 +321,10 @@ service cloud.firestore {
&& isOwner(userId) && isOwner(userId)
&& request.resource.data.userId == request.auth.uid && request.resource.data.userId == request.auth.uid
&& coupleEncryptionEnabled(coupleId) && coupleEncryptionEnabled(coupleId)
&& ( && isSealedThreadAnswerCreate(request.resource.data);
isSealedThreadAnswerCreate(request.resource.data)
|| (request.resource.data.schemaVersion == 2
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'createdAt', 'updatedAt'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
&& isOwner(userId) && isOwner(userId)
&& ( && isSealedThreadAnswerUpdate();
isSealedThreadAnswerUpdate()
|| (coupleEncryptionEnabled(coupleId)
&& resource.data.schemaVersion != 3
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'createdAt', 'updatedAt'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
// One-time key release for sealed thread answers (same guards as daily answer release keys). // One-time key release for sealed thread answers (same guards as daily answer release keys).
match /releaseKeys/{recipientId} { match /releaseKeys/{recipientId} {
@ -518,17 +452,48 @@ service cloud.firestore {
'completedBy', 'completedAt', 'isCompleted' 'completedBy', 'completedAt', 'isCompleted'
]) ])
&& request.resource.data.addedBy == request.auth.uid && request.resource.data.addedBy == request.auth.uid
&& isValidBucketListCategory(request.resource.data.category); && isValidBucketListCategory(request.resource.data.category)
// Strict E2EE: user content must be ciphertext.
&& isCiphertext(request.resource.data.title)
&& (!('description' in request.resource.data)
|| request.resource.data.description == null
|| isCiphertext(request.resource.data.description));
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([ && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt' 'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
]) ])
&& isImmutable(['addedBy', 'addedAt']) && isImmutable(['addedBy', 'addedAt'])
// completedBy must be the caller when marking an item complete // completedBy must be the caller when marking an item complete
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid); && (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid)
// Strict E2EE: title/description remain ciphertext (merged result is always encrypted).
&& isCiphertext(request.resource.data.title)
&& (!('description' in request.resource.data)
|| request.resource.data.description == null
|| isCiphertext(request.resource.data.description));
allow delete: if isCouplesMember(coupleId); allow delete: if isCouplesMember(coupleId);
} }
// Couple Lore stores revealed answer summaries. Summary text must remain
// encrypted with the couple key; prompts/metadata can stay plaintext.
match /lore/{loreId} {
allow read: if isCouplesMember(coupleId);
allow create, update: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId)
&& request.resource.data.keys().hasOnly([
'questionId', 'questionText', 'ownAnswer', 'partnerAnswer',
'modeTag', 'date', 'schemaVersion', 'savedAt'
])
&& request.resource.data.questionId is string
&& request.resource.data.questionText is string
&& request.resource.data.date is string
&& request.resource.data.schemaVersion == 2
&& isCiphertext(request.resource.data.ownAnswer)
&& (!('partnerAnswer' in request.resource.data)
|| request.resource.data.partnerAnswer == null
|| isCiphertext(request.resource.data.partnerAnswer));
allow delete: if false;
}
// Outcomes: couple-level 30/60/90 day check-ins. Both members can read. // Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
// Writes are server-side only via submitOutcomeCallable; direct client writes denied. // Writes are server-side only via submitOutcomeCallable; direct client writes denied.
match /outcomes/{dayKey} { match /outcomes/{dayKey} {
@ -557,40 +522,16 @@ service cloud.firestore {
// whose metadata disagrees with the path it lands in. // whose metadata disagrees with the path it lands in.
&& request.resource.data.answerDate is string && request.resource.data.answerDate is string
&& request.resource.data.answerDate == date && request.resource.data.answerDate == date
&& ( // schemaVersion 3: partner-proof sealed answer (the only accepted shape).
// schemaVersion 3: partner-proof sealed answer. && isSealedAnswerCreate(request.resource.data);
isSealedAnswerCreate(request.resource.data)
||
// schemaVersion 2: couple-key encrypted answer (legacy path).
(coupleEncryptionEnabled(coupleId)
&& request.resource.data.schemaVersion == 2
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId && request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId && request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId && request.resource.data.questionId == resource.data.questionId
&& request.resource.data.answerType == resource.data.answerType && request.resource.data.answerType == resource.data.answerType
&& ( // Sealed answers: only reveal metadata may change; payload is immutable.
// Sealed answers: only reveal metadata may change; payload is immutable. && isSealedAnswerUpdate();
isSealedAnswerUpdate()
||
// enc:v1: answers: same field set, content may be updated.
(coupleEncryptionEnabled(coupleId)
&& resource.data.schemaVersion != 3
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow delete: if false; allow delete: if false;

View File

@ -115,15 +115,16 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
const coupleId = db.collection('couples').doc().id const coupleId = db.collection('couples').doc().id
const coupleRef = db.collection('couples').doc(coupleId) const coupleRef = db.collection('couples').doc(coupleId)
// Derive encryption version from E2EE field presence. // Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't,
// encryptionVersion must stay in sync with EncryptionVersion.kt: // the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
// 0 = plaintext (no couple key; iOS MVP path) // broken plaintext couple the client can't use.
// 1 = legacy migration (mixed) if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
// 2 = strict E2EE (all new Android couples) throw new functions.https.HttpsError(
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state 'failed-precondition',
// where the client expects a key that does not exist. 'Invite is missing encryption material. Ask your partner to create a new invite.'
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null )
const encryptionVersion = hasE2EE ? 2 : 0 }
const encryptionVersion = 2
const batch = db.batch() const batch = db.batch()

View File

@ -15,13 +15,11 @@ import * as admin from 'firebase-admin'
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF * - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
* - kdfSalt: base64 KDF salt * - kdfSalt: base64 KDF salt
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1) * - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using * - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the client using the invite
* the invite code as the KDF input. The server stores it opaquely and never sees the * code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
* *
* When E2EE fields are omitted the function writes nulls; iOS MVP creates * Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not * all required. There is no plaintext-couple path.
* supply these fields. Android always supplies them.
* *
* Response: { code: string, expiresAt: Timestamp } * Response: { code: string, expiresAt: Timestamp }
* *
@ -89,13 +87,15 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
const kdfParams = data?.kdfParams as string | undefined const kdfParams = data?.kdfParams as string | undefined
const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
// E2EE fields must be supplied together or omitted together. // Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams] // code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
const suppliedE2ee = e2eeFields.filter((v) => v != null).length if (!clientCode) {
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) { throw new functions.https.HttpsError('invalid-argument', 'code is required.')
}
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError( throw new functions.https.HttpsError(
'invalid-argument', 'invalid-argument',
'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.' 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.'
) )
} }