diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index da3088e5..1d34050d 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -595,4 +595,28 @@
It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here.
Set up a profile
We could not find a profile for this key on the relays we asked.
+
+ Copied the nsec
+ Copy nsec
+ Could not forget the key. Please try again.
+ Could not unlock your key. Please try again.
+ Mantra will delete the nsec for %1$s from this device. Anything not yet published is lost. The profile stays on the relays, and the key can be signed in again.
+ Display nostr secret key
+ Forget
+ Forget this key
+ Forget this key?
+ I have saved my nostr secret key somewhere safe.
+ I understand that if I lose this phone and my nostr secret key, I lose this profile.
+ Keep this key safe.\nDo not share it.
+ Key forgotten
+ No profile is open on this device, so there is no key to show.
+ Nostr secret key
+ Remove the key from this device. The profile stays on the relays.
+ Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup.
+ The nsec is the key that signs as you. This profile was signed in with it rather than made from a recovery phrase, so there is no phrase to write down and no wallet attached: the key is the whole of the backup.\n\nOnly you have it. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Store it somewhere safe that is not this phone. If you lose both the phone and the key, this profile is gone for good.
+ This device holds no nostr secret key for the profile that is open. If it was made from a recovery phrase, the phrase is its backup.
+ Unlocking your key…
+ You have not backed up your nostr secret key
+ These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone.
+ You said you stored it
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt
index a6894d90..ba1e6cfe 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt
@@ -23,6 +23,15 @@ interface UnsignedNostrEventDao {
@Upsert
suspend fun upsert(unsignedNostrEvent: UnsignedNostrEvent): Long
+ /**
+ * Every unsigned row of a key this device no longer holds: the kind 0 that makes it a
+ * local account, and anything queued that can now never be signed. The requests that
+ * hang off them cascade; published events and the profile cache do not, and stay as
+ * they would for anyone else's key.
+ */
+ @Query("DELETE FROM UnsignedNostrEvent WHERE pubKey = :publicKey")
+ suspend fun deleteAllForPublicKey(publicKey: String): Int
+
@Insert
suspend fun insert(unsignedNostrEvents: List)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt
index 906f0d62..baa14952 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt
@@ -154,6 +154,11 @@ class DatabaseNostrRepository(
saveUnsignedNostrEvents(events.filterNot { it.kind == MetadataEvent.KIND })
}
+ override suspend fun forgetLocalAccount(publicKey: HexKey) {
+ logger.d("forgetLocalAccount: $publicKey")
+ database.unsignedNostrEventDao().deleteAllForPublicKey(publicKey)
+ }
+
/**
* What a key gets when it first becomes a profile here: the kind 0, a contact list
* that follows itself, and the relay lists the app reads, all pointed at the defaults.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt
index a6b2a484..40ff06dc 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt
@@ -206,4 +206,42 @@ object IdentityWriter {
return WriteNostrKeyResult.Written(stored.id)
}
+
+ sealed class ForgetNostrKeyResult {
+ data object Forgotten : ForgetNostrKeyResult()
+
+ /** The key is not in `nostr-keys.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */
+ data object NotABareKey : ForgetNostrKeyResult()
+
+ data object CannotLoadKeys : ForgetNostrKeyResult()
+ }
+
+ /**
+ * The inverse of [writeNostrKey]: removes the key from `nostr-keys.dat` and deletes
+ * the identity's preference files. The profile stays on the relays, and the same key
+ * can be signed in again. Only for a bare key -- see [ForgetNostrKeyResult.NotABareKey].
+ */
+ suspend fun forgetNostrKey(
+ log: Logger,
+ phoenixGlobal: PhoenixGlobal,
+ id: WalletId,
+ nostrPublicKey: HexKey,
+ ): ForgetNostrKeyResult {
+ val existing = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)
+ if (existing == null) {
+ log.e("could not load the existing keys, aborting...")
+ return ForgetNostrKeyResult.CannotLoadKeys
+ }
+ if (!existing.containsKey(nostrPublicKey)) {
+ log.i("asked to forget a key that is not a bare key on this device")
+ return ForgetNostrKeyResult.NotABareKey
+ }
+
+ NostrKeyManager.writeToDisk(phoenixGlobal, EncryptedNostrKeys.encrypt(existing - nostrPublicKey))
+ log.i("forgot nostr key for identity=$id")
+
+ DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(id)
+
+ return ForgetNostrKeyResult.Forgotten
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt
index aaf5147a..2ea083e5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt
@@ -73,6 +73,13 @@ interface NostrRepository {
biography: String?,
)
+ /**
+ * Stops treating [publicKey] as one of this device's own: its unsigned rows go, and
+ * with them the requests that hang off them. What it published, and its profile, stay
+ * cached as anyone else's would.
+ */
+ suspend fun forgetLocalAccount(publicKey: HexKey)
+
suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List,
@@ -260,6 +267,10 @@ interface NostrRepository {
TODO("Not yet implemented")
}
+ override suspend fun forgetLocalAccount(publicKey: HexKey) {
+ TODO("Not yet implemented")
+ }
+
override suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List,
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
index ed56c4a1..b7e12aa3 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt
@@ -16,6 +16,7 @@ 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.Key
import androidx.compose.material.icons.filled.Spellcheck
import androidx.compose.material.icons.filled.VolunteerActivism
import androidx.compose.material.icons.filled.Warning
@@ -42,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.identity.Identity
+import press.mantra.compose.identity.IdentityKind
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
@@ -52,7 +54,11 @@ import mantra.composeapp.generated.resources.emergency_kit
import mantra.composeapp.generated.resources.encrypt_and_back_your_recovery_information
import mantra.composeapp.generated.resources.key_recovery
import mantra.composeapp.generated.resources.not_backed_up_yet
+import mantra.composeapp.generated.resources.nostr_secret_key
import mantra.composeapp.generated.resources.recovery_phrase
+import mantra.composeapp.generated.resources.store_the_nsec_that_signs_as_this_profile
+import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_no_wallet
+import mantra.composeapp.generated.resources.you_said_you_stored_it
import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_so_they
import mantra.composeapp.generated.resources.write_down_and_secure_the_12_word_phrase
import mantra.composeapp.generated.resources.yolo
@@ -61,6 +67,7 @@ import mantra.composeapp.generated.resources.you_said_you_wrote_it_down
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
+import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute
import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.Decorative
@@ -71,11 +78,12 @@ import press.mantra.compose.ui.theme.spacing
/**
* 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.
+ * A Mantra profile is its key: the npub that signs and, for a wallet, the coins
+ * it holds, both come out of one secret that never leaves the device -- twelve
+ * words, or a bare nostr key that was signed in with. 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
@@ -133,7 +141,11 @@ fun KeyRecoveryScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
- text = stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they),
+ // A bare-key identity has no coins to speak of.
+ text = when (activeIdentity?.kind) {
+ IdentityKind.NostrSecret -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet)
+ else -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they)
+ },
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@@ -147,27 +159,55 @@ fun KeyRecoveryScreen(
}
item {
- KeyRecoveryOption(
- icon = Icons.Default.Spellcheck,
- title = stringResource(Res.string.recovery_phrase),
- description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase),
- containerColor = MaterialTheme.colorScheme.secondaryContainer,
- contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
- status = if (showBackupNotice) {
- KeyRecoveryStatus(
- icon = Icons.Default.Warning,
- text = stringResource(Res.string.not_backed_up_yet)
- )
- } else {
- KeyRecoveryStatus(
- icon = Icons.Default.CheckCircle,
- text = stringResource(Res.string.you_said_you_wrote_it_down)
- )
- },
- onClick = {
- onNavigateToRoute.invoke(RecoveryPhraseRoute)
- }
- )
+ // The backup an identity has depends on what it was made from. Twelve words
+ // for a wallet; for a bare key there is no phrase, and the key is the whole
+ // backup. Same flags underneath -- they are per identity and mean "this
+ // identity's secret is not backed up" for whichever secret it is.
+ when (activeIdentity?.kind) {
+ IdentityKind.NostrSecret -> KeyRecoveryOption(
+ icon = Icons.Default.Key,
+ title = stringResource(Res.string.nostr_secret_key),
+ description = stringResource(Res.string.store_the_nsec_that_signs_as_this_profile),
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ status = if (showBackupNotice) {
+ KeyRecoveryStatus(
+ icon = Icons.Default.Warning,
+ text = stringResource(Res.string.not_backed_up_yet)
+ )
+ } else {
+ KeyRecoveryStatus(
+ icon = Icons.Default.CheckCircle,
+ text = stringResource(Res.string.you_said_you_stored_it)
+ )
+ },
+ onClick = {
+ onNavigateToRoute.invoke(NostrSecretRoute)
+ }
+ )
+
+ else -> KeyRecoveryOption(
+ icon = Icons.Default.Spellcheck,
+ title = stringResource(Res.string.recovery_phrase),
+ description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase),
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ status = if (showBackupNotice) {
+ KeyRecoveryStatus(
+ icon = Icons.Default.Warning,
+ text = stringResource(Res.string.not_backed_up_yet)
+ )
+ } else {
+ KeyRecoveryStatus(
+ icon = Icons.Default.CheckCircle,
+ text = stringResource(Res.string.you_said_you_wrote_it_down)
+ )
+ },
+ onClick = {
+ onNavigateToRoute.invoke(RecoveryPhraseRoute)
+ }
+ )
+ }
}
item {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt
new file mode 100644
index 00000000..12d49d6c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt
@@ -0,0 +1,490 @@
+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.lazy.LazyColumn
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.DeleteForever
+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.AlertDialog
+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.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.lifecycle.viewmodel.compose.viewModel
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.back
+import mantra.composeapp.generated.resources.backup_confirmation
+import mantra.composeapp.generated.resources.cancel
+import mantra.composeapp.generated.resources.copied_the_nsec
+import mantra.composeapp.generated.resources.copy_nsec
+import mantra.composeapp.generated.resources.could_not_forget_the_key_please_try_again
+import mantra.composeapp.generated.resources.could_not_unlock_your_key_please_try
+import mantra.composeapp.generated.resources.mantra_will_delete_the_nsec_for_s_from_this
+import mantra.composeapp.generated.resources.display_nostr_secret_key
+import mantra.composeapp.generated.resources.forget
+import mantra.composeapp.generated.resources.forget_this_key
+import mantra.composeapp.generated.resources.forget_this_key_question
+import mantra.composeapp.generated.resources.hide
+import mantra.composeapp.generated.resources.i_have_saved_my_nostr_secret_key_somewhere
+import mantra.composeapp.generated.resources.i_understand_that_if_i_lose_this_phone_and_my_key
+import mantra.composeapp.generated.resources.keep_this_key_safe_do_not_share_it
+import mantra.composeapp.generated.resources.key_forgotten
+import mantra.composeapp.generated.resources.loading_preferences
+import mantra.composeapp.generated.resources.lose_this_phone_before_you_do_and_the
+import mantra.composeapp.generated.resources.no_identity_is_open_on_this_device_so_there
+import mantra.composeapp.generated.resources.nostr_secret_key
+import mantra.composeapp.generated.resources.remove_the_key_from_this_device_the_profile
+import mantra.composeapp.generated.resources.the_nsec_is_the_key_that_signs_as_you_this
+import mantra.composeapp.generated.resources.this_device_holds_no_key_for_the_profile
+import mantra.composeapp.generated.resources.unlocking_your_key
+import mantra.composeapp.generated.resources.you_have_not_backed_up_your_nostr_secret_key
+import fr.acinq.phoenix.PhoenixGlobal
+import kotlinx.coroutines.flow.StateFlow
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.extensions.hexToNpubHrp
+import press.mantra.compose.identity.Identity
+import press.mantra.compose.identity.IdentityWriter
+import press.mantra.compose.repository.NostrRepository
+import press.mantra.compose.ui.composable.widgets.Decorative
+import press.mantra.compose.ui.composable.widgets.ErrorState
+import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
+import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
+import press.mantra.compose.ui.composable.widgets.rememberNotifier
+import press.mantra.compose.ui.theme.ConformancePreviews
+import press.mantra.compose.ui.theme.readableContent
+import press.mantra.compose.ui.theme.spacing
+import press.mantra.compose.ui.view.model.NostrSecretViewModel
+import press.mantra.compose.ui.view.state.NostrSecretUIState
+
+/**
+ * The nostr secret key behind a bare-key identity, shown once and on request.
+ *
+ * `RecoveryPhraseScreen` with the word grid replaced by the nsec, for the identity kind
+ * that has a key and no phrase. The reveal is a separate step from opening the screen
+ * for the same reason: reading the key goes to the keystore, and nobody should walk past
+ * a phone that is quietly displaying it. Below the backup section is the one thing this
+ * screen has that the phrase screen does not: the way to take the key off the device.
+ */
+@Composable
+fun NostrSecretScreen(
+ phoenixGlobal: PhoenixGlobal,
+ activeIdentityStateFlow: StateFlow,
+ nostrRepository: NostrRepository,
+ forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult,
+ hideIdentityMetadata: suspend (Identity) -> Unit,
+ onNavigateBack: () -> Unit,
+ onForgotten: () -> Unit,
+) {
+ val viewModel: NostrSecretViewModel = viewModel(
+ factory = NostrSecretViewModel.factory(
+ phoenixGlobal = phoenixGlobal,
+ activeIdentityStateFlow = activeIdentityStateFlow,
+ nostrRepository = nostrRepository,
+ forgetNostrKey = forgetNostrKey,
+ hideIdentityMetadata = hideIdentityMetadata,
+ )
+ )
+
+ val uiState by viewModel.uiState.collectAsState()
+ val forgetting by viewModel.forgetting.collectAsState()
+ val isBareKeyIdentity by viewModel.isBareKeyIdentity.collectAsState()
+ val isBackupDone by viewModel.isBackupDone.collectAsState()
+ val isDisclaimerRead by viewModel.isDisclaimerRead.collectAsState()
+ val showBackupNotice by viewModel.showBackupNotice.collectAsState()
+ val activeIdentity by activeIdentityStateFlow.collectAsState()
+
+ // Both composable, and the handlers that show these are not: read above them.
+ val notify = rememberNotifier(rememberCoroutineScope())
+ val keyForgotten = stringResource(Res.string.key_forgotten)
+
+ NostrSecretContent(
+ uiState = uiState,
+ forgetting = forgetting,
+ npub = activeIdentity?.nostrPublicKey?.hexToNpubHrp(),
+ canForget = isBareKeyIdentity,
+ isBackupDone = isBackupDone,
+ isDisclaimerRead = isDisclaimerRead,
+ showBackupNotice = showBackupNotice,
+ onReveal = viewModel::revealNostrSecret,
+ onHide = viewModel::hideNostrSecret,
+ onBackupDoneChange = viewModel::setBackupDone,
+ onDisclaimerReadChange = viewModel::setDisclaimerRead,
+ onAskToForget = viewModel::askToForgetKey,
+ onCancelForget = viewModel::cancelForgetKey,
+ onForget = {
+ viewModel.forgetKey {
+ notify(keyForgotten)
+ onForgotten()
+ }
+ },
+ onNavigateBack = onNavigateBack,
+ )
+}
+
+/**
+ * The screen once the state and the 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 key on disk.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+private fun NostrSecretContent(
+ uiState: NostrSecretUIState,
+ forgetting: NostrSecretUIState.Forgetting,
+ npub: String?,
+ canForget: Boolean,
+ isBackupDone: Boolean?,
+ isDisclaimerRead: Boolean?,
+ showBackupNotice: Boolean,
+ onReveal: () -> Unit,
+ onHide: () -> Unit,
+ onBackupDoneChange: (Boolean) -> Unit,
+ onDisclaimerReadChange: (Boolean) -> Unit,
+ onAskToForget: () -> Unit,
+ onCancelForget: () -> Unit,
+ onForget: () -> Unit,
+ onNavigateBack: () -> Unit,
+) {
+ if (forgetting is NostrSecretUIState.Forgetting.Confirming) {
+ AlertDialog(
+ onDismissRequest = onCancelForget,
+ icon = { Icon(Icons.Default.DeleteForever, contentDescription = Decorative) },
+ title = { Text(stringResource(Res.string.forget_this_key_question)) },
+ text = { Text(stringResource(Res.string.mantra_will_delete_the_nsec_for_s_from_this, npub ?: "")) },
+ confirmButton = {
+ TextButton(onClick = onForget) { Text(stringResource(Res.string.forget)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onCancelForget) { Text(stringResource(Res.string.cancel)) }
+ },
+ )
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
+ topBar = {
+ TopAppBar(
+ title = { Text(text = stringResource(Res.string.nostr_secret_key)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(Res.string.back)
+ )
+ }
+ }
+ )
+ }
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(innerPadding)
+ .readableContent()
+ .padding(horizontal = MaterialTheme.spacing.screenMargin),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ item {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = MaterialTheme.spacing.containerPadding),
+ text = stringResource(Res.string.the_nsec_is_the_key_that_signs_as_you_this),
+ 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(MaterialTheme.spacing.containerPadding),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Warning, contentDescription = Decorative)
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+
+ Column {
+ Text(
+ text = stringResource(Res.string.you_have_not_backed_up_your_nostr_secret_key),
+ style = MaterialTheme.typography.titleSmall
+ )
+
+ Text(
+ text = stringResource(Res.string.lose_this_phone_before_you_do_and_the),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(MaterialTheme.spacing.containerPadding),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ when (uiState) {
+ is NostrSecretUIState.Hidden -> {
+ Button(onClick = onReveal) {
+ Icon(Icons.Default.Visibility, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+ Text(text = stringResource(Res.string.display_nostr_secret_key))
+ }
+ }
+
+ is NostrSecretUIState.Revealing -> {
+ LoadingDataIndicator(
+ fillScreen = false,
+ text = stringResource(Res.string.unlocking_your_key)
+ )
+ }
+
+ is NostrSecretUIState.Revealed -> {
+ RevealedNsec(nsec = uiState.nsec)
+
+ TextButton(onClick = onHide) {
+ Icon(Icons.Default.VisibilityOff, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+ Text(text = stringResource(Res.string.hide))
+ }
+ }
+
+ is NostrSecretUIState.Error -> {
+ ErrorState(
+ message = when (uiState) {
+ is NostrSecretUIState.Error.NoActiveIdentity ->
+ stringResource(Res.string.no_identity_is_open_on_this_device_so_there)
+ is NostrSecretUIState.Error.NoKeyForThisIdentity ->
+ stringResource(Res.string.this_device_holds_no_key_for_the_profile)
+ is NostrSecretUIState.Error.KeysUnreadable ->
+ stringResource(Res.string.could_not_unlock_your_key_please_try)
+ },
+ // Retrying without an identity, or for one whose key is in
+ // the seed rather than here, cannot help.
+ onRetry = when (uiState) {
+ is NostrSecretUIState.Error.KeysUnreadable -> onReveal
+ else -> null
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(Res.string.backup_confirmation),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ if (isBackupDone == null || isDisclaimerRead == null) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(MaterialTheme.spacing.containerPadding),
+ text = stringResource(Res.string.loading_preferences),
+ style = MaterialTheme.typography.bodySmall
+ )
+ } else {
+ BackupCheckbox(
+ checked = isBackupDone,
+ text = stringResource(Res.string.i_have_saved_my_nostr_secret_key_somewhere),
+ onCheckedChange = onBackupDoneChange
+ )
+
+ BackupCheckbox(
+ checked = isDisclaimerRead,
+ text = stringResource(Res.string.i_understand_that_if_i_lose_this_phone_and_my_key),
+ onCheckedChange = onDisclaimerReadChange
+ )
+ }
+ }
+ }
+
+ if (canForget) {
+ item {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onAskToForget,
+ enabled = forgetting !is NostrSecretUIState.Forgetting.InProgress,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(MaterialTheme.spacing.containerPadding),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.DeleteForever, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+ Text(
+ text = stringResource(Res.string.forget_this_key),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ Text(
+ text = when (forgetting) {
+ is NostrSecretUIState.Forgetting.Failed ->
+ stringResource(Res.string.could_not_forget_the_key_please_try_again)
+ else -> stringResource(Res.string.remove_the_key_from_this_device_the_profile)
+ },
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.containerPadding))
+ }
+ }
+ }
+}
+
+/** The key itself, selectable and with a copy action, since nobody transcribes sixty-three characters by hand. */
+@Composable
+private fun RevealedNsec(nsec: String) {
+ val clipboardManager = LocalClipboardManager.current
+ val notify = rememberNotifier(rememberCoroutineScope())
+ val copied = stringResource(Res.string.copied_the_nsec)
+
+ Text(
+ text = stringResource(Res.string.keep_this_key_safe_do_not_share_it),
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center
+ )
+
+ SelectionContainer {
+ Text(
+ text = nsec,
+ style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
+ textAlign = TextAlign.Center
+ )
+ }
+
+ TextButton(
+ onClick = {
+ clipboardManager.setText(AnnotatedString(nsec))
+ notify(copied)
+ }
+ ) {
+ Icon(Icons.Default.ContentCopy, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+ Text(text = stringResource(Res.string.copy_nsec))
+ }
+}
+
+@Composable
+private fun BackupCheckbox(
+ checked: Boolean,
+ text: String,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ // The whole row toggles, so the row is the target and has to carry the 48dp
+ // the Checkbox alone would.
+ .minimumInteractiveComponentSize()
+ .clickable { onCheckedChange(!checked) }
+ .padding(
+ horizontal = MaterialTheme.spacing.containerPadding,
+ vertical = MaterialTheme.spacing.space150
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked = checked, onCheckedChange = onCheckedChange)
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+
+ Text(text = text, style = MaterialTheme.typography.bodyMedium)
+ }
+}
+
+@ConformancePreviews
+@Composable
+private fun NostrSecretScreenPreview() {
+ press.mantra.compose.ui.theme.MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ NostrSecretContent(
+ uiState = NostrSecretUIState.Revealed("nsec1" + "q".repeat(58)),
+ forgetting = NostrSecretUIState.Forgetting.Idle,
+ npub = "npub1" + "q".repeat(58),
+ canForget = true,
+ isBackupDone = false,
+ isDisclaimerRead = true,
+ showBackupNotice = true,
+ onReveal = {},
+ onHide = {},
+ onBackupDoneChange = {},
+ onDisclaimerReadChange = {},
+ onAskToForget = {},
+ onCancelForget = {},
+ onForget = {},
+ 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 7048f9df..3e0753a9 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
@@ -52,6 +52,7 @@ 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.NostrSecretScreen
import press.mantra.compose.ui.composable.RecoveryPhraseScreen
import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen
import press.mantra.compose.ui.composable.SearchResultScreen
@@ -87,6 +88,7 @@ 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.NostrSecretRoute
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
@@ -865,6 +867,27 @@ fun MantraNavHost(
}
)
}
+ composable {
+ NostrSecretScreen(
+ phoenixGlobal = phoenixGlobal,
+ activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
+ nostrRepository = databaseNostrRepository,
+ forgetNostrKey = { identity -> sovereignWalletViewModel.forgetNostrKey(identity) },
+ hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) },
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ // The device no longer holds the key. Re-list so the selector drops it,
+ // then clear the active identity: the navigation observer sends a null
+ // identity to startup, which shows the selector -- or Landing, if this
+ // was the last one.
+ onForgotten = {
+ sovereignWalletViewModel.listIdentities {
+ sovereignWalletViewModel.resetToSelector()
+ }
+ },
+ )
+ }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt
new file mode 100644
index 00000000..c7bceaca
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt
@@ -0,0 +1,7 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/** The nostr secret key behind a bare-key identity: shown on request, and the way to forget it. */
+@Serializable
+data object NostrSecretRoute : Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt
new file mode 100644
index 00000000..6662236e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt
@@ -0,0 +1,213 @@
+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.DecryptNostrKeysResult
+import fr.acinq.phoenix.managers.NostrKeyManager
+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.first
+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.extensions.hexToNsecHrp
+import press.mantra.compose.identity.Identity
+import press.mantra.compose.identity.IdentityKind
+import press.mantra.compose.identity.IdentityWriter
+import press.mantra.compose.repository.NostrRepository
+import press.mantra.compose.ui.view.state.NostrSecretUIState
+
+/**
+ * Reads back the nostr secret key an identity was signed in with.
+ *
+ * The counterpart of [RecoveryPhraseViewModel] for the identity kind that has a key and
+ * no phrase. There is no wallet behind such an identity, so the key is the whole of the
+ * backup, and everything here is about getting it in front of the user once and
+ * recording that they say they stored it.
+ *
+ * The key is read from the file at reveal time, not off the active identity, so the
+ * screen's contract -- nothing secret held longer than it is shown -- is the same as the
+ * phrase screen's. The backup flags are the same per-identity preferences the phrase
+ * screen writes; they mean "this identity's secret is not backed up" for whichever
+ * secret it is.
+ *
+ * It also owns the inverse of an import: [forgetKey], which removes the key from the
+ * device and the account with it. Only for a bare key; a mnemonic identity's key is in
+ * the seed, and removing a seed is a wallet question this does not answer.
+ */
+class NostrSecretViewModel(
+ val phoenixGlobal: PhoenixGlobal,
+ val activeIdentityStateFlow: StateFlow,
+ private val nostrRepository: NostrRepository,
+ private val forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult,
+ private val hideIdentityMetadata: suspend (Identity) -> Unit,
+) : ViewModel() {
+
+ private val logger = Logger.withTag(TAG)
+
+ private val _uiState = MutableStateFlow(NostrSecretUIState.Hidden)
+ val uiState = _uiState.asStateFlow()
+
+ private val _forgetting = MutableStateFlow(NostrSecretUIState.Forgetting.Idle)
+ val forgetting = _forgetting.asStateFlow()
+
+ /** Whether the active identity is one whose key this screen can show and forget. */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val isBareKeyIdentity: StateFlow = activeIdentityStateFlow
+ .flatMapLatest { flowOf(it?.kind == IdentityKind.NostrSecret) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val isBackupDone: StateFlow = activeIdentityStateFlow
+ .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val isDisclaimerRead: StateFlow = activeIdentityStateFlow
+ .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val showBackupNotice: StateFlow = activeIdentityStateFlow
+ .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
+
+ fun revealNostrSecret() {
+ if (_uiState.value is NostrSecretUIState.Revealing) return
+
+ val identity = activeIdentityStateFlow.value
+ if (identity == null) {
+ logger.e { "no active identity, cannot read a nostr key" }
+ _uiState.value = NostrSecretUIState.Error.NoActiveIdentity
+ return
+ }
+
+ viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
+ logger.e("failed to read the nostr keys", throwable)
+ _uiState.value = NostrSecretUIState.Error.KeysUnreadable
+ }) {
+ _uiState.value = NostrSecretUIState.Revealing
+
+ // Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread.
+ val result = withContext(Dispatchers.IO) {
+ NostrKeyManager.loadAndDecrypt(phoenixGlobal)
+ }
+
+ _uiState.value = when (result) {
+ is DecryptNostrKeysResult.Success -> {
+ val privateKey = result.keys[identity.nostrPublicKey]
+ if (privateKey == null) {
+ logger.e { "key file holds no key for identity=${identity.id}" }
+ NostrSecretUIState.Error.NoKeyForThisIdentity
+ } else {
+ NostrSecretUIState.Revealed(privateKey.value.toHex().hexToNsecHrp())
+ }
+ }
+ is DecryptNostrKeysResult.Failure.FileNotFound -> {
+ logger.e { "no key file, so no key for identity=${identity.id}" }
+ NostrSecretUIState.Error.NoKeyForThisIdentity
+ }
+ is DecryptNostrKeysResult.Failure -> {
+ logger.e { "unable to read the nostr keys: $result" }
+ NostrSecretUIState.Error.KeysUnreadable
+ }
+ }
+ }
+ }
+
+ fun hideNostrSecret() {
+ _uiState.value = NostrSecretUIState.Hidden
+ }
+
+ fun setBackupDone(isDone: Boolean) {
+ val internalPrefs = activeIdentityStateFlow.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 = activeIdentityStateFlow.value?.internalPrefs ?: return
+ viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
+ logger.e("could not save the disclaimer flag", throwable)
+ }) {
+ internalPrefs.saveSeedLossDisclaimerRead(isRead)
+ }
+ }
+
+ fun askToForgetKey() {
+ _forgetting.value = NostrSecretUIState.Forgetting.Confirming
+ }
+
+ fun cancelForgetKey() {
+ _forgetting.value = NostrSecretUIState.Forgetting.Idle
+ }
+
+ /**
+ * Key out of the file, preferences deleted, metadata hidden, account rows gone -- in
+ * that order, so a failure partway leaves the key on disk rather than an identity the
+ * selector lists but nothing can open. [onForgotten] runs on the main thread once the
+ * device no longer holds the key; the caller clears the active identity and re-lists.
+ */
+ fun forgetKey(onForgotten: () -> Unit) {
+ val identity = activeIdentityStateFlow.value ?: return
+ if (_forgetting.value is NostrSecretUIState.Forgetting.InProgress) return
+ _forgetting.value = NostrSecretUIState.Forgetting.InProgress
+ _uiState.value = NostrSecretUIState.Hidden
+
+ viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable ->
+ logger.e("could not forget the key", throwable)
+ _forgetting.value = NostrSecretUIState.Forgetting.Failed
+ }) {
+ when (forgetNostrKey(identity)) {
+ is IdentityWriter.ForgetNostrKeyResult.Forgotten -> {
+ hideIdentityMetadata(identity)
+ nostrRepository.forgetLocalAccount(identity.nostrPublicKey)
+ _forgetting.value = NostrSecretUIState.Forgetting.Idle
+ withContext(Dispatchers.Main) { onForgotten() }
+ }
+ is IdentityWriter.ForgetNostrKeyResult.NotABareKey,
+ is IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys -> {
+ _forgetting.value = NostrSecretUIState.Forgetting.Failed
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "NostrSecretViewModel"
+
+ fun factory(
+ phoenixGlobal: PhoenixGlobal,
+ activeIdentityStateFlow: StateFlow,
+ nostrRepository: NostrRepository,
+ forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult,
+ hideIdentityMetadata: suspend (Identity) -> Unit,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ NostrSecretViewModel(
+ phoenixGlobal = phoenixGlobal,
+ activeIdentityStateFlow = activeIdentityStateFlow,
+ nostrRepository = nostrRepository,
+ forgetNostrKey = forgetNostrKey,
+ hideIdentityMetadata = hideIdentityMetadata,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
index a22d6ba5..9997f884 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
@@ -367,6 +367,30 @@ class SovereignWalletViewModel(
customElectrumServer = customElectrumServer.value,
)
+ /** The inverse of [writeNostrKey]. Only for a bare key; see [IdentityWriter.forgetNostrKey]. */
+ suspend fun forgetNostrKey(identity: Identity): IdentityWriter.ForgetNostrKeyResult =
+ IdentityWriter.forgetNostrKey(
+ log = log,
+ phoenixGlobal = phoenixGlobal,
+ id = identity.id,
+ nostrPublicKey = identity.nostrPublicKey,
+ )
+
+ /**
+ * Hides a forgotten identity's entry in the wallet metadata rather than deleting it:
+ * the metadata store has no delete, and `isHidden` is what the selector filters on.
+ */
+ suspend fun hideIdentityMetadata(identity: Identity) {
+ val existing = getAvailableWalletsMeta(phoenixGlobal).first()[identity.id] ?: return
+ saveAvailableWalletMeta(
+ phoenixGlobal = phoenixGlobal,
+ walletId = identity.id,
+ name = existing.name,
+ avatar = existing.avatar,
+ isHidden = true,
+ )
+ }
+
companion object {
private const val TAG = "SovereignWalletViewModel"
fun factory(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt
new file mode 100644
index 00000000..9e0754e0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt
@@ -0,0 +1,35 @@
+package press.mantra.compose.ui.view.state
+
+/**
+ * What the nostr secret key screen is showing. The key is never held here longer than
+ * the user is looking at it: [Revealed] is the only state that carries it, and leaving
+ * the screen (or hiding it) drops back to [Hidden]. The shape of [RecoveryPhraseUIState],
+ * for the identity kind that has a key and no phrase.
+ */
+sealed interface NostrSecretUIState {
+ data object Hidden : NostrSecretUIState
+
+ /** Reading and decrypting the key file. */
+ data object Revealing : NostrSecretUIState
+
+ data class Revealed(val nsec: String) : NostrSecretUIState
+
+ sealed interface Error : NostrSecretUIState {
+ /** No identity is active, so there is nothing to look up. */
+ data object NoActiveIdentity : Error
+
+ /** The key file was read, but holds no key for the active identity -- it is a mnemonic identity, or the key was forgotten. */
+ data object NoKeyForThisIdentity : Error
+
+ /** The key file could not be read or decrypted. */
+ data object KeysUnreadable : Error
+ }
+
+ /** The forget flow's own states, alongside whatever the reveal is showing. */
+ sealed interface Forgetting {
+ data object Idle : Forgetting
+ data object Confirming : Forgetting
+ data object InProgress : Forgetting
+ data object Failed : Forgetting
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt
new file mode 100644
index 00000000..d18e4c2c
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt
@@ -0,0 +1,56 @@
+package press.mantra.compose.database.repository
+
+import androidx.room3.Room
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import press.mantra.compose.database.MantraDatabase
+import press.mantra.compose.database.builder.getRoomDatabase
+import press.mantra.compose.nostr.SignInSync
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+/**
+ * Forgetting a key at the rows: the account it made goes, and so does everything that
+ * hung off it -- while an unrelated account is untouched.
+ */
+class ForgetLocalAccountJvmTest {
+
+ private val db: MantraDatabase = getRoomDatabase(
+ Room.inMemoryDatabaseBuilder()
+ )
+ private val scope = CoroutineScope(Job())
+ private val repository = DatabaseNostrRepository(db, scope)
+
+ private val forgotten = "a".repeat(64)
+ private val kept = "b".repeat(64)
+
+ @AfterTest
+ fun closeDb() {
+ scope.cancel()
+ db.close()
+ }
+
+ @Test
+ fun `the account and its requests go, the other account stays`() = runBlocking {
+ repository.signInToProfile(forgotten)
+ repository.signInToProfile(kept)
+ val account = repository.getLocalAccounts().first { it.unsignedNostrEvent?.pubKey == forgotten }
+ val placeholderId = assertNotNull(account.unsignedNostrEvent).id
+ repository.queueSynchronizeNostrEvent(
+ SignInSync.requests(placeholderId, forgotten, listOf(RelayUrlNormalizer.normalize("wss://one.example")), level = 0)
+ )
+ assertEquals(1, repository.observeLocalAccount(forgotten).first()?.synchronizeNostrEventRequests?.size)
+
+ repository.forgetLocalAccount(forgotten)
+
+ assertEquals(listOf(kept), repository.getLocalAccounts().map { it.unsignedNostrEvent?.pubKey })
+ assertEquals(0, db.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestByPurposeAndStatusCount(SignInSync.PURPOSE, listOf("pending")).first(),
+ "the requests cascaded with the row they hung off")
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt
new file mode 100644
index 00000000..db77f8b4
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt
@@ -0,0 +1,122 @@
+package press.mantra.compose.identity
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import co.touchlab.kermit.Logger
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.lightning.Lightning
+import fr.acinq.phoenix.PhoenixGlobal
+import fr.acinq.phoenix.managers.NostrKeyManager
+import fr.acinq.phoenix.managers.computePreferencePath
+import fr.acinq.phoenix.security.JvmKeyStore
+import fr.acinq.phoenix.utils.PlatformContext
+import fr.acinq.phoenix.utils.preferences.GlobalPrefs
+import kotlinx.coroutines.runBlocking
+import okio.FileSystem
+import okio.SYSTEM
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+/**
+ * The two writers for a bare key, against a real key store and a real directory.
+ *
+ * What is pinned is the duplicate rule and the inverse. A second import of the same key
+ * is refused by public key, the same npub imported twice being the one thing the id
+ * check cannot see; and forgetting takes the key out of the file and the identity's
+ * preference files off the disk, leaving every other key where it was.
+ */
+class IdentityWriterJvmTest {
+
+ private lateinit var storeDir: File
+ private lateinit var appDir: File
+ private lateinit var phoenixGlobal: PhoenixGlobal
+ private lateinit var globalPrefs: GlobalPrefs
+
+ private val log = Logger.withTag("IdentityWriterJvmTest")
+
+ // Fresh keys per test, not fixed ones: DataStoreManager caches each id's preferences
+ // in a companion object for the life of the process, so a fixed key would be served
+ // the UserPrefs an earlier test created in an earlier temp directory.
+ private val first = PrivateKey(Lightning.randomBytes(32))
+ private val second = PrivateKey(Lightning.randomBytes(32))
+
+ @BeforeTest
+ fun setUp() {
+ storeDir = Files.createTempDirectory("mantra-identity-writer-store").toFile()
+ appDir = Files.createTempDirectory("mantra-identity-writer-app").toFile()
+ JvmKeyStore.lock()
+ JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir)
+ phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir))
+ globalPrefs = GlobalPrefs(
+ PreferenceDataStoreFactory.createWithPath {
+ computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb")
+ }
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ JvmKeyStore.lock()
+ storeDir.deleteRecursively()
+ appDir.deleteRecursively()
+ }
+
+ private suspend fun write(key: PrivateKey) = IdentityWriter.writeNostrKey(
+ log = log,
+ phoenixGlobal = phoenixGlobal,
+ globalPrefs = globalPrefs,
+ privateKey = key,
+ isTorEnabled = false,
+ customElectrumServer = null,
+ )
+
+ private fun userPrefsFile(stored: StoredIdentity.NostrSecret) =
+ computePreferencePath(phoenixGlobal.ctx, "userprefs_${stored.id.nodeIdHash}.preferences_pb")
+
+ @Test
+ fun `a key is written once and refused the second time, by public key`() = runBlocking {
+ val stored = StoredIdentity.nostrSecret(first)
+
+ val written = assertIs(write(first))
+ assertEquals(stored.id, written.id)
+ assertEquals(mapOf(stored.nostrPublicKey to first), NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal))
+ assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read")
+
+ assertIs(write(first))
+ }
+
+ @Test
+ fun `forgetting one key leaves the other, and takes the preferences with it`() = runBlocking {
+ write(first)
+ write(second)
+ val stored = StoredIdentity.nostrSecret(first)
+
+ val result = IdentityWriter.forgetNostrKey(log, phoenixGlobal, stored.id, stored.nostrPublicKey)
+
+ assertIs(result)
+ assertEquals(
+ mapOf(StoredIdentity.nostrSecret(second).nostrPublicKey to second),
+ NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal),
+ )
+ assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(stored)))
+ assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(second))))
+ }
+
+ /** A wallet's nostr key is in the seed, not here; removing a seed is a wallet question. */
+ @Test
+ fun `a key that is not in the file is not forgotten`() = runBlocking {
+ write(first)
+ val other = StoredIdentity.nostrSecret(second)
+
+ assertIs(
+ IdentityWriter.forgetNostrKey(log, phoenixGlobal, other.id, other.nostrPublicKey)
+ )
+ assertEquals(1, NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)?.size)
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt
new file mode 100644
index 00000000..a6bd120c
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt
@@ -0,0 +1,120 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import fr.acinq.bitcoin.ByteVector32
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.phoenix.PhoenixGlobal
+import fr.acinq.phoenix.utils.PlatformContext
+import fr.acinq.phoenix.utils.preferences.InternalPrefs
+import fr.acinq.phoenix.utils.preferences.UserPrefs
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import okio.Path.Companion.toPath
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+import press.mantra.compose.identity.Identity
+import press.mantra.compose.identity.IdentityKind
+import press.mantra.compose.identity.IdentityWriter
+import press.mantra.compose.identity.toWalletId
+import press.mantra.compose.repository.NostrRepository
+import press.mantra.compose.ui.view.state.NostrSecretUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Forgetting a key is four effects, and their order is what this pins: the key comes
+ * out of the file first, and only then are the metadata hidden, the account rows
+ * removed and the caller told. A failure at the first step must leave everything else
+ * untouched -- an identity the selector lists but nothing can open is worse than one
+ * that is still there.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NostrSecretViewModelJvmTest {
+
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ private val privateKey = PrivateKey(ByteVector32("01".repeat(32)))
+
+ private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
+ val effects = mutableListOf()
+ override suspend fun forgetLocalAccount(publicKey: HexKey) {
+ effects += "forgetLocalAccount"
+ }
+ }
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath {
+ temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath()
+ }
+
+ private fun identity() = Identity(
+ id = privateKey.publicKey().xOnly().toWalletId(),
+ kind = IdentityKind.NostrSecret,
+ nostrPrivateKey = privateKey,
+ userPrefs = UserPrefs(prefsStore("user")),
+ internalPrefs = InternalPrefs(prefsStore("internal")),
+ business = null,
+ )
+
+ private fun viewModel(
+ recorder: Recorder,
+ forgetOutcome: IdentityWriter.ForgetNostrKeyResult,
+ ) = NostrSecretViewModel(
+ phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = temporaryFolder.newFolder())),
+ activeIdentityStateFlow = MutableStateFlow(identity()),
+ nostrRepository = recorder,
+ forgetNostrKey = { recorder.effects += "forgetNostrKey"; forgetOutcome },
+ hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" },
+ )
+
+ @Test
+ fun `the key comes out first, then the metadata, the account, and the caller is told`() {
+ val recorder = Recorder()
+ val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.Forgotten)
+ val told = CompletableDeferred()
+
+ viewModel.forgetKey { told.complete(Unit) }
+ runBlocking { withTimeout(10_000) { told.await() } }
+
+ assertEquals(
+ listOf("forgetNostrKey", "hideIdentityMetadata", "forgetLocalAccount"),
+ recorder.effects,
+ )
+ assertEquals(NostrSecretUIState.Forgetting.Idle, viewModel.forgetting.value)
+ }
+
+ @Test
+ fun `a key that could not be removed leaves everything else alone`() {
+ val recorder = Recorder()
+ val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys)
+
+ viewModel.forgetKey { error("must not be told") }
+ val state = runBlocking {
+ withTimeout(10_000) { viewModel.forgetting.first { it !is NostrSecretUIState.Forgetting.InProgress } }
+ }
+
+ assertEquals(NostrSecretUIState.Forgetting.Failed, state)
+ assertEquals(listOf("forgetNostrKey"), recorder.effects)
+ }
+}