diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 5b62747e..dd80052c 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) { diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index c7b08229..f98d9b84 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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") ) diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index 34ee2c9d..18e656e7 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -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 - } } diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt index 65885891..27581075 100644 --- a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt +++ b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt @@ -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() diff --git a/app/src/main/java/app/closer/crypto/EncryptionVersion.kt b/app/src/main/java/app/closer/crypto/EncryptionVersion.kt index 5cb449ac..19a75177 100644 --- a/app/src/main/java/app/closer/crypto/EncryptionVersion.kt +++ b/app/src/main/java/app/closer/crypto/EncryptionVersion.kt @@ -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 } diff --git a/app/src/main/java/app/closer/crypto/FieldEncryptor.kt b/app/src/main/java/app/closer/crypto/FieldEncryptor.kt index 2ae997ac..b57507df 100644 --- a/app/src/main/java/app/closer/crypto/FieldEncryptor.kt +++ b/app/src/main/java/app/closer/crypto/FieldEncryptor.kt @@ -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" } } diff --git a/app/src/main/java/app/closer/data/remote/CoupleAnswerMigrationDataSource.kt b/app/src/main/java/app/closer/data/remote/CoupleAnswerMigrationDataSource.kt deleted file mode 100644 index 47d832f0..00000000 --- a/app/src/main/java/app/closer/data/remote/CoupleAnswerMigrationDataSource.kt +++ /dev/null @@ -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 { - val updates = mutableMapOf() - snapshot.getString("writtenText")?.let { value -> - if (!fieldEncryptor.isEncrypted(value)) { - updates["writtenText"] = fieldEncryptor.encrypt(value, aead, coupleId) - } - } - - val rawIds = (snapshot.get("selectedOptionIds") as? List<*>)?.filterIsInstance() - 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() ?: 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() ?: 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() - } - } -} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt index 7d333fb0..0cd3c14b 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -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,10 +97,12 @@ class FirestoreAnswerDataSource @Inject constructor( "isRevealed" to answer.isRevealed ) - answerRef(coupleId, date, userId) - .set(data) - .addOnSuccessListener { cont.resume(Unit) } - .addOnFailureListener { cont.resumeWithException(it) } + 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. */ @@ -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,64 +172,20 @@ 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) { - 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 ?: 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 - } - + 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 = "", 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, + schemaVersion = 3, + isSealed = getBoolean("answerKeyReleased") != true, + encryptedPayload = getString("encryptedPayload"), answerDate = getString("answerDate") ?: "" ) } @@ -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, diff --git a/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt index 19b2bc3d..c8a4e7ac 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt @@ -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, diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt index 84f344a7..34c8f7f4 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -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, diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt index c8d04bd4..1d83cc68 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt @@ -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() - ) - ) - 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().orEmpty() - @Suppress("UNCHECKED_CAST") - val existing = snapshot.get("encryptionMigrationUsers") as? Map - ?: emptyMap() - val completed = existing + (userId to true) - val allComplete = userIds.isNotEmpty() && userIds.all { completed[it] == true } - val updates = mutableMapOf("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) - ?: emptyMap() + kdfParams = getString("kdfParams") ) private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) { diff --git a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt index 8c4a589d..88fdfeaf 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt @@ -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) - } + submitAnswerSealed(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,57 +251,17 @@ 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) { - 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) ?: 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 - } - + // 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") ?: "", answerType = getString("answerType") ?: "written", - writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId), - selectedOptionIds = selectedOptionIds, - scaleValue = scaleValue, - schemaVersion = schemaVersion, + schemaVersion = 3, + isSealed = getBoolean("answerKeyReleased") != true, + encryptedPayload = getString("encryptedPayload"), createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L, updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L ) @@ -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)) + } } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt index b7dfae5f..8cbfcd69 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt @@ -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() } } diff --git a/app/src/main/java/app/closer/domain/model/Couple.kt b/app/src/main/java/app/closer/domain/model/Couple.kt index e21ed2e7..0440e6e9 100644 --- a/app/src/main/java/app/closer/domain/model/Couple.kt +++ b/app/src/main/java/app/closer/domain/model/Couple.kt @@ -1,5 +1,7 @@ package app.closer.domain.model +import app.closer.crypto.EncryptionVersion + data class Couple( val id: String = "", val userIds: List = 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 = emptyMap() + val kdfParams: String? = null ) diff --git a/app/src/main/java/app/closer/ui/auth/AuthVisuals.kt b/app/src/main/java/app/closer/ui/auth/AuthVisuals.kt index b4173f8b..4e9b9a2a 100644 --- a/app/src/main/java/app/closer/ui/auth/AuthVisuals.kt +++ b/app/src/main/java/app/closer/ui/auth/AuthVisuals.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt index 01631300..5c6d9564 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt @@ -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", diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index 4118250f..1c3f266f 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -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) }) { diff --git a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt index 2ad4d2f0..8b439d39 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt @@ -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." } } diff --git a/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt b/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt index e4f2cf24..6ac2456e 100644 --- a/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt @@ -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,32 +166,47 @@ private fun BucketListContent( private fun Header( onBack: () -> Unit ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(top = 12.dp, bottom = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.weight(1f) + IconButton( + onClick = onBack, + modifier = Modifier.padding(top = 4.dp) ) { - 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 + 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 + ) { + 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 + ) + } + } } } diff --git a/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt b/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt index 79d7cd42..6002355e 100644 --- a/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt @@ -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,9 +56,12 @@ class BucketListViewModel @Inject constructor( ) viewModelScope.launch { - val itemId = repository.addItem(newItem) - val updatedItems = _uiState.value.items + newItem.copy(id = itemId) - _uiState.update { it.copy(items = updatedItems) } + 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) } } } @@ -66,24 +71,26 @@ class BucketListViewModel @Inject constructor( if (coupleId.isEmpty()) return viewModelScope.launch { - if (item.isCompleted) { - repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null)) - _uiState.update { - it.copy( - items = it.items.map { if (it.id == itemId) it.copy(isCompleted = false) else it } - ) + runCatching { + if (item.isCompleted) { + repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null)) + _uiState.update { + 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 { - 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 - } - ) - } - } + }.onFailure { Log.w(TAG, "Could not toggle bucket list item", it) } } } @@ -92,10 +99,13 @@ class BucketListViewModel @Inject constructor( if (coupleId.isEmpty()) return viewModelScope.launch { - repository.deleteItem(coupleId, itemId) - _uiState.update { - it.copy(items = it.items.filter { it.id != 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) } } } @@ -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 } diff --git a/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt b/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt index c2ab2f22..5e1fed63 100644 --- a/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt @@ -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,16 +67,38 @@ fun DateBuilderScreen( viewModel: DateBuilderViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } - DateBuilderContent( - state = state, - onDateChange = viewModel::updateDate, - onTimeChange = viewModel::updateTime, - onBudgetChange = viewModel::updateBudget, - onDurationChange = viewModel::updateDuration, - onSave = { viewModel.savePreference() }, - onBack = { onNavigate("back") } - ) + 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, + 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 @@ -126,29 +155,44 @@ private fun DateBuilderContent( private fun Header( onBack: () -> Unit ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(top = 12.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 - ) + 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 + ) { + 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 + ) + } } } diff --git a/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt index 19d5cce5..235988ae 100644 --- a/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt @@ -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 ) diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 43528d35..b017fbb7 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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(null) } diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 038b59de..595ed537 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -120,7 +120,6 @@ data class HomeUiState( val secondaryActions: List = 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( diff --git a/app/src/main/java/app/closer/ui/pairing/EncryptionUpgradeScreen.kt b/app/src/main/java/app/closer/ui/pairing/EncryptionUpgradeScreen.kt deleted file mode 100644 index 652be346..00000000 --- a/app/src/main/java/app/closer/ui/pairing/EncryptionUpgradeScreen.kt +++ /dev/null @@ -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 = _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") } - } - } - } -} diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index bca3caf4..467ed74d 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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", diff --git a/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt b/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt index 95a30071..9aee24d5 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt @@ -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 = 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 = _isPaired.asStateFlow() + + init { + viewModelScope.launch { + val uid = gameSessionManager.currentUserId + val paired = uid != null && + runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null + _isPaired.value = paired + } + } } diff --git a/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt b/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt index 04d639c4..5ce99600 100644 --- a/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt @@ -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) { diff --git a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt index 55cc4e2d..6cc11eee 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt @@ -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 } diff --git a/app/src/test/java/app/closer/crypto/FieldEncryptorTest.kt b/app/src/test/java/app/closer/crypto/FieldEncryptorTest.kt index 9c0836ab..fdaa0518 100644 --- a/app/src/test/java/app/closer/crypto/FieldEncryptorTest.kt +++ b/app/src/test/java/app/closer/crypto/FieldEncryptorTest.kt @@ -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() diff --git a/firestore.rules b/firestore.rules index c03bba1c..7ac1fcc4 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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)) - ); + // Sealed answers: only reveal metadata may change; payload is immutable. + && isSealedAnswerUpdate(); allow delete: if false; diff --git a/functions/src/couples/acceptInviteCallable.ts b/functions/src/couples/acceptInviteCallable.ts index c7a2e9e0..600b9e33 100644 --- a/functions/src/couples/acceptInviteCallable.ts +++ b/functions/src/couples/acceptInviteCallable.ts @@ -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() diff --git a/functions/src/couples/createInviteCallable.ts b/functions/src/couples/createInviteCallable.ts index 809b0455..8d2631e6 100644 --- a/functions/src/couples/createInviteCallable.ts +++ b/functions/src/couples/createInviteCallable.ts @@ -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.' ) }