feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written
Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence about alpha testing and becomes the screen: one field, two things it accepts, and the npub it signs as shown before anything is written. One field, because a user does not choose an input type -- they paste what they have -- and the two shapes are unambiguous: words have spaces, keys do not. CredentialParser is that decision as a pure function. Words go through MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed nsec or sixty-four hex characters decode to a PrivateKey and must pass isValid(). The two shapes a reasonable person might paste and that cannot work are refused by name rather than lumped in with garbage: an npub is PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey (NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is, and a capital is a paste artefact worth showing rather than silently fixing. Problems with the input stay on the field, as supporting text, while the prompt is still showing; problems after confirming -- the key was already here, the write failed -- are a state of the screen, through ErrorState with a retry back to the field. Four states, wrapped in ScreenStateTransition. Confirm shows the npub in full, in monospace, and which kind was recognised, with the one consequence that differs: a recovery phrase also restores the wallet, a bare key comes with none. The secret itself is never echoed. SignInToProfileViewModel.commit is the effect, and its order is the point. The secret is written to its store first -- IdentityWriter.writeNostrKey for an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a phrase, both injected as functions so the commit can be driven without a key store -- and only on success is the placeholder account planted with nostrRepository.signInToProfile. The placeholder before the write would send the user to fetch a profile they can never sign for; the write before the placeholder is what the nav host then does. Both writers already refuse a duplicate by nostr public key, so a wallet's own nostr secret pasted as an nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare. The nav host wires the route with the same tail the create flow uses after writeSeed: re-list identities, select the new one, go to startup with the form popped, so back does not return to a field holding a secret. Startup finds the identity, activates it, and NavigationViewModel takes it from the placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from both entrances. Strings: nineteen new, sentence case, including the refusals as sentences that say what to do instead. The six Torch-era sign-in strings nothing referenced any more are gone, and the landing caption no longer promises a remote signer this does not deliver. Tests. CredentialParserJvmTest is every row of the table in and out, all three spellings of NIP-06's vector -- words, nsec, hex, with and without the nostr: prefix, in either case -- asserted to land on the same public key, which is the equality the duplicate check rests on; and each refusal by name. SignInToProfileViewModelJvmTest records the effects of a commit and asserts their order and count: write then plant for both kinds, and no planting after a refusal or a throw. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885 tests) and :composeApp:m3Audit, which found no spacing literal or title-case string in the new screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@220616019e
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package press.mantra.compose.identity
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
|
||||
import fr.acinq.bitcoin.MnemonicCode
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.phoenix.managers.nostrPublicKeyHex
|
||||
import fr.acinq.phoenix.utils.MnemonicLanguage
|
||||
|
||||
/**
|
||||
* What a user pasted into the sign-in field, once it has been recognised.
|
||||
*
|
||||
* Both carry the nostr public key, because the confirm step shows it and the duplicate
|
||||
* check compares it. Neither prints its secret.
|
||||
*/
|
||||
sealed interface SignInCredential {
|
||||
val nostrPublicKey: HexKey
|
||||
|
||||
/** Twelve (or more) words: a wallet, and the nostr key derived from it. */
|
||||
data class RecoveryPhrase(
|
||||
val words: List<String>,
|
||||
override val nostrPublicKey: HexKey,
|
||||
) : SignInCredential {
|
||||
override fun toString(): String = "RecoveryPhrase(npub=$nostrPublicKey, words=***)"
|
||||
}
|
||||
|
||||
/** A bare nostr secret. No wallet comes with it. */
|
||||
data class NostrSecret(
|
||||
val privateKey: PrivateKey,
|
||||
override val nostrPublicKey: HexKey,
|
||||
) : SignInCredential {
|
||||
override fun toString(): String = "NostrSecret(npub=$nostrPublicKey, key=<redacted>)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Why an input was not accepted. The screen turns each into a sentence. */
|
||||
enum class CredentialProblem {
|
||||
/** Nothing was entered. */
|
||||
Empty,
|
||||
|
||||
/** Not words, not an nsec, not hex: nothing this screen knows how to read. */
|
||||
NotRecognised,
|
||||
|
||||
/** Words, but the checksum or the wordlist says no: a wrong word, or one out of order. */
|
||||
InvalidPhrase,
|
||||
|
||||
/** An nsec or hex that does not decode to a valid secp256k1 secret. */
|
||||
InvalidKey,
|
||||
|
||||
/** An npub. A public key cannot sign, and every write path here assumes a key that can. */
|
||||
PublicKeyOnly,
|
||||
|
||||
/** An ncryptsec (NIP-49). Decrypting one is a follow-on; say so rather than fail opaquely. */
|
||||
EncryptedKey,
|
||||
|
||||
/** An identity with this nostr public key is already on the device, as a wallet or a key. */
|
||||
AlreadyOnThisDevice,
|
||||
|
||||
/** The secret could not be written to disk. */
|
||||
CouldNotWrite,
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognises a pasted credential. Pure, so it can be pinned row by row.
|
||||
*
|
||||
* A user does not choose an input type; they paste what they have. The two accepted
|
||||
* shapes are unambiguous -- words have spaces, keys do not -- and the two refused
|
||||
* shapes are named for what they are rather than lumped in with garbage, because a
|
||||
* user who pasted an npub or an ncryptsec did something reasonable and should be told
|
||||
* what to do instead.
|
||||
*/
|
||||
object CredentialParser {
|
||||
|
||||
sealed interface Result {
|
||||
data class Recognised(val credential: SignInCredential) : Result
|
||||
data class Refused(val problem: CredentialProblem) : Result
|
||||
}
|
||||
|
||||
private val hex64 = Regex("[0-9a-f]{64}")
|
||||
|
||||
fun parse(raw: String): Result {
|
||||
val text = raw.trim()
|
||||
if (text.isEmpty()) return Result.Refused(CredentialProblem.Empty)
|
||||
|
||||
// Whitespace anywhere inside means words. Not lower-cased: the wordlist already
|
||||
// is, and a capitalised word is a paste artefact worth showing rather than fixing.
|
||||
val words = text.split(Regex("\\s+"))
|
||||
if (words.size > 1) return recoveryPhrase(words)
|
||||
|
||||
// Bech32 is case-insensitive as a whole but rejects mixed case, and a `nostr:`
|
||||
// prefix is how a key arrives from a link.
|
||||
val token = text.removePrefix("nostr:").lowercase()
|
||||
return when {
|
||||
token.startsWith("npub1") -> Result.Refused(CredentialProblem.PublicKeyOnly)
|
||||
token.startsWith("ncryptsec1") -> Result.Refused(CredentialProblem.EncryptedKey)
|
||||
token.startsWith("nsec1") -> nsec(token)
|
||||
hex64.matches(token) -> secret(runCatching { PrivateKey.fromHex(token) }.getOrNull())
|
||||
else -> Result.Refused(CredentialProblem.NotRecognised)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoveryPhrase(words: List<String>): Result = try {
|
||||
MnemonicCode.validate(mnemonics = words, wordlist = MnemonicLanguage.English.wordlist())
|
||||
Result.Recognised(SignInCredential.RecoveryPhrase(words, StoredIdentity.nostrPublicKeyOf(words)))
|
||||
} catch (e: Exception) {
|
||||
Result.Refused(CredentialProblem.InvalidPhrase)
|
||||
}
|
||||
|
||||
private fun nsec(token: String): Result {
|
||||
val bytes = runCatching {
|
||||
val (hrp, data) = Bech32.decodeBytes(token)
|
||||
require(hrp == "nsec") { "not an nsec" }
|
||||
data
|
||||
}.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey)
|
||||
return secret(runCatching { PrivateKey(bytes) }.getOrNull())
|
||||
}
|
||||
|
||||
private fun secret(privateKey: PrivateKey?): Result {
|
||||
if (privateKey == null || !privateKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey)
|
||||
return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex()))
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import mantra.composeapp.generated.resources.keep_the_feed_alive
|
||||
import mantra.composeapp.generated.resources.learn_more
|
||||
import mantra.composeapp.generated.resources.mantra
|
||||
import mantra.composeapp.generated.resources.sign_in
|
||||
import mantra.composeapp.generated.resources.sign_in_to_torch_via_nsec_or_remote_signer
|
||||
import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_or_an_nsec
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
@@ -82,7 +82,7 @@ fun LandingScreen(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.sign_in_to_torch_via_nsec_or_remote_signer),
|
||||
text = stringResource(Res.string.sign_in_with_a_recovery_phrase_or_an_nsec),
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.space125),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center
|
||||
|
||||
@@ -3,45 +3,304 @@ package press.mantra.compose.ui.composable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Spellcheck
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.TextField
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import mantra.composeapp.generated.resources.a_password_protected_key_ncryptsec_is_not
|
||||
import mantra.composeapp.generated.resources.an_npub_is_a_public_key_mantra_needs_the
|
||||
import mantra.composeapp.generated.resources.back
|
||||
import mantra.composeapp.generated.resources.enter_a_recovery_phrase_or_an_nsec_to_sign_in
|
||||
import mantra.composeapp.generated.resources.next
|
||||
import mantra.composeapp.generated.resources.paste
|
||||
import mantra.composeapp.generated.resources.recognised_as_a_nostr_secret_key_no_wallet
|
||||
import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_also
|
||||
import mantra.composeapp.generated.resources.recovery_phrase_or_nsec
|
||||
import mantra.composeapp.generated.resources.sign_in
|
||||
import mantra.composeapp.generated.resources.signed_in
|
||||
import mantra.composeapp.generated.resources.signing_you_in
|
||||
import mantra.composeapp.generated.resources.that_is_not_a_recovery_phrase_or_a_nostr_key
|
||||
import mantra.composeapp.generated.resources.that_nostr_secret_key_is_not_valid
|
||||
import mantra.composeapp.generated.resources.that_recovery_phrase_is_not_valid_check
|
||||
import mantra.composeapp.generated.resources.the_key_could_not_be_saved_on_this_device
|
||||
import mantra.composeapp.generated.resources.the_words_or_the_key_never_leave_this_device
|
||||
import mantra.composeapp.generated.resources.this_key_is_already_on_this_device
|
||||
import mantra.composeapp.generated.resources.twelve_words_or_nsec1
|
||||
import mantra.composeapp.generated.resources.use_a_different_key
|
||||
import mantra.composeapp.generated.resources.you_are_signing_in_as
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.phoenix.data.WalletId
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import mantra.composeapp.generated.resources.sign_in_is_not_yet_available_while_mantra_is
|
||||
import press.mantra.compose.extensions.hexToNpubHrp
|
||||
import press.mantra.compose.identity.CredentialProblem
|
||||
import press.mantra.compose.identity.IdentityWriter
|
||||
import press.mantra.compose.identity.SignInCredential
|
||||
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.theme.readableContent
|
||||
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
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.SignInToProfileViewModel
|
||||
import press.mantra.compose.ui.view.model.WritingSeedState
|
||||
import press.mantra.compose.ui.view.state.SignInToProfileUIState
|
||||
|
||||
// The message is the whole screen while sign in is held back, so it is centred in
|
||||
// the window rather than given the leading edge the readable column normally keeps.
|
||||
/**
|
||||
* Signing in with a secret the user already has.
|
||||
*
|
||||
* One field, two things it accepts -- a recovery phrase or an nsec -- because a user
|
||||
* does not choose an input type, they paste what they have. What was recognised, and
|
||||
* the npub it signs as, is shown before anything is written; that is the step that
|
||||
* catches the wrong key. Problems with the input stay on the field; problems after
|
||||
* confirming are a state of the screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignInToProfileScreen() {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding)
|
||||
.readableContent()
|
||||
.padding(MaterialTheme.spacing.screenMargin),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.sign_in_is_not_yet_available_while_mantra_is),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
fun SignInToProfileScreen(
|
||||
nostrRepository: NostrRepository,
|
||||
writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult,
|
||||
writeRecoveryPhrase: (
|
||||
words: List<String>,
|
||||
onWritten: (WalletId) -> Unit,
|
||||
onError: (WritingSeedState.Error) -> Unit,
|
||||
) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSignedIn: (WalletId) -> Unit,
|
||||
) {
|
||||
val viewModel: SignInToProfileViewModel = viewModel(
|
||||
factory = SignInToProfileViewModel.factory(
|
||||
nostrRepository = nostrRepository,
|
||||
writeNostrKey = writeNostrKey,
|
||||
writeRecoveryPhrase = writeRecoveryPhrase,
|
||||
)
|
||||
)
|
||||
|
||||
// Both composable, and the handler that shows this is not: read above it.
|
||||
val notify = rememberNotifier(rememberCoroutineScope())
|
||||
val signedIn = stringResource(Res.string.signed_in)
|
||||
|
||||
// imePadding: there is a text field, and without it the keyboard covers it.
|
||||
Scaffold(
|
||||
modifier = Modifier.imePadding(),
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
ScreenStateTransition(
|
||||
state = viewModel.uiState.value,
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
label = "sign in",
|
||||
) { uiState ->
|
||||
when (val state = uiState) {
|
||||
is SignInToProfileUIState.InputPrompt -> InputPrompt(viewModel)
|
||||
|
||||
is SignInToProfileUIState.Confirm -> Confirm(
|
||||
credential = state.credential,
|
||||
onConfirm = {
|
||||
viewModel.confirm(state.credential) { id ->
|
||||
notify(signedIn)
|
||||
onSignedIn(id)
|
||||
}
|
||||
},
|
||||
onUseADifferentKey = { viewModel.useADifferentKey() },
|
||||
)
|
||||
|
||||
is SignInToProfileUIState.Committing -> LoadingDataIndicator(
|
||||
text = stringResource(Res.string.signing_you_in)
|
||||
)
|
||||
|
||||
is SignInToProfileUIState.Error -> ErrorState(
|
||||
message = problemMessage(state.problem),
|
||||
onRetry = { viewModel.retry() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputPrompt(viewModel: SignInToProfileViewModel) {
|
||||
val field = viewModel.signInToProfileFormState.textFieldForm.textFieldState
|
||||
val problem = viewModel.inputProblem.value
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.readableContent()
|
||||
.padding(MaterialTheme.spacing.screenMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.the_words_or_the_key_never_leave_this_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = field,
|
||||
isError = problem != null,
|
||||
supportingText = problem?.let { { Text(text = problemMessage(it)) } },
|
||||
// Two lines shows a whole phrase without scrolling and an nsec on one; four is
|
||||
// room for a phrase pasted with line breaks.
|
||||
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 2, maxHeightInLines = 4),
|
||||
// A key is not prose: no capitalisation, no autocorrect, no suggestions rewriting
|
||||
// a word of the phrase. Monospace so an nsec can be read back character by character.
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
|
||||
label = { Text(text = stringResource(Res.string.recovery_phrase_or_nsec), maxLines = 1) },
|
||||
placeholder = { Text(text = stringResource(Res.string.twelve_words_or_nsec1), maxLines = 1) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.getText()?.text?.let { field.setTextAndPlaceCursorAtEnd(it) }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentPaste,
|
||||
contentDescription = stringResource(Res.string.paste)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
onClick = { viewModel.recognise() },
|
||||
) {
|
||||
Text(text = stringResource(Res.string.next))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Confirm(
|
||||
credential: SignInCredential,
|
||||
onConfirm: () -> Unit,
|
||||
onUseADifferentKey: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.readableContent()
|
||||
.padding(MaterialTheme.spacing.screenMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.you_are_signing_in_as),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// In full, so a wrong paste can be seen for what it is. The secret is never echoed.
|
||||
Text(
|
||||
text = credential.nostrPublicKey.hexToNpubHrp(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
|
||||
) {
|
||||
// The label beside each icon says what it is; the icon only decorates it.
|
||||
when (credential) {
|
||||
is SignInCredential.RecoveryPhrase -> {
|
||||
Icon(Icons.Default.Spellcheck, contentDescription = Decorative)
|
||||
Text(
|
||||
text = stringResource(Res.string.recognised_as_a_recovery_phrase_it_also),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
is SignInCredential.NostrSecret -> {
|
||||
Icon(Icons.Default.Key, contentDescription = Decorative)
|
||||
Text(
|
||||
text = stringResource(Res.string.recognised_as_a_nostr_secret_key_no_wallet),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
|
||||
) {
|
||||
Button(onClick = onConfirm) {
|
||||
Text(text = stringResource(Res.string.sign_in))
|
||||
}
|
||||
TextButton(onClick = onUseADifferentKey) {
|
||||
Text(text = stringResource(Res.string.use_a_different_key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Each refusal is a sentence that says what to do instead, not a code. */
|
||||
@Composable
|
||||
private fun problemMessage(problem: CredentialProblem): String = stringResource(
|
||||
when (problem) {
|
||||
CredentialProblem.Empty -> Res.string.enter_a_recovery_phrase_or_an_nsec_to_sign_in
|
||||
CredentialProblem.NotRecognised -> Res.string.that_is_not_a_recovery_phrase_or_a_nostr_key
|
||||
CredentialProblem.InvalidPhrase -> Res.string.that_recovery_phrase_is_not_valid_check
|
||||
CredentialProblem.InvalidKey -> Res.string.that_nostr_secret_key_is_not_valid
|
||||
CredentialProblem.PublicKeyOnly -> Res.string.an_npub_is_a_public_key_mantra_needs_the
|
||||
CredentialProblem.EncryptedKey -> Res.string.a_password_protected_key_ncryptsec_is_not
|
||||
CredentialProblem.AlreadyOnThisDevice -> Res.string.this_key_is_already_on_this_device
|
||||
CredentialProblem.CouldNotWrite -> Res.string.the_key_could_not_be_saved_on_this_device
|
||||
}
|
||||
)
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun SignInToProfileScreenPreview() {
|
||||
@@ -49,7 +308,13 @@ private fun SignInToProfileScreenPreview() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
SignInToProfileScreen()
|
||||
SignInToProfileScreen(
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
writeNostrKey = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys },
|
||||
writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) },
|
||||
onNavigateBack = {},
|
||||
onSignedIn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +662,36 @@ fun MantraNavHost(
|
||||
)
|
||||
}
|
||||
composable<SignInRoute> {
|
||||
SignInToProfileScreen()
|
||||
SignInToProfileScreen(
|
||||
nostrRepository = databaseNostrRepository,
|
||||
writeNostrKey = { privateKey -> sovereignWalletViewModel.writeNostrKey(privateKey) },
|
||||
writeRecoveryPhrase = { words, onWritten, onError ->
|
||||
sovereignWalletViewModel.writeSeed(
|
||||
words,
|
||||
isRestoringWallet = true,
|
||||
onSeedWritten = onWritten,
|
||||
onSeedWriteError = onError,
|
||||
)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
// The tail the create flow uses after writeSeed: re-list, select, go to
|
||||
// startup. Startup finds the new identity, activates it, and the navigation
|
||||
// machine takes it from the placeholder account the sign-in planted. The
|
||||
// form is popped so that back does not return to a field holding a secret.
|
||||
onSignedIn = { walletId ->
|
||||
sovereignWalletViewModel.loadSovereignData(walletId)
|
||||
sovereignWalletViewModel.listIdentities {
|
||||
sovereignWalletViewModel.switchToWallet(walletId)
|
||||
navController.navigate(
|
||||
route = SovereignWalletStartupRoute
|
||||
) {
|
||||
popUpTo(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<UnsignedProfileRoute> { backStackEntry ->
|
||||
backStackEntry.toRoute<UnsignedProfileRoute>()
|
||||
|
||||
@@ -8,30 +8,66 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.phoenix.data.WalletId
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import press.mantra.compose.identity.CredentialParser
|
||||
import press.mantra.compose.identity.CredentialProblem
|
||||
import press.mantra.compose.identity.IdentityWriter
|
||||
import press.mantra.compose.identity.SignInCredential
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.view.state.SignInToProfileUIState
|
||||
import press.mantra.compose.ui.view.state.form.SignInToProfileFormState
|
||||
|
||||
/**
|
||||
* Signs in with a secret the user already has: a recovery phrase or an nsec.
|
||||
*
|
||||
* The two writers are injected as functions rather than reached for, so the commit can
|
||||
* be driven in a test without a key store, and because the recovery-phrase writer is
|
||||
* `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives a
|
||||
* state machine of its own.
|
||||
*
|
||||
* What this does not do is decide where the user goes next. It writes the secret, plants
|
||||
* the placeholder account, and hands the new id to [onSignedIn]; the nav host re-lists
|
||||
* identities, selects that one and goes to startup, which is the same tail the create
|
||||
* flow uses. The **order** inside [commit] is load-bearing: the account has to be in the
|
||||
* database before the identity is activated, because `NavigationViewModel.observeProfile`
|
||||
* starts reading the moment the identity flow emits, and an account that is not there
|
||||
* yet reads as "go and make one".
|
||||
*/
|
||||
class SignInToProfileViewModel(
|
||||
initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState,
|
||||
val signInToProfileFormState: press.mantra.compose.ui.view.state.form.SignInToProfileFormState = press.mantra.compose.ui.view.state.form.SignInToProfileFormState(),
|
||||
val nostrRepository: press.mantra.compose.repository.NostrRepository
|
||||
): ViewModel() {
|
||||
private val nostrRepository: NostrRepository,
|
||||
private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult,
|
||||
private val writeRecoveryPhrase: (
|
||||
words: List<String>,
|
||||
onWritten: (WalletId) -> Unit,
|
||||
onError: (WritingSeedState.Error) -> Unit,
|
||||
) -> Unit,
|
||||
val signInToProfileFormState: SignInToProfileFormState = SignInToProfileFormState(),
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SignInToProfileViewModel"
|
||||
|
||||
fun factory(
|
||||
initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState,
|
||||
nostrRepository: press.mantra.compose.repository.NostrRepository
|
||||
nostrRepository: NostrRepository,
|
||||
writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult,
|
||||
writeRecoveryPhrase: (
|
||||
words: List<String>,
|
||||
onWritten: (WalletId) -> Unit,
|
||||
onError: (WritingSeedState.Error) -> Unit,
|
||||
) -> Unit,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
SignInToProfileViewModel(
|
||||
initialWriteNewNoteUIState,
|
||||
nostrRepository = nostrRepository
|
||||
nostrRepository = nostrRepository,
|
||||
writeNostrKey = writeNostrKey,
|
||||
writeRecoveryPhrase = writeRecoveryPhrase,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -39,51 +75,98 @@ class SignInToProfileViewModel(
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
val uiState: MutableState<SignInToProfileUIState> = mutableStateOf(SignInToProfileUIState.InputPrompt)
|
||||
|
||||
var signInToProfileUIState: MutableState<press.mantra.compose.ui.view.state.SignInToProfileUIState> = mutableStateOf(
|
||||
initialWriteNewNoteUIState
|
||||
)
|
||||
/** Why the field's contents were refused, shown as its supporting text; null when they were not. */
|
||||
val inputProblem: MutableState<CredentialProblem?> = mutableStateOf(null)
|
||||
|
||||
fun validateCredential(): Entity? {
|
||||
return signInToProfileFormState.textFieldForm.textFieldState.text.toString().let { credentialText ->
|
||||
if (credentialText.isBlank()) {
|
||||
signInToProfileFormState.textFieldForm.errorMessage.value = "Please input a credential"
|
||||
return null
|
||||
} else {
|
||||
signInToProfileFormState.textFieldForm.errorMessage.value = null
|
||||
/** Reads the field and either moves to [SignInToProfileUIState.Confirm] or marks the field. */
|
||||
fun recognise() {
|
||||
when (val result = CredentialParser.parse(signInToProfileFormState.textFieldForm.textFieldState.text.toString())) {
|
||||
is CredentialParser.Result.Refused -> inputProblem.value = result.problem
|
||||
is CredentialParser.Result.Recognised -> {
|
||||
inputProblem.value = null
|
||||
uiState.value = SignInToProfileUIState.Confirm(result.credential)
|
||||
}
|
||||
|
||||
Nip19Parser.uriToRoute(signInToProfileFormState.textFieldForm.textFieldState.text.toString())?.entity
|
||||
}
|
||||
}
|
||||
|
||||
fun signInToAccount(
|
||||
) {
|
||||
logger.d { "signInToAccount" }
|
||||
validateCredential().let { credentialEntity ->
|
||||
isActionPending.value = true
|
||||
/** Back to the field, keeping what was typed. */
|
||||
fun useADifferentKey() {
|
||||
uiState.value = SignInToProfileUIState.InputPrompt
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
when (credentialEntity) {
|
||||
is NPub -> {
|
||||
nostrRepository.signInToProfile(
|
||||
publicKey = credentialEntity.hex
|
||||
)
|
||||
}
|
||||
is NSec -> {
|
||||
nostrRepository.signInToProfile(
|
||||
publicKey = credentialEntity.toPubKeyHex()
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// TODO: Might want to give reasons for the error.
|
||||
signInToProfileUIState.value = press.mantra.compose.ui.view.state.SignInToProfileUIState.Error
|
||||
}
|
||||
/** The outcome of [commit], for the screen to act on and the tests to assert. */
|
||||
sealed interface Outcome {
|
||||
data class SignedIn(val id: WalletId) : Outcome
|
||||
data class Failed(val problem: CredentialProblem) : Outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the credential's secret to its store, then plants the placeholder account
|
||||
* that tells the navigation machine this pubkey has a history to fetch rather than a
|
||||
* profile to create. Suspends until both are done or one has failed.
|
||||
*/
|
||||
suspend fun commit(credential: SignInCredential): Outcome {
|
||||
val written: Outcome = when (credential) {
|
||||
is SignInCredential.NostrSecret -> try {
|
||||
when (val result = writeNostrKey(credential.privateKey)) {
|
||||
is IdentityWriter.WriteNostrKeyResult.Written -> Outcome.SignedIn(result.id)
|
||||
is IdentityWriter.WriteNostrKeyResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice)
|
||||
is IdentityWriter.WriteNostrKeyResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("could not write the nostr key", e)
|
||||
Outcome.Failed(CredentialProblem.CouldNotWrite)
|
||||
}
|
||||
|
||||
is SignInCredential.RecoveryPhrase -> {
|
||||
// Callback-shaped writer, awaited: the seed writer drives WritingSeedState and
|
||||
// reports through two callbacks, exactly one of which fires.
|
||||
val outcome = CompletableDeferred<Outcome>()
|
||||
writeRecoveryPhrase(
|
||||
credential.words,
|
||||
{ id -> outcome.complete(Outcome.SignedIn(id)) },
|
||||
{ error ->
|
||||
outcome.complete(
|
||||
Outcome.Failed(
|
||||
when (error) {
|
||||
is WritingSeedState.Error.SeedAlreadyExists -> CredentialProblem.AlreadyOnThisDevice
|
||||
is WritingSeedState.Error.CannotLoadSeedMap,
|
||||
is WritingSeedState.Error.Generic -> CredentialProblem.CouldNotWrite
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
outcome.await()
|
||||
}
|
||||
}
|
||||
|
||||
if (written is Outcome.SignedIn) {
|
||||
// Before the identity is activated -- see the class comment.
|
||||
nostrRepository.signInToProfile(publicKey = credential.nostrPublicKey)
|
||||
}
|
||||
return written
|
||||
}
|
||||
}
|
||||
|
||||
/** [commit] from the confirm button, moving the screen through Committing to wherever it ends. */
|
||||
fun confirm(credential: SignInCredential, onSignedIn: (WalletId) -> Unit) {
|
||||
if (uiState.value is SignInToProfileUIState.Committing) return
|
||||
uiState.value = SignInToProfileUIState.Committing
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
when (val outcome = commit(credential)) {
|
||||
is Outcome.SignedIn -> withContext(Dispatchers.Main) { onSignedIn(outcome.id) }
|
||||
is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** From the error state back to the field. */
|
||||
fun retry() {
|
||||
uiState.value = SignInToProfileUIState.InputPrompt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,20 @@ class SovereignWalletViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a bare nostr key as a new identity, with the same initialisation options
|
||||
* [writeSeed] applies. Suspends; the caller is the sign-in view model's coroutine.
|
||||
*/
|
||||
suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrKeyResult =
|
||||
IdentityWriter.writeNostrKey(
|
||||
log = log,
|
||||
phoenixGlobal = phoenixGlobal,
|
||||
globalPrefs = getGlobalPrefs(),
|
||||
privateKey = privateKey,
|
||||
isTorEnabled = isTorEnabled.value,
|
||||
customElectrumServer = customElectrumServer.value,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SovereignWalletViewModel"
|
||||
fun factory(
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.identity.CredentialProblem
|
||||
import press.mantra.compose.identity.SignInCredential
|
||||
|
||||
abstract class SignInToProfileUIState {
|
||||
data object Error: SignInToProfileUIState()
|
||||
/**
|
||||
* The sign-in screen's four states. Problems with the *input* are not one of them: those
|
||||
* stay on the field as supporting text while the prompt is still showing. [Error] is for
|
||||
* what happens after the user has confirmed -- the write failed, or the key was already
|
||||
* here -- where there is no field to put the message under.
|
||||
*/
|
||||
sealed interface SignInToProfileUIState {
|
||||
/** The field, waiting for a recovery phrase or an nsec. */
|
||||
data object InputPrompt : SignInToProfileUIState
|
||||
|
||||
data object InputPrompt: SignInToProfileUIState()
|
||||
/**
|
||||
* What was recognised, and the npub it signs as, before anything is written. This is
|
||||
* the step that catches a paste of the wrong key; for a recovery phrase it is where
|
||||
* the user learns which profile the words open. The secret itself is never shown.
|
||||
*/
|
||||
data class Confirm(val credential: SignInCredential) : SignInToProfileUIState
|
||||
|
||||
data class ConfirmNpubSignIn(
|
||||
val npub: String,
|
||||
val hexKey: HexKey
|
||||
) : SignInToProfileUIState()
|
||||
/** Writing the secret and planting the account. */
|
||||
data object Committing : SignInToProfileUIState
|
||||
|
||||
data class ConfirmNsecSignIn(
|
||||
val nsec: String,
|
||||
val hexKey: HexKey
|
||||
) : SignInToProfileUIState()
|
||||
|
||||
}
|
||||
data class Error(val problem: CredentialProblem) : SignInToProfileUIState
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user