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