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