refactor(e2ee): remove v0/v1 migration paths, fail-closed decrypt, strict-only rules
This commit is contained in:
parent
17c7ed60b9
commit
039752d691
|
|
@ -45,7 +45,6 @@ import app.closer.ui.pairing.InviteConfirmScreen
|
||||||
import app.closer.ui.pairing.PairPromptScreen
|
import app.closer.ui.pairing.PairPromptScreen
|
||||||
import app.closer.ui.pairing.PairingSuccessScreen
|
import app.closer.ui.pairing.PairingSuccessScreen
|
||||||
import app.closer.ui.pairing.RecoveryScreen
|
import app.closer.ui.pairing.RecoveryScreen
|
||||||
import app.closer.ui.pairing.EncryptionUpgradeScreen
|
|
||||||
import app.closer.ui.dates.DateMatchScreen
|
import app.closer.ui.dates.DateMatchScreen
|
||||||
import app.closer.ui.dates.DateMatchesScreen
|
import app.closer.ui.dates.DateMatchesScreen
|
||||||
import app.closer.ui.dates.DateBuilderScreen
|
import app.closer.ui.dates.DateBuilderScreen
|
||||||
|
|
@ -318,20 +317,6 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.ENCRYPTION_UPGRADE) {
|
|
||||||
EncryptionUpgradeScreen(
|
|
||||||
onComplete = {
|
|
||||||
navController.navigate(AppRoute.HOME) {
|
|
||||||
popUpTo(AppRoute.ENCRYPTION_UPGRADE) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRecoveryNeeded = {
|
|
||||||
navController.navigate(AppRoute.RECOVERY) {
|
|
||||||
popUpTo(AppRoute.ENCRYPTION_UPGRADE) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel / Category Selection
|
// Wheel / Category Selection
|
||||||
composable(route = AppRoute.CATEGORY_PICKER) {
|
composable(route = AppRoute.CATEGORY_PICKER) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ object AppRoute {
|
||||||
const val MEMORY_LANE_CAPSULE = "memory_lane_capsule/{capsuleId}"
|
const val MEMORY_LANE_CAPSULE = "memory_lane_capsule/{capsuleId}"
|
||||||
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
||||||
const val RECOVERY = "recovery"
|
const val RECOVERY = "recovery"
|
||||||
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
|
|
||||||
const val YOUR_PROGRESS = "your_progress"
|
const val YOUR_PROGRESS = "your_progress"
|
||||||
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
||||||
|
|
||||||
|
|
@ -122,7 +121,6 @@ object AppRoute {
|
||||||
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
|
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
|
||||||
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
|
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
|
||||||
Definition(RECOVERY, "Unlock Answers", "security"),
|
Definition(RECOVERY, "Unlock Answers", "security"),
|
||||||
Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security"),
|
|
||||||
Definition(YOUR_PROGRESS, "Your Progress", "settings")
|
Definition(YOUR_PROGRESS, "Your Progress", "settings")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package app.closer.crypto
|
package app.closer.crypto
|
||||||
|
|
||||||
import app.closer.crypto.EncryptionVersion.STRICT
|
|
||||||
import app.closer.domain.model.Couple
|
import app.closer.domain.model.Couple
|
||||||
import com.google.crypto.tink.Aead
|
import com.google.crypto.tink.Aead
|
||||||
import com.google.crypto.tink.KeysetHandle
|
import com.google.crypto.tink.KeysetHandle
|
||||||
|
|
@ -12,14 +11,10 @@ import javax.inject.Singleton
|
||||||
enum class EncryptionStatus {
|
enum class EncryptionStatus {
|
||||||
/** Local keyset present -- ready to encrypt/decrypt. */
|
/** Local keyset present -- ready to encrypt/decrypt. */
|
||||||
UNLOCKED,
|
UNLOCKED,
|
||||||
/** Found keyset in the invite slot; moved to coupleId slot automatically. */
|
/** Found keyset in the invite slot; moved to coupleId slot automatically (inviter's first load). */
|
||||||
RECONCILED_FROM_INVITE,
|
RECONCILED_FROM_INVITE,
|
||||||
/** encryptionVersion == 1 but no local keyset -- prompt for recovery phrase. */
|
/** No local keyset on this device -- prompt for the recovery phrase. */
|
||||||
NEEDS_RECOVERY,
|
NEEDS_RECOVERY
|
||||||
/** encryptionVersion == 0 -- this couple must create a key before writing more answers. */
|
|
||||||
NEEDS_ENCRYPTION_UPGRADE,
|
|
||||||
/** encryptionVersion == 1 with a local key -- this device must rewrite its legacy answers. */
|
|
||||||
NEEDS_CONTENT_MIGRATION
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MissingCoupleKeyException(coupleId: String) :
|
class MissingCoupleKeyException(coupleId: String) :
|
||||||
|
|
@ -80,30 +75,15 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
|
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called on app launch / Home load after the couple doc is resolved.
|
* Called on app launch / Home load after the couple doc is resolved. Every couple is
|
||||||
* Handles inviter reconciliation (flow B') transparently.
|
* strict-E2EE, so the only question is whether this device holds the couple key:
|
||||||
|
* present -> UNLOCKED; recoverable from the inviter's invite slot -> RECONCILED_FROM_INVITE;
|
||||||
|
* otherwise -> NEEDS_RECOVERY (prompt for the recovery phrase).
|
||||||
*/
|
*/
|
||||||
fun checkStatus(couple: Couple): EncryptionStatus {
|
fun checkStatus(couple: Couple): EncryptionStatus {
|
||||||
// v2 couples were created by Android with a strict couple key.
|
if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
|
||||||
// v1 couples are mid-migration; v0 couples are plaintext (iOS MVP).
|
|
||||||
when (couple.encryptionVersion) {
|
|
||||||
EncryptionVersion.PLAINTEXT -> return EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE
|
|
||||||
EncryptionVersion.MIGRATING -> { /* fall through to keyset checks below */ }
|
|
||||||
EncryptionVersion.STRICT -> { /* fall through to keyset checks below */ }
|
|
||||||
}
|
|
||||||
if (keyStore.hasKeyset(couple.id)) {
|
|
||||||
return if (couple.encryptionVersion >= STRICT) {
|
|
||||||
EncryptionStatus.UNLOCKED
|
|
||||||
} else {
|
|
||||||
EncryptionStatus.NEEDS_CONTENT_MIGRATION
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
|
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
|
||||||
return if (couple.encryptionVersion >= STRICT) {
|
return EncryptionStatus.RECONCILED_FROM_INVITE
|
||||||
EncryptionStatus.RECONCILED_FROM_INVITE
|
|
||||||
} else {
|
|
||||||
EncryptionStatus.NEEDS_CONTENT_MIGRATION
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return EncryptionStatus.NEEDS_RECOVERY
|
return EncryptionStatus.NEEDS_RECOVERY
|
||||||
}
|
}
|
||||||
|
|
@ -133,24 +113,4 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
|
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
|
||||||
|
|
||||||
suspend fun setupLegacyCouple(coupleId: String): SetupResult = withContext(Dispatchers.Default) {
|
|
||||||
val phrase = keyManager.generateRecoveryPhrase()
|
|
||||||
val handle = keyManager.newCoupleKeyset()
|
|
||||||
val wrapped = keyManager.wrap(handle, phrase)
|
|
||||||
keyStore.storeKeyset(coupleId, handle)
|
|
||||||
keyStore.storePendingRecoveryPhrase(coupleId, phrase)
|
|
||||||
SetupResult(handle, wrapped, phrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pendingRecoveryPhrase(coupleId: String): String? =
|
|
||||||
keyStore.pendingRecoveryPhrase(coupleId)
|
|
||||||
|
|
||||||
fun acknowledgeRecoveryPhrase(coupleId: String) =
|
|
||||||
keyStore.clearPendingRecoveryPhrase(coupleId)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Kept for backwards compatibility; prefer [EncryptionVersion.STRICT]. */
|
|
||||||
const val STRICT_ENCRYPTION_VERSION = EncryptionVersion.STRICT
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,23 +79,11 @@ class CoupleKeyStore @Inject constructor(
|
||||||
fun deleteKeyset(coupleId: String) {
|
fun deleteKeyset(coupleId: String) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove(prefKey(coupleId))
|
.remove(prefKey(coupleId))
|
||||||
.remove(pendingPhraseKey(coupleId))
|
|
||||||
.remove(recoveryPhraseKey(coupleId))
|
.remove(recoveryPhraseKey(coupleId))
|
||||||
.apply()
|
.apply()
|
||||||
aeadCache.remove(coupleId)
|
aeadCache.remove(coupleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun storePendingRecoveryPhrase(coupleId: String, phrase: String) {
|
|
||||||
prefs.edit().putString(pendingPhraseKey(coupleId), phrase).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pendingRecoveryPhrase(coupleId: String): String? =
|
|
||||||
prefs.getString(pendingPhraseKey(coupleId), null)
|
|
||||||
|
|
||||||
fun clearPendingRecoveryPhrase(coupleId: String) {
|
|
||||||
prefs.edit().remove(pendingPhraseKey(coupleId)).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun aeadFor(coupleId: String): Aead? {
|
fun aeadFor(coupleId: String): Aead? {
|
||||||
aeadCache[coupleId]?.let { return it }
|
aeadCache[coupleId]?.let { return it }
|
||||||
val handle = loadKeyset(coupleId) ?: return null
|
val handle = loadKeyset(coupleId) ?: return null
|
||||||
|
|
@ -108,7 +96,6 @@ class CoupleKeyStore @Inject constructor(
|
||||||
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
||||||
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
|
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
|
||||||
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
|
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
|
||||||
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
|
|
||||||
|
|
||||||
private fun serialize(handle: KeysetHandle): String {
|
private fun serialize(handle: KeysetHandle): String {
|
||||||
val baos = ByteArrayOutputStream()
|
val baos = ByteArrayOutputStream()
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,16 @@
|
||||||
package app.closer.crypto
|
package app.closer.crypto
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for couple encryption versions shared by Android,
|
* Couple encryption version stamp. The app is strict-E2EE only: every couple is
|
||||||
* iOS, and Cloud Functions.
|
* created with a wrapped couple key and all answer-bearing paths require ciphertext.
|
||||||
*
|
*
|
||||||
* v0 = legacy plaintext (no couple key, all answer paths write plaintext).
|
* The constant is kept as a forward-compatibility marker written on couple creation
|
||||||
* Used by the iOS MVP because E2EE is skipped for the initial port.
|
* (Android client + acceptInviteCallable). There are no v0 (plaintext) or v1
|
||||||
* v1 = legacy Tink key migration-in-progress (mixed plaintext + encrypted).
|
* (migration) couples.
|
||||||
* Kept for backwards compatibility with older couples; no new couples
|
|
||||||
* should be created at v1.
|
|
||||||
* v2 = strict E2EE (all answer-bearing paths require a couple key and
|
|
||||||
* ciphertext). This is the default for all new Android couples.
|
|
||||||
*
|
|
||||||
* IMPORTANT: keep this mapping in sync with:
|
|
||||||
* - functions/src/couples/acceptInviteCallable.ts
|
|
||||||
* - iphone/ARCHITECTURE_AUDIT.md (E2EE section)
|
|
||||||
* - iphone/Closer/Services/FirestoreService.swift (couple creation TODOs)
|
|
||||||
*/
|
*/
|
||||||
object EncryptionVersion {
|
object EncryptionVersion {
|
||||||
const val PLAINTEXT = 0
|
|
||||||
const val MIGRATING = 1
|
|
||||||
const val STRICT = 2
|
const val STRICT = 2
|
||||||
|
|
||||||
/** Version used when creating a new couple from the Android client. */
|
/** Version used when creating a new couple. */
|
||||||
const val NEW_COUPLE_DEFAULT = STRICT
|
const val NEW_COUPLE_DEFAULT = STRICT
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ import javax.inject.Singleton
|
||||||
* Stateless helper that encrypts/decrypts individual Firestore field values.
|
* Stateless helper that encrypts/decrypts individual Firestore field values.
|
||||||
*
|
*
|
||||||
* Wire format: "enc:v1:{base64(tinkCiphertext)}"
|
* Wire format: "enc:v1:{base64(tinkCiphertext)}"
|
||||||
* Plaintext values (no prefix) pass through unchanged so legacy data works.
|
*
|
||||||
|
* Fail-closed: every couple is strict-E2EE, so all stored content is encrypted.
|
||||||
|
* A value WITHOUT the prefix is not treated as trusted plaintext — [decrypt]
|
||||||
|
* returns null for it. There is no plaintext fallback. Writers must always go
|
||||||
|
* through [encrypt] (callers use requireAead so a missing key throws rather than
|
||||||
|
* writing plaintext).
|
||||||
*
|
*
|
||||||
* AAD = coupleId bytes -- binds ciphertext to the couple and prevents
|
* AAD = coupleId bytes -- binds ciphertext to the couple and prevents
|
||||||
* copy-paste of one couple's ciphertext into another couple's document.
|
* copy-paste of one couple's ciphertext into another couple's document.
|
||||||
|
|
@ -34,7 +39,9 @@ class FieldEncryptor @Inject constructor() {
|
||||||
*/
|
*/
|
||||||
fun decrypt(value: String?, aead: Aead?, coupleId: String): String? {
|
fun decrypt(value: String?, aead: Aead?, coupleId: String): String? {
|
||||||
if (value == null) return null
|
if (value == null) return null
|
||||||
if (!value.startsWith(PREFIX)) return value
|
// Fail-closed: a value without the enc:v1: prefix is not trusted plaintext.
|
||||||
|
// All content is encrypted, so an unprefixed value is unexpected and rejected.
|
||||||
|
if (!value.startsWith(PREFIX)) return null
|
||||||
if (aead == null) return null
|
if (aead == null) return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val cipher = Base64.getDecoder().decode(value.removePrefix(PREFIX))
|
val cipher = Base64.getDecoder().decode(value.removePrefix(PREFIX))
|
||||||
|
|
@ -45,7 +52,26 @@ class FieldEncryptor @Inject constructor() {
|
||||||
|
|
||||||
fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true
|
fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-safe decrypt. Returns:
|
||||||
|
* - the decrypted text for `enc:v1:` values,
|
||||||
|
* - the value unchanged for legacy plaintext,
|
||||||
|
* - [LOCKED_PLACEHOLDER] when the value IS encrypted but cannot be decrypted on
|
||||||
|
* this device (missing/wrong couple key) — never the raw ciphertext.
|
||||||
|
*
|
||||||
|
* Use this anywhere a decrypted value is shown to the user. [decrypt] (which returns
|
||||||
|
* null on failure) is reserved for structured fields that are parsed, not displayed.
|
||||||
|
*/
|
||||||
|
fun decryptForDisplay(value: String?, aead: Aead?, coupleId: String): String? {
|
||||||
|
if (value == null) return null
|
||||||
|
// decrypt() returns null only when the value is encrypted but unreadable here.
|
||||||
|
return decrypt(value, aead, coupleId) ?: LOCKED_PLACEHOLDER
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PREFIX = "enc:v1:"
|
const val PREFIX = "enc:v1:"
|
||||||
|
|
||||||
|
/** Shown in place of content that is encrypted but cannot be decrypted on this device. */
|
||||||
|
const val LOCKED_PLACEHOLDER = "🔒 Couldn't unlock on this device"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
package app.closer.data.remote
|
|
||||||
|
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
|
||||||
import app.closer.crypto.FieldEncryptor
|
|
||||||
import com.google.crypto.tink.Aead
|
|
||||||
import com.google.firebase.firestore.DocumentReference
|
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
|
||||||
import com.google.firebase.firestore.FieldPath
|
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
|
||||||
import kotlinx.coroutines.tasks.await
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/** One-time, per-user rewrite of every historical answer-bearing field to ciphertext. */
|
|
||||||
@Singleton
|
|
||||||
class CoupleAnswerMigrationDataSource @Inject constructor(
|
|
||||||
private val db: FirebaseFirestore,
|
|
||||||
private val encryptionManager: CoupleEncryptionManager,
|
|
||||||
private val fieldEncryptor: FieldEncryptor
|
|
||||||
) {
|
|
||||||
suspend fun migrateUser(coupleId: String, userId: String) {
|
|
||||||
val aead = encryptionManager.requireAead(coupleId)
|
|
||||||
migrateDailyAnswers(coupleId, userId, aead)
|
|
||||||
migrateThreadContent(coupleId, userId, aead)
|
|
||||||
migrateThisOrThat(coupleId, userId, aead)
|
|
||||||
migrateDesireSync(coupleId, userId, aead)
|
|
||||||
migrateHowWell(coupleId, userId, aead)
|
|
||||||
migrateWheel(coupleId, userId, aead)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun coupleRef(coupleId: String) =
|
|
||||||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
|
||||||
|
|
||||||
private suspend fun migrateDailyAnswers(coupleId: String, userId: String, aead: Aead) {
|
|
||||||
val days = coupleRef(coupleId)
|
|
||||||
.collection(FirestoreCollections.Couples.DAILY_QUESTION)
|
|
||||||
.get().await()
|
|
||||||
for (day in days.documents) {
|
|
||||||
val ref = day.reference
|
|
||||||
.collection(FirestoreCollections.DailyQuestion.ANSWERS)
|
|
||||||
.document(userId)
|
|
||||||
migrateAnswerDocument(ref, coupleId, aead)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateThreadContent(coupleId: String, userId: String, aead: Aead) {
|
|
||||||
val threads = coupleRef(coupleId)
|
|
||||||
.collection(FirestoreCollections.Couples.QUESTION_THREADS)
|
|
||||||
.get().await()
|
|
||||||
for (thread in threads.documents) {
|
|
||||||
migrateAnswerDocument(
|
|
||||||
thread.reference.collection(FirestoreCollections.QuestionThreads.ANSWERS).document(userId),
|
|
||||||
coupleId,
|
|
||||||
aead
|
|
||||||
)
|
|
||||||
val messages = thread.reference
|
|
||||||
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
|
|
||||||
.whereEqualTo("authorUserId", userId)
|
|
||||||
.get().await()
|
|
||||||
for (message in messages.documents) {
|
|
||||||
val text = message.getString("text") ?: continue
|
|
||||||
if (!fieldEncryptor.isEncrypted(text)) {
|
|
||||||
message.reference.update(
|
|
||||||
"text",
|
|
||||||
fieldEncryptor.encrypt(text, aead, coupleId)
|
|
||||||
).await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateAnswerDocument(ref: DocumentReference, coupleId: String, aead: Aead) {
|
|
||||||
val snapshot = ref.get().await()
|
|
||||||
if (!snapshot.exists()) return
|
|
||||||
val updates = encryptedAnswerUpdates(snapshot, coupleId, aead)
|
|
||||||
if (updates.isNotEmpty()) ref.update(updates).await()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun encryptedAnswerUpdates(
|
|
||||||
snapshot: DocumentSnapshot,
|
|
||||||
coupleId: String,
|
|
||||||
aead: Aead
|
|
||||||
): Map<String, Any> {
|
|
||||||
val updates = mutableMapOf<String, Any>()
|
|
||||||
snapshot.getString("writtenText")?.let { value ->
|
|
||||||
if (!fieldEncryptor.isEncrypted(value)) {
|
|
||||||
updates["writtenText"] = fieldEncryptor.encrypt(value, aead, coupleId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val rawIds = (snapshot.get("selectedOptionIds") as? List<*>)?.filterIsInstance<String>()
|
|
||||||
if (!rawIds.isNullOrEmpty() && !(rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0]))) {
|
|
||||||
updates["selectedOptionIds"] = listOf(
|
|
||||||
fieldEncryptor.encrypt(JSONArray(rawIds).toString(), aead, coupleId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot.get("scaleValue")?.let { value ->
|
|
||||||
if (value !is String || !fieldEncryptor.isEncrypted(value)) {
|
|
||||||
updates["scaleValue"] = fieldEncryptor.encrypt(value.toString(), aead, coupleId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updates
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateThisOrThat(coupleId: String, userId: String, aead: Aead) {
|
|
||||||
migrateUserAnswerCollection(
|
|
||||||
coupleId,
|
|
||||||
userId,
|
|
||||||
FirestoreCollections.Couples.THIS_OR_THAT
|
|
||||||
) { value ->
|
|
||||||
val answers = (value as? List<*>)?.filterIsInstance<String>() ?: return@migrateUserAnswerCollection null
|
|
||||||
fieldEncryptor.encrypt(JSONArray(answers).toString(), aead, coupleId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateDesireSync(coupleId: String, userId: String, aead: Aead) {
|
|
||||||
migrateUserAnswerCollection(
|
|
||||||
coupleId,
|
|
||||||
userId,
|
|
||||||
FirestoreCollections.Couples.DESIRE_SYNC
|
|
||||||
) { value ->
|
|
||||||
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@migrateUserAnswerCollection null
|
|
||||||
if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) list[0]
|
|
||||||
else fieldEncryptor.encrypt(JSONArray(list).toString(), aead, coupleId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateHowWell(coupleId: String, userId: String, aead: Aead) {
|
|
||||||
migrateUserAnswerCollection(
|
|
||||||
coupleId,
|
|
||||||
userId,
|
|
||||||
FirestoreCollections.Couples.HOW_WELL
|
|
||||||
) { value ->
|
|
||||||
val list = value as? List<*> ?: return@migrateUserAnswerCollection null
|
|
||||||
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
|
|
||||||
list[0] as String
|
|
||||||
} else {
|
|
||||||
fieldEncryptor.encrypt(JSONArray(list).toString(), aead, coupleId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateWheel(coupleId: String, userId: String, aead: Aead) {
|
|
||||||
migrateUserAnswerCollection(
|
|
||||||
coupleId,
|
|
||||||
userId,
|
|
||||||
FirestoreCollections.Couples.WHEEL
|
|
||||||
) { value ->
|
|
||||||
val list = value as? List<*> ?: return@migrateUserAnswerCollection null
|
|
||||||
val normalized = list.mapNotNull { item ->
|
|
||||||
val map = item as? Map<*, *> ?: return@mapNotNull null
|
|
||||||
val rawDisplay = map["display"] as? String ?: ""
|
|
||||||
val display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
|
|
||||||
JSONObject().apply {
|
|
||||||
put("questionId", map["questionId"] as? String ?: "")
|
|
||||||
put("display", display)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fieldEncryptor.encrypt(JSONArray(normalized).toString(), aead, coupleId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun migrateUserAnswerCollection(
|
|
||||||
coupleId: String,
|
|
||||||
userId: String,
|
|
||||||
collection: String,
|
|
||||||
encryptLegacyValue: (Any?) -> String?
|
|
||||||
) {
|
|
||||||
val documents = coupleRef(coupleId).collection(collection).get().await()
|
|
||||||
for (document in documents.documents) {
|
|
||||||
val value = (document.get("answers") as? Map<*, *>)?.get(userId) ?: continue
|
|
||||||
if (value is String && fieldEncryptor.isEncrypted(value)) continue
|
|
||||||
val encrypted = encryptLegacyValue(value) ?: continue
|
|
||||||
document.reference.update(FieldPath.of("answers", userId), encrypted).await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -34,6 +34,7 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
private val encryptionManager: CoupleEncryptionManager,
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
private val fieldEncryptor: FieldEncryptor,
|
private val fieldEncryptor: FieldEncryptor,
|
||||||
private val userKeyManager: UserKeyManager,
|
private val userKeyManager: UserKeyManager,
|
||||||
|
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
|
||||||
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
||||||
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||||
private val answerCommitment: AnswerCommitment
|
private val answerCommitment: AnswerCommitment
|
||||||
|
|
@ -66,7 +67,8 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
date: String
|
date: String
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
) {
|
||||||
|
ensureUserPublicKeyPublished(userId)
|
||||||
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
|
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
|
||||||
val payload = SealedAnswerEncryptor.AnswerPayload(
|
val payload = SealedAnswerEncryptor.AnswerPayload(
|
||||||
writtenText = answer.writtenText,
|
writtenText = answer.writtenText,
|
||||||
|
|
@ -95,10 +97,12 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
"isRevealed" to answer.isRevealed
|
"isRevealed" to answer.isRevealed
|
||||||
)
|
)
|
||||||
|
|
||||||
answerRef(coupleId, date, userId)
|
suspendCancellableCoroutine { cont ->
|
||||||
.set(data)
|
answerRef(coupleId, date, userId)
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
.set(data)
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
|
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
|
||||||
|
|
@ -125,8 +129,7 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
cont.resume(null)
|
cont.resume(null)
|
||||||
return@addOnSuccessListener
|
return@addOnSuccessListener
|
||||||
}
|
}
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
cont.resume(snap.toLocalAnswer())
|
||||||
cont.resume(snap.toLocalAnswer(aead, coupleId))
|
|
||||||
}
|
}
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
@ -169,64 +172,20 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(
|
// All answers are sealed (schemaVersion 3): content lives in encryptedPayload and is
|
||||||
aead: com.google.crypto.tink.Aead?,
|
// decrypted later by SealedRevealManager. Nothing is ever stored in plaintext.
|
||||||
coupleId: String
|
|
||||||
): LocalAnswer {
|
|
||||||
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
|
|
||||||
|
|
||||||
// schemaVersion 3: sealed:v1: — content is in encryptedPayload, not top-level fields.
|
|
||||||
// The calling code (reveal flow) is responsible for decrypting via SealedRevealManager.
|
|
||||||
if (schemaVersion == 3) {
|
|
||||||
return LocalAnswer(
|
|
||||||
questionId = getString("questionId") ?: "",
|
|
||||||
questionText = "",
|
|
||||||
category = "",
|
|
||||||
answerType = getString("answerType") ?: "written",
|
|
||||||
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
|
||||||
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
|
||||||
isRevealed = getBoolean("isRevealed") ?: false,
|
|
||||||
schemaVersion = 3,
|
|
||||||
isSealed = getBoolean("answerKeyReleased") != true,
|
|
||||||
encryptedPayload = getString("encryptedPayload"),
|
|
||||||
answerDate = getString("answerDate") ?: ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
|
|
||||||
val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList()
|
|
||||||
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
|
||||||
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
|
||||||
if (decrypted != null) runCatching {
|
|
||||||
val arr = org.json.JSONArray(decrypted)
|
|
||||||
(0 until arr.length()).map { arr.getString(it) }
|
|
||||||
}.getOrDefault(emptyList()) else emptyList()
|
|
||||||
} else rawIds
|
|
||||||
|
|
||||||
val rawScale = get("scaleValue")
|
|
||||||
val scaleValue: Int? = when {
|
|
||||||
rawScale == null -> null
|
|
||||||
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
|
|
||||||
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
|
|
||||||
rawScale is Long -> rawScale.toInt()
|
|
||||||
rawScale is Int -> rawScale
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
return LocalAnswer(
|
return LocalAnswer(
|
||||||
questionId = getString("questionId") ?: "",
|
questionId = getString("questionId") ?: "",
|
||||||
questionText = "",
|
questionText = "",
|
||||||
category = "",
|
category = "",
|
||||||
answerType = getString("answerType") ?: "written",
|
answerType = getString("answerType") ?: "written",
|
||||||
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
|
|
||||||
selectedOptionIds = selectedOptionIds,
|
|
||||||
selectedOptionTexts = emptyList(),
|
|
||||||
scaleValue = scaleValue,
|
|
||||||
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
||||||
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
||||||
isRevealed = getBoolean("isRevealed") ?: false,
|
isRevealed = getBoolean("isRevealed") ?: false,
|
||||||
schemaVersion = schemaVersion,
|
schemaVersion = 3,
|
||||||
|
isSealed = getBoolean("answerKeyReleased") != true,
|
||||||
|
encryptedPayload = getString("encryptedPayload"),
|
||||||
answerDate = getString("answerDate") ?: ""
|
answerDate = getString("answerDate") ?: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -244,13 +203,15 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
modeTag: String?,
|
modeTag: String?,
|
||||||
date: String
|
date: String
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val doc = mapOf(
|
val doc = mapOf(
|
||||||
"questionId" to questionId,
|
"questionId" to questionId,
|
||||||
"questionText" to questionText,
|
"questionText" to questionText,
|
||||||
"ownAnswer" to ownAnswer,
|
"ownAnswer" to fieldEncryptor.encrypt(ownAnswer, aead, coupleId),
|
||||||
"partnerAnswer" to partnerAnswer,
|
"partnerAnswer" to fieldEncryptor.encryptNullable(partnerAnswer, aead, coupleId),
|
||||||
"modeTag" to modeTag,
|
"modeTag" to modeTag,
|
||||||
"date" to date,
|
"date" to date,
|
||||||
|
"schemaVersion" to 2,
|
||||||
"savedAt" to com.google.firebase.Timestamp.now()
|
"savedAt" to com.google.firebase.Timestamp.now()
|
||||||
)
|
)
|
||||||
db.collection(FirestoreCollections.COUPLES)
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -262,6 +223,12 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureUserPublicKeyPublished(userId: String) {
|
||||||
|
if (deviceKeyDataSource.getPublicKey(userId) != null) return
|
||||||
|
val privateKey = userKeyManager.getOrCreatePrivateKey()
|
||||||
|
deviceKeyDataSource.publishPublicKey(userId, userKeyManager.publicKeyB64(privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
data class DailyQuestionAssignment(
|
data class DailyQuestionAssignment(
|
||||||
val questionId: String,
|
val questionId: String,
|
||||||
val date: String,
|
val date: String,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
import app.closer.domain.model.BucketListItem
|
import app.closer.domain.model.BucketListItem
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
|
|
@ -22,7 +24,11 @@ import kotlin.coroutines.resumeWithException
|
||||||
* Categories: Adventure, Travel, Food, Learning, Romance, Intimacy, Seasonal.
|
* Categories: Adventure, Travel, Food, Learning, Romance, Intimacy, Seasonal.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreBucketListDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
class FirestoreBucketListDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
|
) {
|
||||||
private fun itemsRef(coupleId: String) =
|
private fun itemsRef(coupleId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
.collection(FirestoreCollections.Couples.BUCKET_LIST)
|
.collection(FirestoreCollections.Couples.BUCKET_LIST)
|
||||||
|
|
@ -30,10 +36,13 @@ class FirestoreBucketListDataSource @Inject constructor(private val db: Firebase
|
||||||
// ─── CRUD methods ────────────────────────────────────────────────────────
|
// ─── CRUD methods ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun addItem(coupleId: String, item: BucketListItem): String {
|
suspend fun addItem(coupleId: String, item: BucketListItem): String {
|
||||||
|
// Strict E2EE: title/description are user content and must never be stored in plaintext.
|
||||||
|
// requireAead throws if the couple key is missing rather than falling back to plaintext.
|
||||||
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val doc = itemsRef(coupleId).document()
|
val doc = itemsRef(coupleId).document()
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"title" to item.title,
|
"title" to fieldEncryptor.encrypt(item.title, aead, coupleId),
|
||||||
"description" to item.description,
|
"description" to fieldEncryptor.encrypt(item.description, aead, coupleId),
|
||||||
"category" to item.category,
|
"category" to item.category,
|
||||||
"addedBy" to item.addedBy,
|
"addedBy" to item.addedBy,
|
||||||
"addedAt" to item.addedAt,
|
"addedAt" to item.addedAt,
|
||||||
|
|
@ -46,10 +55,11 @@ class FirestoreBucketListDataSource @Inject constructor(private val db: Firebase
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateItem(coupleId: String, item: BucketListItem) {
|
suspend fun updateItem(coupleId: String, item: BucketListItem) {
|
||||||
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val path = itemsRef(coupleId).document(item.id)
|
val path = itemsRef(coupleId).document(item.id)
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"title" to item.title,
|
"title" to fieldEncryptor.encrypt(item.title, aead, coupleId),
|
||||||
"description" to item.description,
|
"description" to fieldEncryptor.encrypt(item.description, aead, coupleId),
|
||||||
"category" to item.category,
|
"category" to item.category,
|
||||||
"completedBy" to item.completedBy,
|
"completedBy" to item.completedBy,
|
||||||
"completedAt" to item.completedAt,
|
"completedAt" to item.completedAt,
|
||||||
|
|
@ -143,15 +153,17 @@ class FirestoreBucketListDataSource @Inject constructor(private val db: Firebase
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toBucketListItem(coupleId: String): BucketListItem? {
|
private fun com.google.firebase.firestore.DocumentSnapshot.toBucketListItem(coupleId: String): BucketListItem? {
|
||||||
val title = getString("title") ?: return null
|
val rawTitle = getString("title") ?: return null
|
||||||
val addedBy = getString("addedBy") ?: return null
|
val addedBy = getString("addedBy") ?: return null
|
||||||
val addedAt = (get("addedAt") as? Number)?.toLong() ?: 0L
|
val addedAt = (get("addedAt") as? Number)?.toLong() ?: 0L
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
|
||||||
return BucketListItem(
|
return BucketListItem(
|
||||||
id = id,
|
id = id,
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
title = title,
|
// decryptForDisplay shows a locked placeholder if the key is missing, never ciphertext.
|
||||||
description = getString("description") ?: "",
|
title = fieldEncryptor.decryptForDisplay(rawTitle, aead, coupleId) ?: FieldEncryptor.LOCKED_PLACEHOLDER,
|
||||||
|
description = fieldEncryptor.decryptForDisplay(getString("description"), aead, coupleId) ?: "",
|
||||||
category = getString("category") ?: "",
|
category = getString("category") ?: "",
|
||||||
addedBy = addedBy,
|
addedBy = addedBy,
|
||||||
addedAt = addedAt,
|
addedAt = addedAt,
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ class FirestoreCapsuleDataSource @Inject constructor(
|
||||||
id = doc.id,
|
id = doc.id,
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
authorId = doc.getString("authorId") ?: "",
|
authorId = doc.getString("authorId") ?: "",
|
||||||
title = decryptField(rawTitle, coupleId) ?: "— Encrypted —",
|
title = decryptField(rawTitle, coupleId) ?: app.closer.crypto.FieldEncryptor.LOCKED_PLACEHOLDER,
|
||||||
content = decryptField(rawContent, coupleId) ?: "— Encrypted —",
|
content = decryptField(rawContent, coupleId) ?: app.closer.crypto.FieldEncryptor.LOCKED_PLACEHOLDER,
|
||||||
promptUsed = decryptField(rawPrompt, coupleId),
|
promptUsed = decryptField(rawPrompt, coupleId),
|
||||||
unlockAt = doc.getLong("unlockAt") ?: 0L,
|
unlockAt = doc.getLong("unlockAt") ?: 0L,
|
||||||
createdAt = doc.getLong("createdAt") ?: 0L,
|
createdAt = doc.getLong("createdAt") ?: 0L,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
import app.closer.crypto.RecoveryKeyManager
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
import app.closer.crypto.EncryptionVersion
|
||||||
import app.closer.domain.model.Couple
|
import app.closer.domain.model.Couple
|
||||||
import com.google.firebase.Timestamp
|
import com.google.firebase.Timestamp
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
|
|
@ -53,7 +53,7 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
"createdAt" to now,
|
"createdAt" to now,
|
||||||
"streakCount" to 0
|
"streakCount" to 0
|
||||||
)
|
)
|
||||||
data["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
|
data["encryptionVersion"] = EncryptionVersion.STRICT
|
||||||
data["wrappedCoupleKey"] = wrappedKey.cipherB64
|
data["wrappedCoupleKey"] = wrappedKey.cipherB64
|
||||||
data["kdfSalt"] = wrappedKey.saltB64
|
data["kdfSalt"] = wrappedKey.saltB64
|
||||||
data["kdfParams"] = wrappedKey.params
|
data["kdfParams"] = wrappedKey.params
|
||||||
|
|
@ -76,47 +76,6 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Atomically claims a version-0 couple for client-side ciphertext migration. */
|
|
||||||
suspend fun beginEncryptionMigration(
|
|
||||||
coupleId: String,
|
|
||||||
wrappedKey: RecoveryKeyManager.WrappedKey
|
|
||||||
): Boolean = db.runTransaction { tx ->
|
|
||||||
val ref = coupleRef(coupleId)
|
|
||||||
val snapshot = tx.get(ref)
|
|
||||||
val version = (snapshot.getLong("encryptionVersion") ?: 0L).toInt()
|
|
||||||
if (version != 0) return@runTransaction false
|
|
||||||
tx.update(
|
|
||||||
ref,
|
|
||||||
mapOf(
|
|
||||||
"encryptionVersion" to 1,
|
|
||||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
|
||||||
"kdfSalt" to wrappedKey.saltB64,
|
|
||||||
"kdfParams" to wrappedKey.params,
|
|
||||||
"encryptionMigrationUsers" to emptyMap<String, Boolean>()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
true
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
/** Marks one partner's historical content migrated; version 2 requires both partners. */
|
|
||||||
suspend fun markEncryptionMigrationComplete(coupleId: String, userId: String): Boolean =
|
|
||||||
db.runTransaction { tx ->
|
|
||||||
val ref = coupleRef(coupleId)
|
|
||||||
val snapshot = tx.get(ref)
|
|
||||||
val userIds = (snapshot.get("userIds") as? List<*>)?.filterIsInstance<String>().orEmpty()
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val existing = snapshot.get("encryptionMigrationUsers") as? Map<String, Boolean>
|
|
||||||
?: emptyMap()
|
|
||||||
val completed = existing + (userId to true)
|
|
||||||
val allComplete = userIds.isNotEmpty() && userIds.all { completed[it] == true }
|
|
||||||
val updates = mutableMapOf<String, Any>("encryptionMigrationUsers" to completed)
|
|
||||||
if (allComplete) {
|
|
||||||
updates["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
|
|
||||||
}
|
|
||||||
tx.update(ref, updates)
|
|
||||||
allComplete
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
|
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
userRef(uid).set(
|
userRef(uid).set(
|
||||||
|
|
@ -176,9 +135,7 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
|
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
|
||||||
wrappedCoupleKey = getString("wrappedCoupleKey"),
|
wrappedCoupleKey = getString("wrappedCoupleKey"),
|
||||||
kdfSalt = getString("kdfSalt"),
|
kdfSalt = getString("kdfSalt"),
|
||||||
kdfParams = getString("kdfParams"),
|
kdfParams = getString("kdfParams")
|
||||||
encryptionMigrationUsers = (get("encryptionMigrationUsers") as? Map<String, Boolean>)
|
|
||||||
?: emptyMap()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) {
|
private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
private val encryptionManager: CoupleEncryptionManager,
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
private val fieldEncryptor: FieldEncryptor,
|
private val fieldEncryptor: FieldEncryptor,
|
||||||
private val userKeyManager: UserKeyManager,
|
private val userKeyManager: UserKeyManager,
|
||||||
|
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
|
||||||
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
||||||
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||||
private val answerCommitment: AnswerCommitment
|
private val answerCommitment: AnswerCommitment
|
||||||
|
|
@ -85,17 +86,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
// ─── Answers ─────────────────────────────────────────────────────────────────
|
// ─── Answers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
if (userKeyManager.loadPrivateKey() != null) {
|
submitAnswerSealed(coupleId, threadId, userId, answer)
|
||||||
submitAnswerSealed(coupleId, threadId, userId, answer)
|
|
||||||
} else {
|
|
||||||
submitAnswerEncrypted(coupleId, threadId, userId, answer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaVersion 3: per-answer one-time key — partner-proof before reveal.
|
// schemaVersion 3: per-answer one-time key — partner-proof before reveal.
|
||||||
// threadId is used as the AAD "questionId" so thread keys are distinct from
|
// threadId is used as the AAD "questionId" so thread keys are distinct from
|
||||||
// daily-question keys even when the same question appears in both contexts.
|
// daily-question keys even when the same question appears in both contexts.
|
||||||
private suspend fun submitAnswerSealed(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
private suspend fun submitAnswerSealed(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
|
ensureUserPublicKeyPublished(userId)
|
||||||
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
|
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
|
||||||
val payload = SealedAnswerEncryptor.AnswerPayload(
|
val payload = SealedAnswerEncryptor.AnswerPayload(
|
||||||
writtenText = answer.writtenText,
|
writtenText = answer.writtenText,
|
||||||
|
|
@ -127,33 +125,6 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
).voidAwait()
|
).voidAwait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaVersion 2: shared couple key (company-proof, not partner-proof).
|
|
||||||
private suspend fun submitAnswerEncrypted(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
|
||||||
val now = FieldValue.serverTimestamp()
|
|
||||||
val aead = encryptionManager.requireAead(coupleId)
|
|
||||||
threadsRef(coupleId)
|
|
||||||
.document(threadId)
|
|
||||||
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
|
||||||
.document(userId)
|
|
||||||
.set(
|
|
||||||
mapOf(
|
|
||||||
"userId" to answer.userId,
|
|
||||||
"questionId" to answer.questionId,
|
|
||||||
"answerType" to answer.answerType,
|
|
||||||
"writtenText" to fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId),
|
|
||||||
"selectedOptionIds" to if (answer.selectedOptionIds.isNotEmpty())
|
|
||||||
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
|
||||||
else answer.selectedOptionIds,
|
|
||||||
"scaleValue" to if (answer.scaleValue != null)
|
|
||||||
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
|
||||||
else answer.scaleValue,
|
|
||||||
"schemaVersion" to 2,
|
|
||||||
"createdAt" to now,
|
|
||||||
"updatedAt" to now
|
|
||||||
)
|
|
||||||
).voidAwait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call after releasing the one-time key so the answer doc reflects the released state.
|
// Call after releasing the one-time key so the answer doc reflects the released state.
|
||||||
// Required for correct phase detection on cold restart of the reveal screen.
|
// Required for correct phase detection on cold restart of the reveal screen.
|
||||||
suspend fun markAnswerKeyReleased(coupleId: String, threadId: String, userId: String) {
|
suspend fun markAnswerKeyReleased(coupleId: String, threadId: String, userId: String) {
|
||||||
|
|
@ -178,8 +149,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
.addSnapshotListener { snap, err ->
|
.addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
trySend(snap.documents.mapNotNull { it.toQuestionAnswer() })
|
||||||
trySend(snap.documents.mapNotNull { it.toQuestionAnswer(aead, coupleId) })
|
|
||||||
}
|
}
|
||||||
awaitClose { listener.remove() }
|
awaitClose { listener.remove() }
|
||||||
}
|
}
|
||||||
|
|
@ -281,57 +251,17 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? {
|
||||||
private fun DocumentSnapshot.toQuestionAnswer(
|
|
||||||
aead: com.google.crypto.tink.Aead?,
|
|
||||||
coupleId: String
|
|
||||||
): QuestionAnswer? {
|
|
||||||
val userId = getString("userId") ?: return null
|
val userId = getString("userId") ?: return null
|
||||||
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
|
// All thread answers are sealed (schemaVersion 3): content lives in encryptedPayload
|
||||||
|
// and is decrypted by the reveal flow, never stored in plaintext.
|
||||||
// schemaVersion 3: sealed:v1: — content is in encryptedPayload.
|
|
||||||
// Decryption requires the partner's release key; the reveal flow handles it.
|
|
||||||
if (schemaVersion == 3) {
|
|
||||||
return QuestionAnswer(
|
|
||||||
userId = userId,
|
|
||||||
questionId = getString("questionId") ?: "",
|
|
||||||
answerType = getString("answerType") ?: "written",
|
|
||||||
schemaVersion = 3,
|
|
||||||
isSealed = getBoolean("answerKeyReleased") != true,
|
|
||||||
encryptedPayload = getString("encryptedPayload"),
|
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
|
||||||
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
|
|
||||||
val rawIds = (get("selectedOptionIds") as? List<String>) ?: emptyList()
|
|
||||||
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
|
||||||
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
|
||||||
if (decrypted != null) runCatching {
|
|
||||||
val arr = org.json.JSONArray(decrypted)
|
|
||||||
(0 until arr.length()).map { arr.getString(it) }
|
|
||||||
}.getOrDefault(emptyList()) else emptyList()
|
|
||||||
} else rawIds
|
|
||||||
|
|
||||||
val rawScale = get("scaleValue")
|
|
||||||
val scaleValue: Int? = when {
|
|
||||||
rawScale == null -> null
|
|
||||||
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
|
|
||||||
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
|
|
||||||
rawScale is Long -> rawScale.toInt()
|
|
||||||
rawScale is Int -> rawScale
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
return QuestionAnswer(
|
return QuestionAnswer(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
questionId = getString("questionId") ?: "",
|
questionId = getString("questionId") ?: "",
|
||||||
answerType = getString("answerType") ?: "written",
|
answerType = getString("answerType") ?: "written",
|
||||||
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
|
schemaVersion = 3,
|
||||||
selectedOptionIds = selectedOptionIds,
|
isSealed = getBoolean("answerKeyReleased") != true,
|
||||||
scaleValue = scaleValue,
|
encryptedPayload = getString("encryptedPayload"),
|
||||||
schemaVersion = schemaVersion,
|
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
||||||
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
|
|
@ -345,7 +275,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
return QuestionMessage(
|
return QuestionMessage(
|
||||||
id = id,
|
id = id,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "",
|
text = fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "",
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -360,4 +290,10 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureUserPublicKeyPublished(userId: String) {
|
||||||
|
if (deviceKeyDataSource.getPublicKey(userId) != null) return
|
||||||
|
val privateKey = userKeyManager.getOrCreatePrivateKey()
|
||||||
|
deviceKeyDataSource.publishPublicKey(userId, userKeyManager.publicKeyB64(privateKey))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,13 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
|
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
|
||||||
when (value) {
|
when (value) {
|
||||||
is String -> {
|
is String -> {
|
||||||
val json = fieldEncryptor.decrypt(value, aead, coupleId) ?: return@mapValues emptyList()
|
// An encrypted blob that can't be decrypted here means the partner DID
|
||||||
|
// answer but this device lacks the key — surface a locked row per prompt
|
||||||
|
// instead of silently showing nothing (which reads as "didn't answer").
|
||||||
|
val json = fieldEncryptor.decrypt(value, aead, coupleId)
|
||||||
|
?: return@mapValues questions.map {
|
||||||
|
WheelAnswerEntry(it.id, FieldEncryptor.LOCKED_PLACEHOLDER)
|
||||||
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
val array = JSONArray(json)
|
val array = JSONArray(json)
|
||||||
(0 until array.length()).map { index ->
|
(0 until array.length()).map { index ->
|
||||||
|
|
@ -111,16 +117,6 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
// Version-0/1 compatibility exists only until migration completes.
|
|
||||||
is List<*> -> value.mapNotNull { item ->
|
|
||||||
(item as? Map<*, *>)?.let {
|
|
||||||
val rawDisplay = it["display"] as? String ?: ""
|
|
||||||
WheelAnswerEntry(
|
|
||||||
questionId = it["questionId"] as? String ?: "",
|
|
||||||
display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> emptyList()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package app.closer.domain.model
|
package app.closer.domain.model
|
||||||
|
|
||||||
|
import app.closer.crypto.EncryptionVersion
|
||||||
|
|
||||||
data class Couple(
|
data class Couple(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val userIds: List<String> = emptyList(),
|
val userIds: List<String> = emptyList(),
|
||||||
|
|
@ -9,10 +11,9 @@ data class Couple(
|
||||||
val streakCount: Int = 0,
|
val streakCount: Int = 0,
|
||||||
val lastAnsweredAt: Long? = null,
|
val lastAnsweredAt: Long? = null,
|
||||||
val activePackId: String? = null,
|
val activePackId: String? = null,
|
||||||
// E2EE: 0 = legacy plaintext, 1 = migration in progress, 2 = all answer paths strict E2EE.
|
// Strict E2EE: every couple has a wrapped couple key. Version is stamped at 2 on creation.
|
||||||
val encryptionVersion: Int = 0,
|
val encryptionVersion: Int = EncryptionVersion.STRICT,
|
||||||
val wrappedCoupleKey: String? = null,
|
val wrappedCoupleKey: String? = null,
|
||||||
val kdfSalt: String? = null,
|
val kdfSalt: String? = null,
|
||||||
val kdfParams: String? = null,
|
val kdfParams: String? = null
|
||||||
val encryptionMigrationUsers: Map<String, Boolean> = emptyMap()
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import app.closer.ui.theme.closerCardColor
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
|
@ -18,11 +20,16 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
|
@ -49,6 +56,35 @@ internal val AuthPrimaryDeep: Color
|
||||||
internal val AuthOnPrimary: Color
|
internal val AuthOnPrimary: Color
|
||||||
@Composable get() = MaterialTheme.colorScheme.onPrimary
|
@Composable get() = MaterialTheme.colorScheme.onPrimary
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AuthLogoMark(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 88.dp,
|
||||||
|
radius: Dp = 24.dp,
|
||||||
|
elevation: Dp = 18.dp
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(radius)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.shadow(elevation = elevation, shape = shape, clip = false)
|
||||||
|
.clip(shape)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_background),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = "Closer",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.matchParentSize().alpha(0.96f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun GoogleSignInButton(
|
internal fun GoogleSignInButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,11 @@ fun LoginScreen(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(48.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
AuthLogoMark()
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Welcome back",
|
text = "Welcome back",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
package app.closer.ui.auth
|
package app.closer.ui.auth
|
||||||
|
|
||||||
|
import app.closer.R
|
||||||
|
import androidx.credentials.CredentialManager
|
||||||
|
import androidx.credentials.CustomCredential
|
||||||
|
import androidx.credentials.GetCredentialRequest
|
||||||
|
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||||
|
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
||||||
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -38,11 +45,14 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
|
@ -61,10 +71,15 @@ fun SignUpScreen(
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(state.success) {
|
LaunchedEffect(state.success) {
|
||||||
if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
|
if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(state.googleSuccess) {
|
||||||
|
if (state.googleSuccess) onNavigate(AppRoute.ONBOARDING)
|
||||||
|
}
|
||||||
LaunchedEffect(state.error) {
|
LaunchedEffect(state.error) {
|
||||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +117,10 @@ fun SignUpScreen(
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
AuthLogoMark(size = 72.dp, radius = 20.dp, elevation = 12.dp)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Create your account",
|
text = "Create your account",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
|
@ -183,6 +202,37 @@ fun SignUpScreen(
|
||||||
else Text("Create account", style = MaterialTheme.typography.labelLarge)
|
else Text("Create account", style = MaterialTheme.typography.labelLarge)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
GoogleSignInButton(
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val credMgr = CredentialManager.create(context)
|
||||||
|
val option = GetSignInWithGoogleOption
|
||||||
|
.Builder(context.getString(R.string.default_web_client_id))
|
||||||
|
.build()
|
||||||
|
val request = GetCredentialRequest.Builder().addCredentialOption(option).build()
|
||||||
|
val result = credMgr.getCredential(context, request)
|
||||||
|
val credential = result.credential
|
||||||
|
if (credential is CustomCredential &&
|
||||||
|
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
|
||||||
|
) {
|
||||||
|
val idToken = GoogleIdTokenCredential.createFrom(credential.data).idToken
|
||||||
|
viewModel.signInWithGoogle(idToken)
|
||||||
|
} else {
|
||||||
|
viewModel.reportError("Google sign-up failed. Please try again.")
|
||||||
|
}
|
||||||
|
} catch (_: GetCredentialCancellationException) {
|
||||||
|
// user dismissed — do nothing
|
||||||
|
} catch (e: Exception) {
|
||||||
|
viewModel.reportError("Google sign-up failed. Please try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ package app.closer.ui.auth
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.domain.model.GoogleSignInResult
|
||||||
|
import app.closer.domain.model.User
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.repository.UserRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -18,12 +21,16 @@ data class SignUpUiState(
|
||||||
val isPasswordVisible: Boolean = false,
|
val isPasswordVisible: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val success: Boolean = false
|
val success: Boolean = false,
|
||||||
|
// Google sign-up: profile already comes from Google, so route through ONBOARDING
|
||||||
|
// (which decides HOME vs CREATE_PROFILE) rather than the email CREATE_PROFILE step.
|
||||||
|
val googleSuccess: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SignUpViewModel @Inject constructor(
|
class SignUpViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository
|
private val authRepository: AuthRepository,
|
||||||
|
private val userRepository: UserRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SignUpUiState())
|
private val _uiState = MutableStateFlow(SignUpUiState())
|
||||||
|
|
@ -59,9 +66,49 @@ class SignUpViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun signInWithGoogle(idToken: String) {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
authRepository.signInWithGoogle(idToken)
|
||||||
|
.onSuccess { result ->
|
||||||
|
mergeGoogleProfile(result)
|
||||||
|
_uiState.update { it.copy(isLoading = false, googleSuccess = true) }
|
||||||
|
}
|
||||||
|
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportError(message: String) = _uiState.update { it.copy(error = message) }
|
||||||
|
|
||||||
|
private suspend fun mergeGoogleProfile(result: GoogleSignInResult) {
|
||||||
|
val uid = result.uid
|
||||||
|
if (uid.isBlank()) return
|
||||||
|
val existing = runCatching { userRepository.getUser(uid) }.getOrNull()
|
||||||
|
if (existing == null) {
|
||||||
|
userRepository.createUser(
|
||||||
|
User(
|
||||||
|
id = uid,
|
||||||
|
email = result.email,
|
||||||
|
displayName = result.displayName,
|
||||||
|
photoUrl = result.photoUrl,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
lastActiveAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (existing.displayName.isBlank() && result.displayName.isNotBlank()) {
|
||||||
|
userRepository.updateDisplayName(uid, result.displayName)
|
||||||
|
}
|
||||||
|
if (existing.photoUrl.isBlank() && result.photoUrl.isNotBlank()) {
|
||||||
|
userRepository.updatePhotoUrl(uid, result.photoUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun friendlyError(e: Throwable): String = when {
|
private fun friendlyError(e: Throwable): String = when {
|
||||||
e.message?.contains("email address is already") == true -> "An account with this email already exists."
|
e.message?.contains("email address is already") == true -> "An account with this email already exists."
|
||||||
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
|
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
|
||||||
|
e.message?.contains("network") == true -> "Check your connection and try again."
|
||||||
else -> e.message ?: "Something went wrong. Please try again."
|
else -> e.message ?: "Something went wrong. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
|
@ -164,32 +166,47 @@ private fun BucketListContent(
|
||||||
private fun Header(
|
private fun Header(
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(top = 12.dp, bottom = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Column(
|
IconButton(
|
||||||
modifier = Modifier.weight(1f)
|
onClick = onBack,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = "Our Bucket List",
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
contentDescription = "Back",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Dream dates you both want to experience",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp, bottom = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Our Bucket List",
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Dream dates you both want to experience",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.ui.dates
|
package app.closer.ui.dates
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.domain.model.BucketListItem
|
import app.closer.domain.model.BucketListItem
|
||||||
|
|
@ -31,8 +32,9 @@ class BucketListViewModel @Inject constructor(
|
||||||
if (coupleId.isEmpty()) return
|
if (coupleId.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val items = repository.getItems(coupleId)
|
runCatching { repository.getItems(coupleId) }
|
||||||
_uiState.update { it.copy(items = items) }
|
.onSuccess { items -> _uiState.update { it.copy(items = items) } }
|
||||||
|
.onFailure { Log.w(TAG, "Could not load bucket list items", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,9 +56,12 @@ class BucketListViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val itemId = repository.addItem(newItem)
|
runCatching { repository.addItem(newItem) }
|
||||||
val updatedItems = _uiState.value.items + newItem.copy(id = itemId)
|
.onSuccess { itemId ->
|
||||||
_uiState.update { it.copy(items = updatedItems) }
|
val updatedItems = _uiState.value.items + newItem.copy(id = itemId)
|
||||||
|
_uiState.update { it.copy(items = updatedItems) }
|
||||||
|
}
|
||||||
|
.onFailure { Log.w(TAG, "Could not add bucket list item", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,24 +71,26 @@ class BucketListViewModel @Inject constructor(
|
||||||
if (coupleId.isEmpty()) return
|
if (coupleId.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (item.isCompleted) {
|
runCatching {
|
||||||
repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null))
|
if (item.isCompleted) {
|
||||||
_uiState.update {
|
repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null))
|
||||||
it.copy(
|
_uiState.update {
|
||||||
items = it.items.map { if (it.id == itemId) it.copy(isCompleted = false) else it }
|
it.copy(
|
||||||
)
|
items = it.items.map { if (it.id == itemId) it.copy(isCompleted = false) else it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
repository.completeItem(coupleId, itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "")
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
items = it.items.map {
|
||||||
|
if (it.id == itemId) it.copy(isCompleted = true, completedAt = System.currentTimeMillis())
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}.onFailure { Log.w(TAG, "Could not toggle bucket list item", it) }
|
||||||
repository.completeItem(coupleId, itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "")
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
items = it.items.map {
|
|
||||||
if (it.id == itemId) it.copy(isCompleted = true, completedAt = System.currentTimeMillis())
|
|
||||||
else it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,10 +99,13 @@ class BucketListViewModel @Inject constructor(
|
||||||
if (coupleId.isEmpty()) return
|
if (coupleId.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteItem(coupleId, itemId)
|
runCatching { repository.deleteItem(coupleId, itemId) }
|
||||||
_uiState.update {
|
.onSuccess {
|
||||||
it.copy(items = it.items.filter { it.id != itemId })
|
_uiState.update {
|
||||||
}
|
it.copy(items = it.items.filter { it.id != itemId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { Log.w(TAG, "Could not delete bucket list item", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +118,7 @@ class BucketListViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val TAG = "BucketListViewModel"
|
||||||
const val MAX_TITLE_LENGTH = 100
|
const val MAX_TITLE_LENGTH = 100
|
||||||
const val MAX_DESCRIPTION_LENGTH = 500
|
const val MAX_DESCRIPTION_LENGTH = 500
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,20 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DatePicker
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.DatePickerDialog
|
import androidx.compose.material3.DatePickerDialog
|
||||||
import androidx.compose.material3.DisplayMode
|
import androidx.compose.material3.DisplayMode
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
|
@ -38,6 +44,7 @@ import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -60,16 +67,38 @@ fun DateBuilderScreen(
|
||||||
viewModel: DateBuilderViewModel = hiltViewModel()
|
viewModel: DateBuilderViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
DateBuilderContent(
|
LaunchedEffect(state.saved) {
|
||||||
state = state,
|
if (state.saved) {
|
||||||
onDateChange = viewModel::updateDate,
|
viewModel.consumeSaved()
|
||||||
onTimeChange = viewModel::updateTime,
|
onNavigate("back")
|
||||||
onBudgetChange = viewModel::updateBudget,
|
}
|
||||||
onDurationChange = viewModel::updateDuration,
|
}
|
||||||
onSave = { viewModel.savePreference() },
|
LaunchedEffect(state.error) {
|
||||||
onBack = { onNavigate("back") }
|
state.error?.let {
|
||||||
)
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.consumeError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
DateBuilderContent(
|
||||||
|
state = state,
|
||||||
|
onDateChange = viewModel::updateDate,
|
||||||
|
onTimeChange = viewModel::updateTime,
|
||||||
|
onBudgetChange = viewModel::updateBudget,
|
||||||
|
onDurationChange = viewModel::updateDuration,
|
||||||
|
onSave = { viewModel.savePreference() },
|
||||||
|
onBack = { onNavigate("back") }
|
||||||
|
)
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -126,29 +155,44 @@ private fun DateBuilderContent(
|
||||||
private fun Header(
|
private fun Header(
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(top = 12.dp, bottom = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Text(
|
IconButton(
|
||||||
text = "Plan a Date",
|
onClick = onBack,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
Icon(
|
||||||
maxLines = 1,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
overflow = TextOverflow.Ellipsis
|
contentDescription = "Back",
|
||||||
)
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
Text(
|
)
|
||||||
text = "Tell us what you're looking for",
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Row(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier
|
||||||
maxLines = 1,
|
.fillMaxWidth()
|
||||||
overflow = TextOverflow.Ellipsis
|
.padding(top = 4.dp, bottom = 6.dp),
|
||||||
)
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Plan a Date",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Tell us what you're looking for",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.ui.dates
|
package app.closer.ui.dates
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.domain.model.DatePlanPreference
|
import app.closer.domain.model.DatePlanPreference
|
||||||
|
|
@ -53,15 +54,27 @@ class DateBuilderViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.savePreference(preference)
|
_uiState.update { it.copy(isSaving = true, error = null) }
|
||||||
|
runCatching { repository.savePreference(preference) }
|
||||||
|
.onSuccess { _uiState.update { it.copy(isSaving = false, saved = true) } }
|
||||||
|
.onFailure { e ->
|
||||||
|
Log.w(TAG, "Could not save date preference", e)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isSaving = false, error = "Couldn't save. Check your connection and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun consumeSaved() = _uiState.update { it.copy(saved = false) }
|
||||||
|
fun consumeError() = _uiState.update { it.copy(error = null) }
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_uiState.update { DateBuilderUiState() }
|
_uiState.update { DateBuilderUiState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val TAG = "DateBuilderViewModel"
|
||||||
const val MAX_TIME_LENGTH = 20
|
const val MAX_TIME_LENGTH = 20
|
||||||
const val MAX_DURATION_LENGTH = 50
|
const val MAX_DURATION_LENGTH = 50
|
||||||
}
|
}
|
||||||
|
|
@ -72,5 +85,8 @@ data class DateBuilderUiState(
|
||||||
val scheduledDate: Long = 0L,
|
val scheduledDate: Long = 0L,
|
||||||
val scheduledTime: String = "",
|
val scheduledTime: String = "",
|
||||||
val budget: Int = 0,
|
val budget: Int = 0,
|
||||||
val duration: String = ""
|
val duration: String = "",
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val saved: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,6 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state.needsEncryptionUpgrade) {
|
|
||||||
if (state.needsEncryptionUpgrade) {
|
|
||||||
onNavigate(AppRoute.ENCRYPTION_UPGRADE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showBaselineDialog by remember { mutableStateOf(false) }
|
var showBaselineDialog by remember { mutableStateOf(false) }
|
||||||
var showFollowUpDialog by remember { mutableStateOf(false) }
|
var showFollowUpDialog by remember { mutableStateOf(false) }
|
||||||
var pendingFollowUpDay by remember { mutableStateOf<OutcomeDay?>(null) }
|
var pendingFollowUpDay by remember { mutableStateOf<OutcomeDay?>(null) }
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,6 @@ data class HomeUiState(
|
||||||
val secondaryActions: List<HomeAction> = emptyList(),
|
val secondaryActions: List<HomeAction> = emptyList(),
|
||||||
val partnerLeftEvent: Boolean = false,
|
val partnerLeftEvent: Boolean = false,
|
||||||
val needsRecovery: Boolean = false,
|
val needsRecovery: Boolean = false,
|
||||||
val needsEncryptionUpgrade: Boolean = false,
|
|
||||||
val coupleId: String? = null,
|
val coupleId: String? = null,
|
||||||
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
|
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
|
||||||
val hasPartnerAnsweredToday: Boolean = false,
|
val hasPartnerAnsweredToday: Boolean = false,
|
||||||
|
|
@ -204,12 +203,6 @@ class HomeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
|
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
|
||||||
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
|
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
|
||||||
val needsEncryptionUpgrade = when (encryptionStatus) {
|
|
||||||
EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE -> true
|
|
||||||
EncryptionStatus.NEEDS_CONTENT_MIGRATION ->
|
|
||||||
couple.encryptionMigrationUsers[uid] != true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outcome check-in due-state calculation
|
// Outcome check-in due-state calculation
|
||||||
val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt
|
val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt
|
||||||
|
|
@ -275,7 +268,6 @@ class HomeViewModel @Inject constructor(
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
partnerLeftEvent = false,
|
partnerLeftEvent = false,
|
||||||
needsRecovery = needsRecovery,
|
needsRecovery = needsRecovery,
|
||||||
needsEncryptionUpgrade = needsEncryptionUpgrade,
|
|
||||||
hasWaitingGame = hasWaitingGame,
|
hasWaitingGame = hasWaitingGame,
|
||||||
hasActiveChallenge = hasActiveChallenge,
|
hasActiveChallenge = hasActiveChallenge,
|
||||||
hasUpcomingDatePlan = hasUpcomingDatePlan,
|
hasUpcomingDatePlan = hasUpcomingDatePlan,
|
||||||
|
|
@ -503,7 +495,7 @@ class HomeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val engineInput = PriorityInput(
|
val engineInput = PriorityInput(
|
||||||
needsCriticalAction = needsRecovery || needsEncryptionUpgrade,
|
needsCriticalAction = needsRecovery,
|
||||||
isPaired = isPaired,
|
isPaired = isPaired,
|
||||||
needsEncryptionUnlock = needsRecovery,
|
needsEncryptionUnlock = needsRecovery,
|
||||||
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
|
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
|
||||||
|
|
@ -539,13 +531,6 @@ class HomeViewModel @Inject constructor(
|
||||||
cta = "Start recovery",
|
cta = "Start recovery",
|
||||||
target = HomeActionTarget.Settings,
|
target = HomeActionTarget.Settings,
|
||||||
tone = HomeActionTone.Utility
|
tone = HomeActionTone.Utility
|
||||||
) else if (needsEncryptionUpgrade) HomeAction(
|
|
||||||
eyebrow = "Encryption update",
|
|
||||||
title = "Upgrade your answer security.",
|
|
||||||
body = "Your encryption needs a quick update so your answers stay private.",
|
|
||||||
cta = "Update encryption",
|
|
||||||
target = HomeActionTarget.Settings,
|
|
||||||
tone = HomeActionTone.Utility
|
|
||||||
) else null
|
) else null
|
||||||
|
|
||||||
Priority.PAIRING_NEEDED -> HomeAction(
|
Priority.PAIRING_NEEDED -> HomeAction(
|
||||||
|
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
package app.closer.ui.pairing
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
|
||||||
import app.closer.data.remote.CoupleAnswerMigrationDataSource
|
|
||||||
import app.closer.data.remote.FirestoreCoupleDataSource
|
|
||||||
import app.closer.domain.repository.AuthRepository
|
|
||||||
import app.closer.domain.repository.CoupleRepository
|
|
||||||
import app.closer.ui.components.BrandMessageRotator
|
|
||||||
import app.closer.ui.components.CloserHeartLoader
|
|
||||||
import app.closer.ui.components.StatusGlyph
|
|
||||||
import app.closer.ui.settings.SettingsBackgroundBrush
|
|
||||||
import app.closer.ui.settings.SettingsInk
|
|
||||||
import app.closer.ui.settings.SettingsMuted
|
|
||||||
import app.closer.ui.settings.SettingsOnPrimary
|
|
||||||
import app.closer.ui.settings.SettingsPrimary
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
data class EncryptionUpgradeUiState(
|
|
||||||
val isLoading: Boolean = true,
|
|
||||||
val recoveryPhrase: String? = null,
|
|
||||||
val complete: Boolean = false,
|
|
||||||
val allPartnersComplete: Boolean = false,
|
|
||||||
val error: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class EncryptionUpgradeViewModel @Inject constructor(
|
|
||||||
private val authRepository: AuthRepository,
|
|
||||||
private val coupleRepository: CoupleRepository,
|
|
||||||
private val coupleDataSource: FirestoreCoupleDataSource,
|
|
||||||
private val migrationDataSource: CoupleAnswerMigrationDataSource,
|
|
||||||
private val encryptionManager: CoupleEncryptionManager
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _uiState = MutableStateFlow(EncryptionUpgradeUiState())
|
|
||||||
val uiState: StateFlow<EncryptionUpgradeUiState> = _uiState.asStateFlow()
|
|
||||||
private var coupleId: String? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
upgrade()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun upgrade() {
|
|
||||||
if (_uiState.value.isLoading && coupleId != null) return
|
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
val userId = authRepository.currentUserId ?: error("Sign in again to secure your history.")
|
|
||||||
var couple = coupleRepository.getCoupleForUser(userId)
|
|
||||||
?: error("Your couple could not be loaded.")
|
|
||||||
coupleId = couple.id
|
|
||||||
|
|
||||||
if (couple.encryptionVersion >= CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION) {
|
|
||||||
return@runCatching UpgradeResult(null, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
var phrase = encryptionManager.pendingRecoveryPhrase(couple.id)
|
|
||||||
if (couple.encryptionVersion == 0) {
|
|
||||||
val wrapped = if (phrase != null && encryptionManager.isUnlocked(couple.id)) {
|
|
||||||
encryptionManager.rewrapWithNewPhrase(couple.id, phrase).getOrThrow()
|
|
||||||
} else {
|
|
||||||
val setup = encryptionManager.setupLegacyCouple(couple.id)
|
|
||||||
phrase = setup.recoveryPhrase
|
|
||||||
setup.wrapped
|
|
||||||
}
|
|
||||||
val claimed = coupleDataSource.beginEncryptionMigration(couple.id, wrapped)
|
|
||||||
if (!claimed) {
|
|
||||||
encryptionManager.deleteKeyset(couple.id)
|
|
||||||
error("Your partner started the upgrade. Ask them for the recovery phrase, then unlock this device.")
|
|
||||||
}
|
|
||||||
couple = coupleRepository.getCoupleForUser(userId)
|
|
||||||
?: error("Your couple could not be reloaded.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!encryptionManager.isUnlocked(couple.id)) {
|
|
||||||
error("This device needs your shared recovery phrase before it can migrate answers.")
|
|
||||||
}
|
|
||||||
|
|
||||||
migrationDataSource.migrateUser(couple.id, userId)
|
|
||||||
val allComplete = coupleDataSource.markEncryptionMigrationComplete(couple.id, userId)
|
|
||||||
UpgradeResult(phrase, allComplete)
|
|
||||||
}.onSuccess { result ->
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
recoveryPhrase = result.phrase,
|
|
||||||
complete = true,
|
|
||||||
allPartnersComplete = result.allPartnersComplete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(isLoading = false, error = error.message ?: "The encryption upgrade failed. Try again.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun acknowledgePhrase() {
|
|
||||||
coupleId?.let(encryptionManager::acknowledgeRecoveryPhrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class UpgradeResult(val phrase: String?, val allPartnersComplete: Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EncryptionUpgradeScreen(
|
|
||||||
onComplete: () -> Unit,
|
|
||||||
onRecoveryNeeded: () -> Unit,
|
|
||||||
viewModel: EncryptionUpgradeViewModel = hiltViewModel()
|
|
||||||
) {
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(SettingsBackgroundBrush)
|
|
||||||
.safeDrawingPadding()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = 28.dp, vertical = 32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
StatusGlyph(
|
|
||||||
icon = Icons.Filled.Lock,
|
|
||||||
tint = SettingsPrimary,
|
|
||||||
container = SettingsPrimary.copy(alpha = 0.14f)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Text(
|
|
||||||
text = if (state.complete) "Your answers are secured" else "Securing your history",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = SettingsInk,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
|
|
||||||
when {
|
|
||||||
state.isLoading -> {
|
|
||||||
Text(
|
|
||||||
"This one-time upgrade encrypts answer content on this device before replacing its cloud copy.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = SettingsMuted,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
CloserHeartLoader()
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
state.error != null -> {
|
|
||||||
val error = state.error.orEmpty()
|
|
||||||
Text(
|
|
||||||
error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Button(onClick = viewModel::upgrade) { Text("Try again") }
|
|
||||||
if (error.contains("recovery phrase", ignoreCase = true)) {
|
|
||||||
OutlinedButton(onClick = onRecoveryNeeded) { Text("Enter recovery phrase") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.recoveryPhrase != null -> {
|
|
||||||
val recoveryPhrase = state.recoveryPhrase.orEmpty()
|
|
||||||
Text(
|
|
||||||
"Save this phrase somewhere private and share it directly with your partner. Closer does not store the phrase and cannot recover it.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = SettingsMuted,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
SelectionContainer {
|
|
||||||
Text(
|
|
||||||
recoveryPhrase,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = SettingsInk,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.acknowledgePhrase(); onComplete() },
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = SettingsPrimary,
|
|
||||||
contentColor = SettingsOnPrimary
|
|
||||||
)
|
|
||||||
) { Text("I've saved and shared it") }
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
Text(
|
|
||||||
if (state.allPartnersComplete)
|
|
||||||
"Both sides have migrated. New and historical answer content now uses strict end-to-end encryption."
|
|
||||||
else
|
|
||||||
"This device is ready. Your partner will finish the upgrade after unlocking with the shared recovery phrase.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = SettingsMuted,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Button(onClick = onComplete, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,14 +58,21 @@ fun PlayHubScreen(
|
||||||
viewModel: PlayHubViewModel = hiltViewModel()
|
viewModel: PlayHubViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val hasPremium by viewModel.hasPremium.collectAsState()
|
val hasPremium by viewModel.hasPremium.collectAsState()
|
||||||
PlayHubContent(onNavigate = onNavigate, hasPremium = hasPremium)
|
val isPaired by viewModel.isPaired.collectAsState()
|
||||||
|
PlayHubContent(onNavigate = onNavigate, hasPremium = hasPremium, isPaired = isPaired)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayHubContent(
|
private fun PlayHubContent(
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
hasPremium: Boolean = true
|
hasPremium: Boolean = true,
|
||||||
|
isPaired: Boolean = true
|
||||||
) {
|
) {
|
||||||
|
// Games are couple activities: an unpaired user who taps any of them is sent to
|
||||||
|
// invite their partner instead. Non-game tiles (history) use onNavigate directly.
|
||||||
|
val onPlay: (String) -> Unit = { route ->
|
||||||
|
if (isPaired) onNavigate(route) else onNavigate(AppRoute.CREATE_INVITE)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -103,37 +110,37 @@ private fun PlayHubContent(
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FeaturedPlayCard(
|
FeaturedPlayCard(
|
||||||
onClick = { onNavigate(AppRoute.SPIN_WHEEL_RANDOM) }
|
onClick = { onPlay(AppRoute.SPIN_WHEEL_RANDOM) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
ThisOrThatCard(
|
ThisOrThatCard(
|
||||||
onClick = { onNavigate(AppRoute.THIS_OR_THAT) }
|
onClick = { onPlay(AppRoute.THIS_OR_THAT) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
HowWellCard(
|
HowWellCard(
|
||||||
onClick = { onNavigate(AppRoute.HOW_WELL) }
|
onClick = { onPlay(AppRoute.HOW_WELL) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
DesireSyncCard(
|
DesireSyncCard(
|
||||||
onClick = { onNavigate(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
|
onClick = { onPlay(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
ConnectionChallengesCard(
|
ConnectionChallengesCard(
|
||||||
onClick = { onNavigate(AppRoute.CONNECTION_CHALLENGES) }
|
onClick = { onPlay(AppRoute.CONNECTION_CHALLENGES) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
MemoryLaneCard(
|
MemoryLaneCard(
|
||||||
onClick = { onNavigate(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) }
|
onClick = { onPlay(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +155,7 @@ private fun PlayHubContent(
|
||||||
icon = Icons.Filled.Favorite,
|
icon = Icons.Filled.Favorite,
|
||||||
tint = MaterialTheme.colorScheme.secondary,
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = { onNavigate(AppRoute.DATE_MATCH) }
|
onClick = { onPlay(AppRoute.DATE_MATCH) }
|
||||||
)
|
)
|
||||||
CompactPlayCard(
|
CompactPlayCard(
|
||||||
title = "Plan Date",
|
title = "Plan Date",
|
||||||
|
|
@ -156,7 +163,7 @@ private fun PlayHubContent(
|
||||||
icon = Icons.Filled.Star,
|
icon = Icons.Filled.Star,
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = { onNavigate(AppRoute.DATE_BUILDER) }
|
onClick = { onPlay(AppRoute.DATE_BUILDER) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +179,7 @@ private fun PlayHubContent(
|
||||||
icon = Icons.Filled.Done,
|
icon = Icons.Filled.Done,
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = { onNavigate(AppRoute.BUCKET_LIST) }
|
onClick = { onPlay(AppRoute.BUCKET_LIST) }
|
||||||
)
|
)
|
||||||
CompactPlayCard(
|
CompactPlayCard(
|
||||||
title = "Past Games",
|
title = "Past Games",
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,34 @@ package app.closer.ui.play
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlayHubViewModel @Inject constructor(
|
class PlayHubViewModel @Inject constructor(
|
||||||
entitlementChecker: EntitlementChecker
|
entitlementChecker: EntitlementChecker,
|
||||||
|
private val gameSessionManager: GameSessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
|
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
// Default true so paired users never see an invite redirect flash while this loads.
|
||||||
|
private val _isPaired = MutableStateFlow(true)
|
||||||
|
val isPaired: StateFlow<Boolean> = _isPaired.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val uid = gameSessionManager.currentUserId
|
||||||
|
val paired = uid != null &&
|
||||||
|
runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null
|
||||||
|
_isPaired.value = paired
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ class CategoryPickerViewModel @Inject constructor(
|
||||||
private fun checkActiveSession() {
|
private fun checkActiveSession() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val uid = gameSessionManager.currentUserId ?: return@launch
|
val uid = gameSessionManager.currentUserId ?: return@launch
|
||||||
val couple = gameSessionManager.getCoupleForUser(uid) ?: return@launch
|
val couple = gameSessionManager.getCoupleForUser(uid) ?: run {
|
||||||
|
_uiState.update { it.copy(navigateTo = AppRoute.CREATE_INVITE) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }
|
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }
|
||||||
.getOrNull() ?: return@launch
|
.getOrNull() ?: return@launch
|
||||||
val target = if (active.gameType == GameType.WHEEL) {
|
val target = if (active.gameType == GameType.WHEEL) {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,12 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
val uid = gameSessionManager.currentUserId
|
val uid = gameSessionManager.currentUserId
|
||||||
val paired = uid != null &&
|
val paired = uid != null &&
|
||||||
runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null
|
runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null
|
||||||
_uiState.update { it.copy(isPaired = paired) }
|
// Games are couple activities — an unpaired user is sent to invite their partner.
|
||||||
|
if (!paired) {
|
||||||
|
_uiState.update { it.copy(isPaired = false, navigateTo = AppRoute.CREATE_INVITE) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPaired = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +165,7 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
if (couple == null) {
|
if (couple == null) {
|
||||||
_uiState.update { it.copy(error = "Not in a couple") }
|
_uiState.update { it.copy(navigateTo = AppRoute.CREATE_INVITE) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,36 @@ class FieldEncryptorTest {
|
||||||
assertNull(subject.decrypt(encrypted, null, "couple-a"))
|
assertNull(subject.decrypt(encrypted, null, "couple-a"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unprefixed value is not trusted as plaintext (fail closed)`() {
|
||||||
|
// No legacy plaintext exists; an unprefixed value must never pass through.
|
||||||
|
assertNull(subject.decrypt("sneaky plaintext", aead, "couple-a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decryptForDisplay shows the locked placeholder for unreadable content`() {
|
||||||
|
// Encrypted but no key on this device.
|
||||||
|
val encrypted = subject.encrypt("private", aead, "couple-a")
|
||||||
|
assertEquals(FieldEncryptor.LOCKED_PLACEHOLDER, subject.decryptForDisplay(encrypted, null, "couple-a"))
|
||||||
|
|
||||||
|
// Encrypted for a different couple (wrong AAD).
|
||||||
|
assertEquals(FieldEncryptor.LOCKED_PLACEHOLDER, subject.decryptForDisplay(encrypted, aead, "couple-b"))
|
||||||
|
|
||||||
|
// Unprefixed value is treated as locked, never shown as plaintext.
|
||||||
|
assertEquals(FieldEncryptor.LOCKED_PLACEHOLDER, subject.decryptForDisplay("sneaky plaintext", aead, "couple-a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decryptForDisplay round trips a valid encrypted value`() {
|
||||||
|
val encrypted = subject.encrypt("hello", aead, "couple-a")
|
||||||
|
assertEquals("hello", subject.decryptForDisplay(encrypted, aead, "couple-a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decryptForDisplay passes through null`() {
|
||||||
|
assertNull(subject.decryptForDisplay(null, aead, "couple-a"))
|
||||||
|
}
|
||||||
|
|
||||||
private class AssociatedDataCheckingAead : Aead {
|
private class AssociatedDataCheckingAead : Aead {
|
||||||
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
|
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
|
||||||
digest(associatedData) + plaintext.reversedArray()
|
digest(associatedData) + plaintext.reversedArray()
|
||||||
|
|
|
||||||
141
firestore.rules
141
firestore.rules
|
|
@ -60,16 +60,6 @@ service cloud.firestore {
|
||||||
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
|
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEncryptedAnswerPayload(data) {
|
|
||||||
return (!('writtenText' in data) || data.writtenText == null || isCiphertext(data.writtenText))
|
|
||||||
&& (!('selectedOptionIds' in data)
|
|
||||||
|| (data.selectedOptionIds is list
|
|
||||||
&& (data.selectedOptionIds.size() == 0
|
|
||||||
|| (data.selectedOptionIds.size() == 1
|
|
||||||
&& isCiphertext(data.selectedOptionIds[0])))))
|
|
||||||
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
|
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
|
||||||
|
|
||||||
function isSealedPayload(value) {
|
function isSealedPayload(value) {
|
||||||
|
|
@ -130,42 +120,6 @@ service cloud.firestore {
|
||||||
.hasOnly(['answerKeyReleased', 'updatedAt']);
|
.hasOnly(['answerKeyReleased', 'updatedAt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStartingEncryptionMigration() {
|
|
||||||
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
|
|
||||||
&& request.resource.data.encryptionVersion == 1
|
|
||||||
&& request.resource.data.wrappedCoupleKey is string
|
|
||||||
&& request.resource.data.kdfSalt is string
|
|
||||||
&& request.resource.data.kdfParams is string
|
|
||||||
&& request.resource.data.encryptionMigrationUsers is map
|
|
||||||
&& request.resource.data.encryptionMigrationUsers.size() == 0
|
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|
||||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams',
|
|
||||||
'encryptionVersion', 'encryptionMigrationUsers'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCompletingOwnEncryptionMigration() {
|
|
||||||
let migrated = request.resource.data.encryptionMigrationUsers;
|
|
||||||
// Some version-1 couples predate the migration marker. Treat that missing
|
|
||||||
// map as empty so either partner can safely record their own completion.
|
|
||||||
let previous = ('encryptionMigrationUsers' in resource.data)
|
|
||||||
? resource.data.encryptionMigrationUsers
|
|
||||||
: {};
|
|
||||||
let changed = migrated.diff(previous).affectedKeys();
|
|
||||||
let users = resource.data.userIds;
|
|
||||||
return resource.data.encryptionVersion == 1
|
|
||||||
&& request.resource.data.encryptionVersion >= 1
|
|
||||||
&& request.resource.data.encryptionVersion <= 2
|
|
||||||
&& migrated is map
|
|
||||||
&& changed.hasOnly([request.auth.uid])
|
|
||||||
&& migrated[request.auth.uid] == true
|
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|
||||||
'encryptionVersion', 'encryptionMigrationUsers'
|
|
||||||
])
|
|
||||||
&& (request.resource.data.encryptionVersion == 1
|
|
||||||
|| (migrated[users[0]] == true && migrated[users[1]] == true));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUpdatingRecoveryWrap() {
|
function isUpdatingRecoveryWrap() {
|
||||||
return request.resource.data.encryptionVersion >= 1
|
return request.resource.data.encryptionVersion >= 1
|
||||||
&& request.resource.data.wrappedCoupleKey is string
|
&& request.resource.data.wrappedCoupleKey is string
|
||||||
|
|
@ -301,8 +255,6 @@ service cloud.firestore {
|
||||||
&& (
|
&& (
|
||||||
isUpdatingCoupleRhythm()
|
isUpdatingCoupleRhythm()
|
||||||
|| isUpdatingRecoveryWrap()
|
|| isUpdatingRecoveryWrap()
|
||||||
|| isStartingEncryptionMigration()
|
|
||||||
|| isCompletingOwnEncryptionMigration()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
||||||
|
|
@ -360,7 +312,8 @@ service cloud.firestore {
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
||||||
// Answers: each user writes their own; both members can read all answers.
|
// Answers: each user writes their own; both members can read all answers.
|
||||||
// Accepts schemaVersion 3 (sealed:v1: partner-proof) or schemaVersion 2 (enc:v1: couple-key).
|
// Strict couples must use schemaVersion 3 (sealed:v1: partner-proof).
|
||||||
|
// schemaVersion 2 is accepted only for v1 migration couples.
|
||||||
match /answers/{userId} {
|
match /answers/{userId} {
|
||||||
allow read: if isCouplesMember(coupleId);
|
allow read: if isCouplesMember(coupleId);
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
@ -368,29 +321,10 @@ service cloud.firestore {
|
||||||
&& isOwner(userId)
|
&& isOwner(userId)
|
||||||
&& request.resource.data.userId == request.auth.uid
|
&& request.resource.data.userId == request.auth.uid
|
||||||
&& coupleEncryptionEnabled(coupleId)
|
&& coupleEncryptionEnabled(coupleId)
|
||||||
&& (
|
&& isSealedThreadAnswerCreate(request.resource.data);
|
||||||
isSealedThreadAnswerCreate(request.resource.data)
|
|
||||||
|| (request.resource.data.schemaVersion == 2
|
|
||||||
&& request.resource.data.keys().hasOnly([
|
|
||||||
'userId', 'questionId', 'answerType', 'writtenText',
|
|
||||||
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
|
||||||
'createdAt', 'updatedAt'
|
|
||||||
])
|
|
||||||
&& isEncryptedAnswerPayload(request.resource.data))
|
|
||||||
);
|
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& isOwner(userId)
|
&& isOwner(userId)
|
||||||
&& (
|
&& isSealedThreadAnswerUpdate();
|
||||||
isSealedThreadAnswerUpdate()
|
|
||||||
|| (coupleEncryptionEnabled(coupleId)
|
|
||||||
&& resource.data.schemaVersion != 3
|
|
||||||
&& request.resource.data.keys().hasOnly([
|
|
||||||
'userId', 'questionId', 'answerType', 'writtenText',
|
|
||||||
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
|
||||||
'createdAt', 'updatedAt'
|
|
||||||
])
|
|
||||||
&& isEncryptedAnswerPayload(request.resource.data))
|
|
||||||
);
|
|
||||||
|
|
||||||
// One-time key release for sealed thread answers (same guards as daily answer release keys).
|
// One-time key release for sealed thread answers (same guards as daily answer release keys).
|
||||||
match /releaseKeys/{recipientId} {
|
match /releaseKeys/{recipientId} {
|
||||||
|
|
@ -518,17 +452,48 @@ service cloud.firestore {
|
||||||
'completedBy', 'completedAt', 'isCompleted'
|
'completedBy', 'completedAt', 'isCompleted'
|
||||||
])
|
])
|
||||||
&& request.resource.data.addedBy == request.auth.uid
|
&& request.resource.data.addedBy == request.auth.uid
|
||||||
&& isValidBucketListCategory(request.resource.data.category);
|
&& isValidBucketListCategory(request.resource.data.category)
|
||||||
|
// Strict E2EE: user content must be ciphertext.
|
||||||
|
&& isCiphertext(request.resource.data.title)
|
||||||
|
&& (!('description' in request.resource.data)
|
||||||
|
|| request.resource.data.description == null
|
||||||
|
|| isCiphertext(request.resource.data.description));
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||||
'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
|
'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
|
||||||
])
|
])
|
||||||
&& isImmutable(['addedBy', 'addedAt'])
|
&& isImmutable(['addedBy', 'addedAt'])
|
||||||
// completedBy must be the caller when marking an item complete
|
// completedBy must be the caller when marking an item complete
|
||||||
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid);
|
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid)
|
||||||
|
// Strict E2EE: title/description remain ciphertext (merged result is always encrypted).
|
||||||
|
&& isCiphertext(request.resource.data.title)
|
||||||
|
&& (!('description' in request.resource.data)
|
||||||
|
|| request.resource.data.description == null
|
||||||
|
|| isCiphertext(request.resource.data.description));
|
||||||
allow delete: if isCouplesMember(coupleId);
|
allow delete: if isCouplesMember(coupleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Couple Lore stores revealed answer summaries. Summary text must remain
|
||||||
|
// encrypted with the couple key; prompts/metadata can stay plaintext.
|
||||||
|
match /lore/{loreId} {
|
||||||
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
allow create, update: if isCouplesMember(coupleId)
|
||||||
|
&& coupleEncryptionEnabled(coupleId)
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'questionId', 'questionText', 'ownAnswer', 'partnerAnswer',
|
||||||
|
'modeTag', 'date', 'schemaVersion', 'savedAt'
|
||||||
|
])
|
||||||
|
&& request.resource.data.questionId is string
|
||||||
|
&& request.resource.data.questionText is string
|
||||||
|
&& request.resource.data.date is string
|
||||||
|
&& request.resource.data.schemaVersion == 2
|
||||||
|
&& isCiphertext(request.resource.data.ownAnswer)
|
||||||
|
&& (!('partnerAnswer' in request.resource.data)
|
||||||
|
|| request.resource.data.partnerAnswer == null
|
||||||
|
|| isCiphertext(request.resource.data.partnerAnswer));
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
|
// Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
|
||||||
// Writes are server-side only via submitOutcomeCallable; direct client writes denied.
|
// Writes are server-side only via submitOutcomeCallable; direct client writes denied.
|
||||||
match /outcomes/{dayKey} {
|
match /outcomes/{dayKey} {
|
||||||
|
|
@ -557,40 +522,16 @@ service cloud.firestore {
|
||||||
// whose metadata disagrees with the path it lands in.
|
// whose metadata disagrees with the path it lands in.
|
||||||
&& request.resource.data.answerDate is string
|
&& request.resource.data.answerDate is string
|
||||||
&& request.resource.data.answerDate == date
|
&& request.resource.data.answerDate == date
|
||||||
&& (
|
// schemaVersion 3: partner-proof sealed answer (the only accepted shape).
|
||||||
// schemaVersion 3: partner-proof sealed answer.
|
&& isSealedAnswerCreate(request.resource.data);
|
||||||
isSealedAnswerCreate(request.resource.data)
|
|
||||||
||
|
|
||||||
// schemaVersion 2: couple-key encrypted answer (legacy path).
|
|
||||||
(coupleEncryptionEnabled(coupleId)
|
|
||||||
&& request.resource.data.schemaVersion == 2
|
|
||||||
&& request.resource.data.keys().hasOnly([
|
|
||||||
'userId', 'questionId', 'answerType', 'writtenText',
|
|
||||||
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
|
||||||
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
|
|
||||||
])
|
|
||||||
&& isEncryptedAnswerPayload(request.resource.data))
|
|
||||||
);
|
|
||||||
|
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& request.auth.uid == userId
|
&& request.auth.uid == userId
|
||||||
&& request.resource.data.userId == resource.data.userId
|
&& request.resource.data.userId == resource.data.userId
|
||||||
&& request.resource.data.questionId == resource.data.questionId
|
&& request.resource.data.questionId == resource.data.questionId
|
||||||
&& request.resource.data.answerType == resource.data.answerType
|
&& request.resource.data.answerType == resource.data.answerType
|
||||||
&& (
|
// Sealed answers: only reveal metadata may change; payload is immutable.
|
||||||
// Sealed answers: only reveal metadata may change; payload is immutable.
|
&& isSealedAnswerUpdate();
|
||||||
isSealedAnswerUpdate()
|
|
||||||
||
|
|
||||||
// enc:v1: answers: same field set, content may be updated.
|
|
||||||
(coupleEncryptionEnabled(coupleId)
|
|
||||||
&& resource.data.schemaVersion != 3
|
|
||||||
&& request.resource.data.keys().hasOnly([
|
|
||||||
'userId', 'questionId', 'answerType', 'writtenText',
|
|
||||||
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
|
||||||
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
|
|
||||||
])
|
|
||||||
&& isEncryptedAnswerPayload(request.resource.data))
|
|
||||||
);
|
|
||||||
|
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,15 +115,16 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
const coupleId = db.collection('couples').doc().id
|
const coupleId = db.collection('couples').doc().id
|
||||||
const coupleRef = db.collection('couples').doc(coupleId)
|
const coupleRef = db.collection('couples').doc(coupleId)
|
||||||
|
|
||||||
// Derive encryption version from E2EE field presence.
|
// Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't,
|
||||||
// encryptionVersion must stay in sync with EncryptionVersion.kt:
|
// the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
|
||||||
// 0 = plaintext (no couple key; iOS MVP path)
|
// broken plaintext couple the client can't use.
|
||||||
// 1 = legacy migration (mixed)
|
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
|
||||||
// 2 = strict E2EE (all new Android couples)
|
throw new functions.https.HttpsError(
|
||||||
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state
|
'failed-precondition',
|
||||||
// where the client expects a key that does not exist.
|
'Invite is missing encryption material. Ask your partner to create a new invite.'
|
||||||
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null
|
)
|
||||||
const encryptionVersion = hasE2EE ? 2 : 0
|
}
|
||||||
|
const encryptionVersion = 2
|
||||||
|
|
||||||
const batch = db.batch()
|
const batch = db.batch()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,11 @@ import * as admin from 'firebase-admin'
|
||||||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||||
* - kdfSalt: base64 KDF salt
|
* - kdfSalt: base64 KDF salt
|
||||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||||
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
|
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the client using the invite
|
||||||
* the invite code as the KDF input. The server stores it opaquely and never sees the
|
* code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
|
||||||
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
|
|
||||||
*
|
*
|
||||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
* Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
|
||||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
* all required. There is no plaintext-couple path.
|
||||||
* supply these fields. Android always supplies them.
|
|
||||||
*
|
*
|
||||||
* Response: { code: string, expiresAt: Timestamp }
|
* Response: { code: string, expiresAt: Timestamp }
|
||||||
*
|
*
|
||||||
|
|
@ -89,13 +87,15 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
const kdfParams = data?.kdfParams as string | undefined
|
const kdfParams = data?.kdfParams as string | undefined
|
||||||
const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
|
const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
|
||||||
|
|
||||||
// E2EE fields must be supplied together or omitted together.
|
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
|
||||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]
|
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
|
||||||
const suppliedE2ee = e2eeFields.filter((v) => v != null).length
|
if (!clientCode) {
|
||||||
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) {
|
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
|
||||||
|
}
|
||||||
|
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
|
||||||
throw new functions.https.HttpsError(
|
throw new functions.https.HttpsError(
|
||||||
'invalid-argument',
|
'invalid-argument',
|
||||||
'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.'
|
'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue