feat(recovery): the key behind an identity that has no phrase, and the way to forget it
Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the
recovery phrase, whose screen reads userWallet.words out of the seed map. For
an identity signed in with a bare key there are no words, and the honest
answer that screen would give is "this device holds no phrase for the
profile". So:
KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity
keeps the phrase option. A NostrSecret identity gets a nostr secret key option
in its place, routing to NostrSecretRoute, with its own status line ("you said
you stored it") and a header that no longer promises coins. The backup flags
underneath are the same two per-identity preferences the phrase screen
writes; they already mean "this identity's secret is not backed up" for
whichever secret it is.
NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the
nsec: hidden until revealed, revealed in monospace, selectable, with a copy
action -- nobody transcribes sixty-three characters by hand -- the same two
checkboxes, hidden again on leaving. The view model reads the key from
nostr-keys.dat at reveal time, by the identity's public key, not off the
active identity, so the screen's contract -- nothing secret held longer than
it is shown -- is the phrase screen's. The disclaimer is a new string: the
old one said "...and the funds in its wallet", and this identity has no
wallet to warn about.
Forget this key. An import needs an inverse, and this is the first real "sign
out" in the app; ActiveProfileScreen's button still routes to a pending
screen, and the general case stays there, because removing a seed is a wallet
question with funds behind it. Behind a dialog that names the npub and says
the profile stays on the relays, four effects in an order that matters: the
key out of nostr-keys.dat and the identity's preference files off the disk
(IdentityWriter.forgetNostrKey, which refuses a key that is not in the file
-- a wallet's nostr key lives in the seed); the metadata entry hidden, since
the metadata store has no delete and isHidden is what the selector filters
on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0
that made it a local account and anything queued that can now never be
signed, with the requests that hang off them cascading; published events
and the profile cache stay, as anyone else's would); and only then the
caller told, which re-lists identities and clears the active one so the
navigation observer sends a null identity to startup. A failure at the first
step leaves the rest untouched -- an identity the selector lists but nothing
can open is worse than one that is still there.
Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key
store and a real directory: a key written once and refused the second time
by public key, with its preferences file created; forgetting one key leaves
the other and takes its preferences with it; a key that is not in the file is
not forgotten. Its keys are fresh per test rather than fixed, because
DataStoreManager caches each id's preferences in a companion object for the
life of the process, and a fixed key was served the UserPrefs an earlier test
had created in an earlier temp directory -- worth knowing about that cache.
NostrSecretViewModelJvmTest records the four effects and asserts their order,
and that a key that could not be removed stops the sequence at one.
ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded
requests go while an unrelated account stays.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901
tests) and :composeApp:m3Audit.
Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e3cc23ae31
This commit is contained in:
@@ -595,4 +595,28 @@
|
||||
<string name="it_may_be_new_or_it_may_live_on_relays_we">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.</string>
|
||||
<string name="set_up_a_profile">Set up a profile</string>
|
||||
<string name="we_could_not_find_a_profile_for_this_key">We could not find a profile for this key on the relays we asked.</string>
|
||||
<!-- Recovery for an identity with a key and no phrase, docs/nsec-sign-in.md, phase 6. -->
|
||||
<string name="copied_the_nsec">Copied the nsec</string>
|
||||
<string name="copy_nsec">Copy nsec</string>
|
||||
<string name="could_not_forget_the_key_please_try_again">Could not forget the key. Please try again.</string>
|
||||
<string name="could_not_unlock_your_key_please_try">Could not unlock your key. Please try again.</string>
|
||||
<string name="mantra_will_delete_the_nsec_for_s_from_this">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.</string>
|
||||
<string name="display_nostr_secret_key">Display nostr secret key</string>
|
||||
<string name="forget">Forget</string>
|
||||
<string name="forget_this_key">Forget this key</string>
|
||||
<string name="forget_this_key_question">Forget this key?</string>
|
||||
<string name="i_have_saved_my_nostr_secret_key_somewhere">I have saved my nostr secret key somewhere safe.</string>
|
||||
<string name="i_understand_that_if_i_lose_this_phone_and_my_key">I understand that if I lose this phone and my nostr secret key, I lose this profile.</string>
|
||||
<string name="keep_this_key_safe_do_not_share_it">Keep this key safe.\nDo not share it.</string>
|
||||
<string name="key_forgotten">Key forgotten</string>
|
||||
<string name="no_identity_is_open_on_this_device_so_there">No profile is open on this device, so there is no key to show.</string>
|
||||
<string name="nostr_secret_key">Nostr secret key</string>
|
||||
<string name="remove_the_key_from_this_device_the_profile">Remove the key from this device. The profile stays on the relays.</string>
|
||||
<string name="store_the_nsec_that_signs_as_this_profile">Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup.</string>
|
||||
<string name="the_nsec_is_the_key_that_signs_as_you_this">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.</string>
|
||||
<string name="this_device_holds_no_key_for_the_profile">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.</string>
|
||||
<string name="unlocking_your_key">Unlocking your key…</string>
|
||||
<string name="you_have_not_backed_up_your_nostr_secret_key">You have not backed up your nostr secret key</string>
|
||||
<string name="these_are_your_keys_keep_them_safe_no_wallet">These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone.</string>
|
||||
<string name="you_said_you_stored_it">You said you stored it</string>
|
||||
</resources>
|
||||
|
||||
@@ -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<UnsignedNostrEvent>)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HexKey>,
|
||||
@@ -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<HexKey>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Identity?>,
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NostrSecretRoute> {
|
||||
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<ChatRoomMessagingRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ChatRoomMessagingRoute>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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<Identity?>,
|
||||
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>(NostrSecretUIState.Hidden)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val _forgetting = MutableStateFlow<NostrSecretUIState.Forgetting>(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<Boolean> = activeIdentityStateFlow
|
||||
.flatMapLatest { flowOf(it?.kind == IdentityKind.NostrSecret) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val isBackupDone: StateFlow<Boolean?> = activeIdentityStateFlow
|
||||
.flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val isDisclaimerRead: StateFlow<Boolean?> = activeIdentityStateFlow
|
||||
.flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val showBackupNotice: StateFlow<Boolean> = 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<Identity?>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user