feat(backup): add RestoreViewModel + RestoreRequestScreen + RestoreConsentScreen (partner-assisted restore UI)

This commit is contained in:
null 2026-06-30 20:43:02 -05:00
parent ed3c3e4d22
commit f161fa49a5
3 changed files with 610 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -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."
}
}

View File

@ -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)
}
}