feat(backup): add RestoreViewModel + RestoreRequestScreen + RestoreConsentScreen (partner-assisted restore UI)
This commit is contained in:
parent
ed3c3e4d22
commit
f161fa49a5
|
|
@ -0,0 +1,265 @@
|
|||
package app.closer.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.width
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
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.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.ui.components.CloserGlyphs
|
||||
import app.closer.ui.components.CloserHeartLoader
|
||||
import app.closer.ui.components.StatusGlyph
|
||||
import app.closer.ui.settings.SettingsBackgroundBrush
|
||||
import app.closer.ui.settings.SettingsInk
|
||||
import app.closer.ui.settings.SettingsMuted
|
||||
import app.closer.ui.settings.SettingsOnPrimary
|
||||
import app.closer.ui.settings.SettingsPrimary
|
||||
import app.closer.ui.settings.SettingsPrimaryDeep
|
||||
|
||||
/** Recipient A: request the partner's help, then show the code to read aloud while waiting. */
|
||||
@Composable
|
||||
fun RestoreRequestScreen(
|
||||
onDone: () -> Unit,
|
||||
viewModel: RestoreViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
LaunchedEffect(state.restoreComplete) { if (state.restoreComplete) onDone() }
|
||||
LaunchedEffect(state.error) { state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() } }
|
||||
|
||||
RestoreScaffold(snackbar, icon = CloserGlyphs.Couple, title = "Restore from your partner") {
|
||||
val code = state.verificationCode
|
||||
if (code == null) {
|
||||
Text(
|
||||
"Your partner's phone has your full history. Ask them to help you restore it here — no recovery phrase needed.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = SettingsMuted, textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PrimaryButton("Start restore", loading = state.loading) { viewModel.startPartnerRestore() }
|
||||
} else {
|
||||
Text("Read this code to your partner", style = MaterialTheme.typography.bodyMedium,
|
||||
color = SettingsInk, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(code, fontSize = 40.sp, fontWeight = FontWeight.Bold, color = SettingsPrimaryDeep, letterSpacing = 8.sp)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Call or text your partner, read them these 6 digits, and ask them to approve on their phone. Keeping the code on a separate channel is what keeps this secure.",
|
||||
style = MaterialTheme.typography.bodySmall, color = SettingsMuted, textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
CloserHeartLoader(size = 28.dp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Waiting for your partner…", style = MaterialTheme.typography.bodySmall, color = SettingsMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Partner B: an incoming restore request — enter the code A reads aloud, then approve. */
|
||||
@Composable
|
||||
fun RestoreConsentScreen(
|
||||
onDone: () -> Unit,
|
||||
viewModel: RestoreViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) { viewModel.observeIncomingRequest() }
|
||||
LaunchedEffect(state.consentComplete) { if (state.consentComplete) onDone() }
|
||||
LaunchedEffect(state.error) { state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() } }
|
||||
|
||||
RestoreScaffold(snackbar, icon = CloserGlyphs.Lock, title = "Help your partner restore") {
|
||||
when {
|
||||
// Wait for the first observation before deciding what to show (avoids flashing "no request").
|
||||
!state.consentChecked -> {
|
||||
CloserHeartLoader(size = 28.dp)
|
||||
}
|
||||
// Reached here via a stale notification / deep-link with nothing pending, or already handled.
|
||||
!state.hasIncomingRequest -> {
|
||||
Text(
|
||||
if (state.requestExpired)
|
||||
"This request expired. Ask your partner to start the restore again on their new device."
|
||||
else
|
||||
"There's no restore request waiting right now. When your partner starts one, we'll show it here.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = SettingsMuted, textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
else -> ConsentContent(state, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The active-request body: who's asking (email = the anchor), the code, and an explicit confirm gate. */
|
||||
@Composable
|
||||
private fun ConsentContent(state: RestoreUiState, viewModel: RestoreViewModel) {
|
||||
// Identity card — email is the anti-impersonation anchor (hard to forge), name is secondary + editable.
|
||||
RecipientCard(state)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Ask them for the 6-digit code on their phone and read it back over a call or text. Only approve if you actually reached them — this hands over your shared key.",
|
||||
style = MaterialTheme.typography.bodySmall, color = SettingsMuted, textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedTextField(
|
||||
value = state.enteredCode,
|
||||
onValueChange = viewModel::onEnteredCodeChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("6-digit code from your partner") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = SettingsPrimaryDeep,
|
||||
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
|
||||
focusedLabelColor = SettingsPrimaryDeep,
|
||||
unfocusedLabelColor = SettingsMuted,
|
||||
cursorColor = SettingsPrimaryDeep
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
val confirmLabel = state.recipientName?.let { "I reached $it directly and this is their account." }
|
||||
?: "I reached my partner directly and this is their account."
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.onConfirmChanged(!state.confirmed) },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.confirmed,
|
||||
onCheckedChange = { viewModel.onConfirmChanged(it) },
|
||||
colors = CheckboxDefaults.colors(checkedColor = SettingsPrimaryDeep, uncheckedColor = SettingsMuted)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(confirmLabel, style = MaterialTheme.typography.bodySmall, color = SettingsInk)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
PrimaryButton(
|
||||
"Approve restore",
|
||||
loading = state.loading,
|
||||
enabled = state.enteredCode.length == 6 && state.confirmed
|
||||
) { viewModel.approvePartnerRestore() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientCard(state: RestoreUiState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(SettingsPrimary.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Setting up on a new device", style = MaterialTheme.typography.labelMedium, color = SettingsMuted)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
if (state.identityUnavailable) {
|
||||
Text(
|
||||
"Couldn't load their profile — confirm by voice before approving.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = SettingsInk, textAlign = TextAlign.Center
|
||||
)
|
||||
} else {
|
||||
state.recipientEmail?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium, color = SettingsInk,
|
||||
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
state.recipientName?.let {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = SettingsMuted,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Make sure this is your partner's real account before approving.",
|
||||
style = MaterialTheme.typography.bodySmall, color = SettingsMuted, textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreScaffold(
|
||||
snackbar: SnackbarHostState,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
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 = icon, tint = SettingsPrimaryDeep, container = SettingsPrimary.copy(alpha = 0.12f))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, color = SettingsInk,
|
||||
fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryButton(label: String, loading: Boolean, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled && !loading,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = SettingsPrimary, contentColor = SettingsOnPrimary)
|
||||
) {
|
||||
if (loading) CloserHeartLoader(size = 22.dp) else Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
package app.closer.ui.pairing
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.data.backup.BackupRestoreManager
|
||||
import app.closer.data.backup.RestoreManager
|
||||
import app.closer.crypto.FieldEncryptor
|
||||
import app.closer.data.remote.FirestoreBackupDataSource
|
||||
import app.closer.domain.model.RestoreStatus
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.UserRepository
|
||||
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 RestoreUiState(
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
// Recipient (A) — request side
|
||||
val verificationCode: String? = null,
|
||||
val waitingForPartner: Boolean = false,
|
||||
val restoreComplete: Boolean = false,
|
||||
val restoredCount: Int? = null,
|
||||
// Partner (B) — consent side
|
||||
val consentChecked: Boolean = false, // observed the request state at least once
|
||||
val hasIncomingRequest: Boolean = false, // a live REQUESTED (non-expired) request exists
|
||||
val requestExpired: Boolean = false, // the request exists but its TTL has passed
|
||||
val recipientName: String? = null, // decrypted locally (Sam holds the couple key)
|
||||
val recipientEmail: String? = null, // plaintext — the anti-impersonation anchor
|
||||
val identityUnavailable: Boolean = false, // couldn't load who's requesting → confirm by voice
|
||||
val enteredCode: String = "",
|
||||
val confirmed: Boolean = false, // "I reached them directly and this is their account"
|
||||
val consentComplete: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives partner-assisted restore for both roles + plain self-restore. See [RestoreManager] /
|
||||
* [BackupRestoreManager]. Never surfaces key material — only UI state + a short code.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RestoreViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val restoreManager: RestoreManager,
|
||||
private val backupRestoreManager: BackupRestoreManager,
|
||||
private val backupDataSource: FirestoreBackupDataSource
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RestoreUiState())
|
||||
val uiState: StateFlow<RestoreUiState> = _uiState.asStateFlow()
|
||||
|
||||
// ─── Recipient A: request help + wait for the partner's keybox ───────────
|
||||
fun startPartnerRestore() {
|
||||
_uiState.update { it.copy(loading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
restoreManager.requestRestore().fold(
|
||||
onSuccess = { session ->
|
||||
_uiState.update {
|
||||
it.copy(loading = false, verificationCode = session.verificationCode, waitingForPartner = true)
|
||||
}
|
||||
// Observe our own request; complete once the partner writes the keybox.
|
||||
backupDataSource.observeRestoreRequest(session.coupleId, session.recipientUid).collect { req ->
|
||||
if (req != null && req.status == RestoreStatus.READY && req.keybox != null) {
|
||||
restoreManager.completeRestore(session, req).fold(
|
||||
onSuccess = { result ->
|
||||
val count = (result as? BackupRestoreManager.RestoreResult.Success)?.restored
|
||||
_uiState.update {
|
||||
it.copy(waitingForPartner = false, restoreComplete = true, restoredCount = count)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(waitingForPartner = false, error = friendly(e)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { e -> _uiState.update { it.copy(loading = false, error = friendly(e)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Partner B: observe an incoming request + approve with the OOB code ───
|
||||
fun observeIncomingRequest() {
|
||||
viewModelScope.launch {
|
||||
val uid = authRepository.currentUserId ?: return@launch
|
||||
val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch
|
||||
val partnerUid = couple.userIds.firstOrNull { it != uid } ?: return@launch
|
||||
// Resolve WHO is requesting exactly once — Sam holds the couple key, so getUser() decrypts the
|
||||
// recipient's displayName at the datasource chokepoint; email is plaintext. The SERVER never sees
|
||||
// either in the clear, so this identity check is necessarily client-side.
|
||||
val partner = runCatching { userRepository.getUser(partnerUid) }.getOrNull()
|
||||
val email = partner?.email?.takeIf { it.isNotBlank() }
|
||||
val name = partner?.displayName
|
||||
?.takeIf { it.isNotBlank() && it != FieldEncryptor.LOCKED_PLACEHOLDER }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
recipientName = name,
|
||||
recipientEmail = email,
|
||||
identityUnavailable = partner == null || (name == null && email == null)
|
||||
)
|
||||
}
|
||||
backupDataSource.observeRestoreRequest(couple.id, partnerUid).collect { req ->
|
||||
val now = System.currentTimeMillis()
|
||||
val isRequested = req != null && req.status == RestoreStatus.REQUESTED
|
||||
val expired = isRequested && req!!.expiresAt in 1..now
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
consentChecked = true,
|
||||
// Only a live, un-expired REQUESTED doc is actionable for consent.
|
||||
hasIncomingRequest = isRequested && !expired,
|
||||
requestExpired = expired
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEnteredCodeChanged(code: String) = _uiState.update { it.copy(enteredCode = code.filter { c -> c.isDigit() }.take(6)) }
|
||||
|
||||
fun onConfirmChanged(value: Boolean) = _uiState.update { it.copy(confirmed = value) }
|
||||
|
||||
fun approvePartnerRestore() {
|
||||
val current = _uiState.value
|
||||
// Defensive: the button is gated on both, but never wrap the key without an explicit confirm + full code.
|
||||
if (!current.confirmed || current.enteredCode.length != 6) return
|
||||
val code = current.enteredCode
|
||||
_uiState.update { it.copy(loading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val uid = authRepository.currentUserId
|
||||
val couple = uid?.let { coupleRepository.getCoupleForUser(it) }
|
||||
val partnerUid = couple?.userIds?.firstOrNull { it != uid }
|
||||
if (couple == null || partnerUid == null) {
|
||||
_uiState.update { it.copy(loading = false, error = "Couldn't load your couple. Try again.") }
|
||||
return@launch
|
||||
}
|
||||
restoreManager.fulfillRestore(couple.id, partnerUid, code).fold(
|
||||
onSuccess = { _uiState.update { it.copy(loading = false, consentComplete = true) } },
|
||||
onFailure = { e -> _uiState.update { it.copy(loading = false, error = friendly(e)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Self-restore (key already present, e.g. after phrase recovery) ───────
|
||||
fun restoreHistory() {
|
||||
_uiState.update { it.copy(loading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
when (val r = backupRestoreManager.restore()) {
|
||||
is BackupRestoreManager.RestoreResult.Success ->
|
||||
_uiState.update { it.copy(loading = false, restoreComplete = true, restoredCount = r.restored) }
|
||||
is BackupRestoreManager.RestoreResult.NothingToRestore ->
|
||||
_uiState.update { it.copy(loading = false, restoreComplete = true, restoredCount = 0) }
|
||||
is BackupRestoreManager.RestoreResult.Unavailable ->
|
||||
_uiState.update { it.copy(loading = false, error = "Restore isn't available yet: ${r.reason}") }
|
||||
is BackupRestoreManager.RestoreResult.Failed ->
|
||||
_uiState.update { it.copy(loading = false, error = "Couldn't restore right now. Try again.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
private fun friendly(e: Throwable): String = when {
|
||||
e.message?.contains("code does not match", ignoreCase = true) == true ->
|
||||
"That code doesn't match. Ask your partner to read it again."
|
||||
e.message?.contains("expired", ignoreCase = true) == true ->
|
||||
"This request expired — ask them to start again."
|
||||
else -> "Something went wrong. Please try again."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package app.closer.ui.pairing
|
||||
|
||||
import app.closer.crypto.FieldEncryptor
|
||||
import app.closer.data.backup.BackupRestoreManager
|
||||
import app.closer.data.backup.RestoreManager
|
||||
import app.closer.data.remote.FirestoreBackupDataSource
|
||||
import app.closer.domain.model.Couple
|
||||
import app.closer.domain.model.RestoreRequest
|
||||
import app.closer.domain.model.RestoreStatus
|
||||
import app.closer.domain.model.User
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.UserRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the partner-consent hardening: the recipient's identity (email = the anti-impersonation anchor,
|
||||
* name secondary) is resolved for the consent screen, the expired/no-request states are distinguished, and
|
||||
* the couple key is never wrapped without BOTH the 6-digit code and an explicit confirmation.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RestoreViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
private val authRepository: AuthRepository = mockk()
|
||||
private val coupleRepository: CoupleRepository = mockk()
|
||||
private val userRepository: UserRepository = mockk()
|
||||
private val restoreManager: RestoreManager = mockk()
|
||||
private val backupRestoreManager: BackupRestoreManager = mockk(relaxed = true)
|
||||
private val backupDataSource: FirestoreBackupDataSource = mockk()
|
||||
|
||||
private val couple = Couple(id = "c1", userIds = listOf("uidSam", "uidQA"))
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
every { authRepository.currentUserId } returns "uidSam"
|
||||
coEvery { coupleRepository.getCoupleForUser("uidSam") } returns couple
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun viewModel() = RestoreViewModel(
|
||||
authRepository, coupleRepository, userRepository, restoreManager, backupRestoreManager, backupDataSource
|
||||
)
|
||||
|
||||
private fun requested(expiresAt: Long) = RestoreRequest(
|
||||
recipientUid = "uidQA", recipientPublicKey = "pub:v1:x", requestNonce = "n",
|
||||
status = RestoreStatus.REQUESTED, createdAt = 1L, expiresAt = expiresAt
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `resolves recipient identity and marks a live request active`() = runTest(dispatcher) {
|
||||
coEvery { userRepository.getUser("uidQA") } returns
|
||||
User(id = "uidQA", email = "qa@example.com", displayName = "QA")
|
||||
every { backupDataSource.observeRestoreRequest("c1", "uidQA") } returns
|
||||
flowOf(requested(expiresAt = System.currentTimeMillis() + 60_000))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.observeIncomingRequest()
|
||||
advanceUntilIdle()
|
||||
|
||||
val s = vm.uiState.value
|
||||
assertEquals("qa@example.com", s.recipientEmail)
|
||||
assertEquals("QA", s.recipientName)
|
||||
assertFalse(s.identityUnavailable)
|
||||
assertTrue(s.hasIncomingRequest)
|
||||
assertFalse(s.requestExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identity unavailable when the profile lookup fails`() = runTest(dispatcher) {
|
||||
coEvery { userRepository.getUser("uidQA") } throws RuntimeException("network")
|
||||
every { backupDataSource.observeRestoreRequest("c1", "uidQA") } returns
|
||||
flowOf(requested(expiresAt = System.currentTimeMillis() + 60_000))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.observeIncomingRequest()
|
||||
advanceUntilIdle()
|
||||
|
||||
val s = vm.uiState.value
|
||||
assertTrue(s.identityUnavailable)
|
||||
assertNull(s.recipientEmail)
|
||||
assertNull(s.recipientName)
|
||||
assertTrue(s.hasIncomingRequest) // the request is still actionable behind the confirm gate
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `name falls back to email when displayName cannot be decrypted`() = runTest(dispatcher) {
|
||||
coEvery { userRepository.getUser("uidQA") } returns
|
||||
User(id = "uidQA", email = "qa@example.com", displayName = FieldEncryptor.LOCKED_PLACEHOLDER)
|
||||
every { backupDataSource.observeRestoreRequest("c1", "uidQA") } returns
|
||||
flowOf(requested(expiresAt = System.currentTimeMillis() + 60_000))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.observeIncomingRequest()
|
||||
advanceUntilIdle()
|
||||
|
||||
val s = vm.uiState.value
|
||||
assertNull(s.recipientName)
|
||||
assertEquals("qa@example.com", s.recipientEmail)
|
||||
assertFalse(s.identityUnavailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired request is not actionable`() = runTest(dispatcher) {
|
||||
coEvery { userRepository.getUser("uidQA") } returns User(id = "uidQA", email = "qa@example.com")
|
||||
every { backupDataSource.observeRestoreRequest("c1", "uidQA") } returns
|
||||
flowOf(requested(expiresAt = System.currentTimeMillis() - 1_000))
|
||||
|
||||
val vm = viewModel()
|
||||
vm.observeIncomingRequest()
|
||||
advanceUntilIdle()
|
||||
|
||||
val s = vm.uiState.value
|
||||
assertFalse(s.hasIncomingRequest)
|
||||
assertTrue(s.requestExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approve is a no-op without both the full code and confirmation`() = runTest(dispatcher) {
|
||||
val vm = viewModel()
|
||||
|
||||
vm.onEnteredCodeChanged("123456") // code ok, but not confirmed
|
||||
vm.approvePartnerRestore()
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.onConfirmChanged(true)
|
||||
vm.onEnteredCodeChanged("12345") // confirmed, but code too short
|
||||
vm.approvePartnerRestore()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { restoreManager.fulfillRestore(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approve wraps the key only with code and confirmation`() = runTest(dispatcher) {
|
||||
coEvery { restoreManager.fulfillRestore("c1", "uidQA", "123456") } returns Result.success(Unit)
|
||||
val vm = viewModel()
|
||||
|
||||
vm.onEnteredCodeChanged("123456")
|
||||
vm.onConfirmChanged(true)
|
||||
vm.approvePartnerRestore()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { restoreManager.fulfillRestore("c1", "uidQA", "123456") }
|
||||
assertTrue(vm.uiState.value.consentComplete)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue