From f161fa49a577b3abb8842ecc73a0174eb9971a78 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 20:43:02 -0500 Subject: [PATCH] feat(backup): add RestoreViewModel + RestoreRequestScreen + RestoreConsentScreen (partner-assisted restore UI) --- .../app/closer/ui/pairing/RestoreScreens.kt | 265 ++++++++++++++++++ .../app/closer/ui/pairing/RestoreViewModel.kt | 176 ++++++++++++ .../closer/ui/pairing/RestoreViewModelTest.kt | 169 +++++++++++ 3 files changed, 610 insertions(+) create mode 100644 app/src/main/java/app/closer/ui/pairing/RestoreScreens.kt create mode 100644 app/src/main/java/app/closer/ui/pairing/RestoreViewModel.kt create mode 100644 app/src/test/java/app/closer/ui/pairing/RestoreViewModelTest.kt diff --git a/app/src/main/java/app/closer/ui/pairing/RestoreScreens.kt b/app/src/main/java/app/closer/ui/pairing/RestoreScreens.kt new file mode 100644 index 00000000..f3d52215 --- /dev/null +++ b/app/src/main/java/app/closer/ui/pairing/RestoreScreens.kt @@ -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) + } +} diff --git a/app/src/main/java/app/closer/ui/pairing/RestoreViewModel.kt b/app/src/main/java/app/closer/ui/pairing/RestoreViewModel.kt new file mode 100644 index 00000000..50113b7e --- /dev/null +++ b/app/src/main/java/app/closer/ui/pairing/RestoreViewModel.kt @@ -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 = _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." + } +} diff --git a/app/src/test/java/app/closer/ui/pairing/RestoreViewModelTest.kt b/app/src/test/java/app/closer/ui/pairing/RestoreViewModelTest.kt new file mode 100644 index 00000000..b62f6ef3 --- /dev/null +++ b/app/src/test/java/app/closer/ui/pairing/RestoreViewModelTest.kt @@ -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) + } +}