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.PairingSuccessScreen
import app.closer.ui.pairing.RecoveryScreen
import app.closer.ui.pairing.EncryptionUpgradeScreen
import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen
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
composable(route = AppRoute.CATEGORY_PICKER) {

View File

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

View File

@ -1,6 +1,5 @@
package app.closer.crypto
import app.closer.crypto.EncryptionVersion.STRICT
import app.closer.domain.model.Couple
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeysetHandle
@ -12,14 +11,10 @@ import javax.inject.Singleton
enum class EncryptionStatus {
/** Local keyset present -- ready to encrypt/decrypt. */
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,
/** encryptionVersion == 1 but no local keyset -- prompt for recovery phrase. */
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
/** No local keyset on this device -- prompt for the recovery phrase. */
NEEDS_RECOVERY
}
class MissingCoupleKeyException(coupleId: String) :
@ -80,30 +75,15 @@ class CoupleEncryptionManager @Inject constructor(
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
/**
* Called on app launch / Home load after the couple doc is resolved.
* Handles inviter reconciliation (flow B') transparently.
* Called on app launch / Home load after the couple doc is resolved. Every couple is
* 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 {
// v2 couples were created by Android with a strict couple key.
// 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.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
return if (couple.encryptionVersion >= STRICT) {
EncryptionStatus.RECONCILED_FROM_INVITE
} else {
EncryptionStatus.NEEDS_CONTENT_MIGRATION
}
return EncryptionStatus.RECONCILED_FROM_INVITE
}
return EncryptionStatus.NEEDS_RECOVERY
}
@ -133,24 +113,4 @@ class CoupleEncryptionManager @Inject constructor(
}
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) {
prefs.edit()
.remove(prefKey(coupleId))
.remove(pendingPhraseKey(coupleId))
.remove(recoveryPhraseKey(coupleId))
.apply()
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? {
aeadCache[coupleId]?.let { return it }
val handle = loadKeyset(coupleId) ?: return null
@ -108,7 +96,6 @@ class CoupleKeyStore @Inject constructor(
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
private fun serialize(handle: KeysetHandle): String {
val baos = ByteArrayOutputStream()

View File

@ -1,27 +1,16 @@
package app.closer.crypto
/**
* Single source of truth for couple encryption versions shared by Android,
* iOS, and Cloud Functions.
* Couple encryption version stamp. The app is strict-E2EE only: every couple is
* created with a wrapped couple key and all answer-bearing paths require ciphertext.
*
* v0 = legacy plaintext (no couple key, all answer paths write plaintext).
* Used by the iOS MVP because E2EE is skipped for the initial port.
* v1 = legacy Tink key migration-in-progress (mixed plaintext + encrypted).
* 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)
* The constant is kept as a forward-compatibility marker written on couple creation
* (Android client + acceptInviteCallable). There are no v0 (plaintext) or v1
* (migration) couples.
*/
object EncryptionVersion {
const val PLAINTEXT = 0
const val MIGRATING = 1
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
}

View File

@ -9,7 +9,12 @@ import javax.inject.Singleton
* Stateless helper that encrypts/decrypts individual Firestore field values.
*
* 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
* 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? {
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
return runCatching {
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
/**
* 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 {
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 fieldEncryptor: FieldEncryptor,
private val userKeyManager: UserKeyManager,
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val answerCommitment: AnswerCommitment
@ -66,7 +67,8 @@ class FirestoreAnswerDataSource @Inject constructor(
userId: String,
answer: LocalAnswer,
date: String
): Unit = suspendCancellableCoroutine { cont ->
) {
ensureUserPublicKeyPublished(userId)
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
val payload = SealedAnswerEncryptor.AnswerPayload(
writtenText = answer.writtenText,
@ -95,11 +97,13 @@ class FirestoreAnswerDataSource @Inject constructor(
"isRevealed" to answer.isRevealed
)
suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId)
.set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String): Unit =
@ -125,8 +129,7 @@ class FirestoreAnswerDataSource @Inject constructor(
cont.resume(null)
return@addOnSuccessListener
}
val aead = encryptionManager.aeadFor(coupleId)
cont.resume(snap.toLocalAnswer(aead, coupleId))
cont.resume(snap.toLocalAnswer())
}
.addOnFailureListener { cont.resumeWithException(it) }
}
@ -169,16 +172,9 @@ class FirestoreAnswerDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) }
}
@Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(
aead: com.google.crypto.tink.Aead?,
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) {
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
// All answers are sealed (schemaVersion 3): content lives in encryptedPayload and is
// decrypted later by SealedRevealManager. Nothing is ever stored in plaintext.
return LocalAnswer(
questionId = getString("questionId") ?: "",
questionText = "",
@ -194,43 +190,6 @@ class FirestoreAnswerDataSource @Inject constructor(
)
}
// 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(
questionId = getString("questionId") ?: "",
questionText = "",
category = "",
answerType = getString("answerType") ?: "written",
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = selectedOptionIds,
selectedOptionTexts = emptyList(),
scaleValue = scaleValue,
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false,
schemaVersion = schemaVersion,
answerDate = getString("answerDate") ?: ""
)
}
/**
* Saves a "Couple Lore" entry to Firestore.
* Path: couples/{coupleId}/lore/{questionId}
@ -244,13 +203,15 @@ class FirestoreAnswerDataSource @Inject constructor(
modeTag: String?,
date: String
): Unit = suspendCancellableCoroutine { cont ->
val aead = encryptionManager.requireAead(coupleId)
val doc = mapOf(
"questionId" to questionId,
"questionText" to questionText,
"ownAnswer" to ownAnswer,
"partnerAnswer" to partnerAnswer,
"ownAnswer" to fieldEncryptor.encrypt(ownAnswer, aead, coupleId),
"partnerAnswer" to fieldEncryptor.encryptNullable(partnerAnswer, aead, coupleId),
"modeTag" to modeTag,
"date" to date,
"schemaVersion" to 2,
"savedAt" to com.google.firebase.Timestamp.now()
)
db.collection(FirestoreCollections.COUPLES)
@ -262,6 +223,12 @@ class FirestoreAnswerDataSource @Inject constructor(
.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(
val questionId: String,
val date: String,

View File

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

View File

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

View File

@ -1,7 +1,7 @@
package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionVersion
import app.closer.domain.model.Couple
import com.google.firebase.Timestamp
import com.google.firebase.firestore.DocumentSnapshot
@ -53,7 +53,7 @@ class FirestoreCoupleDataSource @Inject constructor(
"createdAt" to now,
"streakCount" to 0
)
data["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
data["encryptionVersion"] = EncryptionVersion.STRICT
data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params
@ -76,47 +76,6 @@ class FirestoreCoupleDataSource @Inject constructor(
.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 =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
@ -176,9 +135,7 @@ class FirestoreCoupleDataSource @Inject constructor(
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
wrappedCoupleKey = getString("wrappedCoupleKey"),
kdfSalt = getString("kdfSalt"),
kdfParams = getString("kdfParams"),
encryptionMigrationUsers = (get("encryptionMigrationUsers") as? Map<String, Boolean>)
?: emptyMap()
kdfParams = getString("kdfParams")
)
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 fieldEncryptor: FieldEncryptor,
private val userKeyManager: UserKeyManager,
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val answerCommitment: AnswerCommitment
@ -85,17 +86,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
// ─── Answers ─────────────────────────────────────────────────────────────────
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
if (userKeyManager.loadPrivateKey() != null) {
submitAnswerSealed(coupleId, threadId, userId, answer)
} else {
submitAnswerEncrypted(coupleId, threadId, userId, answer)
}
}
// schemaVersion 3: per-answer one-time key — partner-proof before reveal.
// 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.
private suspend fun submitAnswerSealed(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
ensureUserPublicKeyPublished(userId)
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
val payload = SealedAnswerEncryptor.AnswerPayload(
writtenText = answer.writtenText,
@ -127,33 +125,6 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
).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.
// Required for correct phase detection on cold restart of the reveal screen.
suspend fun markAnswerKeyReleased(coupleId: String, threadId: String, userId: String) {
@ -178,8 +149,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(snap.documents.mapNotNull { it.toQuestionAnswer(aead, coupleId) })
trySend(snap.documents.mapNotNull { it.toQuestionAnswer() })
}
awaitClose { listener.remove() }
}
@ -281,17 +251,10 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
)
@Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toQuestionAnswer(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): QuestionAnswer? {
private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? {
val userId = getString("userId") ?: return null
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
// schemaVersion 3: sealed:v1: — content is in encryptedPayload.
// Decryption requires the partner's release key; the reveal flow handles it.
if (schemaVersion == 3) {
// All thread answers are sealed (schemaVersion 3): content lives in encryptedPayload
// and is decrypted by the reveal flow, never stored in plaintext.
return QuestionAnswer(
userId = userId,
questionId = getString("questionId") ?: "",
@ -304,39 +267,6 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
)
}
// 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(
userId = userId,
questionId = getString("questionId") ?: "",
answerType = getString("answerType") ?: "written",
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = selectedOptionIds,
scaleValue = scaleValue,
schemaVersion = schemaVersion,
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
)
}
private fun DocumentSnapshot.toQuestionMessage(
aead: com.google.crypto.tink.Aead?,
coupleId: String
@ -345,7 +275,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
return QuestionMessage(
id = id,
userId = userId,
text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "",
text = fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
)
}
@ -360,4 +290,10 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
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) ->
when (value) {
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 {
val array = JSONArray(json)
(0 until array.length()).map { index ->
@ -111,16 +117,6 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
}
}.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()
}
}

View File

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

View File

@ -5,11 +5,13 @@ import app.closer.ui.theme.closerCardColor
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@ -18,11 +20,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -49,6 +56,35 @@ internal val AuthPrimaryDeep: Color
internal val AuthOnPrimary: Color
@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
internal fun GoogleSignInButton(
onClick: () -> Unit,

View File

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

View File

@ -1,5 +1,12 @@
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -38,11 +45,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import kotlinx.coroutines.launch
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -61,10 +71,15 @@ fun SignUpScreen(
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(state.success) {
if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
}
LaunchedEffect(state.googleSuccess) {
if (state.googleSuccess) onNavigate(AppRoute.ONBOARDING)
}
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
@ -102,6 +117,10 @@ fun SignUpScreen(
) {
Spacer(Modifier.height(16.dp))
AuthLogoMark(size = 72.dp, radius = 20.dp, elevation = 12.dp)
Spacer(Modifier.height(20.dp))
Text(
text = "Create your account",
style = MaterialTheme.typography.headlineMedium,
@ -183,6 +202,37 @@ fun SignUpScreen(
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))
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {

View File

@ -2,7 +2,10 @@ package app.closer.ui.auth
import androidx.lifecycle.ViewModel
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.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -18,12 +21,16 @@ data class SignUpUiState(
val isPasswordVisible: Boolean = false,
val isLoading: Boolean = false,
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
class SignUpViewModel @Inject constructor(
private val authRepository: AuthRepository
private val authRepository: AuthRepository,
private val userRepository: UserRepository
) : ViewModel() {
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 {
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("network") == true -> "Check your connection and 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.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
@ -32,6 +33,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@ -164,11 +166,25 @@ private fun BucketListContent(
private fun Header(
onBack: () -> Unit
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = 12.dp, bottom = 6.dp),
) {
IconButton(
onClick = onBack,
modifier = Modifier.padding(top = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurface
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@ -192,6 +208,7 @@ private fun Header(
}
}
}
}
@Composable
private fun CategoryFilterChips(

View File

@ -1,5 +1,6 @@
package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.BucketListItem
@ -31,8 +32,9 @@ class BucketListViewModel @Inject constructor(
if (coupleId.isEmpty()) return
viewModelScope.launch {
val items = repository.getItems(coupleId)
_uiState.update { it.copy(items = items) }
runCatching { repository.getItems(coupleId) }
.onSuccess { items -> _uiState.update { it.copy(items = items) } }
.onFailure { Log.w(TAG, "Could not load bucket list items", it) }
}
}
@ -54,10 +56,13 @@ class BucketListViewModel @Inject constructor(
)
viewModelScope.launch {
val itemId = repository.addItem(newItem)
runCatching { repository.addItem(newItem) }
.onSuccess { itemId ->
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) }
}
}
fun toggleComplete(itemId: String) {
@ -66,6 +71,7 @@ class BucketListViewModel @Inject constructor(
if (coupleId.isEmpty()) return
viewModelScope.launch {
runCatching {
if (item.isCompleted) {
repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null))
_uiState.update {
@ -84,6 +90,7 @@ class BucketListViewModel @Inject constructor(
)
}
}
}.onFailure { Log.w(TAG, "Could not toggle bucket list item", it) }
}
}
@ -92,11 +99,14 @@ class BucketListViewModel @Inject constructor(
if (coupleId.isEmpty()) return
viewModelScope.launch {
repository.deleteItem(coupleId, itemId)
runCatching { repository.deleteItem(coupleId, itemId) }
.onSuccess {
_uiState.update {
it.copy(items = it.items.filter { it.id != itemId })
}
}
.onFailure { Log.w(TAG, "Could not delete bucket list item", it) }
}
}
fun setCategoryFilter(category: String?) {
@ -108,6 +118,7 @@ class BucketListViewModel @Inject constructor(
}
private companion object {
const val TAG = "BucketListViewModel"
const val MAX_TITLE_LENGTH = 100
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.shape.RoundedCornerShape
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.ButtonDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DisplayMode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -38,6 +44,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -60,7 +67,22 @@ fun DateBuilderScreen(
viewModel: DateBuilderViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.saved) {
if (state.saved) {
viewModel.consumeSaved()
onNavigate("back")
}
}
LaunchedEffect(state.error) {
state.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
Box(modifier = Modifier.fillMaxSize()) {
DateBuilderContent(
state = state,
onDateChange = viewModel::updateDate,
@ -70,6 +92,13 @@ fun DateBuilderScreen(
onSave = { viewModel.savePreference() },
onBack = { onNavigate("back") }
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
)
}
}
@Composable
@ -126,11 +155,25 @@ private fun DateBuilderContent(
private fun Header(
onBack: () -> Unit
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = 12.dp, bottom = 6.dp),
) {
IconButton(
onClick = onBack,
modifier = Modifier.padding(top = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurface
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@ -151,6 +194,7 @@ private fun Header(
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@ -1,5 +1,6 @@
package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.DatePlanPreference
@ -53,15 +54,27 @@ class DateBuilderViewModel @Inject constructor(
)
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() {
_uiState.update { DateBuilderUiState() }
}
private companion object {
const val TAG = "DateBuilderViewModel"
const val MAX_TIME_LENGTH = 20
const val MAX_DURATION_LENGTH = 50
}
@ -72,5 +85,8 @@ data class DateBuilderUiState(
val scheduledDate: Long = 0L,
val scheduledTime: String = "",
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 showFollowUpDialog by remember { mutableStateOf(false) }
var pendingFollowUpDay by remember { mutableStateOf<OutcomeDay?>(null) }

View File

@ -120,7 +120,6 @@ data class HomeUiState(
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false,
val needsEncryptionUpgrade: Boolean = false,
val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false,
@ -204,12 +203,6 @@ class HomeViewModel @Inject constructor(
}
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
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
val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt
@ -275,7 +268,6 @@ class HomeViewModel @Inject constructor(
coupleId = coupleId,
partnerLeftEvent = false,
needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade,
hasWaitingGame = hasWaitingGame,
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
@ -503,7 +495,7 @@ class HomeViewModel @Inject constructor(
}
val engineInput = PriorityInput(
needsCriticalAction = needsRecovery || needsEncryptionUpgrade,
needsCriticalAction = needsRecovery,
isPaired = isPaired,
needsEncryptionUnlock = needsRecovery,
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
@ -539,13 +531,6 @@ class HomeViewModel @Inject constructor(
cta = "Start recovery",
target = HomeActionTarget.Settings,
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
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()
) {
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
private fun PlayHubContent(
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(
modifier = Modifier
.fillMaxSize()
@ -103,37 +110,37 @@ private fun PlayHubContent(
item {
FeaturedPlayCard(
onClick = { onNavigate(AppRoute.SPIN_WHEEL_RANDOM) }
onClick = { onPlay(AppRoute.SPIN_WHEEL_RANDOM) }
)
}
item {
ThisOrThatCard(
onClick = { onNavigate(AppRoute.THIS_OR_THAT) }
onClick = { onPlay(AppRoute.THIS_OR_THAT) }
)
}
item {
HowWellCard(
onClick = { onNavigate(AppRoute.HOW_WELL) }
onClick = { onPlay(AppRoute.HOW_WELL) }
)
}
item {
DesireSyncCard(
onClick = { onNavigate(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
onClick = { onPlay(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
)
}
item {
ConnectionChallengesCard(
onClick = { onNavigate(AppRoute.CONNECTION_CHALLENGES) }
onClick = { onPlay(AppRoute.CONNECTION_CHALLENGES) }
)
}
item {
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,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.DATE_MATCH) }
onClick = { onPlay(AppRoute.DATE_MATCH) }
)
CompactPlayCard(
title = "Plan Date",
@ -156,7 +163,7 @@ private fun PlayHubContent(
icon = Icons.Filled.Star,
tint = MaterialTheme.colorScheme.tertiary,
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,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.BUCKET_LIST) }
onClick = { onPlay(AppRoute.BUCKET_LIST) }
)
CompactPlayCard(
title = "Past Games",

View File

@ -3,16 +3,34 @@ package app.closer.ui.play
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class PlayHubViewModel @Inject constructor(
entitlementChecker: EntitlementChecker
entitlementChecker: EntitlementChecker,
private val gameSessionManager: GameSessionManager
) : ViewModel() {
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
.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() {
viewModelScope.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) }
.getOrNull() ?: return@launch
val target = if (active.gameType == GameType.WHEEL) {

View File

@ -66,7 +66,12 @@ class SpinWheelViewModel @Inject constructor(
val uid = gameSessionManager.currentUserId
val paired = uid != 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()
if (couple == null) {
_uiState.update { it.copy(error = "Not in a couple") }
_uiState.update { it.copy(navigateTo = AppRoute.CREATE_INVITE) }
return@launch
}

View File

@ -43,6 +43,36 @@ class FieldEncryptorTest {
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 {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
digest(associatedData) + plaintext.reversedArray()

View File

@ -60,16 +60,6 @@ service cloud.firestore {
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).
function isSealedPayload(value) {
@ -130,42 +120,6 @@ service cloud.firestore {
.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() {
return request.resource.data.encryptionVersion >= 1
&& request.resource.data.wrappedCoupleKey is string
@ -301,8 +255,6 @@ service cloud.firestore {
&& (
isUpdatingCoupleRhythm()
|| isUpdatingRecoveryWrap()
|| isStartingEncryptionMigration()
|| isCompletingOwnEncryptionMigration()
);
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
@ -360,7 +312,8 @@ service cloud.firestore {
allow delete: if false;
// 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} {
allow read: if isCouplesMember(coupleId);
allow delete: if false;
@ -368,29 +321,10 @@ service cloud.firestore {
&& isOwner(userId)
&& request.resource.data.userId == request.auth.uid
&& coupleEncryptionEnabled(coupleId)
&& (
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))
);
&& isSealedThreadAnswerCreate(request.resource.data);
allow update: if isCouplesMember(coupleId)
&& isOwner(userId)
&& (
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))
);
&& isSealedThreadAnswerUpdate();
// One-time key release for sealed thread answers (same guards as daily answer release keys).
match /releaseKeys/{recipientId} {
@ -518,17 +452,48 @@ service cloud.firestore {
'completedBy', 'completedAt', 'isCompleted'
])
&& 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)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
])
&& isImmutable(['addedBy', 'addedAt'])
// 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);
}
// 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.
// Writes are server-side only via submitOutcomeCallable; direct client writes denied.
match /outcomes/{dayKey} {
@ -557,40 +522,16 @@ service cloud.firestore {
// whose metadata disagrees with the path it lands in.
&& request.resource.data.answerDate is string
&& request.resource.data.answerDate == date
&& (
// schemaVersion 3: partner-proof sealed answer.
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))
);
// schemaVersion 3: partner-proof sealed answer (the only accepted shape).
&& isSealedAnswerCreate(request.resource.data);
allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId
&& request.resource.data.answerType == resource.data.answerType
&& (
// Sealed answers: only reveal metadata may change; payload is immutable.
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))
);
&& isSealedAnswerUpdate();
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 coupleRef = db.collection('couples').doc(coupleId)
// Derive encryption version from E2EE field presence.
// encryptionVersion must stay in sync with EncryptionVersion.kt:
// 0 = plaintext (no couple key; iOS MVP path)
// 1 = legacy migration (mixed)
// 2 = strict E2EE (all new Android couples)
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state
// where the client expects a key that does not exist.
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null
const encryptionVersion = hasE2EE ? 2 : 0
// Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't,
// the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
// broken plaintext couple the client can't use.
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
throw new functions.https.HttpsError(
'failed-precondition',
'Invite is missing encryption material. Ask your partner to create a new invite.'
)
}
const encryptionVersion = 2
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
* - kdfSalt: base64 KDF salt
* - 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
* the invite code as the KDF input. The server stores it opaquely and never sees the
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the client using the invite
* code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
*
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
* supply these fields. Android always supplies them.
* Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
* all required. There is no plaintext-couple path.
*
* 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 encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
// E2EE fields must be supplied together or omitted together.
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]
const suppliedE2ee = e2eeFields.filter((v) => v != null).length
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) {
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
if (!clientCode) {
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
}
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError(
'invalid-argument',
'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.'
'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.'
)
}