feat(e2ee): encrypt date plan content; live answer observation; own-thread-answer decrypt; strict rules
This commit is contained in:
parent
039752d691
commit
d4b20a9845
|
|
@ -145,10 +145,29 @@ class SealedRevealManager @Inject constructor(
|
||||||
encryptedAnswerKey = keybox
|
encryptedAnswerKey = keybox
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingAnswerKeyStore.remove(storeKey)
|
// Unlike daily answers (whose plaintext is mirrored to local storage), a thread answer's
|
||||||
|
// only plaintext source on this device is this one-time key. Keep it so the reveal screen
|
||||||
|
// can re-decrypt our own answer on cold restart.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decrypts the user's OWN sealed thread answer using their locally-held one-time key. */
|
||||||
|
suspend fun decryptOwnThreadAnswer(
|
||||||
|
coupleId: String,
|
||||||
|
threadId: String,
|
||||||
|
userId: String,
|
||||||
|
encryptedPayload: String
|
||||||
|
): SealedAnswerEncryptor.AnswerPayload? {
|
||||||
|
val oneTimeKey = pendingAnswerKeyStore.load("thread_$threadId") ?: return null
|
||||||
|
return sealedAnswerEncryptor.open(
|
||||||
|
encryptedPayload = encryptedPayload,
|
||||||
|
keyHandle = oneTimeKey,
|
||||||
|
coupleId = coupleId,
|
||||||
|
questionId = threadId,
|
||||||
|
userId = userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun decryptPartnerThreadAnswer(
|
suspend fun decryptPartnerThreadAnswer(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
threadId: String,
|
threadId: String,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import app.closer.crypto.SealedAnswerEncryptor
|
||||||
import app.closer.crypto.UserKeyManager
|
import app.closer.crypto.UserKeyManager
|
||||||
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.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
|
|
@ -134,6 +137,24 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live view of a partner's answer doc — emits whenever they submit or release their
|
||||||
|
* reveal key. Lets the reveal screen complete the client-side decryption in real time
|
||||||
|
* instead of waiting for a manual refresh. Errors are swallowed (the screen keeps its
|
||||||
|
* current state) so the flow never crashes the collector.
|
||||||
|
*/
|
||||||
|
fun observeAnswerForUser(
|
||||||
|
coupleId: String,
|
||||||
|
userId: String,
|
||||||
|
date: String = todayLocalDateString()
|
||||||
|
): Flow<LocalAnswer?> = callbackFlow {
|
||||||
|
val reg = answerRef(coupleId, date, userId).addSnapshotListener { snap, err ->
|
||||||
|
if (err != null) return@addSnapshotListener
|
||||||
|
trySend(snap?.takeIf { it.exists() }?.toLocalAnswer())
|
||||||
|
}
|
||||||
|
awaitClose { reg.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the couple-scoped daily question assignment for today.
|
* Reads the couple-scoped daily question assignment for today.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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.DatePlan
|
import app.closer.domain.model.DatePlan
|
||||||
import app.closer.domain.model.DatePlanPreference
|
import app.closer.domain.model.DatePlanPreference
|
||||||
import app.closer.domain.model.DatePlanStatus
|
import app.closer.domain.model.DatePlanStatus
|
||||||
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 org.json.JSONArray
|
||||||
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
|
||||||
|
|
@ -25,7 +28,11 @@ import kotlin.coroutines.resumeWithException
|
||||||
* Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled.
|
* Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
class FirestoreDatePlanDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
|
) {
|
||||||
private fun preferencesRef(coupleId: String) =
|
private fun preferencesRef(coupleId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
.collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES)
|
.collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES)
|
||||||
|
|
@ -37,13 +44,16 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi
|
||||||
// ─── Preference methods ──────────────────────────────────────────────────
|
// ─── Preference methods ──────────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) {
|
suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) {
|
||||||
|
// Strict E2EE: user-entered details are encrypted. dateIdeaId stays plaintext
|
||||||
|
// (queried via whereEqualTo); dates/timestamps stay plaintext (structural).
|
||||||
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val path = preferencesRef(coupleId).document()
|
val path = preferencesRef(coupleId).document()
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"dateIdeaId" to preference.dateIdeaId,
|
"dateIdeaId" to preference.dateIdeaId,
|
||||||
"preferredDate" to preference.preferredDate,
|
"preferredDate" to preference.preferredDate,
|
||||||
"preferredTime" to preference.preferredTime,
|
"preferredTime" to fieldEncryptor.encrypt(preference.preferredTime, aead, coupleId),
|
||||||
"budget" to preference.budget,
|
"budget" to fieldEncryptor.encrypt(preference.budget.toString(), aead, coupleId),
|
||||||
"duration" to preference.duration,
|
"duration" to fieldEncryptor.encrypt(preference.duration, aead, coupleId),
|
||||||
"createdAt" to preference.createdAt,
|
"createdAt" to preference.createdAt,
|
||||||
"updatedAt" to preference.updatedAt
|
"updatedAt" to preference.updatedAt
|
||||||
)
|
)
|
||||||
|
|
@ -74,40 +84,36 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi
|
||||||
|
|
||||||
suspend fun createPlan(coupleId: String, plan: DatePlan): String {
|
suspend fun createPlan(coupleId: String, plan: DatePlan): String {
|
||||||
val doc = plansRef(coupleId).document()
|
val doc = plansRef(coupleId).document()
|
||||||
val data = mapOf(
|
doc.set(plan.toEncryptedData(coupleId, includeCreatedAt = true), SetOptions.merge()).voidAwait()
|
||||||
"dateIdeaId" to plan.dateIdeaId,
|
|
||||||
"scheduledDate" to plan.scheduledDate,
|
|
||||||
"scheduledTime" to plan.scheduledTime,
|
|
||||||
"budget" to plan.budget,
|
|
||||||
"duration" to plan.duration,
|
|
||||||
"status" to plan.status.toFirestoreValue(),
|
|
||||||
"activity" to plan.activity,
|
|
||||||
"food" to plan.food,
|
|
||||||
"conversationPrompts" to plan.conversationPrompts,
|
|
||||||
"optionalChallenge" to plan.optionalChallenge,
|
|
||||||
"createdAt" to plan.createdAt,
|
|
||||||
"updatedAt" to plan.updatedAt
|
|
||||||
)
|
|
||||||
doc.set(data, SetOptions.merge()).voidAwait()
|
|
||||||
return doc.id
|
return doc.id
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updatePlan(coupleId: String, plan: DatePlan) {
|
suspend fun updatePlan(coupleId: String, plan: DatePlan) {
|
||||||
val path = plansRef(coupleId).document(plan.id)
|
val path = plansRef(coupleId).document(plan.id)
|
||||||
val data = mapOf(
|
path.set(plan.toEncryptedData(coupleId, includeCreatedAt = false), SetOptions.merge()).voidAwait()
|
||||||
"dateIdeaId" to plan.dateIdeaId,
|
}
|
||||||
"scheduledDate" to plan.scheduledDate,
|
|
||||||
"scheduledTime" to plan.scheduledTime,
|
/**
|
||||||
"budget" to plan.budget,
|
* Builds the Firestore payload with user content encrypted. dateIdeaId / scheduledDate /
|
||||||
"duration" to plan.duration,
|
* status / timestamps stay plaintext because Firestore queries and ordering depend on them.
|
||||||
"status" to plan.status.toFirestoreValue(),
|
*/
|
||||||
"activity" to plan.activity,
|
private fun DatePlan.toEncryptedData(coupleId: String, includeCreatedAt: Boolean): Map<String, Any?> {
|
||||||
"food" to plan.food,
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
"conversationPrompts" to plan.conversationPrompts,
|
val data = mutableMapOf<String, Any?>(
|
||||||
"optionalChallenge" to plan.optionalChallenge,
|
"dateIdeaId" to dateIdeaId,
|
||||||
"updatedAt" to plan.updatedAt
|
"scheduledDate" to scheduledDate,
|
||||||
|
"scheduledTime" to fieldEncryptor.encrypt(scheduledTime, aead, coupleId),
|
||||||
|
"budget" to fieldEncryptor.encrypt(budget.toString(), aead, coupleId),
|
||||||
|
"duration" to fieldEncryptor.encrypt(duration, aead, coupleId),
|
||||||
|
"status" to status.toFirestoreValue(),
|
||||||
|
"activity" to fieldEncryptor.encrypt(activity, aead, coupleId),
|
||||||
|
"food" to fieldEncryptor.encrypt(food, aead, coupleId),
|
||||||
|
"conversationPrompts" to fieldEncryptor.encrypt(JSONArray(conversationPrompts).toString(), aead, coupleId),
|
||||||
|
"optionalChallenge" to fieldEncryptor.encryptNullable(optionalChallenge, aead, coupleId),
|
||||||
|
"updatedAt" to updatedAt
|
||||||
)
|
)
|
||||||
path.set(data, SetOptions.merge()).voidAwait()
|
if (includeCreatedAt) data["createdAt"] = createdAt
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPlan(coupleId: String, planId: String): DatePlan? {
|
suspend fun getPlan(coupleId: String, planId: String): DatePlan? {
|
||||||
|
|
@ -163,39 +169,46 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi
|
||||||
|
|
||||||
// ─── Mappers ─────────────────────────────────────────────────────────────
|
// ─── Mappers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
|
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
|
||||||
val dateIdeaId = getString("dateIdeaId") ?: return null
|
val dateIdeaId = getString("dateIdeaId") ?: return null
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
return DatePlanPreference(
|
return DatePlanPreference(
|
||||||
id = id,
|
id = id,
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
dateIdeaId = dateIdeaId,
|
dateIdeaId = dateIdeaId,
|
||||||
preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L,
|
preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L,
|
||||||
preferredTime = getString("preferredTime") ?: "",
|
preferredTime = fieldEncryptor.decryptForDisplay(getString("preferredTime"), aead, coupleId) ?: "",
|
||||||
budget = (get("budget") as? Number)?.toInt() ?: 0,
|
budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0,
|
||||||
duration = getString("duration") ?: "",
|
duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "",
|
||||||
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
|
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
|
||||||
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
|
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? {
|
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? {
|
||||||
val dateIdeaId = getString("dateIdeaId") ?: return null
|
val dateIdeaId = getString("dateIdeaId") ?: return null
|
||||||
val scheduledDate = (get("scheduledDate") as? Number)?.toLong() ?: 0L
|
val scheduledDate = (get("scheduledDate") as? Number)?.toLong() ?: 0L
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
val prompts = runCatching {
|
||||||
|
val json = fieldEncryptor.decrypt(getString("conversationPrompts"), aead, coupleId)
|
||||||
|
if (json != null) {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} else emptyList()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
return DatePlan(
|
return DatePlan(
|
||||||
id = id,
|
id = id,
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
dateIdeaId = dateIdeaId,
|
dateIdeaId = dateIdeaId,
|
||||||
scheduledDate = scheduledDate,
|
scheduledDate = scheduledDate,
|
||||||
scheduledTime = getString("scheduledTime") ?: "",
|
scheduledTime = fieldEncryptor.decryptForDisplay(getString("scheduledTime"), aead, coupleId) ?: "",
|
||||||
budget = (get("budget") as? Number)?.toInt() ?: 0,
|
budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0,
|
||||||
duration = getString("duration") ?: "",
|
duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "",
|
||||||
status = DatePlanStatus.fromFirestoreValue(getString("status") ?: "draft"),
|
status = DatePlanStatus.fromFirestoreValue(getString("status") ?: "draft"),
|
||||||
activity = getString("activity") ?: "",
|
activity = fieldEncryptor.decryptForDisplay(getString("activity"), aead, coupleId) ?: "",
|
||||||
food = getString("food") ?: "",
|
food = fieldEncryptor.decryptForDisplay(getString("food"), aead, coupleId) ?: "",
|
||||||
conversationPrompts = (get("conversationPrompts") as? List<String>) ?: emptyList(),
|
conversationPrompts = prompts,
|
||||||
optionalChallenge = getString("optionalChallenge"),
|
optionalChallenge = fieldEncryptor.decryptForDisplay(getString("optionalChallenge"), aead, coupleId),
|
||||||
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
|
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
|
||||||
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
|
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import app.closer.domain.repository.LocalAnswerRepository
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -86,6 +87,8 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
private val _uiState = MutableStateFlow(AnswerRevealUiState())
|
private val _uiState = MutableStateFlow(AnswerRevealUiState())
|
||||||
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var partnerObserveJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
observeAnswer()
|
observeAnswer()
|
||||||
|
|
@ -121,6 +124,12 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
sealedRevealPhase = sealedPhase,
|
sealedRevealPhase = sealedPhase,
|
||||||
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Live-watch the partner's answer doc so the reveal completes on this device
|
||||||
|
// the moment they release their key — no manual refresh needed.
|
||||||
|
if (coupleId != null && partnerId != null) {
|
||||||
|
observePartnerAnswerLive(coupleId, partnerId)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
crashReporter.recordException(e)
|
crashReporter.recordException(e)
|
||||||
_uiState.value = AnswerRevealUiState(
|
_uiState.value = AnswerRevealUiState(
|
||||||
|
|
@ -160,6 +169,36 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches the partner's answer doc live. When they submit (BOTH_ANSWERED appears) or
|
||||||
|
* release their reveal key, the sealed phase is recomputed and — if our key is already
|
||||||
|
* out — the partner's answer is decrypted on-device immediately. Decryption never leaves
|
||||||
|
* the client; this only changes *when* it happens, not who can do it.
|
||||||
|
*/
|
||||||
|
private fun observePartnerAnswerLive(coupleId: String, partnerId: String) {
|
||||||
|
partnerObserveJob?.cancel()
|
||||||
|
partnerObserveJob = viewModelScope.launch {
|
||||||
|
val date = effectiveDate(_uiState.value.answer)
|
||||||
|
firestoreAnswerDataSource.observeAnswerForUser(coupleId, partnerId, date).collect { partnerAnswer ->
|
||||||
|
if (partnerAnswer == null) return@collect
|
||||||
|
val current = _uiState.value
|
||||||
|
// Once revealed, the partner answer in state is already decrypted — never
|
||||||
|
// overwrite it with the sealed doc. Also leave an in-flight key release alone.
|
||||||
|
if (current.sealedRevealPhase == SealedRevealPhase.REVEALED ||
|
||||||
|
current.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY
|
||||||
|
) {
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
val newPhase = computeSealedPhase(current.answer, partnerAnswer)
|
||||||
|
_uiState.update { it.copy(partnerAnswer = partnerAnswer, sealedRevealPhase = newPhase) }
|
||||||
|
// Our key is already released and the partner just released theirs → finish now.
|
||||||
|
if (newPhase == SealedRevealPhase.WAITING_FOR_PARTNER) {
|
||||||
|
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshPartnerAnswer() {
|
fun refreshPartnerAnswer() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val coupleId = state.coupleId ?: return
|
val coupleId = state.coupleId ?: return
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package app.closer.ui.questions
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.crypto.SealedRevealManager
|
||||||
import app.closer.data.local.QuestionDao
|
import app.closer.data.local.QuestionDao
|
||||||
import app.closer.data.local.mapper.toQuestion
|
import app.closer.data.local.mapper.toQuestion
|
||||||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||||
|
|
@ -46,6 +47,7 @@ data class QuestionThreadUiState(
|
||||||
class QuestionThreadViewModel @Inject constructor(
|
class QuestionThreadViewModel @Inject constructor(
|
||||||
private val repository: QuestionThreadRepository,
|
private val repository: QuestionThreadRepository,
|
||||||
private val questionDao: QuestionDao,
|
private val questionDao: QuestionDao,
|
||||||
|
private val sealedRevealManager: SealedRevealManager,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -53,6 +55,9 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
private val questionId: String = savedStateHandle["questionId"] ?: ""
|
private val questionId: String = savedStateHandle["questionId"] ?: ""
|
||||||
val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: ""
|
val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: ""
|
||||||
|
|
||||||
|
// Released-once guard for our thread reveal key.
|
||||||
|
private var threadKeyReleased = false
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
QuestionThreadUiState(
|
QuestionThreadUiState(
|
||||||
previousQuestionId = savedStateHandle["prevId"],
|
previousQuestionId = savedStateHandle["prevId"],
|
||||||
|
|
@ -81,20 +86,7 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
repository.observeAnswers(coupleId, threadId).collect { answers ->
|
repository.observeAnswers(coupleId, threadId).collect { answers ->
|
||||||
val my = answers.find { it.userId == currentUserId }
|
handleAnswers(threadId, answers)
|
||||||
val partner = answers.find { it.userId != currentUserId }
|
|
||||||
val phase = when {
|
|
||||||
my == null -> QuestionPhase.INPUT
|
|
||||||
partner == null -> QuestionPhase.WAITING
|
|
||||||
else -> QuestionPhase.REVEALED
|
|
||||||
}
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
phase = phase,
|
|
||||||
myAnswer = my,
|
|
||||||
partnerAnswer = if (phase == QuestionPhase.REVEALED) partner else null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
|
|
@ -113,6 +105,71 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the thread reveal entirely on-device. Both answers are sealed (schemaVersion 3):
|
||||||
|
* once both partners have answered, we release our one-time key (so the partner can decrypt
|
||||||
|
* us) and decrypt their answer with our private key. Our own answer is decrypted with the
|
||||||
|
* one-time key held locally. The server never sees plaintext or any usable key.
|
||||||
|
*/
|
||||||
|
private suspend fun handleAnswers(threadId: String, answers: List<QuestionAnswer>) {
|
||||||
|
val mySealed = answers.find { it.userId == currentUserId }
|
||||||
|
val partnerSealed = answers.find { it.userId != currentUserId }
|
||||||
|
when {
|
||||||
|
mySealed == null ->
|
||||||
|
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
|
||||||
|
|
||||||
|
partnerSealed == null ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Both answered — release our key so the partner can decrypt us, then decrypt theirs.
|
||||||
|
releaseThreadKeyOnce(threadId, partnerSealed.userId)
|
||||||
|
val mine = decryptOwn(threadId, mySealed)
|
||||||
|
val partner = decryptPartner(threadId, partnerSealed)
|
||||||
|
if (partner != null) {
|
||||||
|
_uiState.update { it.copy(phase = QuestionPhase.REVEALED, myAnswer = mine, partnerAnswer = partner) }
|
||||||
|
} else {
|
||||||
|
// Partner answered but hasn't released their key yet — keep waiting.
|
||||||
|
_uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun releaseThreadKeyOnce(threadId: String, partnerId: String) {
|
||||||
|
if (threadKeyReleased) return
|
||||||
|
val released = runCatching {
|
||||||
|
sealedRevealManager.releaseOwnKeyForThread(coupleId, threadId, currentUserId, partnerId)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (released) threadKeyReleased = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun decryptOwn(threadId: String, sealed: QuestionAnswer): QuestionAnswer {
|
||||||
|
val payload = sealed.encryptedPayload ?: return sealed
|
||||||
|
val decrypted = runCatching {
|
||||||
|
sealedRevealManager.decryptOwnThreadAnswer(coupleId, threadId, currentUserId, payload)
|
||||||
|
}.getOrNull() ?: return sealed
|
||||||
|
return sealed.copy(
|
||||||
|
writtenText = decrypted.writtenText,
|
||||||
|
selectedOptionIds = decrypted.selectedOptionIds,
|
||||||
|
scaleValue = decrypted.scaleValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun decryptPartner(threadId: String, sealed: QuestionAnswer): QuestionAnswer? {
|
||||||
|
val payload = sealed.encryptedPayload ?: return null
|
||||||
|
val decrypted = runCatching {
|
||||||
|
sealedRevealManager.decryptPartnerThreadAnswer(coupleId, threadId, sealed.userId, currentUserId, payload)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
return sealed.copy(
|
||||||
|
writtenText = decrypted.writtenText,
|
||||||
|
selectedOptionIds = decrypted.selectedOptionIds,
|
||||||
|
scaleValue = decrypted.scaleValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Answer input mutations ──────────────────────────────────────────────────
|
// ─── Answer input mutations ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fun updateWrittenText(text: String) {
|
fun updateWrittenText(text: String) {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,23 @@ service cloud.firestore {
|
||||||
return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$');
|
return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A field is acceptable if it's absent, null, or enc:v1 ciphertext — never plaintext.
|
||||||
|
function cipherOrAbsent(data, key) {
|
||||||
|
return !(key in data) || data[key] == null || isCiphertext(data[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date plan user content must be ciphertext; dateIdeaId/scheduledDate/status/timestamps
|
||||||
|
// stay plaintext because Firestore queries and ordering depend on them.
|
||||||
|
function isDatePlanContentEncrypted(data) {
|
||||||
|
return cipherOrAbsent(data, 'scheduledTime')
|
||||||
|
&& cipherOrAbsent(data, 'budget')
|
||||||
|
&& cipherOrAbsent(data, 'duration')
|
||||||
|
&& cipherOrAbsent(data, 'activity')
|
||||||
|
&& cipherOrAbsent(data, 'food')
|
||||||
|
&& cipherOrAbsent(data, 'conversationPrompts')
|
||||||
|
&& cipherOrAbsent(data, 'optionalChallenge');
|
||||||
|
}
|
||||||
|
|
||||||
function coupleEncryptionEnabled(coupleId) {
|
function coupleEncryptionEnabled(coupleId) {
|
||||||
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
|
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +428,11 @@ service cloud.firestore {
|
||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'dateIdeaId', 'preferredDate', 'preferredTime',
|
'dateIdeaId', 'preferredDate', 'preferredTime',
|
||||||
'budget', 'duration', 'createdAt', 'updatedAt'
|
'budget', 'duration', 'createdAt', 'updatedAt'
|
||||||
]);
|
])
|
||||||
|
// Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries).
|
||||||
|
&& cipherOrAbsent(request.resource.data, 'preferredTime')
|
||||||
|
&& cipherOrAbsent(request.resource.data, 'budget')
|
||||||
|
&& cipherOrAbsent(request.resource.data, 'duration');
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,7 +448,8 @@ service cloud.firestore {
|
||||||
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
|
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
|
||||||
'createdAt', 'updatedAt'
|
'createdAt', 'updatedAt'
|
||||||
])
|
])
|
||||||
&& isValidDatePlanStatus(request.resource.data.status);
|
&& isValidDatePlanStatus(request.resource.data.status)
|
||||||
|
&& isDatePlanContentEncrypted(request.resource.data);
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
// Only the explicitly-listed fields may change on update.
|
// Only the explicitly-listed fields may change on update.
|
||||||
// createdAt is intentionally absent — it cannot be modified after creation.
|
// createdAt is intentionally absent — it cannot be modified after creation.
|
||||||
|
|
@ -436,7 +458,8 @@ service cloud.firestore {
|
||||||
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
|
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
|
||||||
'updatedAt'
|
'updatedAt'
|
||||||
])
|
])
|
||||||
&& isValidDatePlanStatus(request.resource.data.status);
|
&& isValidDatePlanStatus(request.resource.data.status)
|
||||||
|
&& isDatePlanContentEncrypted(request.resource.data);
|
||||||
allow delete: if isCouplesMember(coupleId);
|
allow delete: if isCouplesMember(coupleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue