From ccb9942a187c0c21516260ee843681ef4adfd5de Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:24:14 +0200 Subject: [PATCH] feat(identity): adding a profile from inside, as a key and not a second wallet Phase 5 of docs/multiple-profiles.md. The two entrances, and the two things that were only safe with one profile. Under the list, on the switcher and on the startup selector alike, two rows: "sign in with a key", to the sign-in screen, and "create a new profile", to the create screen. Both push a screen that has a back button. Landing is not one of them -- it has no back button and clears the stack on its way out, because it is the first-run screen, and pushing it from inside would make it a pushed screen on some days and a root on others. A device with one profile can now be given a second, which it could not: Landing showed only when the device held nothing. A profile created from inside is a bare key. CreateProfileScreen generated twelve words behind its form -- a seed, a node, a wallet, and at the NIP-06 path the key that becomes the profile -- which is right for the first profile on a device, the one the wallet will belong to, and wrong for the second: a user who wants another profile has not asked for another Lightning node, another set of channels, or a second phrase with funds behind it. The screen now takes a NewProfileWriter and does not know which it was given: seedProfileWriter from Landing, bareKeyProfileWriter from the switcher, thirty-two random bytes written the way a pasted nsec is. CreateProfileRoute carries the choice. The view model writes the secret first, then the six bootstrap events for the key it derives, then hands the id to the tail -- so the events are only ever queued for a key the device holds, and they are in the database before the tail activates the identity, an ordering the sign-in screen documents as load-bearing and the create flow used to get away with by luck. The tail gains the popUpTo(0) the sign-in tail has. A second wallet is not offered from inside; a pasted recovery phrase still brings its own. "End this" goes. It wiped the database -- every profile's rooms, MLS state, key packages and queues -- from the screen whose purpose is to add a profile; it was only ever a development exit, and with several profiles on a device it was a way to lose the others. wipeDatabase leaves the view model and stays on the repository for the tests that use it. A key already on the device is refused with the id it is listed under. WriteNostrCredentialResult.AlreadyExists and WritingSeedState.Error. SeedAlreadyExists carry it -- the wallet's id where a seed derives the key -- and the sign-in screen's error state gains one action, "switch to it", which is the sign-in tail with that id. From Landing "already on this device" was the whole message; from inside it is a sentence with an obvious next step, and a duplicate paste becomes the fastest switch in the app. Found on the way, and fixed in the jvm actuals: DataStoreManager caches one GlobalPrefs per process behind an unsynchronised check-then-set, and DataStore refuses a second instance over the same file. Two first calls at once -- the listing on IO and a sign-in's write, or the startup screen on Main -- could both see the empty cache and both build one, and the loser threw "multiple DataStores active for the same file" at first use. The test for this phase hit it one run in three. JvmGlobalPrefs is now the one way the jvm target reaches the prefs, under a lock; Android goes through the Application's single instance and never raced; the library's own callers come at node start, when the cache is long populated. Tests: AddProfileFromInsideJvmTest, through the real view models on an in-memory database -- A open, B's nsec committed and switched to, and back, both accounts intact with their own kind 0; A's key pasted again refused with A's id; a profile made through the bare-key writer lists as a bare key with the kind 0 first of its six bootstrap rows, none signed yet, and no node ran. SignInToProfileViewModelJvmTest and IdentityWriterJvmTest assert the ids the three refusals carry. ProfilesScreenJvmTest: the two rows call their callbacks and switch nothing, under the caption that the open profile stays. CreateProfileScreenJvmTest: the state that carried "end this" is composed and the words are not there. Replayed onto Mantra by docs/curated-to-mantra.md: CreateProfileViewModel.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision). Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@2f4202308155085707f065129e2fdfeecd08ea9d --- .../composeResources/values/strings.xml | 6 +- .../mantra/compose/identity/IdentityWriter.kt | 41 ++-- .../compose/identity/NewProfileWriter.kt | 24 +++ .../ui/composable/CreateProfileScreen.kt | 54 ++--- .../compose/ui/composable/ProfilesScreen.kt | 10 +- .../compose/ui/composable/SignInScreen.kt | 25 ++- .../SovereignWalletStartupScreen.kt | 11 +- .../ui/composable/navigation/MantraNavHost.kt | 64 +++--- .../navigation/routes/CreateProfileRoute.kt | 10 +- .../widgets/wallet/AddProfileRows.kt | 75 +++++++ .../ui/view/model/CreateProfileViewModel.kt | 95 +++------ .../ui/view/model/NewProfileWriters.kt | 40 ++++ .../ui/view/model/SignInToProfileViewModel.kt | 20 +- .../ui/view/model/SovereignWalletViewModel.kt | 3 +- .../ui/view/state/SignInToProfileUIState.kt | 8 +- .../compose/extensions/JvmGlobalPrefs.kt | 26 +++ .../mantra/compose/extensions/Phoenix.jvm.kt | 6 +- .../ui/view/model/NavigationViewModel.jvm.kt | 18 +- .../identity/AddProfileFromInsideJvmTest.kt | 186 ++++++++++++++++++ .../compose/identity/IdentitySwitchJvmTest.kt | 15 +- .../compose/identity/IdentityWriterJvmTest.kt | 7 +- .../composable/CreateProfileScreenJvmTest.kt | 49 +++++ .../ui/composable/ProfilesScreenJvmTest.kt | 17 ++ .../model/SignInToProfileViewModelJvmTest.kt | 15 +- 24 files changed, 624 insertions(+), 201 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2eeb8f7b..b74dcb34 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -85,7 +85,6 @@ eg. st eg. To Kill a Mocking Bird Emergency kit - end this ENDED Encrypt and back your recovery information up to your Google Drive or iCloud. Enter the name you want to use for your group @@ -481,4 +480,9 @@ Loading profiles… Switch profile Wallet + + Create a new profile + Sign in with a key + Switch to it + The profile you are in stays on this device. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt index c784828c..62ea1fd5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -55,12 +55,11 @@ import press.mantra.compose.ui.view.model.WritingSeedState */ object IdentityWriter { - /** What the seed store says, or null if it could not be read. */ - private fun seedPublicKeys(phoenixGlobal: PhoenixGlobal): Set? = + /** Each seed's nostr public key and the wallet id it is filed under, or null if the store could not be read. */ + private fun seedWalletIds(phoenixGlobal: PhoenixGlobal): Map? = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?.values - ?.map { StoredIdentity.nostrPublicKeyOf(it.words) } - ?.toSet() + ?.associate { StoredIdentity.nostrPublicKeyOf(it.words) to it.walletId } /** * Creates the preference files a new id needs and records the choices made before @@ -137,10 +136,12 @@ object IdentityWriter { existingSeeds.containsKey(newWalletId) -> { // The same seed, and so the same nostr key with a wallet already attached. // Two different seeds cannot derive one nostr key, so this is the only - // way a profile with a wallet attached is offered its phrase again. + // way a profile with a wallet attached is offered its phrase again. The + // id goes with the refusal: from inside the app, "already here" has an + // obvious next step, which is to switch to it. log.i("attempting to import a seed that already exists, aborting...") onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists + WritingSeedState.Error.SeedAlreadyExists(newWalletId) ) return@launch } @@ -205,8 +206,10 @@ object IdentityWriter { /** * An identity with this nostr public key is already on the device and holds at least * what was pasted: a wallet or a secret, for either input; a public key, for an npub. + * [id] is the one it is listed under -- the wallet's where a seed derives the key -- + * so that the refusal can offer to switch to it. */ - data object AlreadyExists : WriteNostrCredentialResult() + data class AlreadyExists(val id: WalletId) : WriteNostrCredentialResult() /** One of the two stores exists and could not be read; nothing was written. */ data object CannotLoadKeys : WriteNostrCredentialResult() @@ -233,15 +236,19 @@ object IdentityWriter { log.d("writing nostr key to disk...") val stored = StoredIdentity.nostrSecret(privateKey) - val seeds = seedPublicKeys(phoenixGlobal) + val seeds = seedWalletIds(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return WriteNostrCredentialResult.CannotLoadKeys } - if (stored.nostrPublicKey in seeds || existing[stored.nostrPublicKey] is NostrCredential.Secret) { + seeds[stored.nostrPublicKey]?.let { walletId -> + log.i("attempting to import the nostr key of a wallet that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(walletId) + } + if (existing[stored.nostrPublicKey] is NostrCredential.Secret) { log.i("attempting to import a nostr key that is already here, aborting...") - return WriteNostrCredentialResult.AlreadyExists + return WriteNostrCredentialResult.AlreadyExists(stored.id) } val upgrading = existing[stored.nostrPublicKey] is NostrCredential.Public @@ -277,15 +284,19 @@ object IdentityWriter { log.d("writing nostr public key to disk...") val stored = StoredIdentity.nostrPublic(nostrPublicKey) - val seeds = seedPublicKeys(phoenixGlobal) + val seeds = seedWalletIds(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return WriteNostrCredentialResult.CannotLoadKeys } - if (stored.nostrPublicKey in seeds || existing.containsKey(stored.nostrPublicKey)) { + seeds[stored.nostrPublicKey]?.let { walletId -> + log.i("attempting to add the public key of a wallet that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(walletId) + } + if (existing.containsKey(stored.nostrPublicKey)) { log.i("attempting to add a public key the device already holds something for, aborting...") - return WriteNostrCredentialResult.AlreadyExists + return WriteNostrCredentialResult.AlreadyExists(stored.id) } NostrCredentialManager.writeToDisk( @@ -327,13 +338,13 @@ object IdentityWriter { id: WalletId, nostrPublicKey: HexKey, ): ForgetNostrCredentialResult { - val seeds = seedPublicKeys(phoenixGlobal) + val seeds = seedWalletIds(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return ForgetNostrCredentialResult.CannotLoadKeys } - if (nostrPublicKey in seeds) { + if (seeds.containsKey(nostrPublicKey)) { log.i("asked to forget a key a seed derives; the wallet has to go first") return ForgetNostrCredentialResult.WalletAttached } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt new file mode 100644 index 00000000..fb8df175 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.WalletId + +/** What a [NewProfileWriter] made: the key the new profile signs as, and the id it is listed under. */ +data class NewProfile(val nostrPublicKey: HexKey, val id: WalletId) + +/** + * Makes the secret a new profile signs with and puts it on disk. + * + * Two live in the app, and the create screen does not know which it was given. One + * generates twelve words and writes a seed -- for Landing, where the first profile on a + * device is the one the wallet will belong to. The other generates a bare key and + * writes a credential -- for the switcher, where a user who wants another profile has + * not asked for another Lightning node, another set of channels, or a second phrase + * with funds behind it. Under docs/multiple-profiles.md the two differ by exactly one + * thing, whether a wallet is attached to the profile they make, which is what the model + * was for. + */ +fun interface NewProfileWriter { + /** Generates and writes the secret. A failure is a write that did not happen; nothing is half-made. */ + suspend fun write(): Result +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt index 28cc9ee3..13991e6c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -34,6 +33,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.identity.NewProfileWriter +import fr.acinq.phoenix.data.WalletId import press.mantra.compose.ui.theme.LocalExtendedColors import press.mantra.compose.ui.view.model.CreateProfileViewModel import press.mantra.compose.ui.view.state.CreateProfileUIState @@ -43,7 +44,6 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.after_this_there_is_no_turning_back import mantra.composeapp.generated.resources.bio import mantra.composeapp.generated.resources.create_profile -import mantra.composeapp.generated.resources.end_this import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 import mantra.composeapp.generated.resources.introduce_yourself import mantra.composeapp.generated.resources.it_is_cryptographical_secure_and @@ -70,11 +70,13 @@ import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun CreateProfileScreen( initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration, - onNavigateToEndThis: () -> Unit, onNavigateBack: () -> Unit, nostrRepository: press.mantra.compose.repository.NostrRepository, marmotRepository: MarmotRepository, - writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit + /** Which secret the profile is made of -- a seed from Landing, a bare key from the switcher. The screen does not know which. */ + newProfile: NewProfileWriter, + /** The nav host's tail: re-list, select, go to startup. */ + onProfileCreated: (WalletId) -> Unit, ) { val createProfileViewModel: CreateProfileViewModel = viewModel ( factory = CreateProfileViewModel.factory( @@ -309,7 +311,7 @@ fun CreateProfileScreen( } else { Button( onClick = { - createProfileViewModel.createAccount(writeSeed) + createProfileViewModel.createAccount(newProfile, onProfileCreated) } ) { Text( @@ -333,11 +335,16 @@ fun CreateProfileScreen( ) } is CreateProfileUIState.ProfileReady -> { - // Both pills come from the extended families rather than from a - // literal paired with Color.White/Color.DarkGray by eye. The old - // pairings read 4.57:1 and 2.90:1 against their containers, and - // RedPill carried alpha 0.749, so composited over the surface the - // first was really 3.50:1. Both are below the 4.5:1 floor. + // The pill comes from the extended family rather than from a + // literal paired with Color.White by eye: the old pairing read + // 4.57:1 against its container, and with alpha 0.749 composited + // over the surface was really 3.50:1, below the 4.5:1 floor. + // + // The blue pill that stood beside it -- "end this" -- is gone. It + // wiped the database: every profile's rooms, MLS state, key + // packages and queues, from a screen whose purpose is to add a + // profile. It was only ever a development exit, and with several + // profiles on a device it was a way to lose the others. val extendedColors = LocalExtendedColors.current Spacer( @@ -359,34 +366,13 @@ fun CreateProfileScreen( contentColor = extendedColors.redPill.onColor ), onClick = { - createProfileViewModel.createAccount(writeSeed) + createProfileViewModel.createAccount(newProfile, onProfileCreated) } ) { Text( text = stringResource(Res.string.see_how_deep_the_rabbit_hole_goes) ) } - - HorizontalDivider( - modifier = Modifier.padding(MaterialTheme.spacing.space250) - ) - - Button( - colors = ButtonDefaults.buttonColors( - containerColor = extendedColors.bluePill.color, - contentColor = extendedColors.bluePill.onColor - ), - onClick = { - // TODO: Delete everything and close the app - createProfileViewModel.wipeDatabase { - onNavigateToEndThis.invoke() - } - } - ) { - Text( - text = stringResource(Res.string.end_this) - ) - } Spacer( modifier = Modifier.weight(2f) ) @@ -441,9 +427,9 @@ fun CreateAccountScreenPreview() { // about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." // ) // ), - onNavigateToEndThis = {}, onNavigateBack = {}, - writeSeed = { _, _, _ -> }, + newProfile = NewProfileWriter { Result.failure(IllegalStateException("a preview")) }, + onProfileCreated = {}, nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY, marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt index cd0805cc..9efbac71 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt @@ -23,6 +23,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows import press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -49,8 +50,9 @@ fun ProfilesScreen( onSwitch: (WalletId) -> Unit, /** Re-lists. An unreadable key file is exactly the failure a retry can answer. */ onRetry: () -> Unit, - /** The two ways to add a profile, drawn under the list; Phase 5 fills the slot. */ - addProfile: (@Composable () -> Unit)? = null, + /** The two ways to add a profile, under the list. Both push a screen with a back button. */ + onSignIn: () -> Unit, + onCreateProfile: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() @@ -83,7 +85,9 @@ fun ProfilesScreen( canEdit = false, onWalletClick = { onSwitch(it.id) }, profiles = state.profiles, - bottomContent = addProfile, + bottomContent = { + AddProfileRows(onSignIn = onSignIn, onCreateProfile = onCreateProfile, caption = true) + }, ) } } 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 050702ba..a809c665 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 @@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_ import mantra.composeapp.generated.resources.recovery_phrase_nsec_or_npub import mantra.composeapp.generated.resources.sign_in import mantra.composeapp.generated.resources.signed_in +import mantra.composeapp.generated.resources.switch_to_it 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 @@ -102,6 +103,11 @@ fun SignInToProfileScreen( onError: (WritingSeedState.Error) -> Unit, ) -> Unit, onNavigateBack: () -> Unit, + /** + * The tail: re-list, select, go to startup. Also what "switch to it" does when the key + * pasted is already here -- the same tail with the id it is here under, which makes a + * duplicate paste the fastest switch in the app. + */ onSignedIn: (WalletId) -> Unit, ) { val viewModel: SignInToProfileViewModel = viewModel( @@ -151,10 +157,21 @@ fun SignInToProfileScreen( text = stringResource(Res.string.signing_you_in) ) - is SignInToProfileUIState.Error -> ErrorState( - message = problemMessage(state.problem), - onRetry = { viewModel.retry() }, - ) + is SignInToProfileUIState.Error -> Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ErrorState( + message = problemMessage(state.problem), + onRetry = { viewModel.retry() }, + ) + // "Already on this device" is a sentence with an obvious next step. + state.alreadyHere?.let { id -> + TextButton(onClick = { onSignedIn(id) }) { + Text(stringResource(Res.string.switch_to_it)) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 0d80ba68..d0e04ab7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -29,6 +29,7 @@ import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows import press.mantra.compose.ui.composable.widgets.wallet.rememberProfilesOf import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -52,6 +53,9 @@ fun SovereignWalletStartupScreen( nostrRepository: NostrRepository, onNavigateToWalletLandingPage: () -> Unit, onSuccessfulStartup: () -> Unit, + /** The two ways to add a profile from the selector; a device with one profile can be given a second without opening the first. */ + onNavigateToSignIn: () -> Unit, + onNavigateToCreateProfile: () -> Unit, forceWalletId: WalletId?, ) { val sovereignWalletStartupViewModel = viewModel( @@ -168,8 +172,13 @@ fun SovereignWalletStartupScreen( Spacer(Modifier.height(MaterialTheme.spacing.space200)) }, bottomContent = { + AddProfileRows( + onSignIn = onNavigateToSignIn, + onCreateProfile = onNavigateToCreateProfile, + caption = false, + ) // m3-spacing-exempt: room to scroll the - // last wallet clear of the bottom of the + // last row clear of the bottom of the // window, not a step in the spacing // rhythm. The scale tops out at 72dp and // rounding to it would put the last row 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 6d05f453..ad9e3731 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 @@ -90,6 +90,8 @@ import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.ProfilesScreen import press.mantra.compose.ui.view.model.ProfilesViewModel import press.mantra.compose.ui.view.model.getAvailableWalletsMeta +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.model.seedProfileWriter import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute @@ -475,6 +477,12 @@ fun MantraNavHost( navigationViewModel.loadNostrProfile(route) } }, + onNavigateToSignIn = { + navController.navigate(route = SignInRoute) + }, + onNavigateToCreateProfile = { + navController.navigate(route = CreateProfileRoute(withWallet = false)) + }, forceWalletId = null, ) } @@ -499,47 +507,41 @@ fun MantraNavHost( route = SignInRoute ) }, + // The first profile on a device is the one the wallet will belong to. onNavigateToCreateProfile = { navController.navigate( - route = CreateProfileRoute() + route = CreateProfileRoute(withWallet = true) ) } ) } - composable { + composable { backStackEntry -> + val route = backStackEntry.toRoute() CreateProfileScreen( - onNavigateToEndThis = { - navController.navigate( - route = BlankRoute - ) { - popUpTo(0) - } - }, onNavigateBack = { navController.popBackStack() }, nostrRepository = databaseNostrRepository, marmotRepository = databaseMarmotRepository, - writeSeed = { words, onSeedWritten, onSeedWriteError -> - sovereignWalletViewModel.writeSeed( - words, - isRestoringWallet = false, - onSeedWritten = { walletId -> - onSeedWritten() - - sovereignWalletViewModel.loadSovereignData(walletId) - sovereignWalletViewModel.listIdentities { - sovereignWalletViewModel.switchToIdentity(walletId) - navController.navigate( - route = SovereignWalletStartupRoute - ) - } - }, - onSeedWriteError = { onSeedWriteError() } - ) - } + // A seed from Landing, a bare key from inside: the screen does not know + // which. See docs/multiple-profiles.md, Phase 5. + newProfile = if (route.withWallet) sovereignWalletViewModel.seedProfileWriter() else sovereignWalletViewModel.bareKeyProfileWriter(), + // The sign-in tail, with the popUpTo(0) it always had there: back from the + // new profile's startup must not return to a form whose secret is already + // on disk. The account rows are in the database before this runs, so the + // machine finds them the moment the identity is activated. + onProfileCreated = { walletId -> + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToIdentity(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) { + popUpTo(0) + } + } + }, ) - } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -917,6 +919,12 @@ fun MantraNavHost( onRetry = { sovereignWalletViewModel.listIdentities {} }, + onSignIn = { + navController.navigate(route = SignInRoute) + }, + onCreateProfile = { + navController.navigate(route = CreateProfileRoute(withWallet = false)) + }, ) } composable { backStackEntry -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt index b0ea6406..b05b9442 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt @@ -2,7 +2,13 @@ package press.mantra.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable +/** + * @param withWallet whether the profile is made of a seed, with a wallet attached, or of + * a bare key. True from Landing, where the first profile on a device is the one the + * wallet will belong to; false from the switcher, where wanting another profile is not + * wanting another wallet. See docs/multiple-profiles.md, Phase 5. + */ @Serializable data class CreateProfileRoute( - val nostrEventId: String? = null, -): Route() \ No newline at end of file + val withWallet: Boolean, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt new file mode 100644 index 00000000..c21e969b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt @@ -0,0 +1,75 @@ +package press.mantra.compose.ui.composable.widgets.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.create_a_new_profile +import mantra.composeapp.generated.resources.sign_in_with_a_key +import mantra.composeapp.generated.resources.the_profile_you_are_in_stays_on_this_device +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.spacing + +/** + * The two ways to add a profile, under the list of the ones the device has -- on the + * switcher and on the startup selector alike. Phase 5 of docs/multiple-profiles.md. + * + * Both push a screen that has a back button. Landing is not one of them: it has no back + * button and clears the stack on its way out, because it is the first-run screen, and + * pushing it from inside would make it a pushed screen on some days and a root on + * others. + * + * @param caption said under the rows where there is a profile to say it of -- the + * switcher -- and omitted on the startup selector, where nothing is open. + */ +@Composable +fun AddProfileRows( + onSignIn: () -> Unit, + onCreateProfile: () -> Unit, + caption: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(MaterialTheme.spacing.itemGap)) + HorizontalDivider() + Spacer(Modifier.height(MaterialTheme.spacing.itemGap)) + TextButton(onClick = onSignIn) { + Icon(Icons.Default.Key, contentDescription = Decorative) + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.sign_in_with_a_key)) + } + TextButton(onClick = onCreateProfile) { + Icon(Icons.Default.PersonAdd, contentDescription = Decorative) + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.create_a_new_profile)) + } + if (caption) { + Text( + text = stringResource(Res.string.the_profile_you_are_in_stays_on_this_device), + modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt index dce2283c..7c2ec497 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt @@ -12,20 +12,13 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.CreateProfileUIState import press.mantra.compose.ui.view.state.form.CreateProfileFormState import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.Lightning -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.nostrPublicKey -import fr.acinq.phoenix.managers.nsecPassword -import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.data.WalletId import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.NewProfileWriter class CreateProfileViewModel( val initialCreateProfileUIState: CreateProfileUIState, @@ -78,19 +71,24 @@ class CreateProfileViewModel( } - private fun failProfileCreation(e: Throwable? = null) { - if (e != null) { - logger.e("error when creating new profile: ", e) - } else { - logger.e { "seed write failed, aborting profile creation" } - } + private fun failProfileCreation(e: Throwable) { + logger.e("error when creating new profile: ", e) createProfileUIState.value = CreateProfileUIState.Error isActionPending.value = false } - fun createAccount( - writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit - ) { + /** + * Makes the profile: the secret through [newProfile], then the six bootstrap events + * for the key it derives, then [onProfileCreated] with the id, on the main thread. + * + * The secret is written first, so that the events are only ever queued for a key the + * device holds and the notary can sign; and the events are in the database before the + * caller's tail activates the identity, so that the navigation machine finds the + * account the moment it looks -- an ordering the sign-in screen documents as + * load-bearing and the create flow used to get away with by luck. Which secret is made + * -- a seed, or a bare key -- is the writer's business; see [NewProfileWriter]. + */ + fun createAccount(newProfile: NewProfileWriter, onProfileCreated: (WalletId) -> Unit) { logger.d { "createAccount" } if (isActionPending.value) return isActionPending.value = true @@ -98,52 +96,21 @@ class CreateProfileViewModel( viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> failProfileCreation(e) }) { - logger.d("generating new wallet...") - val entropy = Lightning.randomBytes(16) - val mnemonics = MnemonicCode.toMnemonics( - entropy = entropy, - wordlist = MnemonicLanguage.English.wordlist() + val written = newProfile.write().getOrElse { e -> + failProfileCreation(e) + return@launch + } + logger.d("Pubkey: ${written.nostrPublicKey}") + + nostrRepository.createNewProfile( + written.nostrPublicKey, + name = createProfileFormState.nameField.textFieldState.text.toString(), + biography = createProfileFormState.biographyField.textFieldState.text.toString(), + onCompletion = { + logger.d("Created: ${written.nostrPublicKey}") + } ) - - val localKeyManager = LocalKeyManager( - seed = MnemonicCode.toSeed(mnemonics, "").byteVector(), - chain = NodeParamsManager.chain, - remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub - ) - - val pubkey = localKeyManager.nostrPublicKey() - - logger.d("Pubkey: $pubkey" ) - - // Only create the profile events once the seed backing this pubkey is safely - // on disk; otherwise the notary could never sign them. - writeSeed( - mnemonics, - { - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - failProfileCreation(e) - }) { - nostrRepository.createNewProfile( - pubkey, - name = createProfileFormState.nameField.textFieldState.text.toString(), - biography = createProfileFormState.biographyField.textFieldState.text.toString(), - onCompletion = { - logger.d("Created: $pubkey" ) - } - ) - } - }, - { failProfileCreation() } - ) - } - } - - fun wipeDatabase( - onNavigateToEndThis: () -> Unit - ) { - viewModelScope.launch { - nostrRepository.wipeDatabase() - onNavigateToEndThis.invoke() + withContext(Dispatchers.Main) { onProfileCreated(written.id) } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt new file mode 100644 index 00000000..11d06d87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.ui.view.model + +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.MnemonicLanguage +import kotlinx.coroutines.CompletableDeferred +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.NewProfile +import press.mantra.compose.identity.NewProfileWriter +import press.mantra.compose.identity.StoredIdentity + +/** + * A profile with a wallet attached: twelve fresh words through the seed writer, which + * since docs/multiple-profiles.md writes the derived key to the credentials file as + * well. Callback-shaped underneath, awaited here: exactly one of the two callbacks + * fires. + */ +fun SovereignWalletViewModel.seedProfileWriter(): NewProfileWriter = NewProfileWriter { + val words = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist()) + val outcome = CompletableDeferred>() + writeSeed( + mnemonics = words, + isRestoringWallet = false, + onSeedWritten = { id -> outcome.complete(Result.success(NewProfile(StoredIdentity.nostrPublicKeyOf(words), id))) }, + onSeedWriteError = { error -> outcome.complete(Result.failure(IllegalStateException("seed write failed: $error"))) }, + ) + outcome.await() +} + +/** A profile that is a key: thirty-two random bytes, written the way a pasted nsec is. */ +fun SovereignWalletViewModel.bareKeyProfileWriter(): NewProfileWriter = NewProfileWriter { + val key = PrivateKey(Lightning.randomBytes(32)) + when (val result = writeNostrKey(key)) { + is IdentityWriter.WriteNostrCredentialResult.Written -> Result.success(NewProfile(key.nostrPublicKeyHex(), result.id)) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Result.failure(IllegalStateException("a fresh key collided with one already here")) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Result.failure(IllegalStateException("the credentials could not be read")) + } +} 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 82e9a8be..4155310c 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 @@ -104,13 +104,15 @@ class SignInToProfileViewModel( /** 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 + + /** @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is listed under. */ + data class Failed(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : Outcome } private suspend fun write(what: String, writer: suspend () -> IdentityWriter.WriteNostrCredentialResult): Outcome = try { when (val result = writer()) { is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) - is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = result.id) is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) } } catch (e: CancellationException) { @@ -141,13 +143,11 @@ class SignInToProfileViewModel( { 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 - } - ) + when (error) { + is WritingSeedState.Error.SeedAlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = error.id) + is WritingSeedState.Error.CannotLoadSeedMap, + is WritingSeedState.Error.Generic -> Outcome.Failed(CredentialProblem.CouldNotWrite) + } ) }, ) @@ -170,7 +170,7 @@ class SignInToProfileViewModel( 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) + is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem, outcome.alreadyHere) } } } 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 2a9dbe37..1b0ea520 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 @@ -52,7 +52,8 @@ sealed class WritingSeedState { sealed class Error : WritingSeedState() { data class Generic(val cause: Throwable) : Error() data object CannotLoadSeedMap: Error() - data object SeedAlreadyExists: Error() + /** [id] is the wallet's, so that a refusal from inside the app can offer to switch to it. */ + data class SeedAlreadyExists(val id: WalletId): Error() } } 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 fb233399..1710d0e2 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,5 +1,6 @@ package press.mantra.compose.ui.view.state +import fr.acinq.phoenix.data.WalletId import press.mantra.compose.identity.CredentialProblem import press.mantra.compose.identity.SignInCredential @@ -23,5 +24,10 @@ sealed interface SignInToProfileUIState { /** Writing the secret and planting the account. */ data object Committing : SignInToProfileUIState - data class Error(val problem: CredentialProblem) : SignInToProfileUIState + /** + * @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is + * listed under: "already on this device" is a sentence with an obvious next step, + * and from inside the app the screen offers it. + */ + data class Error(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : SignInToProfileUIState } diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt new file mode 100644 index 00000000..74991562 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.extensions + +import fr.acinq.bitcoin.Chain +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.utils.preferences.GlobalPrefs + +/** + * The one way the jvm target reaches the global preferences. + * + * `DataStoreManager.loadGlobalPrefsForWallet` caches one `GlobalPrefs` per process behind + * an unsynchronised check-then-set, and DataStore refuses a second instance over the same + * file. Two first calls at once -- the listing on IO and the startup screen on Main, or a + * sign-in's write and the listing it races -- can both see the empty cache, both build + * one, and the loser throws "multiple DataStores active for the same file" on first use. + * Android is unaffected, since it reaches the prefs through the Application's single + * instance; the jvm actuals all went through the cache, and this serialises them. The + * library's own callers come later, at node start, when the cache is long populated. + */ +internal object JvmGlobalPrefs { + private val lock = Any() + + fun of(phoenixGlobal: PhoenixGlobal): GlobalPrefs = synchronized(lock) { + DataStoreManager(phoenixGlobal.ctx, chain = Chain.Mainnet).loadGlobalPrefsForWallet() + } +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt index d47aa7ec..31d70efd 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt @@ -1,11 +1,9 @@ package press.mantra.compose.extensions import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.StartBusinessResult import fr.acinq.phoenix.jvm.BusinessManager -import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -44,9 +42,9 @@ actual fun schedulePlatformLogic(phoenixGlobal: PhoenixGlobal) { } actual fun getShowIntroFlow(phoenixGlobal: PhoenixGlobal): Flow { - return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro + return JvmGlobalPrefs.of(phoenixGlobal).getShowIntro } actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs { - return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet() + return JvmGlobalPrefs.of(phoenixGlobal) } diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index acea8fb4..1d9fa171 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -8,15 +8,14 @@ package press.mantra.compose.ui.view.model -import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.jvm.BusinessManager -import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import kotlinx.coroutines.flow.Flow +import press.mantra.compose.extensions.JvmGlobalPrefs actual fun updateBusinessActiveInUI(walletId: WalletId) { @@ -34,20 +33,14 @@ actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { } actual fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> { - return DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().getAvailableWalletsMeta + return JvmGlobalPrefs.of(phoenixGlobal).getAvailableWalletsMeta } actual suspend fun saveAvailableWalletMeta( phoenixGlobal: PhoenixGlobal, metadata: UserWalletMetadata ) { - DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().saveAvailableWalletMeta(metadata) + JvmGlobalPrefs.of(phoenixGlobal).saveAvailableWalletMeta(metadata) } actual suspend fun saveAvailableWalletMeta( @@ -57,10 +50,7 @@ actual suspend fun saveAvailableWalletMeta( avatar: String, isHidden: Boolean ) { - DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().saveAvailableWalletMeta( + JvmGlobalPrefs.of(phoenixGlobal).saveAvailableWalletMeta( walletId = walletId, name = name, avatar = avatar, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt new file mode 100644 index 00000000..604b095c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt @@ -0,0 +1,186 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.CreateProfileViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.state.CreateProfileUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A profile added from inside -- Phase 5 of docs/multiple-profiles.md -- through the + * real view models on an in-memory database. + * + * A is open. B's nsec is committed through the sign-in view model, as the switcher's + * "sign in with a key" row leads to; the tail switches; B is open; and back. Both + * accounts are intact, each with its own kind 0 and neither with the other's. Then a + * profile created through the create view model with the bare-key writer, as the + * switcher's "create a new profile" row leads to: it lists as a bare key, its account + * holds the six bootstrap rows, and no node ran -- the assertion the nsec round trip + * already makes, made of a profile the app made rather than one it was handed. + */ +class AddProfileFromInsideJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("AddProfileFromInsideJvmTest") + private val keyA = PrivateKey(Lightning.randomBytes(32)) + private val keyB = PrivateKey(Lightning.randomBytes(32)) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-add-inside-store").toFile() + appDir = Files.createTempDirectory("mantra-add-inside-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own: DataStoreManager caches one per process, and + // when this class is the first in the JVM to populate it, a second instance over + // the same file is exactly what DataStore refuses. Everything here goes through + // the view model, and so through the cache. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private fun sovereign() = SovereignWalletViewModel(phoenixGlobal, stopBusiness = {}) + + private fun signInViewModel(sovereign: SovereignWalletViewModel) = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> sovereign.writeNostrKey(key) }, + writeNostrPublicKey = { pubkey -> sovereign.writeNostrPublicKey(pubkey) }, + writeRecoveryPhrase = { _, _, onError -> onError(press.mantra.compose.ui.view.model.WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + private fun activated(stored: StoredIdentity.NostrSecret): Identity { + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + return Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + } + + private suspend fun listed(sovereign: SovereignWalletViewModel, size: Int): Map { + val done = CompletableDeferred() + sovereign.listIdentities { done.complete(Unit) } + withTimeout(15_000) { done.await() } + return sovereign.availableIdentities.first { it.size == size } + } + + private suspend fun accountsOf(key: PrivateKey) = + nostrRepository.getLocalAccounts().filter { it.unsignedNostrEvent?.pubKey == key.nostrPublicKeyHex() } + + @Test + fun `a key signed in from inside is switched to, and the profile it was signed in from stays`() = runBlocking { + // A is open, the way the app gets there. + val sovereign = sovereign() + val signedInA = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyA, keyA.nostrPublicKeyHex())) + ) + val storedA = assertIs(listed(sovereign, 1)[signedInA.id]) + sovereign.setActiveIdentity(activated(storedA)) + + // B, from the switcher's row: the sign-in screen's commit, then its tail. + val signedInB = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyB, keyB.nostrPublicKeyHex())) + ) + val identities = listed(sovereign, 2) + val storedB = assertIs(identities[signedInB.id]) + assertIs(identities[signedInA.id], "the profile signed in from is still listed") + sovereign.switchToIdentity(signedInB.id) + assertNull(sovereign.activeIdentity.value) + sovereign.setActiveIdentity(activated(storedB)) + assertEquals(keyB.nostrPublicKeyHex(), sovereign.activeIdentity.value?.nostrPublicKey) + + // And back. + sovereign.switchToIdentity(signedInA.id) + sovereign.setActiveIdentity(activated(storedA)) + assertEquals(keyA.nostrPublicKeyHex(), sovereign.activeIdentity.value?.nostrPublicKey) + + // Both accounts intact, each with its own kind 0 and neither with the other's. + assertEquals(1, accountsOf(keyA).size) + assertEquals(1, accountsOf(keyB).size) + assertEquals(2, nostrRepository.getLocalAccounts().size) + + // Pasting A's key again from inside is refused with the id A is listed under. + val again = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyA, keyA.nostrPublicKeyHex())) + ) + assertEquals(CredentialProblem.AlreadyOnThisDevice, again.problem) + assertEquals(signedInA.id, again.alreadyHere, "the refusal names the profile to switch to") + } + + @Test + fun `a profile created from inside is a bare key with its six bootstrap rows, and no node ran`() = runBlocking { + val sovereign = sovereign() + val created = CompletableDeferred() + val viewModel = CreateProfileViewModel( + initialCreateProfileUIState = CreateProfileUIState.ConfirmInput(name = "Bea", bio = "Second"), + nostrRepository = nostrRepository, + marmotRepository = marmotRepository, + ) + viewModel.createProfileFormState.nameField.textFieldState.edit { append("Bea") } + viewModel.createProfileFormState.biographyField.textFieldState.edit { append("Second") } + + viewModel.createAccount(sovereign.bareKeyProfileWriter()) { created.complete(it) } + val id = withTimeout(15_000) { created.await() } + + val stored = assertIs(listed(sovereign, 1)[id], "a key, not a wallet") + val account = nostrRepository.getLocalAccounts().single { it.unsignedNostrEvent?.pubKey == stored.nostrPublicKey } + assertNull(account.unsignedNostrEvent?.signedAt, "the kind 0 waits for the notary, which starts when the profile is opened") + val queued = nostrRepository.observeUnsignedNostrEvents(stored.nostrPublicKey).first() + assertEquals(6, queued.size, "the bootstrap: the kind 0, the contact list, and the relay lists") + assertEquals(0, queued.first().kind, "the kind 0 first") + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a bare key") + assertEquals(CreateProfileUIState.ConfirmInput(name = "Bea", bio = "Second"), viewModel.createProfileUIState.value, "the screen leaves through the tail, not an error") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt index 8a9aed7b..0e94f355 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -1,6 +1,5 @@ package press.mantra.compose.identity -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.room3.Room import co.touchlab.kermit.Logger import fr.acinq.bitcoin.PrivateKey @@ -11,11 +10,10 @@ import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.jvm.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.computePreferencePath import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.security.JvmKeyStore import fr.acinq.phoenix.utils.PlatformContext -import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import press.mantra.compose.extensions.getGlobalPrefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -72,7 +70,6 @@ class IdentitySwitchJvmTest { private lateinit var storeDir: File private lateinit var appDir: File private lateinit var phoenixGlobal: PhoenixGlobal - private lateinit var globalPrefs: GlobalPrefs private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) private val scope = CoroutineScope(Job() + Dispatchers.IO) @@ -92,12 +89,10 @@ class IdentitySwitchJvmTest { appDir = Files.createTempDirectory("mantra-switch-app").toFile() JvmKeyStore.lock() JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own -- see AddProfileFromInsideJvmTest.setUp: the + // process-wide cache and a second instance over the same file is what DataStore + // refuses, and which of the two this class gets depends on the order of the run. phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) - globalPrefs = GlobalPrefs( - PreferenceDataStoreFactory.createWithPath { - computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") - } - ) } @AfterTest @@ -111,7 +106,7 @@ class IdentitySwitchJvmTest { private suspend fun signIn(key: PrivateKey): WalletId { val written = assertIs( - IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null) + IdentityWriter.writeNostrKey(log, phoenixGlobal, getGlobalPrefs(phoenixGlobal), key, isTorEnabled = false, customElectrumServer = null) ) nostrRepository.signInToProfile(key.nostrPublicKeyHex()) return written.id diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt index e19b2f54..dc61a584 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -166,7 +166,7 @@ class IdentityWriterJvmTest { assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Secret(first)), credentials()) assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read") - assertIs(write(first)) + assertEquals(stored.id, assertIs(write(first)).id) } @Test @@ -267,8 +267,9 @@ class IdentityWriterJvmTest { assertEquals(setOf(phrase.walletId), seeds()?.keys) assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb"))) - assertIs(write(phrase.nostrKey)) - assertIs(writePublic(phrase.nostrKey)) + // Refused with the wallet's id, not the bare key's: the id the profile is listed under. + assertEquals(phrase.walletId, assertIs(write(phrase.nostrKey)).id) + assertEquals(phrase.walletId, assertIs(writePublic(phrase.nostrKey)).id) assertIs( IdentityWriter.forgetNostrCredential(log, phoenixGlobal, id, phrase.nostrPublicKey) ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt new file mode 100644 index 00000000..cac213bf --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt @@ -0,0 +1,49 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.identity.NewProfileWriter +import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.CreateProfileUIState +import kotlin.test.Test + +/** + * What the create screen no longer offers -- Phase 5 of docs/multiple-profiles.md. + * + * "End this" wiped the database: every profile's rooms, MLS state, key packages and + * queues, from a screen whose purpose is to add a profile. With several profiles on a + * device it was a way to lose the others. The state that carried it is composed and + * looked at; the words are not there. + */ +@OptIn(ExperimentalTestApi::class) +class CreateProfileScreenJvmTest { + + @Test + fun `the screen that adds a profile cannot wipe the others`() = runDesktopComposeUiTest(400, 900) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CreateProfileScreen( + initialCreateProfileUIState = CreateProfileUIState.ProfileReady( + profile = Profile(publicKey = "ab".repeat(32), nostrEventId = "e".repeat(64), displayName = "Alan"), + ), + onNavigateBack = {}, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE, + newProfile = NewProfileWriter { Result.failure(IllegalStateException("not reached")) }, + onProfileCreated = {}, + ) + } + } + } + + onNodeWithText("Profile is ready", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("End this", useUnmergedTree = true).assertDoesNotExist() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt index c855cc83..1d5a284b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt @@ -86,6 +86,7 @@ class ProfilesScreenJvmTest { } private val switched = mutableListOf() + private val taps = mutableListOf() private fun viewModel(listState: ListWalletState = ListWalletState.Success) = ProfilesViewModel( listWalletState = MutableStateFlow(listState), @@ -104,6 +105,8 @@ class ProfilesScreenJvmTest { onNavigateBack = {}, onSwitch = { switched += it }, onRetry = onRetry, + onSignIn = { taps += "sign in" }, + onCreateProfile = { taps += "create" }, ) } } @@ -157,6 +160,20 @@ class ProfilesScreenJvmTest { assertEquals(1, retried) } + /** The two ways to add a profile, under the list, each pushing its own screen. */ + @Test + fun `under the list are the two ways to add a profile, and the one that is open stays`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("The profile you are in stays on this device.", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign in with a key", useUnmergedTree = true).performClick() + onNodeWithText("Create a new profile", useUnmergedTree = true).performClick() + + assertEquals(listOf("sign in", "create"), taps) + assertEquals(emptyList(), switched, "adding is not switching") + } + @Test fun `the list is a pushed screen, with a way back`() = runDesktopComposeUiTest(400, 900) { setContent { Screen(viewModel()) } 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 index 94c086ef..953a10f7 100644 --- 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 @@ -29,6 +29,9 @@ class SignInToProfileViewModelJvmTest { private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") private val id = WalletId("ab".repeat(20)) + /** The id a key already on the device is listed under, which the refusal carries so the screen can offer to switch to it. */ + private val alreadyHere = WalletId("cd".repeat(20)) + private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey) private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey) private val nostrPublicKey = SignInCredential.NostrPublicKey(publicKey) @@ -67,9 +70,9 @@ class SignInToProfileViewModelJvmTest { fun `the npub of a key already here is refused, and no account is planted`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrPublicKey) + val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists(alreadyHere) }).commit(nostrPublicKey) - assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) assertEquals(listOf("writeNostrPublicKey"), recorder.effects) } @@ -97,9 +100,9 @@ class SignInToProfileViewModelJvmTest { fun `a key already on the device is refused, and no account is planted`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrSecret) + val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists(alreadyHere) }).commit(nostrSecret) - assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) assertEquals(listOf("writeNostrKey"), recorder.effects) } @@ -107,10 +110,10 @@ class SignInToProfileViewModelJvmTest { 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) }) + val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists(alreadyHere)) }) .commit(recoveryPhrase) - assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) assertEquals(listOf("writeRecoveryPhrase"), recorder.effects) }