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) }