320 lines
12 KiB
Kotlin
320 lines
12 KiB
Kotlin
package app.closer.ui.answers
|
|
|
|
import androidx.lifecycle.SavedStateHandle
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import app.closer.core.navigation.AppRoute
|
|
import app.closer.core.crash.CrashReporter
|
|
import app.closer.crypto.PendingAnswerKeyStore
|
|
import app.closer.crypto.SealedRevealManager
|
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
|
import app.closer.domain.model.LocalAnswer
|
|
import app.closer.domain.model.Question
|
|
import app.closer.domain.repository.AuthRepository
|
|
import app.closer.domain.repository.CoupleRepository
|
|
import app.closer.domain.repository.LocalAnswerRepository
|
|
import app.closer.domain.repository.QuestionRepository
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
import javax.inject.Inject
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.update
|
|
import kotlinx.coroutines.launch
|
|
|
|
enum class FollowUpOption(val label: String, val route: String? = null) {
|
|
DEEPER_FOLLOW_UP("Want to ask one deeper follow-up?", AppRoute.QUESTION_COMPOSER),
|
|
DATE_IDEA("Want to turn this into a date idea?", AppRoute.DATE_BUILDER),
|
|
SAVE_MEMORY("Want to save this as a memory?", AppRoute.MEMORY_LANE),
|
|
ANOTHER_QUESTION("Want to try another question from this category?")
|
|
}
|
|
|
|
/**
|
|
* Tracks where we are in the sealed-answer reveal exchange.
|
|
*
|
|
* State machine:
|
|
* NONE — answer is enc:v1: (schemaVersion 2); use existing non-sealed reveal path
|
|
* ANSWER_SEALED — own answer submitted; partner has not answered yet
|
|
* BOTH_ANSWERED — both answers submitted; "Reveal is ready" but keys not released yet
|
|
* RELEASING_KEY — writing our one-time key to Firestore (in-flight)
|
|
* WAITING_FOR_PARTNER — our key released; partner has not released theirs yet
|
|
* REVEALED — both keys released, partner answer decrypted and visible
|
|
* LOST_LOCAL_KEY — the pending answer key is missing from this device (unrecoverable)
|
|
*/
|
|
enum class SealedRevealPhase {
|
|
NONE,
|
|
ANSWER_SEALED,
|
|
BOTH_ANSWERED,
|
|
RELEASING_KEY,
|
|
WAITING_FOR_PARTNER,
|
|
REVEALED,
|
|
LOST_LOCAL_KEY
|
|
}
|
|
|
|
data class AnswerRevealUiState(
|
|
val isLoading: Boolean = true,
|
|
val error: String? = null,
|
|
val question: Question? = null,
|
|
val answer: LocalAnswer? = null,
|
|
val partnerAnswer: LocalAnswer? = null,
|
|
val coupleId: String? = null,
|
|
val partnerId: String? = null,
|
|
val followUpOptions: List<FollowUpOption> = emptyList(),
|
|
val snackbarMessage: String? = null,
|
|
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
|
|
)
|
|
|
|
@HiltViewModel
|
|
class AnswerRevealViewModel @Inject constructor(
|
|
private val questionRepository: QuestionRepository,
|
|
private val localAnswerRepository: LocalAnswerRepository,
|
|
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
|
|
private val authRepository: AuthRepository,
|
|
private val coupleRepository: CoupleRepository,
|
|
private val crashReporter: CrashReporter,
|
|
private val sealedRevealManager: SealedRevealManager,
|
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
|
savedStateHandle: SavedStateHandle
|
|
) : ViewModel() {
|
|
|
|
private val questionId: String = savedStateHandle["questionId"] ?: ""
|
|
|
|
private val _uiState = MutableStateFlow(AnswerRevealUiState())
|
|
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
|
|
|
|
init {
|
|
load()
|
|
observeAnswer()
|
|
}
|
|
|
|
private fun load() {
|
|
viewModelScope.launch {
|
|
_uiState.value = AnswerRevealUiState(isLoading = true)
|
|
try {
|
|
val (coupleId, partnerId) = resolveCoupleAndPartner()
|
|
val question = questionRepository.getQuestionById(questionId)
|
|
val answer = localAnswerRepository.getAnswer(questionId)
|
|
val partnerAnswer = if (coupleId != null && partnerId != null) {
|
|
runCatching {
|
|
firestoreAnswerDataSource.getAnswerForUser(
|
|
coupleId = coupleId,
|
|
userId = partnerId,
|
|
date = effectiveDate(answer)
|
|
)
|
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
|
} else null
|
|
|
|
val sealedPhase = computeSealedPhase(answer, partnerAnswer)
|
|
val category = answer?.category ?: question?.category ?: ""
|
|
|
|
_uiState.value = AnswerRevealUiState(
|
|
isLoading = false,
|
|
question = question,
|
|
answer = answer,
|
|
partnerAnswer = partnerAnswer,
|
|
coupleId = coupleId,
|
|
partnerId = partnerId,
|
|
sealedRevealPhase = sealedPhase,
|
|
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
|
)
|
|
} catch (e: Exception) {
|
|
crashReporter.recordException(e)
|
|
_uiState.value = AnswerRevealUiState(
|
|
isLoading = false,
|
|
error = e.message ?: "Could not load this reveal."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
|
|
if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
|
|
if (answer.isRevealed) return SealedRevealPhase.REVEALED
|
|
if (!pendingAnswerKeyStore.hasPendingKey(questionId)) return SealedRevealPhase.LOST_LOCAL_KEY
|
|
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
|
|
}
|
|
|
|
private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> {
|
|
val userId = authRepository.currentUserId ?: return null to null
|
|
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
|
|
.onFailure { crashReporter.recordException(it) }
|
|
.getOrNull()
|
|
?: return null to null
|
|
val partnerId = couple.userIds.firstOrNull { it != userId }
|
|
return couple.id to partnerId
|
|
}
|
|
|
|
private fun observeAnswer() {
|
|
viewModelScope.launch {
|
|
localAnswerRepository.observeAnswer(questionId).collect { answer ->
|
|
_uiState.update { it.copy(answer = answer) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fun refreshPartnerAnswer() {
|
|
val state = _uiState.value
|
|
val coupleId = state.coupleId ?: return
|
|
val partnerId = state.partnerId ?: return
|
|
viewModelScope.launch {
|
|
if (state.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY) return@launch
|
|
if (state.sealedRevealPhase == SealedRevealPhase.WAITING_FOR_PARTNER) {
|
|
tryDecryptPartnerAnswer(coupleId, partnerId, state)
|
|
return@launch
|
|
}
|
|
val partnerAnswer = runCatching {
|
|
firestoreAnswerDataSource.getAnswerForUser(
|
|
coupleId = coupleId,
|
|
userId = partnerId,
|
|
date = effectiveDate(state.answer)
|
|
)
|
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
|
partnerAnswer?.let { pa ->
|
|
val newPhase = computeSealedPhase(state.answer, pa)
|
|
_uiState.update { it.copy(partnerAnswer = pa, sealedRevealPhase = newPhase) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fun revealAnswer() {
|
|
val state = _uiState.value
|
|
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
|
performSealedReveal(state)
|
|
} else {
|
|
performLegacyReveal()
|
|
}
|
|
}
|
|
|
|
private fun performSealedReveal(state: AnswerRevealUiState) {
|
|
val coupleId = state.coupleId ?: return
|
|
val partnerId = state.partnerId ?: return
|
|
val userId = authRepository.currentUserId ?: return
|
|
val date = effectiveDate(state.answer)
|
|
|
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.RELEASING_KEY) }
|
|
|
|
viewModelScope.launch {
|
|
val released = runCatching {
|
|
sealedRevealManager.releaseOwnKey(
|
|
coupleId = coupleId,
|
|
date = date,
|
|
questionId = questionId,
|
|
userId = userId,
|
|
partnerId = partnerId
|
|
)
|
|
}.onFailure { crashReporter.recordException(it) }.getOrDefault(false)
|
|
|
|
if (!released) {
|
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.LOST_LOCAL_KEY) }
|
|
return@launch
|
|
}
|
|
|
|
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
|
|
}
|
|
}
|
|
|
|
private suspend fun tryDecryptPartnerAnswer(
|
|
coupleId: String,
|
|
partnerId: String,
|
|
state: AnswerRevealUiState
|
|
) {
|
|
val userId = authRepository.currentUserId ?: return
|
|
val encryptedPayload = state.partnerAnswer?.encryptedPayload
|
|
if (encryptedPayload == null) {
|
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
|
|
return
|
|
}
|
|
|
|
val payload = runCatching {
|
|
sealedRevealManager.decryptPartnerAnswer(
|
|
coupleId = coupleId,
|
|
date = effectiveDate(state.partnerAnswer),
|
|
questionId = questionId,
|
|
partnerId = partnerId,
|
|
userId = userId,
|
|
encryptedPayload = encryptedPayload
|
|
)
|
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
|
|
|
if (payload == null) {
|
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
|
|
return
|
|
}
|
|
|
|
val decryptedPartnerAnswer = state.partnerAnswer?.copy(
|
|
writtenText = payload.writtenText,
|
|
selectedOptionIds = payload.selectedOptionIds,
|
|
scaleValue = payload.scaleValue,
|
|
isSealed = false
|
|
)
|
|
|
|
localAnswerRepository.markRevealed(questionId)
|
|
val ownAnswer = localAnswerRepository.getAnswer(questionId)
|
|
val category = ownAnswer?.category ?: state.question?.category ?: ""
|
|
|
|
_uiState.update {
|
|
it.copy(
|
|
answer = ownAnswer,
|
|
partnerAnswer = decryptedPartnerAnswer,
|
|
sealedRevealPhase = SealedRevealPhase.REVEALED,
|
|
followUpOptions = generateFollowUpOptions(ownAnswer, decryptedPartnerAnswer, category)
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun performLegacyReveal() {
|
|
viewModelScope.launch {
|
|
localAnswerRepository.markRevealed(questionId)
|
|
val answer = localAnswerRepository.getAnswer(questionId)
|
|
val partnerAnswer = _uiState.value.partnerAnswer
|
|
val category = answer?.category ?: _uiState.value.question?.category ?: ""
|
|
_uiState.update {
|
|
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun generateFollowUpOptions(
|
|
answer: LocalAnswer?,
|
|
partnerAnswer: LocalAnswer?,
|
|
category: String
|
|
): List<FollowUpOption> {
|
|
if (answer == null || !answer.isRevealed) return emptyList()
|
|
val bothRevealed = partnerAnswer?.isRevealed == true
|
|
val options = mutableListOf<FollowUpOption>()
|
|
options += FollowUpOption.DEEPER_FOLLOW_UP
|
|
options += if (category.isNotBlank()) {
|
|
FollowUpOption.ANOTHER_QUESTION
|
|
} else if (bothRevealed) {
|
|
FollowUpOption.DATE_IDEA
|
|
} else {
|
|
FollowUpOption.SAVE_MEMORY
|
|
}
|
|
return options.take(2)
|
|
}
|
|
|
|
fun onFollowUpSelected(option: FollowUpOption, onNavigate: (String) -> Unit) {
|
|
val route = option.route
|
|
if (route == null && option == FollowUpOption.ANOTHER_QUESTION) {
|
|
val category = _uiState.value.answer?.category
|
|
?: _uiState.value.question?.category
|
|
?: return
|
|
onNavigate(AppRoute.questionCategory(category))
|
|
return
|
|
}
|
|
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
|
|
}
|
|
|
|
fun showSnackbar(message: String) {
|
|
_uiState.update { it.copy(snackbarMessage = message) }
|
|
}
|
|
|
|
fun clearSnackbar() {
|
|
_uiState.update { it.copy(snackbarMessage = null) }
|
|
}
|
|
|
|
private fun effectiveDate(answer: LocalAnswer?): String =
|
|
answer?.answerDate?.takeIf { it.isNotBlank() }
|
|
?: FirestoreAnswerDataSource.todayLocalDateString()
|
|
}
|