From c7b2dd25d934e143db1d5c5871e4a368236fb847 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 09:16:54 +0200 Subject: [PATCH] feat: give the profile's key entry a recovery screen instead of a dead end "Profile Keys" was an ImplementationPendingRoute: a key icon that led nowhere. It is now "Key Recovery", and it opens the recovery hub the Machankura app already has -- recovery phrase, cloud backup, emergency kit, and YOLO -- because a mantra profile *is* its seed. The npub that signs and the wallet that holds coins both come off one seed that never leaves the device, so a phone lost before a backup takes the account with it, and there is no server to ask for it back. The recovery phrase screen is the part that does the work: it decrypts the seed file through the existing loadAndDecryptSeed expect/actual, matches the active wallet's id against it, and shows that wallet's 12 words numbered in two columns behind an explicit reveal. The two backup confirmations are stored in that wallet's InternalPrefs, so they survive a re-install on the same seed, and the not-backed-up warning they clear is also what the hub reads for its status line. Cloud backup and the emergency kit route to the app's "coming soon" screen; they are placeholders in Machankura too. The read runs on Dispatchers.IO -- it is keystore-backed and blocks -- and the state it writes is a MutableStateFlow rather than a mutableStateOf, because a ViewModel built during composition silently loses an off-main-thread write made inside that first composition. Co-Authored-By: Claude Opus 5 --- .../ui/composable/ActiveProfileScreen.kt | 10 +- .../ui/composable/KeyRecoveryScreen.kt | 297 +++++++++++ .../ui/composable/RecoveryPhraseScreen.kt | 498 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 29 + .../navigation/routes/KeyRecoveryRoute.kt | 8 + .../navigation/routes/RecoveryPhraseRoute.kt | 6 + .../ui/view/model/RecoveryPhraseViewModel.kt | 163 ++++++ .../ui/view/state/RecoveryPhraseUIState.kt | 31 ++ 8 files changed, 1038 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/KeyRecoveryRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/RecoveryPhraseRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/RecoveryPhraseUIState.kt 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 + } +}