diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index bf3f0922..2f35337c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -43,6 +43,7 @@ import press.mantra.compose.extensions.hexToNpubHrp import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.KeyPackageManagementRoute +import press.mantra.compose.ui.composable.navigation.routes.KeyRecoveryRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -269,22 +270,23 @@ fun ActiveProfileScreen( item { TextButton( onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Profile Keys") + KeyRecoveryRoute( + activeUserPublicKey = activeUserPublicKey + ) ) } ) { Icon( Icons.Default.Key, - contentDescription = "Profile Keys" + contentDescription = "Key Recovery" ) Spacer( modifier = Modifier.width(10.dp) ) - Text("Profile Keys") + Text("Key Recovery") } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt new file mode 100644 index 00000000..86da7d00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -0,0 +1,297 @@ +package press.mantra.compose.ui.composable + +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddToDrive +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.LocalHospital +import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.VolunteerActivism +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute +import press.mantra.compose.ui.composable.navigation.routes.Route + +/** + * Everything that can put this profile back on another phone. + * + * A mantra profile is its key: the npub that signs, and the wallet that holds + * coins, both come out of one seed that never leaves the device. There is no + * account to reset, so this screen is the only thing standing between a lost + * phone and a lost identity -- it exists to get the user to take a backup while + * they still can. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun KeyRecoveryScreen( + activeUserPublicKey: HexKey, + activeWalletStateFlow: StateFlow, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, +) { + val activeWallet by activeWalletStateFlow.collectAsState() + + // The same preference the recovery phrase screen writes, so the state of the backup is + // visible before the user goes looking for it. + val showBackupNotice by remember(activeWallet) { + activeWallet?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) + }.collectAsState(initial = false) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Key Recovery" + ) + }, + navigationIcon = { + IconButton( + onClick = { + onNavigateBack.invoke() + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(innerPadding).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "These are your keys. Keep them safe so they can keep unlocking " + + "this profile and its coins, even when you lose or change your phone.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.height(10.dp) + ) + + Text( + text = activeUserPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + } + } + + item { + KeyRecoveryOption( + icon = Icons.Default.Spellcheck, + title = "Recovery Phrase", + description = "Write down and secure the 12 word phrase that this profile and " + + "its wallet are derived from.", + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + status = if (showBackupNotice) { + KeyRecoveryStatus( + icon = Icons.Default.Warning, + text = "Not backed up yet" + ) + } else { + KeyRecoveryStatus( + icon = Icons.Default.CheckCircle, + text = "You said you wrote it down" + ) + }, + onClick = { + onNavigateToRoute.invoke(RecoveryPhraseRoute) + } + ) + } + + item { + KeyRecoveryOption( + icon = Icons.Default.AddToDrive, + title = "Cloud Backup", + description = "Encrypt and back your recovery information up to your Google " + + "Drive or iCloud.", + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Cloud Backup") + ) + } + ) + } + + item { + KeyRecoveryOption( + icon = Icons.Default.LocalHospital, + title = "Emergency Kit", + description = "Download and securely store everything needed to recover this " + + "profile and the coins it holds.", + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Emergency Kit") + ) + } + ) + } + + item { + KeyRecoveryOption( + icon = Icons.Default.VolunteerActivism, + title = "YOLO", + description = "You only live once. Lose this phone and the profile goes with " + + "it, along with anything it holds.", + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + onClick = { + onNavigateBack.invoke() + } + ) + } + + item { + Spacer( + modifier = Modifier.height(10.dp) + ) + } + } + } +} + +/** A one-line verdict on an option, shown under it. */ +private data class KeyRecoveryStatus( + val icon: ImageVector, + val text: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KeyRecoveryOption( + icon: ImageVector, + title: String, + description: String, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + status: KeyRecoveryStatus? = null, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = title + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = description, + style = MaterialTheme.typography.bodySmall + ) + + status?.let { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + it.icon, + contentDescription = null, + modifier = Modifier.width(16.dp) + ) + + Spacer( + modifier = Modifier.width(6.dp) + ) + + Text( + text = it.text, + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } +} + +@Preview +@Composable +private fun KeyRecoveryScreenPreview() { + press.mantra.compose.ui.theme.TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + KeyRecoveryScreen( + activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", + activeWalletStateFlow = MutableStateFlow(null), + onNavigateBack = {}, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt new file mode 100644 index 00000000..cd3e89ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -0,0 +1,498 @@ +package press.mantra.compose.ui.composable + +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.ActiveWallet +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.view.model.RecoveryPhraseViewModel +import press.mantra.compose.ui.view.state.RecoveryPhraseUIState + +private const val PHRASE_INSTRUCTIONS = + "The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only " + + "way back to this profile: the key that signs as you, and the wallet that holds your " + + "coins, are both derived from it.\n\n" + + "Only you have this phrase. Keep it private — nobody from mantra will ever ask you " + + "for it.\n\n" + + "Do not lose it. Write it down and keep it somewhere safe that is not this phone. If " + + "you lose both the phone and the phrase, this profile and its funds are gone for good." + +private const val PHRASE_DERIVATION = + "BIP39 seed with the standard BIP84 derivation path. The profile's nostr key comes off the " + + "same seed, so these 12 words restore both." + +/** + * The twelve words behind this profile, shown once and on request. + * + * The reveal is deliberately a separate step from opening the screen: reading + * the seed goes to the keystore, and nobody should walk past a phone that is + * quietly displaying it. + */ +@Composable +fun RecoveryPhraseScreen( + phoenixGlobal: PhoenixGlobal, + activeWalletStateFlow: StateFlow, + onNavigateBack: () -> Unit, +) { + val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel( + factory = RecoveryPhraseViewModel.factory( + phoenixGlobal = phoenixGlobal, + activeWalletStateFlow = activeWalletStateFlow + ) + ) + + val recoveryPhraseUIState by recoveryPhraseViewModel.recoveryPhraseUIState.collectAsState() + val isBackupDone by recoveryPhraseViewModel.isBackupDone.collectAsState() + val isDisclaimerRead by recoveryPhraseViewModel.isDisclaimerRead.collectAsState() + val showBackupNotice by recoveryPhraseViewModel.showBackupNotice.collectAsState() + + RecoveryPhraseContent( + recoveryPhraseUIState = recoveryPhraseUIState, + isBackupDone = isBackupDone, + isDisclaimerRead = isDisclaimerRead, + showBackupNotice = showBackupNotice, + onRevealRecoveryPhrase = recoveryPhraseViewModel::revealRecoveryPhrase, + onHideRecoveryPhrase = recoveryPhraseViewModel::hideRecoveryPhrase, + onBackupDoneChange = recoveryPhraseViewModel::setBackupDone, + onDisclaimerReadChange = recoveryPhraseViewModel::setDisclaimerRead, + onNavigateBack = onNavigateBack, + ) +} + +/** + * The screen once the state and the two backup preferences have been read. + * Everything the view model owns arrives as a value or a callback, so a preview + * can render any branch without a seed on disk. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun RecoveryPhraseContent( + recoveryPhraseUIState: RecoveryPhraseUIState, + isBackupDone: Boolean?, + isDisclaimerRead: Boolean?, + showBackupNotice: Boolean, + onRevealRecoveryPhrase: () -> Unit, + onHideRecoveryPhrase: () -> Unit, + onBackupDoneChange: (Boolean) -> Unit, + onDisclaimerReadChange: (Boolean) -> Unit, + onNavigateBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Recovery Phrase" + ) + }, + navigationIcon = { + IconButton( + onClick = { + onNavigateBack.invoke() + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(innerPadding).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + text = PHRASE_INSTRUCTIONS, + style = MaterialTheme.typography.bodyMedium + ) + } + + if (showBackupNotice) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Column { + Text( + text = "You have not backed up your recovery phrase", + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = "Lose this phone before you do, and the profile goes " + + "with it.", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (recoveryPhraseUIState) { + is RecoveryPhraseUIState.Hidden -> { + Button( + onClick = onRevealRecoveryPhrase + ) { + Icon( + Icons.Default.Visibility, + contentDescription = "Display recovery phrase" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + text = "Display recovery phrase" + ) + } + } + + is RecoveryPhraseUIState.Revealing -> { + LoadingDataIndicator( + fillScreen = false, + text = "Unlocking your phrase…" + ) + } + + is RecoveryPhraseUIState.Revealed -> { + RecoveryPhraseWords( + words = recoveryPhraseUIState.words + ) + + Spacer( + modifier = Modifier.height(10.dp) + ) + + TextButton( + onClick = onHideRecoveryPhrase + ) { + Icon( + Icons.Default.VisibilityOff, + contentDescription = "Hide recovery phrase" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + text = "Hide" + ) + } + } + + is RecoveryPhraseUIState.Error -> { + Text( + text = when (recoveryPhraseUIState) { + is RecoveryPhraseUIState.Error.NoActiveWallet -> + "No wallet is open on this device, so there is no " + + "phrase to show." + is RecoveryPhraseUIState.Error.NoPhraseForThisWallet -> + "This device holds no phrase for the profile that is " + + "signed in." + is RecoveryPhraseUIState.Error.SeedUnreadable -> + "Could not unlock your phrase. Please try again." + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + + Spacer( + modifier = Modifier.height(10.dp) + ) + + TextButton( + onClick = onRevealRecoveryPhrase + ) { + Text( + text = "Try again" + ) + } + } + } + } + } + } + + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Backup confirmation", + style = MaterialTheme.typography.titleMedium + ) + } + + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + if (isBackupDone == null || isDisclaimerRead == null) { + Text( + modifier = Modifier.fillMaxWidth().padding(16.dp), + text = "Loading preferences…", + style = MaterialTheme.typography.bodySmall + ) + } else { + RecoveryPhraseCheckbox( + checked = isBackupDone, + text = "I have saved my recovery phrase somewhere safe.", + onCheckedChange = onBackupDoneChange + ) + + RecoveryPhraseCheckbox( + checked = isDisclaimerRead, + text = "I understand that if I lose this phone and my recovery " + + "phrase, I lose this profile and the funds in its wallet.", + onCheckedChange = onDisclaimerReadChange + ) + } + } + } + + item { + Text( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + text = PHRASE_DERIVATION, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + } + } + } +} + +/** + * The words themselves, numbered and in two columns so they can be checked off + * against what the user writes down. + */ +@Composable +private fun RecoveryPhraseWords(words: List) { + Text( + text = "KEEP THIS PHRASE SAFE.\nDO NOT SHARE IT.", + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.height(16.dp) + ) + + // Read down the left column and then the right, the way the words are written on a card: + // #1..#6 beside #7..#12 rather than odds beside evens. + val pairedWords = remember(words) { + val half = (words.size + 1) / 2 + List(half) { index -> + words[index] to words.getOrNull(index + half) + } + } + + Column( + modifier = Modifier.widthIn(max = 260.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + pairedWords.forEachIndexed { index, (first, second) -> + Row( + modifier = Modifier.fillMaxWidth() + ) { + RecoveryPhraseWord( + position = index + 1, + word = first, + modifier = Modifier.weight(1f) + ) + + RecoveryPhraseWord( + position = index + pairedWords.size + 1, + word = second, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun RecoveryPhraseWord( + position: Int, + word: String?, + modifier: Modifier = Modifier, +) { + if (word == null) { + Spacer(modifier = modifier) + return + } + + Row( + modifier = modifier + ) { + Text( + modifier = Modifier.width(28.dp), + text = "#$position", + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer( + modifier = Modifier.width(6.dp) + ) + + Text( + text = word, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun RecoveryPhraseCheckbox( + checked: Boolean, + text: String, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview +@Composable +private fun RecoveryPhraseScreenPreview() { + press.mantra.compose.ui.theme.TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + RecoveryPhraseContent( + recoveryPhraseUIState = RecoveryPhraseUIState.Revealed( + words = listOf( + "abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident" + ) + ), + isBackupDone = false, + isDisclaimerRead = true, + showBackupNotice = true, + onRevealRecoveryPhrase = {}, + onHideRecoveryPhrase = {}, + onBackupDoneChange = {}, + onDisclaimerReadChange = {}, + onNavigateBack = {}, + ) + } + } +} + +@Preview +@Composable +private fun RecoveryPhraseScreenHiddenPreview() { + press.mantra.compose.ui.theme.TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + RecoveryPhraseContent( + recoveryPhraseUIState = RecoveryPhraseUIState.Hidden, + isBackupDone = false, + isDisclaimerRead = false, + showBackupNotice = true, + onRevealRecoveryPhrase = {}, + onHideRecoveryPhrase = {}, + onBackupDoneChange = {}, + onDisclaimerReadChange = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index f4a39fe4..477ef988 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -38,9 +38,11 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen import press.mantra.compose.ui.composable.KeyPackageManagementScreen +import press.mantra.compose.ui.composable.KeyRecoveryScreen import press.mantra.compose.ui.composable.LandingScreen import press.mantra.compose.ui.composable.LoadingScreen import press.mantra.compose.ui.composable.NostrEventDetailScreen +import press.mantra.compose.ui.composable.RecoveryPhraseScreen import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen import press.mantra.compose.ui.composable.SearchResultScreen import press.mantra.compose.ui.composable.SearchScreen @@ -71,9 +73,11 @@ import press.mantra.compose.ui.composable.navigation.routes.DkgRound2ApprovalRou import press.mantra.compose.ui.composable.navigation.routes.HomeRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.KeyPackageManagementRoute +import press.mantra.compose.ui.composable.navigation.routes.KeyRecoveryRoute import press.mantra.compose.ui.composable.navigation.routes.LandingRoute import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute @@ -730,6 +734,31 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + KeyRecoveryScreen( + activeUserPublicKey = route.activeUserPublicKey, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { + RecoveryPhraseScreen( + phoenixGlobal = phoenixGlobal, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/KeyRecoveryRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/KeyRecoveryRoute.kt new file mode 100644 index 00000000..51b08738 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/KeyRecoveryRoute.kt @@ -0,0 +1,8 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class KeyRecoveryRoute( + val activeUserPublicKey: String, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/RecoveryPhraseRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/RecoveryPhraseRoute.kt new file mode 100644 index 00000000..15185444 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/RecoveryPhraseRoute.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data object RecoveryPhraseRoute: Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt new file mode 100644 index 00000000..dad1108f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt @@ -0,0 +1,163 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.data.DecryptSeedResult +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.ui.view.state.RecoveryPhraseUIState + +/** + * Reads back the twelve words this device's keys are made of. + * + * The phrase is the whole of the profile: the nostr identity that signs, and the + * wallet that holds coins, are both derived from this one seed, so a device that + * loses it loses the account -- there is no server holding a copy. Everything + * here is therefore about getting the words in front of the user *once*, and + * recording that they say they wrote them down. + * + * The words are never cached: [recoveryPhraseUIState] carries them only while + * the user is looking at them, and [hideRecoveryPhrase] (or leaving the screen, + * which clears the view model) drops them. + */ +class RecoveryPhraseViewModel( + val phoenixGlobal: PhoenixGlobal, + val activeWalletStateFlow: StateFlow, +): ViewModel() { + + private val logger = Logger.withTag(TAG) + + // A StateFlow rather than `mutableStateOf`: this view model is built during composition, and + // the seed read answers from another thread. See SovereignWalletViewModel for the write that + // was lost when that state belonged to the composition's snapshot. + private val _recoveryPhraseUIState = MutableStateFlow( + RecoveryPhraseUIState.Hidden + ) + val recoveryPhraseUIState = _recoveryPhraseUIState.asStateFlow() + + /** + * The backup bookkeeping, which lives per wallet in its internal prefs so it + * survives a re-install of the app on the same seed. + * + * `null` means "not read yet" rather than `false`, so the screen can wait + * instead of drawing an unticked box over a ticked preference. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val isBackupDone: StateFlow = activeWalletStateFlow + .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val isDisclaimerRead: StateFlow = activeWalletStateFlow + .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** True until the user has both written the phrase down and read the disclaimer. */ + @OptIn(ExperimentalCoroutinesApi::class) + val showBackupNotice: StateFlow = activeWalletStateFlow + .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** + * Decrypts the seed file and pulls out the words of the wallet that is open. + * + * The file holds every wallet this device has ever been given, keyed by + * wallet id, so which one to show is a question only the active wallet can + * answer -- without it there is nothing to match against, hence + * [RecoveryPhraseUIState.Error.NoActiveWallet] rather than a guess. + */ + fun revealRecoveryPhrase() { + if (_recoveryPhraseUIState.value is RecoveryPhraseUIState.Revealing) return + + val walletId = activeWalletStateFlow.value?.id + if (walletId == null) { + logger.e { "no active wallet, cannot read a recovery phrase" } + _recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.NoActiveWallet + return + } + + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("failed to read the seed", throwable) + _recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.SeedUnreadable + }) { + _recoveryPhraseUIState.value = RecoveryPhraseUIState.Revealing + + // Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread. + val result = withContext(Dispatchers.IO) { + loadAndDecryptSeed(phoenixGlobal) + } + + _recoveryPhraseUIState.value = when (result) { + is DecryptSeedResult.Success -> { + val userWallet = result.userWalletsMap[walletId] + if (userWallet == null) { + logger.e { "seed file holds no words for wallet=$walletId" } + RecoveryPhraseUIState.Error.NoPhraseForThisWallet + } else { + RecoveryPhraseUIState.Revealed(userWallet.words) + } + } + is DecryptSeedResult.Failure -> { + logger.e { "unable to read the seed: $result" } + RecoveryPhraseUIState.Error.SeedUnreadable + } + } + } + } + + /** Takes the words back off the screen. */ + fun hideRecoveryPhrase() { + _recoveryPhraseUIState.value = RecoveryPhraseUIState.Hidden + } + + fun setBackupDone(isDone: Boolean) { + val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("could not save the backup flag", throwable) + }) { + internalPrefs.saveManualSeedBackupDone(isDone) + } + } + + fun setDisclaimerRead(isRead: Boolean) { + val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("could not save the disclaimer flag", throwable) + }) { + internalPrefs.saveSeedLossDisclaimerRead(isRead) + } + } + + companion object { + private const val TAG = "RecoveryPhraseViewModel" + + fun factory( + phoenixGlobal: PhoenixGlobal, + activeWalletStateFlow: StateFlow, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + RecoveryPhraseViewModel( + phoenixGlobal = phoenixGlobal, + activeWalletStateFlow = activeWalletStateFlow, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/RecoveryPhraseUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/RecoveryPhraseUIState.kt new file mode 100644 index 00000000..715f3b47 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/RecoveryPhraseUIState.kt @@ -0,0 +1,31 @@ +package press.mantra.compose.ui.view.state + +/** + * What the recovery phrase screen is showing. + * + * The phrase is never held here longer than the user is looking at it: + * [Revealed] is the only state that carries words, and leaving the screen (or + * hiding the phrase) drops it back to [Hidden]. + */ +sealed interface RecoveryPhraseUIState { + /** Nothing secret on screen; the user has not asked to see the phrase yet. */ + data object Hidden: RecoveryPhraseUIState + + /** Reading and decrypting the seed file. */ + data object Revealing: RecoveryPhraseUIState + + data class Revealed( + val words: List + ): RecoveryPhraseUIState + + sealed interface Error: RecoveryPhraseUIState { + /** No wallet has been started, so there is no seed to read for it. */ + data object NoActiveWallet: Error + + /** The seed file could not be read or decrypted. */ + data object SeedUnreadable: Error + + /** The seed file was read, but holds no words for the active wallet. */ + data object NoPhraseForThisWallet: Error + } +}