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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 09:16:54 +02:00
parent 7c41992ea7
commit c7b2dd25d9
8 changed files with 1038 additions and 4 deletions

View File

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

View File

@@ -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<ActiveWallet?>,
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 = {}
)
}
}
}

View File

@@ -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<ActiveWallet?>,
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<String>) {
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 = {},
)
}
}
}

View File

@@ -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<KeyRecoveryRoute> { backStackEntry ->
val route = backStackEntry.toRoute<KeyRecoveryRoute>()
KeyRecoveryScreen(
activeUserPublicKey = route.activeUserPublicKey,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToRoute = { route ->
navController.navigate(
route = route
)
}
)
}
composable<RecoveryPhraseRoute> {
RecoveryPhraseScreen(
phoenixGlobal = phoenixGlobal,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ChatRoomMessagingRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChatRoomMessagingRoute>()

View File

@@ -0,0 +1,8 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class KeyRecoveryRoute(
val activeUserPublicKey: String,
): Route()

View File

@@ -0,0 +1,6 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data object RecoveryPhraseRoute: Route()

View File

@@ -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<ActiveWallet?>,
): 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>(
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<Boolean?> = activeWalletStateFlow
.flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val isDisclaimerRead: StateFlow<Boolean?> = 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<Boolean> = 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<ActiveWallet?>,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
RecoveryPhraseViewModel(
phoenixGlobal = phoenixGlobal,
activeWalletStateFlow = activeWalletStateFlow,
)
}
}
}
}

View File

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