diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 14648578..b9b5da9e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -91,7 +91,6 @@
Encrypt and back your recovery information up to your Google Drive or iCloud.
Enter the name you want to use for your group
Enter the name you want to use for your profile
- Enter the nsec or npub (read only) that you want to sign in as
Enter the translation for this chunk
Events are indexed so that we can deliver a premium local first experience.
Everything else
@@ -168,7 +167,6 @@
No versions.
Not now
Nothing was created and no key exists. It is safe to run it again.
- nsec, npub, nip-05 (static address)
Open chat
Original
Original text
@@ -228,10 +226,6 @@
This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature.
Sign
Sign in
- Sign in is not yet available while Mantra is in alpha testing.
- Sign in to nsec
- Sign in to Mantra via nsec, or remote signer
- Sign in with an npub
Sign out
Sign with the group's key
Signed their part
@@ -576,4 +570,24 @@
Content must be text.
The content isn't a profile. A kind 0 carries a JSON object of profile fields.
This schema can't be signed as it is:
+
+ A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec.
+ An npub is a public key. Mantra needs the secret key, the nsec, to sign as you.
+ Enter a recovery phrase or an nsec to sign in.
+ Paste
+ Recognised as a nostr secret key. No wallet comes with it.
+ Recognised as a recovery phrase. It also restores the wallet.
+ Recovery phrase or nsec
+ Sign in with a recovery phrase or an nsec
+ Signed in
+ Signing you in…
+ That is not a recovery phrase or a nostr key.
+ That nostr secret key is not valid.
+ That recovery phrase is not valid. Check each word and the order.
+ The key could not be saved on this device.
+ Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm.
+ This key is already on this device.
+ Twelve words, or nsec1…
+ Use a different key
+ You are signing in as
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt
new file mode 100644
index 00000000..ffa2f11c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt
@@ -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,
+ 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=)"
+ }
+}
+
+/** 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): 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()))
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt
index 9b594afc..acf73b4d 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt
index 8bc2e213..f60352fd 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt
@@ -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,
+ 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 = {},
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 7c1b95d5..976bc510 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -662,7 +662,36 @@ fun MantraNavHost(
)
}
composable {
- 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 { backStackEntry ->
backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt
index 27c90689..df24172a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt
@@ -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,
+ 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,
+ 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 = mutableStateOf(false)
+ val uiState: MutableState = mutableStateOf(SignInToProfileUIState.InputPrompt)
- var signInToProfileUIState: MutableState = mutableStateOf(
- initialWriteNewNoteUIState
- )
+ /** Why the field's contents were refused, shown as its supporting text; null when they were not. */
+ val inputProblem: MutableState = 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()
+ 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
}
-}
\ No newline at end of file
+
+ /** [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
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
index 2cf4d2b9..a22d6ba5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt
@@ -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(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt
index 3e6bbb74..fb233399 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt
@@ -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()
-
-}
\ No newline at end of file
+ data class Error(val problem: CredentialProblem) : SignInToProfileUIState
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt
new file mode 100644
index 00000000..e299b4d3
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt
@@ -0,0 +1,91 @@
+package press.mantra.compose.identity
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+/**
+ * Every row of the sign-in screen's table, in and out.
+ *
+ * The accepted rows all use NIP-06's own vector, so the three ways of writing one key --
+ * twelve words, an nsec, sixty-four hex characters -- are asserted to land on the same
+ * public key. That equality is what the duplicate check in `IdentityWriter` rests on.
+ */
+class CredentialParserJvmTest {
+
+ private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean"
+ private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp"
+ private val hex = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"
+ private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917"
+ private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu"
+
+ private fun recognised(input: String): SignInCredential =
+ assertIs(CredentialParser.parse(input), "for '$input'").credential
+
+ private fun refused(input: String): CredentialProblem =
+ assertIs(CredentialParser.parse(input), "for '$input'").problem
+
+ @Test
+ fun `twelve words are a recovery phrase, and derive the NIP-06 key`() {
+ val credential = assertIs(recognised(words))
+ assertEquals(words.split(" "), credential.words)
+ assertEquals(publicKey, credential.nostrPublicKey)
+ }
+
+ @Test
+ fun `a phrase pasted with line breaks and extra spaces is the same phrase`() {
+ val credential = assertIs(
+ recognised(" leader monkey\nparrot ring\tguide accident before fence cannon height naive bean \n")
+ )
+ assertEquals(publicKey, credential.nostrPublicKey)
+ }
+
+ @Test
+ fun `an nsec, a nostr-prefixed nsec and the hex all name the same key`() {
+ for (input in listOf(nsec, "nostr:$nsec", hex, nsec.uppercase(), hex.uppercase())) {
+ val credential = assertIs(recognised(input), "for '$input'")
+ assertEquals(publicKey, credential.nostrPublicKey, "for '$input'")
+ assertEquals(hex, credential.privateKey.value.toHex(), "for '$input'")
+ }
+ }
+
+ @Test
+ fun `nothing is nothing`() {
+ assertEquals(CredentialProblem.Empty, refused(""))
+ assertEquals(CredentialProblem.Empty, refused(" \n "))
+ }
+
+ @Test
+ fun `a single word that is not a key is not recognised`() {
+ assertEquals(CredentialProblem.NotRecognised, refused("hello"))
+ assertEquals(CredentialProblem.NotRecognised, refused("nostr:"))
+ assertEquals(CredentialProblem.NotRecognised, refused("7f7ff03d")) // hex, but not 64 of it
+ }
+
+ @Test
+ fun `a wrong word, a missing word or a capitalised word is an invalid phrase`() {
+ assertEquals(CredentialProblem.InvalidPhrase, refused(words.replace("bean", "beans")))
+ assertEquals(CredentialProblem.InvalidPhrase, refused(words.substringBeforeLast(" ")))
+ // The checksum would pass if this were lower-cased; the wordlist is lower-case and
+ // a capital is a paste artefact the user should see rather than have silently fixed.
+ assertEquals(CredentialProblem.InvalidPhrase, refused(words.replaceFirstChar { it.uppercase() }))
+ }
+
+ @Test
+ fun `a public key is named for what it is`() {
+ assertEquals(CredentialProblem.PublicKeyOnly, refused(npub))
+ assertEquals(CredentialProblem.PublicKeyOnly, refused("nostr:$npub"))
+ }
+
+ @Test
+ fun `an encrypted key is named for what it is`() {
+ assertEquals(CredentialProblem.EncryptedKey, refused("ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"))
+ }
+
+ @Test
+ fun `an nsec that does not decode, or a hex that is not a valid secret, is an invalid key`() {
+ assertEquals(CredentialProblem.InvalidKey, refused(nsec.dropLast(1) + "q"))
+ assertEquals(CredentialProblem.InvalidKey, refused("0".repeat(64)))
+ assertEquals(CredentialProblem.InvalidKey, refused("f".repeat(64)))
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt
new file mode 100644
index 00000000..cc247437
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt
@@ -0,0 +1,119 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import fr.acinq.bitcoin.ByteVector32
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.phoenix.data.WalletId
+import kotlinx.coroutines.runBlocking
+import press.mantra.compose.identity.CredentialProblem
+import press.mantra.compose.identity.IdentityWriter
+import press.mantra.compose.identity.SignInCredential
+import press.mantra.compose.identity.StoredIdentity
+import press.mantra.compose.repository.NostrRepository
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * What a confirmed sign-in does, and in what order.
+ *
+ * The write and the placeholder are two effects, and the second must follow the first
+ * and only on success: a placeholder for a key that was not saved would send the user
+ * to fetch a profile they can never sign for, and a placeholder planted before the
+ * write is a race the navigation observer can lose. The repository fake records every
+ * call, so both "exactly once" and "after" are assertable.
+ */
+class SignInToProfileViewModelJvmTest {
+
+ private val privateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"))
+ private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917"
+ private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ")
+ private val id = WalletId("ab".repeat(20))
+
+ private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey)
+ private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey)
+
+ /** Records the order of effects; throws on anything the sign-in should not touch. */
+ private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
+ val effects = mutableListOf()
+ override suspend fun signInToProfile(publicKey: HexKey) {
+ effects += "signInToProfile:$publicKey"
+ }
+ }
+
+ private fun viewModel(
+ recorder: Recorder,
+ nostrKeyOutcome: () -> IdentityWriter.WriteNostrKeyResult = { IdentityWriter.WriteNostrKeyResult.Written(id) },
+ seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) },
+ ) = SignInToProfileViewModel(
+ nostrRepository = recorder,
+ writeNostrKey = { recorder.effects += "writeNostrKey"; nostrKeyOutcome() },
+ writeRecoveryPhrase = { _, onWritten, onError -> recorder.effects += "writeRecoveryPhrase"; seedOutcome(onWritten, onError) },
+ )
+
+ @Test
+ fun `an nsec is written, then its account is planted, then the id comes back`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = viewModel(recorder).commit(nostrSecret)
+
+ assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome)
+ assertEquals(listOf("writeNostrKey", "signInToProfile:$publicKey"), recorder.effects)
+ }
+
+ @Test
+ fun `a recovery phrase goes through the seed writer and plants the same account`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = viewModel(recorder).commit(recoveryPhrase)
+
+ assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome)
+ assertEquals(listOf("writeRecoveryPhrase", "signInToProfile:$publicKey"), recorder.effects)
+ }
+
+ @Test
+ fun `a key already on the device is refused, and no account is planted`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.AlreadyExists }).commit(nostrSecret)
+
+ assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome)
+ assertEquals(listOf("writeNostrKey"), recorder.effects)
+ }
+
+ @Test
+ fun `a seed already on the device is the same refusal`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists) })
+ .commit(recoveryPhrase)
+
+ assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome)
+ assertEquals(listOf("writeRecoveryPhrase"), recorder.effects)
+ }
+
+ @Test
+ fun `a store that cannot be read, or a write that throws, is could-not-write`() = runBlocking {
+ val recorder = Recorder()
+
+ assertEquals(
+ SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite),
+ viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }).commit(nostrSecret),
+ )
+ assertEquals(
+ SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite),
+ viewModel(recorder, nostrKeyOutcome = { throw IllegalStateException("disk") }).commit(nostrSecret),
+ )
+ assertEquals(
+ SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite),
+ viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.Generic(IllegalStateException("disk"))) })
+ .commit(recoveryPhrase),
+ )
+ assertEquals(emptyList(), recorder.effects.filter { it.startsWith("signInToProfile") })
+ }
+
+ /** The credential the parser builds for the phrase and the one it builds for the key agree on the account. */
+ @Test
+ fun `both credentials of one key plant the same account`() {
+ assertEquals(StoredIdentity.nostrPublicKeyOf(words), nostrSecret.nostrPublicKey)
+ }
+}