feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration) - Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp - Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read - Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager - Generate recovery phrase during invite creation, display in CreateInviteScreen - Add recovery phrase input to InviteConfirmScreen for encrypted invites - Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery - Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams - Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
This commit is contained in:
parent
5caae523e7
commit
30fddcc2df
|
|
@ -130,6 +130,10 @@ dependencies {
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
||||||
|
// E2EE: Google Tink (AEAD) + Bouncy Castle (Argon2id KDF)
|
||||||
|
implementation("com.google.crypto.tink:tink-android:1.13.0")
|
||||||
|
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
||||||
import app.closer.core.firebase.FirebaseInitializer
|
import app.closer.core.firebase.FirebaseInitializer
|
||||||
import app.closer.data.repository.ActivityProvider
|
import app.closer.data.repository.ActivityProvider
|
||||||
import app.closer.domain.security.DeviceIntegrityChecker
|
import app.closer.domain.security.DeviceIntegrityChecker
|
||||||
|
import com.google.crypto.tink.aead.AeadConfig
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -21,6 +22,7 @@ class CloserApp : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
AeadConfig.register()
|
||||||
ActivityProvider.register(this)
|
ActivityProvider.register(this)
|
||||||
firebaseInitializer.initialize()
|
firebaseInitializer.initialize()
|
||||||
appScope.launch { deviceIntegrityChecker.runCheck() }
|
appScope.launch { deviceIntegrityChecker.runCheck() }
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import app.closer.ui.pairing.CreateInviteScreen
|
||||||
import app.closer.ui.pairing.EmailInviteScreen
|
import app.closer.ui.pairing.EmailInviteScreen
|
||||||
import app.closer.ui.pairing.InviteConfirmScreen
|
import app.closer.ui.pairing.InviteConfirmScreen
|
||||||
import app.closer.ui.pairing.PairPromptScreen
|
import app.closer.ui.pairing.PairPromptScreen
|
||||||
|
import app.closer.ui.pairing.RecoveryScreen
|
||||||
import app.closer.ui.dates.DateMatchScreen
|
import app.closer.ui.dates.DateMatchScreen
|
||||||
import app.closer.ui.dates.DateMatchesScreen
|
import app.closer.ui.dates.DateMatchesScreen
|
||||||
import app.closer.ui.dates.DateBuilderScreen
|
import app.closer.ui.dates.DateBuilderScreen
|
||||||
|
|
@ -276,6 +277,15 @@ fun AppNavigation(
|
||||||
onBack = navigateBackOrHome
|
onBack = navigateBackOrHome
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(route = AppRoute.RECOVERY) {
|
||||||
|
RecoveryScreen(
|
||||||
|
onRecovered = {
|
||||||
|
navController.navigate(AppRoute.HOME) {
|
||||||
|
popUpTo(AppRoute.RECOVERY) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Wheel / Category Selection
|
// Wheel / Category Selection
|
||||||
composable(route = AppRoute.CATEGORY_PICKER) {
|
composable(route = AppRoute.CATEGORY_PICKER) {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ object AppRoute {
|
||||||
const val CONNECTION_CHALLENGES = "connection_challenges"
|
const val CONNECTION_CHALLENGES = "connection_challenges"
|
||||||
const val MEMORY_LANE = "memory_lane"
|
const val MEMORY_LANE = "memory_lane"
|
||||||
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
||||||
|
const val RECOVERY = "recovery"
|
||||||
|
|
||||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||||
const val QUESTION_THREAD =
|
const val QUESTION_THREAD =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package app.closer.crypto
|
||||||
|
|
||||||
|
import app.closer.domain.model.Couple
|
||||||
|
import com.google.crypto.tink.Aead
|
||||||
|
import com.google.crypto.tink.KeysetHandle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
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. */
|
||||||
|
RECONCILED_FROM_INVITE,
|
||||||
|
/** encryptionVersion == 1 but no local keyset — prompt for recovery phrase. */
|
||||||
|
NEEDS_RECOVERY,
|
||||||
|
/** encryptionVersion == 0 (old couple) — operates in plaintext passthrough. */
|
||||||
|
PLAINTEXT_COUPLE
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SetupResult(
|
||||||
|
val handle: KeysetHandle,
|
||||||
|
val wrapped: RecoveryKeyManager.WrappedKey,
|
||||||
|
val recoveryPhrase: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level E2EE orchestration injected into repositories and ViewModels.
|
||||||
|
* All KDF operations run on [Dispatchers.Default] to keep the main thread free.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CoupleEncryptionManager @Inject constructor(
|
||||||
|
private val keyStore: CoupleKeyStore,
|
||||||
|
private val keyManager: RecoveryKeyManager
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Called by the inviter when creating an invite.
|
||||||
|
* Generates a new couple keyset + recovery phrase, wraps the keyset,
|
||||||
|
* and stores it locally under the invite slot (coupleId unknown yet).
|
||||||
|
*/
|
||||||
|
suspend fun setupForNewCouple(inviteCode: String): SetupResult = withContext(Dispatchers.Default) {
|
||||||
|
val phrase = keyManager.generateRecoveryPhrase()
|
||||||
|
val handle = keyManager.newCoupleKeyset()
|
||||||
|
val wrapped = keyManager.wrap(handle, phrase)
|
||||||
|
keyStore.storeInviteKeyset(inviteCode, handle)
|
||||||
|
SetupResult(handle, wrapped, phrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the acceptor during pairing, and on any device during recovery.
|
||||||
|
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
|
||||||
|
* locally under [coupleId].
|
||||||
|
* Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong.
|
||||||
|
*/
|
||||||
|
suspend fun unwrapAndStore(
|
||||||
|
coupleId: String,
|
||||||
|
wrapped: RecoveryKeyManager.WrappedKey,
|
||||||
|
phrase: String
|
||||||
|
): Result<Unit> = withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
val handle = keyManager.unwrap(wrapped, phrase)
|
||||||
|
keyStore.storeKeyset(coupleId, handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on app launch / Home load after the couple doc is resolved.
|
||||||
|
* Handles inviter reconciliation (flow B′) transparently.
|
||||||
|
*/
|
||||||
|
fun checkStatus(couple: Couple): EncryptionStatus {
|
||||||
|
if (couple.encryptionVersion == 0) return EncryptionStatus.PLAINTEXT_COUPLE
|
||||||
|
if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
|
||||||
|
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
|
||||||
|
return EncryptionStatus.RECONCILED_FROM_INVITE
|
||||||
|
}
|
||||||
|
return EncryptionStatus.NEEDS_RECOVERY
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aeadFor(coupleId: String): Aead? = keyStore.aeadFor(coupleId)
|
||||||
|
|
||||||
|
fun isUnlocked(coupleId: String): Boolean = keyStore.hasKeyset(coupleId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-wraps the locally-held keyset with a new phrase and returns the new WrappedKey
|
||||||
|
* so the caller can persist it to Firestore. The old phrase is NOT required.
|
||||||
|
* Returns failure if no local keyset exists for [coupleId].
|
||||||
|
*/
|
||||||
|
suspend fun rewrapWithNewPhrase(
|
||||||
|
coupleId: String,
|
||||||
|
newPhrase: String
|
||||||
|
): Result<RecoveryKeyManager.WrappedKey> = withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
val handle = keyStore.loadKeyset(coupleId)
|
||||||
|
?: error("No local keyset for $coupleId — cannot change phrase without recovery first")
|
||||||
|
keyManager.wrap(handle, newPhrase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package app.closer.crypto
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKeys
|
||||||
|
import com.google.crypto.tink.Aead
|
||||||
|
import com.google.crypto.tink.CleartextKeysetHandle
|
||||||
|
import com.google.crypto.tink.JsonKeysetReader
|
||||||
|
import com.google.crypto.tink.JsonKeysetWriter
|
||||||
|
import com.google.crypto.tink.KeysetHandle
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists Tink keyset handles in EncryptedSharedPreferences (Keystore-backed).
|
||||||
|
* Keys are namespaced by coupleId ("keyset_{coupleId}") or invite slot
|
||||||
|
* ("keyset_invite_{inviteCode}") for the inviter reconciliation path.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CoupleKeyStore @Inject constructor(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
) {
|
||||||
|
private val prefs: SharedPreferences = run {
|
||||||
|
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
masterKeyAlias,
|
||||||
|
"couple_crypto_secure",
|
||||||
|
context,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val aeadCache = ConcurrentHashMap<String, Aead>()
|
||||||
|
|
||||||
|
fun hasKeyset(coupleId: String): Boolean =
|
||||||
|
prefs.contains(prefKey(coupleId))
|
||||||
|
|
||||||
|
fun hasInviteKeyset(inviteCode: String): Boolean =
|
||||||
|
prefs.contains(invitePrefKey(inviteCode))
|
||||||
|
|
||||||
|
fun storeKeyset(coupleId: String, handle: KeysetHandle) {
|
||||||
|
val json = serialize(handle)
|
||||||
|
prefs.edit().putString(prefKey(coupleId), json).apply()
|
||||||
|
aeadCache[coupleId] = handle.getPrimitive(Aead::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun storeInviteKeyset(inviteCode: String, handle: KeysetHandle) {
|
||||||
|
val json = serialize(handle)
|
||||||
|
prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadKeyset(coupleId: String): KeysetHandle? =
|
||||||
|
load(prefKey(coupleId))
|
||||||
|
|
||||||
|
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
|
||||||
|
load(invitePrefKey(inviteCode))
|
||||||
|
|
||||||
|
/** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */
|
||||||
|
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
|
||||||
|
val handle = loadInviteKeyset(inviteCode) ?: return false
|
||||||
|
storeKeyset(coupleId, handle)
|
||||||
|
prefs.edit().remove(invitePrefKey(inviteCode)).apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteKeyset(coupleId: String) {
|
||||||
|
prefs.edit().remove(prefKey(coupleId)).apply()
|
||||||
|
aeadCache.remove(coupleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aeadFor(coupleId: String): Aead? {
|
||||||
|
aeadCache[coupleId]?.let { return it }
|
||||||
|
val handle = loadKeyset(coupleId) ?: return null
|
||||||
|
val aead = handle.getPrimitive(Aead::class.java)
|
||||||
|
aeadCache[coupleId] = aead
|
||||||
|
return aead
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefKey(coupleId: String) = "keyset_$coupleId"
|
||||||
|
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
||||||
|
|
||||||
|
private fun serialize(handle: KeysetHandle): String {
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(baos))
|
||||||
|
return baos.toString(Charsets.UTF_8.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(key: String): KeysetHandle? =
|
||||||
|
runCatching {
|
||||||
|
val json = prefs.getString(key, null) ?: return null
|
||||||
|
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package app.closer.crypto
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import com.google.crypto.tink.Aead
|
||||||
|
import javax.inject.Inject
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* AAD = coupleId bytes — binds ciphertext to the couple and prevents
|
||||||
|
* copy-paste of one couple's ciphertext into another couple's document.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FieldEncryptor @Inject constructor() {
|
||||||
|
|
||||||
|
fun encrypt(plaintext: String, aead: Aead, coupleId: String): String {
|
||||||
|
val cipher = aead.encrypt(
|
||||||
|
plaintext.toByteArray(Charsets.UTF_8),
|
||||||
|
coupleId.toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
return PREFIX + Base64.encodeToString(cipher, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encryptNullable(value: String?, aead: Aead, coupleId: String): String? =
|
||||||
|
value?.let { encrypt(it, aead, coupleId) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts if the value has the enc:v1: prefix; returns the original string otherwise.
|
||||||
|
* Returns null when [value] is null or [aead] is null and the value is encrypted.
|
||||||
|
*/
|
||||||
|
fun decrypt(value: String?, aead: Aead?, coupleId: String): String? {
|
||||||
|
if (value == null) return null
|
||||||
|
if (!value.startsWith(PREFIX)) return value
|
||||||
|
if (aead == null) return null
|
||||||
|
return runCatching {
|
||||||
|
val cipher = Base64.decode(value.removePrefix(PREFIX), Base64.NO_WRAP)
|
||||||
|
aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8))
|
||||||
|
.toString(Charsets.UTF_8)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX = "enc:v1:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
package app.closer.crypto
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import com.google.crypto.tink.KeysetHandle
|
||||||
|
import com.google.crypto.tink.aead.AesGcmKeyManager
|
||||||
|
import com.google.crypto.tink.subtle.AesGcmJce
|
||||||
|
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||||
|
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure crypto helper — no Firestore, no Android dependencies.
|
||||||
|
* Handles: keyset generation, Argon2id KDF, couple-key wrap/unwrap, phrase generation.
|
||||||
|
*
|
||||||
|
* Argon2id params (m=46 MiB, t=3, p=1): ~2-3 s on a mid-range phone.
|
||||||
|
* Run on Dispatchers.Default to avoid blocking the main thread.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class RecoveryKeyManager @Inject constructor() {
|
||||||
|
|
||||||
|
data class WrappedKey(
|
||||||
|
val cipherB64: String,
|
||||||
|
val saltB64: String,
|
||||||
|
val params: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun generateRecoveryPhrase(): String {
|
||||||
|
val random = SecureRandom()
|
||||||
|
val indices = (0 until PHRASE_WORD_COUNT).map { random.nextInt(WORDLIST.size) }
|
||||||
|
return indices.joinToString(" ") { WORDLIST[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newCoupleKeyset(): KeysetHandle =
|
||||||
|
KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps [keyset] with Argon2id(passphrase, salt) using AES-256-GCM.
|
||||||
|
* AAD is the fixed constant [WRAP_AAD] so the blob is portable across
|
||||||
|
* invite → couple transition without re-wrapping.
|
||||||
|
*/
|
||||||
|
fun wrap(keyset: KeysetHandle, phrase: String): WrappedKey {
|
||||||
|
val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) }
|
||||||
|
val wrapKey = deriveKey(phrase, salt)
|
||||||
|
val keysetBytes = serializeKeyset(keyset)
|
||||||
|
val cipherBytes = AesGcmJce(wrapKey).encrypt(keysetBytes, WRAP_AAD)
|
||||||
|
return WrappedKey(
|
||||||
|
cipherB64 = Base64.encodeToString(cipherBytes, Base64.NO_WRAP),
|
||||||
|
saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP),
|
||||||
|
params = PARAMS_TAG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwraps a [WrappedKey] using [phrase]. Throws [javax.crypto.AEADBadTagException]
|
||||||
|
* (wrapped as GeneralSecurityException) if the phrase is wrong.
|
||||||
|
*/
|
||||||
|
fun unwrap(wrapped: WrappedKey, phrase: String): KeysetHandle {
|
||||||
|
val salt = Base64.decode(wrapped.saltB64, Base64.NO_WRAP)
|
||||||
|
val cipher = Base64.decode(wrapped.cipherB64, Base64.NO_WRAP)
|
||||||
|
val wrapKey = deriveKey(phrase, salt)
|
||||||
|
val keysetBytes = AesGcmJce(wrapKey).decrypt(cipher, WRAP_AAD)
|
||||||
|
return deserializeKeyset(keysetBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize a KeysetHandle to bytes (cleartext JSON — stored inside EncryptedSharedPreferences). */
|
||||||
|
fun serializeKeyset(handle: KeysetHandle): ByteArray {
|
||||||
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
com.google.crypto.tink.CleartextKeysetHandle.write(
|
||||||
|
handle, com.google.crypto.tink.JsonKeysetWriter.withOutputStream(baos)
|
||||||
|
)
|
||||||
|
return baos.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deserializeKeyset(bytes: ByteArray): KeysetHandle =
|
||||||
|
com.google.crypto.tink.CleartextKeysetHandle.read(
|
||||||
|
com.google.crypto.tink.JsonKeysetReader.withBytes(bytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun deriveKey(phrase: String, salt: ByteArray): ByteArray {
|
||||||
|
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||||
|
.withSalt(salt)
|
||||||
|
.withParallelism(ARGON2_PARALLELISM)
|
||||||
|
.withMemoryAsKB(ARGON2_MEMORY_KB)
|
||||||
|
.withIterations(ARGON2_ITERATIONS)
|
||||||
|
.build()
|
||||||
|
val gen = Argon2BytesGenerator()
|
||||||
|
gen.init(params)
|
||||||
|
val out = ByteArray(KEY_BYTES)
|
||||||
|
gen.generateBytes(phrase.toCharArray(), out, 0, KEY_BYTES)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PHRASE_WORD_COUNT = 6
|
||||||
|
private const val SALT_BYTES = 16
|
||||||
|
private const val KEY_BYTES = 32
|
||||||
|
private const val ARGON2_MEMORY_KB = 46 * 1024
|
||||||
|
private const val ARGON2_ITERATIONS = 3
|
||||||
|
private const val ARGON2_PARALLELISM = 1
|
||||||
|
private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1"
|
||||||
|
private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
// 256-word list → 6 words → 48 bits raw entropy; Argon2id makes brute-force infeasible.
|
||||||
|
val WORDLIST = arrayOf(
|
||||||
|
"able","acid","acre","aged","aide","also","army","atom",
|
||||||
|
"baby","back","bake","ball","bank","barn","base","bath",
|
||||||
|
"bead","beam","bear","beat","belt","best","bill","bird",
|
||||||
|
"bite","blue","boat","bold","bone","book","boot","bore",
|
||||||
|
"cage","cake","call","calm","camp","cape","card","care",
|
||||||
|
"cart","cave","cell","cent","coal","coat","code","coin",
|
||||||
|
"cold","come","cone","cord","core","corn","cost","crew",
|
||||||
|
"dame","damp","dare","dark","dart","date","dead","deal",
|
||||||
|
"dean","deer","dent","dice","disk","dish","dock","done",
|
||||||
|
"dose","dove","down","draw","drip","drop","drum","dusk",
|
||||||
|
"each","earn","east","edge","epic","even","exam","exit",
|
||||||
|
"fact","fail","fair","fall","fame","farm","fast","fate",
|
||||||
|
"feel","fill","film","find","fire","fish","five","flat",
|
||||||
|
"flow","foam","food","foot","form","free","fuel","full",
|
||||||
|
"gain","game","gate","gear","give","glad","glow","goal",
|
||||||
|
"gold","good","gray","grow","gulf","gust","half","hall",
|
||||||
|
"halt","hand","hard","harm","head","heat","help","high",
|
||||||
|
"hill","hint","hold","hole","home","hook","hope","hour",
|
||||||
|
"huge","hull","hunt","hurt","idea","idle","inch","iris",
|
||||||
|
"jade","jail","jest","join","jury","just","keen","kept",
|
||||||
|
"kind","king","knee","knew","know","land","lane","last",
|
||||||
|
"late","lead","leaf","lean","left","less","life","like",
|
||||||
|
"line","lion","list","live","load","lock","long","look",
|
||||||
|
"loop","lord","lost","loud","love","made","mail","main",
|
||||||
|
"make","mark","maze","mean","meet","mild","mind","mine",
|
||||||
|
"miss","mode","more","most","move","much","must","name",
|
||||||
|
"near","need","nest","news","next","nice","nine","node",
|
||||||
|
"noon","norm","note","once","only","open","over","pack",
|
||||||
|
"page","pain","pair","pale","park","part","past","path",
|
||||||
|
"peak","pick","pine","plan","play","plus","poor","port",
|
||||||
|
"post","pull","pure","race","rank","rate","read","real"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import org.json.JSONArray
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -21,7 +24,11 @@ import kotlin.coroutines.resumeWithException
|
||||||
* both partners may read either answer. Firestore rules enforce this.
|
* both partners may read either answer. Firestore rules enforce this.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
class FirestoreAnswerDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
|
) {
|
||||||
|
|
||||||
private fun dailyQuestionRef(coupleId: String, date: String) =
|
private fun dailyQuestionRef(coupleId: String, date: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES)
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -43,13 +50,18 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
answer: LocalAnswer
|
answer: LocalAnswer
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
val date = todayUtcString()
|
val date = todayUtcString()
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"userId" to userId,
|
"userId" to userId,
|
||||||
"questionId" to questionId,
|
"questionId" to questionId,
|
||||||
"answerType" to answer.answerType,
|
"answerType" to answer.answerType,
|
||||||
"writtenText" to answer.writtenText,
|
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
|
||||||
"selectedOptionIds" to answer.selectedOptionIds,
|
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
|
||||||
"scaleValue" to answer.scaleValue,
|
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
||||||
|
else answer.selectedOptionIds,
|
||||||
|
"scaleValue" to if (aead != null && answer.scaleValue != null)
|
||||||
|
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
||||||
|
else answer.scaleValue,
|
||||||
"createdAt" to answer.createdAt,
|
"createdAt" to answer.createdAt,
|
||||||
"updatedAt" to answer.updatedAt,
|
"updatedAt" to answer.updatedAt,
|
||||||
"isRevealed" to answer.isRevealed
|
"isRevealed" to answer.isRevealed
|
||||||
|
|
@ -76,7 +88,8 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
cont.resume(null)
|
cont.resume(null)
|
||||||
return@addOnSuccessListener
|
return@addOnSuccessListener
|
||||||
}
|
}
|
||||||
cont.resume(snap.toLocalAnswer())
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
cont.resume(snap.toLocalAnswer(aead, coupleId))
|
||||||
}
|
}
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
@ -120,17 +133,39 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
|
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(
|
||||||
val ids = get("selectedOptionIds") as? List<String> ?: emptyList()
|
aead: com.google.crypto.tink.Aead?,
|
||||||
|
coupleId: String
|
||||||
|
): LocalAnswer {
|
||||||
|
val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList()
|
||||||
|
// selectedOptionIds is stored as a single encrypted JSON blob OR a plaintext list
|
||||||
|
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
||||||
|
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
||||||
|
if (decrypted != null) runCatching {
|
||||||
|
val arr = org.json.JSONArray(decrypted)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
}.getOrDefault(emptyList()) else emptyList()
|
||||||
|
} else rawIds
|
||||||
|
|
||||||
|
val rawScale = get("scaleValue")
|
||||||
|
val scaleValue: Int? = when {
|
||||||
|
rawScale == null -> null
|
||||||
|
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
|
||||||
|
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
|
||||||
|
rawScale is Long -> rawScale.toInt()
|
||||||
|
rawScale is Int -> rawScale
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
return LocalAnswer(
|
return LocalAnswer(
|
||||||
questionId = getString("questionId") ?: "",
|
questionId = getString("questionId") ?: "",
|
||||||
questionText = "",
|
questionText = "",
|
||||||
category = "",
|
category = "",
|
||||||
answerType = getString("answerType") ?: "written",
|
answerType = getString("answerType") ?: "written",
|
||||||
writtenText = getString("writtenText"),
|
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
|
||||||
selectedOptionIds = ids,
|
selectedOptionIds = selectedOptionIds,
|
||||||
selectedOptionTexts = emptyList(),
|
selectedOptionTexts = emptyList(),
|
||||||
scaleValue = if (getLong("scaleValue") == null && get("scaleValue") == null) null else (getLong("scaleValue")?.toInt() ?: get("scaleValue") as? Int),
|
scaleValue = scaleValue,
|
||||||
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
||||||
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
||||||
isRevealed = getBoolean("isRevealed") ?: false
|
isRevealed = getBoolean("isRevealed") ?: false
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.domain.model.Couple
|
import app.closer.domain.model.Couple
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
@ -16,10 +16,16 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
private fun coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
private fun coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
|
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
|
||||||
|
|
||||||
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): String {
|
/** [coupleId] is pre-generated by the repository so the keyset can be stored locally first. */
|
||||||
val coupleId = UUID.randomUUID().toString()
|
suspend fun createCouple(
|
||||||
|
coupleId: String,
|
||||||
|
inviterUserId: String,
|
||||||
|
acceptorUserId: String,
|
||||||
|
inviteCode: String,
|
||||||
|
wrappedKey: RecoveryKeyManager.WrappedKey?
|
||||||
|
): String {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now)
|
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey)
|
||||||
updateUserCoupleId(inviterUserId, coupleId)
|
updateUserCoupleId(inviterUserId, coupleId)
|
||||||
updateUserCoupleId(acceptorUserId, coupleId)
|
updateUserCoupleId(acceptorUserId, coupleId)
|
||||||
return coupleId
|
return coupleId
|
||||||
|
|
@ -30,21 +36,41 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
inviterUserId: String,
|
inviterUserId: String,
|
||||||
acceptorUserId: String,
|
acceptorUserId: String,
|
||||||
inviteCode: String,
|
inviteCode: String,
|
||||||
now: Long
|
now: Long,
|
||||||
|
wrappedKey: RecoveryKeyManager.WrappedKey?
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
coupleRef(coupleId).set(
|
val data = mutableMapOf<String, Any>(
|
||||||
mapOf(
|
"id" to coupleId,
|
||||||
"id" to coupleId,
|
"userIds" to listOf(inviterUserId, acceptorUserId),
|
||||||
"userIds" to listOf(inviterUserId, acceptorUserId),
|
"inviteCode" to inviteCode,
|
||||||
"inviteCode" to inviteCode,
|
"createdAt" to now,
|
||||||
"createdAt" to now,
|
"streakCount" to 0
|
||||||
"streakCount" to 0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if (wrappedKey != null) {
|
||||||
|
data["encryptionVersion"] = 1
|
||||||
|
data["wrappedCoupleKey"] = wrappedKey.cipherB64
|
||||||
|
data["kdfSalt"] = wrappedKey.saltB64
|
||||||
|
data["kdfParams"] = wrappedKey.params
|
||||||
|
}
|
||||||
|
coupleRef(coupleId).set(data)
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey): Unit =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
coupleRef(coupleId).set(
|
||||||
|
mapOf(
|
||||||
|
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||||
|
"kdfSalt" to wrappedKey.saltB64,
|
||||||
|
"kdfParams" to wrappedKey.params
|
||||||
|
),
|
||||||
|
SetOptions.merge()
|
||||||
|
)
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
|
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
userRef(uid).set(
|
userRef(uid).set(
|
||||||
|
|
@ -122,7 +148,11 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
currentQuestionId = getString("currentQuestionId"),
|
currentQuestionId = getString("currentQuestionId"),
|
||||||
streakCount = (getLong("streakCount") ?: 0L).toInt(),
|
streakCount = (getLong("streakCount") ?: 0L).toInt(),
|
||||||
lastAnsweredAt = getLong("lastAnsweredAt"),
|
lastAnsweredAt = getLong("lastAnsweredAt"),
|
||||||
activePackId = getString("activePackId")
|
activePackId = getString("activePackId"),
|
||||||
|
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
|
||||||
|
wrappedCoupleKey = getString("wrappedCoupleKey"),
|
||||||
|
kdfSalt = getString("kdfSalt"),
|
||||||
|
kdfParams = getString("kdfParams")
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import org.json.JSONArray
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -23,7 +26,9 @@ data class DesireSyncAnswers(
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreDesireSyncDataSource @Inject constructor(
|
class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
private val db: FirebaseFirestore
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
) {
|
) {
|
||||||
private fun doc(coupleId: String, sessionId: String) =
|
private fun doc(coupleId: String, sessionId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES)
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -38,8 +43,12 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
optionIds: List<String>
|
optionIds: List<String>
|
||||||
) {
|
) {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
val value = if (aead != null)
|
||||||
|
listOf(fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId))
|
||||||
|
else optionIds
|
||||||
doc(coupleId, sessionId)
|
doc(coupleId, sessionId)
|
||||||
.set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge())
|
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
|
||||||
.await()
|
.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +56,8 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? =
|
suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? =
|
||||||
runCatching {
|
runCatching {
|
||||||
val snap = doc(coupleId, sessionId).get().await()
|
val snap = doc(coupleId, sessionId).get().await()
|
||||||
DesireSyncAnswers(parseAnswers(snap.get("answers")))
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId))
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
/** Live view of both partners' picks; emits whenever either side submits. */
|
/** Live view of both partners' picks; emits whenever either side submits. */
|
||||||
|
|
@ -55,16 +65,30 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"))))
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
||||||
}
|
}
|
||||||
awaitClose { reg.remove() }
|
awaitClose { reg.remove() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseAnswers(raw: Any?): Map<String, List<String>> {
|
private fun parseAnswers(
|
||||||
|
raw: Any?,
|
||||||
|
aead: com.google.crypto.tink.Aead?,
|
||||||
|
coupleId: String
|
||||||
|
): Map<String, List<String>> {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val map = raw as? Map<String, *> ?: return emptyMap()
|
val map = raw as? Map<String, *> ?: return emptyMap()
|
||||||
return map.mapNotNull { (uid, value) ->
|
return map.mapNotNull { (uid, value) ->
|
||||||
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
|
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@mapNotNull null
|
||||||
|
// Encrypted as a single blob; plaintext as a real list
|
||||||
|
val decrypted = if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) {
|
||||||
|
val json = fieldEncryptor.decrypt(list[0], aead, coupleId) ?: return@mapNotNull null
|
||||||
|
runCatching {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
}.getOrNull() ?: return@mapNotNull null
|
||||||
|
} else list
|
||||||
|
uid to decrypted
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -27,7 +31,9 @@ data class HowWellAnswers(
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreHowWellDataSource @Inject constructor(
|
class FirestoreHowWellDataSource @Inject constructor(
|
||||||
private val db: FirebaseFirestore
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
) {
|
) {
|
||||||
private fun doc(coupleId: String, sessionId: String) =
|
private fun doc(coupleId: String, sessionId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES)
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -42,16 +48,28 @@ class FirestoreHowWellDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
answers: List<HowWellRawAnswer>
|
answers: List<HowWellRawAnswer>
|
||||||
) {
|
) {
|
||||||
val payload = answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
val value: Any = if (aead != null) {
|
||||||
|
val json = JSONArray(answers.map {
|
||||||
|
JSONObject().apply {
|
||||||
|
put("optionId", it.optionId ?: JSONObject.NULL)
|
||||||
|
put("scale", it.scale ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
}.toString())
|
||||||
|
listOf(fieldEncryptor.encrypt(json.toString(), aead, coupleId))
|
||||||
|
} else {
|
||||||
|
answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
|
||||||
|
}
|
||||||
doc(coupleId, sessionId)
|
doc(coupleId, sessionId)
|
||||||
.set(mapOf("answers" to mapOf(userId to payload)), SetOptions.merge())
|
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
|
||||||
.await()
|
.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot read — used to detect whether this user has already answered. */
|
/** One-shot read — used to detect whether this user has already answered. */
|
||||||
suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? =
|
suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? =
|
||||||
runCatching {
|
runCatching {
|
||||||
HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers")))
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers"), aead, coupleId))
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
/** Live view of both partners' answers; emits whenever either side submits. */
|
/** Live view of both partners' answers; emits whenever either side submits. */
|
||||||
|
|
@ -59,16 +77,36 @@ class FirestoreHowWellDataSource @Inject constructor(
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
trySend(HowWellAnswers(parseAnswers(snap.get("answers"))))
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
||||||
}
|
}
|
||||||
awaitClose { reg.remove() }
|
awaitClose { reg.remove() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseAnswers(raw: Any?): Map<String, List<HowWellRawAnswer>> {
|
private fun parseAnswers(
|
||||||
|
raw: Any?,
|
||||||
|
aead: com.google.crypto.tink.Aead?,
|
||||||
|
coupleId: String
|
||||||
|
): Map<String, List<HowWellRawAnswer>> {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val map = raw as? Map<String, *> ?: return emptyMap()
|
val map = raw as? Map<String, *> ?: return emptyMap()
|
||||||
return map.mapNotNull { (uid, value) ->
|
return map.mapNotNull { (uid, value) ->
|
||||||
(value as? List<*>)?.let { list ->
|
val list = (value as? List<*>) ?: return@mapNotNull null
|
||||||
|
// Encrypted: single-element list with JSON blob
|
||||||
|
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
|
||||||
|
val json = fieldEncryptor.decrypt(list[0] as String, aead, coupleId) ?: return@mapNotNull null
|
||||||
|
val answers = runCatching {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
HowWellRawAnswer(
|
||||||
|
optionId = if (obj.isNull("optionId")) null else obj.getString("optionId"),
|
||||||
|
scale = if (obj.isNull("scale")) null else obj.getInt("scale")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: return@mapNotNull null
|
||||||
|
uid to answers
|
||||||
|
} else {
|
||||||
uid to list.mapNotNull { item ->
|
uid to list.mapNotNull { item ->
|
||||||
(item as? Map<*, *>)?.let {
|
(item as? Map<*, *>)?.let {
|
||||||
HowWellRawAnswer(
|
HowWellRawAnswer(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.domain.model.Invite
|
import app.closer.domain.model.Invite
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
|
|
@ -18,21 +19,27 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
||||||
.joinToString("")
|
.joinToString("")
|
||||||
|
|
||||||
suspend fun createInvite(code: String, inviterUserId: String): Unit =
|
suspend fun createInvite(
|
||||||
suspendCancellableCoroutine { cont ->
|
code: String,
|
||||||
val now = System.currentTimeMillis()
|
inviterUserId: String,
|
||||||
inviteRef(code).set(
|
wrappedKey: RecoveryKeyManager.WrappedKey
|
||||||
mapOf(
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
"code" to code,
|
val now = System.currentTimeMillis()
|
||||||
"inviterUserId" to inviterUserId,
|
inviteRef(code).set(
|
||||||
"status" to "pending",
|
mapOf(
|
||||||
"createdAt" to now,
|
"code" to code,
|
||||||
"expiresAt" to now + 24 * 60 * 60 * 1000L
|
"inviterUserId" to inviterUserId,
|
||||||
)
|
"status" to "pending",
|
||||||
|
"createdAt" to now,
|
||||||
|
"expiresAt" to now + 24 * 60 * 60 * 1000L,
|
||||||
|
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||||
|
"kdfSalt" to wrappedKey.saltB64,
|
||||||
|
"kdfParams" to wrappedKey.params
|
||||||
)
|
)
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
)
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
}
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getInviteByCode(code: String): Invite? =
|
suspend fun getInviteByCode(code: String): Invite? =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
|
|
@ -50,7 +57,10 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
createdAt = snap.getLong("createdAt") ?: 0L,
|
createdAt = snap.getLong("createdAt") ?: 0L,
|
||||||
expiresAt = snap.getLong("expiresAt") ?: 0L,
|
expiresAt = snap.getLong("expiresAt") ?: 0L,
|
||||||
acceptedAt = snap.getLong("acceptedAt"),
|
acceptedAt = snap.getLong("acceptedAt"),
|
||||||
acceptedByUserId = snap.getString("acceptedByUserId")
|
acceptedByUserId = snap.getString("acceptedByUserId"),
|
||||||
|
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
|
||||||
|
kdfSalt = snap.getString("kdfSalt"),
|
||||||
|
kdfParams = snap.getString("kdfParams")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
import app.closer.domain.model.QuestionAnswer
|
import app.closer.domain.model.QuestionAnswer
|
||||||
import app.closer.domain.model.QuestionMessage
|
import app.closer.domain.model.QuestionMessage
|
||||||
import app.closer.domain.model.QuestionReaction
|
import app.closer.domain.model.QuestionReaction
|
||||||
import app.closer.domain.model.QuestionThread
|
import app.closer.domain.model.QuestionThread
|
||||||
import app.closer.domain.model.QuestionThreadStatus
|
import app.closer.domain.model.QuestionThreadStatus
|
||||||
|
import org.json.JSONArray
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
import com.google.firebase.firestore.FieldValue
|
import com.google.firebase.firestore.FieldValue
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
|
|
@ -19,7 +22,11 @@ import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreQuestionThreadDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
|
) {
|
||||||
|
|
||||||
private fun threadsRef(coupleId: String) =
|
private fun threadsRef(coupleId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
|
|
@ -71,6 +78,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
|
||||||
|
|
||||||
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
val now = FieldValue.serverTimestamp()
|
val now = FieldValue.serverTimestamp()
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
threadsRef(coupleId)
|
threadsRef(coupleId)
|
||||||
.document(threadId)
|
.document(threadId)
|
||||||
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
|
|
@ -80,9 +88,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
|
||||||
"userId" to answer.userId,
|
"userId" to answer.userId,
|
||||||
"questionId" to answer.questionId,
|
"questionId" to answer.questionId,
|
||||||
"answerType" to answer.answerType,
|
"answerType" to answer.answerType,
|
||||||
"writtenText" to answer.writtenText,
|
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
|
||||||
"selectedOptionIds" to answer.selectedOptionIds,
|
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
|
||||||
"scaleValue" to answer.scaleValue,
|
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
||||||
|
else answer.selectedOptionIds,
|
||||||
|
"scaleValue" to if (aead != null && answer.scaleValue != null)
|
||||||
|
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
||||||
|
else answer.scaleValue,
|
||||||
"createdAt" to now,
|
"createdAt" to now,
|
||||||
"updatedAt" to now
|
"updatedAt" to now
|
||||||
)
|
)
|
||||||
|
|
@ -103,7 +115,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
|
||||||
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
.addSnapshotListener { snap, err ->
|
.addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
trySend(snap.documents.mapNotNull { it.toQuestionAnswer() })
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
trySend(snap.documents.mapNotNull { it.toQuestionAnswer(aead, coupleId) })
|
||||||
}
|
}
|
||||||
awaitClose { listener.remove() }
|
awaitClose { listener.remove() }
|
||||||
}
|
}
|
||||||
|
|
@ -111,13 +124,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
|
||||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
|
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
threadsRef(coupleId)
|
threadsRef(coupleId)
|
||||||
.document(threadId)
|
.document(threadId)
|
||||||
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
|
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
|
||||||
.add(
|
.add(
|
||||||
mapOf(
|
mapOf(
|
||||||
"authorUserId" to message.userId,
|
"authorUserId" to message.userId,
|
||||||
"text" to message.text,
|
"text" to if (aead != null) fieldEncryptor.encrypt(message.text, aead, coupleId) else message.text,
|
||||||
"createdAt" to FieldValue.serverTimestamp()
|
"createdAt" to FieldValue.serverTimestamp()
|
||||||
)
|
)
|
||||||
).refAwait()
|
).refAwait()
|
||||||
|
|
@ -130,7 +144,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
|
||||||
.orderBy("createdAt", Query.Direction.ASCENDING)
|
.orderBy("createdAt", Query.Direction.ASCENDING)
|
||||||
.addSnapshotListener { snap, err ->
|
.addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
trySend(snap.documents.mapNotNull { it.toQuestionMessage() })
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
trySend(snap.documents.mapNotNull { it.toQuestionMessage(aead, coupleId) })
|
||||||
}
|
}
|
||||||
awaitClose { listener.remove() }
|
awaitClose { listener.remove() }
|
||||||
}
|
}
|
||||||
|
|
@ -204,26 +219,51 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? {
|
private fun DocumentSnapshot.toQuestionAnswer(
|
||||||
|
aead: com.google.crypto.tink.Aead?,
|
||||||
|
coupleId: String
|
||||||
|
): QuestionAnswer? {
|
||||||
val userId = getString("userId") ?: return null
|
val userId = getString("userId") ?: return null
|
||||||
|
val rawIds = (get("selectedOptionIds") as? List<String>) ?: emptyList()
|
||||||
|
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
||||||
|
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
||||||
|
if (decrypted != null) runCatching {
|
||||||
|
val arr = org.json.JSONArray(decrypted)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
}.getOrDefault(emptyList()) else emptyList()
|
||||||
|
} else rawIds
|
||||||
|
|
||||||
|
val rawScale = get("scaleValue")
|
||||||
|
val scaleValue: Int? = when {
|
||||||
|
rawScale == null -> null
|
||||||
|
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
|
||||||
|
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
|
||||||
|
rawScale is Long -> rawScale.toInt()
|
||||||
|
rawScale is Int -> rawScale
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
return QuestionAnswer(
|
return QuestionAnswer(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
questionId = getString("questionId") ?: "",
|
questionId = getString("questionId") ?: "",
|
||||||
answerType = getString("answerType") ?: "written",
|
answerType = getString("answerType") ?: "written",
|
||||||
writtenText = getString("writtenText"),
|
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
|
||||||
selectedOptionIds = (get("selectedOptionIds") as? List<String>) ?: emptyList(),
|
selectedOptionIds = selectedOptionIds,
|
||||||
scaleValue = getLong("scaleValue")?.toInt(),
|
scaleValue = scaleValue,
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
||||||
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DocumentSnapshot.toQuestionMessage(): QuestionMessage? {
|
private fun DocumentSnapshot.toQuestionMessage(
|
||||||
|
aead: com.google.crypto.tink.Aead?,
|
||||||
|
coupleId: String
|
||||||
|
): QuestionMessage? {
|
||||||
val userId = getString("authorUserId") ?: return null
|
val userId = getString("authorUserId") ?: return null
|
||||||
return QuestionMessage(
|
return QuestionMessage(
|
||||||
id = id,
|
id = id,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
text = getString("text") ?: "",
|
text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "",
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
|
|
@ -32,7 +34,9 @@ data class WheelRevealDoc(
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreWheelAnswerDataSource @Inject constructor(
|
class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
private val db: FirebaseFirestore
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
) {
|
) {
|
||||||
private fun doc(coupleId: String, sessionId: String) =
|
private fun doc(coupleId: String, sessionId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES)
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -49,11 +53,17 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
questions: List<WheelQuestionRef>,
|
questions: List<WheelQuestionRef>,
|
||||||
answers: List<WheelAnswerEntry>
|
answers: List<WheelAnswerEntry>
|
||||||
) {
|
) {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"categoryName" to categoryName,
|
"categoryName" to categoryName,
|
||||||
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
|
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
|
||||||
"answers" to mapOf(
|
"answers" to mapOf(
|
||||||
userId to answers.map { mapOf("questionId" to it.questionId, "display" to it.display) }
|
userId to answers.map {
|
||||||
|
mapOf(
|
||||||
|
"questionId" to it.questionId,
|
||||||
|
"display" to if (aead != null) fieldEncryptor.encrypt(it.display, aead, coupleId) else it.display
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
|
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
|
||||||
|
|
@ -61,18 +71,19 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
|
|
||||||
/** One-shot read — used to detect whether this user has already answered. */
|
/** One-shot read — used to detect whether this user has already answered. */
|
||||||
suspend fun getDoc(coupleId: String, sessionId: String): WheelRevealDoc? =
|
suspend fun getDoc(coupleId: String, sessionId: String): WheelRevealDoc? =
|
||||||
runCatching { parse(doc(coupleId, sessionId).get().await()) }.getOrNull()
|
runCatching { parse(doc(coupleId, sessionId).get().await(), coupleId) }.getOrNull()
|
||||||
|
|
||||||
/** Live view of both partners' answers; emits whenever either side submits. */
|
/** Live view of both partners' answers; emits whenever either side submits. */
|
||||||
fun observe(coupleId: String, sessionId: String): Flow<WheelRevealDoc> = callbackFlow {
|
fun observe(coupleId: String, sessionId: String): Flow<WheelRevealDoc> = callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
trySend(parse(snap))
|
trySend(parse(snap, coupleId))
|
||||||
}
|
}
|
||||||
awaitClose { reg.remove() }
|
awaitClose { reg.remove() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parse(snap: DocumentSnapshot): WheelRevealDoc {
|
private fun parse(snap: DocumentSnapshot, coupleId: String): WheelRevealDoc {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
val questions = (snap.get("questions") as? List<*>).orEmpty().mapNotNull { item ->
|
val questions = (snap.get("questions") as? List<*>).orEmpty().mapNotNull { item ->
|
||||||
(item as? Map<*, *>)?.let {
|
(item as? Map<*, *>)?.let {
|
||||||
WheelQuestionRef(
|
WheelQuestionRef(
|
||||||
|
|
@ -86,9 +97,10 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
|
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
|
||||||
(value as? List<*>).orEmpty().mapNotNull { item ->
|
(value as? List<*>).orEmpty().mapNotNull { item ->
|
||||||
(item as? Map<*, *>)?.let {
|
(item as? Map<*, *>)?.let {
|
||||||
|
val rawDisplay = it["display"] as? String ?: ""
|
||||||
WheelAnswerEntry(
|
WheelAnswerEntry(
|
||||||
questionId = it["questionId"] as? String ?: "",
|
questionId = it["questionId"] as? String ?: "",
|
||||||
display = it["display"] as? String ?: ""
|
display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
package app.closer.data.repository
|
package app.closer.data.repository
|
||||||
|
|
||||||
import app.closer.core.crash.CrashReporter
|
import app.closer.core.crash.CrashReporter
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.data.remote.FirestoreCoupleDataSource
|
import app.closer.data.remote.FirestoreCoupleDataSource
|
||||||
|
import app.closer.data.remote.FirestoreInviteDataSource
|
||||||
import app.closer.data.remote.FirestoreUserDataSource
|
import app.closer.data.remote.FirestoreUserDataSource
|
||||||
import app.closer.domain.model.Couple
|
import app.closer.domain.model.Couple
|
||||||
import app.closer.domain.repository.CoupleRepository
|
import app.closer.domain.repository.CoupleRepository
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class CoupleRepositoryImpl @Inject constructor(
|
class CoupleRepositoryImpl @Inject constructor(
|
||||||
private val coupleDataSource: FirestoreCoupleDataSource,
|
private val coupleDataSource: FirestoreCoupleDataSource,
|
||||||
|
private val inviteDataSource: FirestoreInviteDataSource,
|
||||||
private val userDataSource: FirestoreUserDataSource,
|
private val userDataSource: FirestoreUserDataSource,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
private val crashReporter: CrashReporter
|
private val crashReporter: CrashReporter
|
||||||
) : CoupleRepository {
|
) : CoupleRepository {
|
||||||
|
|
||||||
|
|
@ -24,8 +30,30 @@ class CoupleRepositoryImpl @Inject constructor(
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> = runCatching {
|
override suspend fun createCouple(
|
||||||
coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode)
|
inviterUserId: String,
|
||||||
|
acceptorUserId: String,
|
||||||
|
inviteCode: String,
|
||||||
|
recoveryPhrase: String
|
||||||
|
): Result<String> = runCatching {
|
||||||
|
val coupleId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
// Load wrapped key from invite to unwrap with the acceptor's phrase
|
||||||
|
val invite = inviteDataSource.getInviteByCode(inviteCode)
|
||||||
|
val wrappedKey = if (invite?.wrappedCoupleKey != null) {
|
||||||
|
RecoveryKeyManager.WrappedKey(
|
||||||
|
cipherB64 = invite.wrappedCoupleKey,
|
||||||
|
saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"),
|
||||||
|
params = invite.kdfParams ?: error("Missing kdfParams on invite")
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
if (wrappedKey != null) {
|
||||||
|
encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase)
|
||||||
|
.getOrElse { throw it }
|
||||||
|
}
|
||||||
|
|
||||||
|
coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
|
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
|
||||||
|
|
@ -33,6 +61,13 @@ class CoupleRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
|
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
|
||||||
|
val coupleId = userDataSource.getUser(userId)?.coupleId
|
||||||
coupleDataSource.leaveCouple(userId)
|
coupleDataSource.leaveCouple(userId)
|
||||||
|
if (coupleId != null) encryptionManager.deleteKeyset(coupleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit> = runCatching {
|
||||||
|
val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it }
|
||||||
|
coupleDataSource.updateWrappedKey(coupleId, newWrapped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
package app.closer.data.repository
|
package app.closer.data.repository
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
import app.closer.data.remote.FirestoreInviteDataSource
|
import app.closer.data.remote.FirestoreInviteDataSource
|
||||||
import app.closer.domain.model.Invite
|
import app.closer.domain.model.Invite
|
||||||
|
import app.closer.domain.repository.CreateInviteResult
|
||||||
import app.closer.domain.repository.InviteRepository
|
import app.closer.domain.repository.InviteRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class InviteRepositoryImpl @Inject constructor(
|
class InviteRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: FirestoreInviteDataSource
|
private val dataSource: FirestoreInviteDataSource,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager
|
||||||
) : InviteRepository {
|
) : InviteRepository {
|
||||||
|
|
||||||
override suspend fun createInvite(inviterUserId: String): Result<String> = runCatching {
|
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
|
||||||
val code = dataSource.generateCode()
|
val code = dataSource.generateCode()
|
||||||
dataSource.createInvite(code, inviterUserId)
|
val setup = encryptionManager.setupForNewCouple(code)
|
||||||
code
|
dataSource.createInvite(code, inviterUserId, setup.wrapped)
|
||||||
|
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {
|
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,10 @@ data class Couple(
|
||||||
val currentQuestionId: String? = null,
|
val currentQuestionId: String? = null,
|
||||||
val streakCount: Int = 0,
|
val streakCount: Int = 0,
|
||||||
val lastAnsweredAt: Long? = null,
|
val lastAnsweredAt: Long? = null,
|
||||||
val activePackId: String? = null
|
val activePackId: String? = null,
|
||||||
|
// E2EE: version 0 = plaintext, version 1 = Tink AES256-GCM + Argon2id recovery
|
||||||
|
val encryptionVersion: Int = 0,
|
||||||
|
val wrappedCoupleKey: String? = null,
|
||||||
|
val kdfSalt: String? = null,
|
||||||
|
val kdfParams: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,9 @@ data class Invite(
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L,
|
val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L,
|
||||||
val acceptedAt: Long? = null,
|
val acceptedAt: Long? = null,
|
||||||
val acceptedByUserId: String? = null
|
val acceptedByUserId: String? = null,
|
||||||
|
// E2EE: wrapped couple keyset fields — null on legacy/unencrypted invites
|
||||||
|
val wrappedCoupleKey: String? = null,
|
||||||
|
val kdfSalt: String? = null,
|
||||||
|
val kdfParams: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import app.closer.domain.model.Couple
|
||||||
|
|
||||||
interface CoupleRepository {
|
interface CoupleRepository {
|
||||||
suspend fun getCoupleForUser(userId: String): Couple?
|
suspend fun getCoupleForUser(userId: String): Couple?
|
||||||
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String>
|
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String, recoveryPhrase: String): Result<String>
|
||||||
suspend fun updateStreak(coupleId: String): Result<Unit>
|
suspend fun updateStreak(coupleId: String): Result<Unit>
|
||||||
suspend fun leaveCouple(userId: String): Result<Unit>
|
suspend fun leaveCouple(userId: String): Result<Unit>
|
||||||
|
suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package app.closer.domain.repository
|
||||||
|
|
||||||
import app.closer.domain.model.Invite
|
import app.closer.domain.model.Invite
|
||||||
|
|
||||||
|
data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
||||||
|
|
||||||
interface InviteRepository {
|
interface InviteRepository {
|
||||||
suspend fun createInvite(inviterUserId: String): Result<String>
|
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
|
||||||
suspend fun getInviteByCode(code: String): Result<Invite?>
|
suspend fun getInviteByCode(code: String): Result<Invite?>
|
||||||
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
|
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.needsRecovery) {
|
||||||
|
if (state.needsRecovery) {
|
||||||
|
onNavigate(app.closer.core.navigation.AppRoute.RECOVERY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HomeContent(
|
HomeContent(
|
||||||
state = state,
|
state = state,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package app.closer.ui.home
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.EncryptionStatus
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
|
|
@ -73,7 +75,8 @@ data class HomeUiState(
|
||||||
val isPaired: Boolean = false,
|
val isPaired: Boolean = false,
|
||||||
val primaryAction: HomeAction? = null,
|
val primaryAction: HomeAction? = null,
|
||||||
val secondaryActions: List<HomeAction> = emptyList(),
|
val secondaryActions: List<HomeAction> = emptyList(),
|
||||||
val partnerLeftEvent: Boolean = false
|
val partnerLeftEvent: Boolean = false,
|
||||||
|
val needsRecovery: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -83,6 +86,7 @@ class HomeViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
private val db: FirebaseFirestore
|
private val db: FirebaseFirestore
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -123,6 +127,8 @@ class HomeViewModel @Inject constructor(
|
||||||
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
val needsRecovery = couple != null &&
|
||||||
|
encryptionManager.checkStatus(couple) == EncryptionStatus.NEEDS_RECOVERY
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -131,7 +137,8 @@ class HomeViewModel @Inject constructor(
|
||||||
partnerName = partnerName,
|
partnerName = partnerName,
|
||||||
streakCount = couple?.streakCount ?: 0,
|
streakCount = couple?.streakCount ?: 0,
|
||||||
isPaired = couple != null,
|
isPaired = couple != null,
|
||||||
partnerLeftEvent = false
|
partnerLeftEvent = false,
|
||||||
|
needsRecovery = needsRecovery
|
||||||
).withHomeActions()
|
).withHomeActions()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -182,6 +189,12 @@ class HomeViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(partnerLeftEvent = false) }
|
_uiState.update { it.copy(partnerLeftEvent = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called after the recovery flow completes so the banner goes away. */
|
||||||
|
fun onRecoveryCompleted() {
|
||||||
|
_uiState.update { it.copy(needsRecovery = false) }
|
||||||
|
loadHome()
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeAnswers() {
|
private fun observeAnswers() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localAnswerRepository.observeAnswers().collect { answers ->
|
localAnswerRepository.observeAnswers().collect { answers ->
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
|
@ -233,6 +235,69 @@ fun CreateInviteScreen(
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Recovery phrase — shown once; user must write it down
|
||||||
|
state.recoveryPhrase?.let { phrase ->
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, SettingsPrimaryDeep.copy(alpha = 0.3f), RoundedCornerShape(16.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = SettingsSoft),
|
||||||
|
elevation = CardDefaults.cardElevation(0.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = SettingsPrimaryDeep,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Recovery phrase",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = SettingsPrimaryDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
phrase,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = SettingsInk,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Write this down and share it with your partner. You'll both need it to access your answers on a new phone.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = SettingsMuted
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(phrase))
|
||||||
|
scope.launch { snackbar.showSnackbar("Recovery phrase copied!") }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = SettingsPrimary.copy(alpha = 0.12f),
|
||||||
|
contentColor = SettingsPrimaryDeep
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Copy phrase", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import javax.inject.Inject
|
||||||
data class CreateInviteUiState(
|
data class CreateInviteUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val inviteCode: String? = null,
|
val inviteCode: String? = null,
|
||||||
|
val recoveryPhrase: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
@ -43,8 +44,8 @@ class CreateInviteViewModel @Inject constructor(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
inviteRepository.createInvite(userId)
|
inviteRepository.createInvite(userId)
|
||||||
.onSuccess { code ->
|
.onSuccess { result ->
|
||||||
_uiState.update { it.copy(isLoading = false, inviteCode = code) }
|
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") }
|
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") }
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
|
@ -41,6 +43,8 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -142,7 +146,39 @@ fun InviteConfirmScreen(
|
||||||
color = SettingsMuted
|
color = SettingsMuted
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Recovery phrase input — only shown for encrypted invites
|
||||||
|
if (state.isEncryptedInvite) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.recoveryPhrase,
|
||||||
|
onValueChange = viewModel::onPhraseChanged,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Recovery phrase") },
|
||||||
|
placeholder = { Text("word word word word word word") },
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
"Your partner sees this when they create the invite.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = SettingsMuted
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 2,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = SettingsPrimaryDeep,
|
||||||
|
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
|
||||||
|
focusedLabelColor = SettingsPrimaryDeep,
|
||||||
|
unfocusedLabelColor = SettingsMuted,
|
||||||
|
cursorColor = SettingsPrimaryDeep
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::confirmPairing,
|
onClick = viewModel::confirmPairing,
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ import javax.inject.Inject
|
||||||
data class InviteConfirmUiState(
|
data class InviteConfirmUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val inviterName: String? = null,
|
val inviterName: String? = null,
|
||||||
|
val recoveryPhrase: String = "",
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val navigateTo: String? = null,
|
val navigateTo: String? = null,
|
||||||
val isConfirming: Boolean = false
|
val isConfirming: Boolean = false,
|
||||||
|
val isEncryptedInvite: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -51,7 +53,13 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
|
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isLoading = false, inviterName = inviterName ?: "your partner") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
inviterName = inviterName ?: "your partner",
|
||||||
|
isEncryptedInvite = invite?.wrappedCoupleKey != null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") }
|
_uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") }
|
||||||
|
|
@ -59,6 +67,8 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
|
||||||
|
|
||||||
fun confirmPairing() {
|
fun confirmPairing() {
|
||||||
val acceptorId = authRepository.currentUserId ?: run {
|
val acceptorId = authRepository.currentUserId ?: run {
|
||||||
_uiState.update { it.copy(error = "Not signed in.") }
|
_uiState.update { it.copy(error = "Not signed in.") }
|
||||||
|
|
@ -68,15 +78,26 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(error = "Invite not loaded yet.") }
|
_uiState.update { it.copy(error = "Invite not loaded yet.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val phrase = _uiState.value.recoveryPhrase.trim()
|
||||||
|
if (invite.wrappedCoupleKey != null && phrase.isBlank()) {
|
||||||
|
_uiState.update { it.copy(error = "Enter the recovery phrase your partner shared with you.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
_uiState.update { it.copy(isConfirming = true, error = null) }
|
_uiState.update { it.copy(isConfirming = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode)
|
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode, phrase)
|
||||||
.onSuccess { coupleId ->
|
.onSuccess { coupleId ->
|
||||||
inviteRepository.markAccepted(inviteCode, acceptorId, coupleId)
|
inviteRepository.markAccepted(inviteCode, acceptorId, coupleId)
|
||||||
_uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) }
|
_uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) }
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_uiState.update { it.copy(isConfirming = false, error = e.message ?: "Couldn't complete pairing. Please try again.") }
|
val msg = when {
|
||||||
|
e.message?.contains("AEADBadTag", ignoreCase = true) == true ||
|
||||||
|
e.message?.contains("decryption", ignoreCase = true) == true ->
|
||||||
|
"That phrase doesn't match. Ask your partner to recheck it."
|
||||||
|
else -> e.message ?: "Couldn't complete pairing. Please try again."
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isConfirming = false, error = msg) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
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.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
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 app.closer.ui.settings.SettingsPrimaryDeep
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecoveryScreen(
|
||||||
|
onRecovered: () -> Unit,
|
||||||
|
viewModel: RecoveryViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(state.success) {
|
||||||
|
if (state.success) onRecovered()
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.error) {
|
||||||
|
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
modifier = Modifier.background(SettingsBackgroundBrush)
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(48.dp))
|
||||||
|
|
||||||
|
StatusGlyph(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
tint = SettingsPrimaryDeep,
|
||||||
|
container = SettingsPrimary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Unlock your history",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = SettingsInk,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Enter the recovery phrase you and your partner were shown when pairing. Either partner's phrase works.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = SettingsMuted,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.phrase,
|
||||||
|
onValueChange = viewModel::onPhraseChanged,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Recovery phrase") },
|
||||||
|
placeholder = { Text("word word word word word word") },
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 2,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = SettingsPrimaryDeep,
|
||||||
|
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
|
||||||
|
focusedLabelColor = SettingsPrimaryDeep,
|
||||||
|
unfocusedLabelColor = SettingsMuted,
|
||||||
|
cursorColor = SettingsPrimaryDeep
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::recover,
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = SettingsPrimary,
|
||||||
|
contentColor = SettingsOnPrimary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = SettingsOnPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Unlock answers", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"If you've lost your phrase and don't have another device, your encrypted history cannot be recovered — this is by design.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = SettingsMuted,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package app.closer.ui.pairing
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.repository.CoupleRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class RecoveryUiState(
|
||||||
|
val phrase: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val success: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RecoveryViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val coupleRepository: CoupleRepository,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(RecoveryUiState())
|
||||||
|
val uiState: StateFlow<RecoveryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(phrase = phrase, error = null) }
|
||||||
|
|
||||||
|
fun recover() {
|
||||||
|
val phrase = _uiState.value.phrase.trim()
|
||||||
|
if (phrase.isBlank()) {
|
||||||
|
_uiState.update { it.copy(error = "Enter your recovery phrase.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val userId = authRepository.currentUserId ?: run {
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = "Not signed in.") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val couple = coupleRepository.getCoupleForUser(userId)
|
||||||
|
if (couple == null || couple.wrappedCoupleKey == null) {
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = "Couldn't load couple data. Try again.") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val wrapped = RecoveryKeyManager.WrappedKey(
|
||||||
|
cipherB64 = couple.wrappedCoupleKey,
|
||||||
|
saltB64 = couple.kdfSalt ?: "",
|
||||||
|
params = couple.kdfParams ?: ""
|
||||||
|
)
|
||||||
|
encryptionManager.unwrapAndStore(couple.id, wrapped, phrase)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
val msg = when {
|
||||||
|
e.message?.contains("AEADBadTag", ignoreCase = true) == true ||
|
||||||
|
e.message?.contains("decryption", ignoreCase = true) == true ->
|
||||||
|
"That phrase doesn't match. Check with your partner and try again."
|
||||||
|
else -> "Recovery failed. Please check your phrase and try again."
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = msg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,11 @@ service cloud.firestore {
|
||||||
match /notification_queue/{notificationId} {
|
match /notification_queue/{notificationId} {
|
||||||
allow read, write: if false;
|
allow read, write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FCM registration tokens: owner can read/write their own tokens.
|
||||||
|
match /fcmTokens/{tokenId} {
|
||||||
|
allow read, write: if isOwner(uid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
|
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
|
||||||
|
|
@ -123,7 +128,8 @@ service cloud.firestore {
|
||||||
&& request.resource.data.expiresAt is timestamp
|
&& request.resource.data.expiresAt is timestamp
|
||||||
&& request.time < request.resource.data.expiresAt
|
&& request.time < request.resource.data.expiresAt
|
||||||
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt'])
|
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt'])
|
||||||
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt']);
|
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt',
|
||||||
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
|
||||||
|
|
||||||
// Update (accept): proper validation for changing status to accepted.
|
// Update (accept): proper validation for changing status to accepted.
|
||||||
// If coupleId is supplied, it must reference an existing couple where
|
// If coupleId is supplied, it must reference an existing couple where
|
||||||
|
|
@ -163,17 +169,26 @@ service cloud.firestore {
|
||||||
// Read: both members can read
|
// Read: both members can read
|
||||||
allow read: if isCouplesMember(coupleId);
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
|
||||||
// Create: only via invite flow (server-side or admin SDK).
|
// Create: acceptor creates the couple doc during pairing (client-side).
|
||||||
// Admin SDK bypasses rules; direct client writes are denied.
|
// Must be a member of the couple and include required fields.
|
||||||
allow create: if false;
|
allow create: if isSignedIn()
|
||||||
|
&& request.auth.uid in request.resource.data.userIds
|
||||||
|
&& request.resource.data.keys().hasAll(['id', 'userIds', 'inviteCode', 'createdAt', 'streakCount'])
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
|
||||||
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);
|
||||||
|
|
||||||
// Update: field-level restrictions
|
// Update: field-level restrictions
|
||||||
// - user IDs are immutable (cannot change who is in the couple)
|
// - user IDs are immutable (cannot change who is in the couple)
|
||||||
// - invite code is immutable (cannot change the code)
|
// - invite code is immutable (cannot change the code)
|
||||||
// - createdAt is immutable (cannot change when the couple was formed)
|
// - createdAt is immutable (cannot change when the couple was formed)
|
||||||
|
// - encryptionVersion is monotonically non-decreasing (cannot downgrade)
|
||||||
|
// - wrappedCoupleKey/kdfSalt/kdfParams: mutable by members (passphrase change)
|
||||||
// - All other fields (including streakCount and lastAnsweredAt): both members can update
|
// - All other fields (including streakCount and lastAnsweredAt): both members can update
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& isImmutable(['userIds', 'inviteCode', 'createdAt']);
|
&& isImmutable(['userIds', 'inviteCode', 'createdAt'])
|
||||||
|
&& (resource.data.encryptionVersion == null
|
||||||
|
|| request.resource.data.encryptionVersion >= resource.data.encryptionVersion);
|
||||||
|
|
||||||
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue