diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1cee3dcc..afcbc220 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -209,7 +209,6 @@ Search member functionality Search message functionality will be here. See how deep the rabbit hole goes - Select a wallet Send message Share profile Shared key @@ -470,4 +469,13 @@ You said you stored it New note + + Choose a profile + Could not load the profiles on this device + Decrypting… + Opening profile… + Preparing profiles… + Starting wallet… + Unlock to continue 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 3846d9bc..a0933d79 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 @@ -31,9 +31,17 @@ import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.choose_a_profile +import mantra.composeapp.generated.resources.could_not_load_the_profiles_on_this_device +import mantra.composeapp.generated.resources.decrypting +import mantra.composeapp.generated.resources.loading_preferences import mantra.composeapp.generated.resources.lock_prompt_coming_soon -import mantra.composeapp.generated.resources.select_a_wallet +import mantra.composeapp.generated.resources.opening_profile +import mantra.composeapp.generated.resources.preparing_profiles +import mantra.composeapp.generated.resources.starting_wallet import mantra.composeapp.generated.resources.startup_error +import mantra.composeapp.generated.resources.unlock_to_continue +import press.mantra.compose.ui.view.state.StartupChoice @Composable fun SovereignWalletStartupScreen( @@ -65,12 +73,12 @@ fun SovereignWalletStartupScreen( when (listWalletState) { is ListWalletState.Init -> { LoadingDataIndicator( - text = "Decrypting..." + text = stringResource(Res.string.decrypting) ) } is ListWalletState.Error -> { ImplementationPendingScreen( - text = "Failed to load wallet data", + text = stringResource(Res.string.could_not_load_the_profiles_on_this_device), // Drawn inside the startup gate, which is the root of the stack. onNavigateBack = null, ) @@ -95,17 +103,17 @@ fun SovereignWalletStartupScreen( availableIdentities.isEmpty() -> { LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() } LoadingDataIndicator( - text = "Initializing..." + text = stringResource(Res.string.preparing_profiles) ) } availableWalletMetadata == null || defaultWallet.value == null -> { LoadingDataIndicator( - text = "Preparing wallet..." + text = stringResource(Res.string.preparing_profiles) ) } activeIdentity != null -> { LoadingDataIndicator( - text = "Opening wallet" + text = stringResource(Res.string.opening_profile) ) LaunchedEffect(Unit) { sovereignWalletViewModel.loadSovereignData(activeIdentity.id) @@ -115,16 +123,18 @@ fun SovereignWalletStartupScreen( else -> { when (val startupState = sovereignWalletStartupViewModel.state.value) { is press.mantra.compose.ui.view.model.StartupViewState.Init -> { + // The table is StartupChoice's, where it can be read and + // tested; the one thing decided here is that the saved default + // is only a default when it names a wallet. var loadingIdentity by remember { mutableStateOf( - when { - forceWalletId != null -> availableIdentities[forceWalletId] - !startWalletImmediately -> null - availableIdentities.size == 1 -> availableIdentities.entries.firstOrNull()?.value - desiredWalletId != null -> availableIdentities[desiredWalletId] - startWalletImmediately -> availableIdentities[defaultWallet.value] - else -> null - } + StartupChoice.resolve( + force = forceWalletId, + desired = desiredWalletId, + startImmediately = startWalletImmediately, + identities = availableIdentities, + default = defaultWallet.value as? WalletId, + ) ) } when (val stored = loadingIdentity) { @@ -144,7 +154,7 @@ fun SovereignWalletStartupScreen( topContent = { Spacer(Modifier.height(MaterialTheme.spacing.space800)) Text( - text = stringResource(Res.string.select_a_wallet), + text = stringResource(Res.string.choose_a_profile), style = MaterialTheme.typography.headlineSmall ) Spacer(Modifier.height(MaterialTheme.spacing.space200)) @@ -229,13 +239,16 @@ fun SovereignWalletStartupScreen( } } is press.mantra.compose.ui.view.model.StartupViewState.StartingBusiness -> { + // The one place the word "wallet" stays: only the branch with + // a wallet attached reaches this state, and what is starting is + // a Lightning node. A bare-key profile never sees it. LoadingDataIndicator( - text = "Starting wallet" + text = stringResource(Res.string.starting_wallet) ) } is press.mantra.compose.ui.view.model.StartupViewState.BusinessActive -> { LoadingDataIndicator( - text = "Opening wallet" + text = stringResource(Res.string.opening_profile) ) } is press.mantra.compose.ui.view.model.StartupViewState.Error -> { @@ -296,10 +309,10 @@ private fun BoxScope.LoadWallet( when (isScreenLockRequired.value) { null -> { - LoadingDataIndicator(text = "Loading preferences...") + LoadingDataIndicator(text = stringResource(Res.string.loading_preferences)) } true -> { - LoadingDataIndicator(text = "Unlock to continue") + LoadingDataIndicator(text = stringResource(Res.string.unlock_to_continue)) ScreenLockPrompt( walletId = identity.id, walletName = metadata.nameOrDefault(), @@ -311,7 +324,7 @@ private fun BoxScope.LoadWallet( ) } false -> { - LoadingDataIndicator(text = "Starting wallet...") + LoadingDataIndicator(text = stringResource(Res.string.opening_profile)) LaunchedEffect(Unit) { doLoadWallet(identity) } 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 e4ec030a..868e5c46 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 @@ -419,6 +419,7 @@ fun MantraNavHost( forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, onSignedOut = { + sovereignWalletViewModel.forgetDefaultIdentity() sovereignWalletViewModel.listIdentities { sovereignWalletViewModel.resetToSelector() } @@ -928,6 +929,7 @@ fun MantraNavHost( // identity to startup, which shows the selector -- or Landing, if this // was the last one. onForgotten = { + sovereignWalletViewModel.forgetDefaultIdentity() sovereignWalletViewModel.listIdentities { sovereignWalletViewModel.resetToSelector() } 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 bf514615..2a9dbe37 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 @@ -149,12 +149,37 @@ class SovereignWalletViewModel( listIdentities(onDone = {}) } - /** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */ + /** + * Makes [identity] the one the app runs as. Everything reading [activeIdentity] + * follows, and the id is saved as the default: every activation goes through here + * -- startup for all three kinds, the sign-in and create tails through startup -- so + * the default is always the profile most recently open, and a cold boot with several + * opens that one. It was read by startup and written by nothing, which made every + * cold boot with two profiles the selector. + */ fun setActiveIdentity(identity: Identity) { _activeIdentity.value = identity + rememberDefault { saveDefaultWallet(identity.id) } // scheduleAutoLock() } + /** + * Forgets which profile opens on launch. For the forget tails: the default is the + * one memory of an identity that would otherwise outlive it, and a null read is + * better than a miss that happens to be handled. + */ + fun forgetDefaultIdentity() { + rememberDefault { clearDefaultWallet() } + } + + /** A default that could not be written is a selector on the next boot, not a crash now. */ + private fun rememberDefault(write: suspend GlobalPrefs.() -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { getGlobalPrefs().write() } + .onFailure { log.e("could not record which profile is open", it) } + } + } + /** * Activates a profile with a wallet attached, once its node has started. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt new file mode 100644 index 00000000..389e61ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt @@ -0,0 +1,45 @@ +package press.mantra.compose.ui.view.state + +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.StoredIdentity + +/** + * Which profile the startup screen opens without asking, and when it asks instead. + * + * Lifted out of the screen's `remember` so the table can be read and tested: the + * order of these rows is the whole of the decision, and it was wrong in the one way a + * switch would hit. `!startImmediately` sat above `desired`, and nothing ever set the + * flag back to true, so after the first visit to the selector every sign-in on the + * device landed on the selector instead of the profile it had just signed in. + * See docs/multiple-profiles.md, Phase 3. + */ +object StartupChoice { + + /** + * The identity to open, or null to show the selector. + * + * @param force an id the host insisted on; nothing passes one today. + * @param desired the id a switch or a sign-in named. The most specific instruction, + * and the one a switch relies on, so it outranks the user's earlier "show me the + * list". + * @param startImmediately false when the user asked for the list -- from a forget + * tail, or the lock prompt's back button -- and true otherwise, which a switch + * sets it back to. + * @param default the last profile opened, saved at every activation, so that a cold + * boot resumes where the user was. + */ + fun resolve( + force: WalletId?, + desired: WalletId?, + startImmediately: Boolean, + identities: Map, + default: WalletId?, + ): StoredIdentity? = when { + force != null -> identities[force] + desired != null -> identities[desired] + !startImmediately -> null + identities.size == 1 -> identities.values.single() + default != null -> identities[default] + else -> null + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt new file mode 100644 index 00000000..1715d159 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.ui.view.state + +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.StoredIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The table, one case per row, and the two orderings Phase 3 of + * docs/multiple-profiles.md exists for: what a switch or a sign-in named outranks the + * user's earlier "show me the list", and one identity with "show me the list" still + * shows it, because the user asked. + */ +class StartupChoiceTest { + + private val a = StoredIdentity.NostrPublic(WalletId("aa".repeat(20)), "a1".repeat(32)) + private val b = StoredIdentity.NostrPublic(WalletId("bb".repeat(20)), "b2".repeat(32)) + private val two = mapOf(a.id to a, b.id to b) + + private fun resolve( + force: WalletId? = null, + desired: WalletId? = null, + startImmediately: Boolean = true, + identities: Map = two, + default: WalletId? = null, + ) = StartupChoice.resolve(force, desired, startImmediately, identities, default) + + @Test + fun `a forced id wins over everything`() { + assertEquals(a, resolve(force = a.id, desired = b.id, startImmediately = false, default = b.id)) + } + + @Test + fun `what a switch or a sign-in named is opened, even after the user once asked for the list`() { + assertEquals(b, resolve(desired = b.id, startImmediately = false)) + } + + @Test + fun `the user asking for the list is honoured over a default and over a single identity`() { + assertNull(resolve(startImmediately = false, default = a.id)) + assertNull(resolve(startImmediately = false, identities = mapOf(a.id to a))) + } + + @Test + fun `one identity opens without asking`() { + assertEquals(a, resolve(identities = mapOf(a.id to a))) + } + + @Test + fun `the last profile opened is the one a cold boot opens`() { + assertEquals(b, resolve(default = b.id)) + } + + @Test + fun `a default the list no longer has, or no default, shows the list`() { + assertNull(resolve(default = WalletId("cc".repeat(20)))) + assertNull(resolve()) + } + + @Test + fun `an id that is not listed opens nothing`() { + assertNull(resolve(desired = WalletId("cc".repeat(20)))) + assertNull(resolve(force = WalletId("cc".repeat(20)))) + } +} 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 ad4017a0..8a9aed7b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -34,6 +34,8 @@ import press.mantra.compose.ui.view.model.NavigationViewModel import press.mantra.compose.ui.view.model.NotaryViewModel import press.mantra.compose.ui.view.model.SovereignWalletViewModel import press.mantra.compose.ui.view.state.NavigationUIState +import press.mantra.compose.ui.view.state.StartupChoice +import fr.acinq.phoenix.data.EmptyWalletId import java.io.File import java.nio.file.Files import kotlin.test.AfterTest @@ -60,6 +62,10 @@ import kotlin.time.Clock * a recorder, since a node cannot be started here; a `PhoenixBusiness` can be built * without one, because everything in it is lazy, and that is enough to make the branch * true. + * + * Then Phase 3's half: every activation saves which profile is open, so that a cold + * boot -- a fresh view model over the same directory -- resolves to it without asking; + * and a forget tail clears it, so the boot after shows the list. */ class IdentitySwitchJvmTest { @@ -214,6 +220,52 @@ class IdentitySwitchJvmTest { assertEquals(other, sovereign.desiredWalletId.value) } + @Test + fun `the last profile opened is the one a cold boot resolves, until it is forgotten`() = runBlocking { + val idA = signIn(keyA) + val idB = signIn(keyB) + val first = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { first.availableIdentities.first { it.size == 2 } } + val storedA = assertIs(first.availableIdentities.value[idA]) + val storedB = assertIs(first.availableIdentities.value[idB]) + + first.setActiveIdentity(activated(storedA)) + first.switchToIdentity(idB) + first.setActiveIdentity(activated(storedB)) + // Read through the view model's own GlobalPrefs, not this test's: DataStoreManager + // caches the global preferences once per process, bound to whichever directory + // was current when the first test in this JVM asked -- the same trap the nsec plan + // recorded for user preferences. The app has one directory; a test JVM has many. + val prefs = first.getGlobalPrefs() + withTimeout(5_000) { prefs.getDefaultWallet.first { it == idB } } + + // A cold boot: a fresh view model over the same directory, nothing desired, + // nothing forced, and the user has not asked for the list. + val booted = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { booted.availableIdentities.first { it.size == 2 } } + val resolved = StartupChoice.resolve( + force = null, + desired = booted.desiredWalletId.value, + startImmediately = booted.startWalletImmediately.value, + identities = booted.availableIdentities.value, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + assertEquals(idB, resolved?.id, "the profile that was open is the one that opens") + + // The forget tail clears it; the boot after shows the list. + booted.forgetDefaultIdentity() + withTimeout(5_000) { prefs.getDefaultWallet.first { it == EmptyWalletId } } + assertNull( + StartupChoice.resolve( + force = null, + desired = null, + startImmediately = true, + identities = booted.availableIdentities.value, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + ) + } + @Test fun `switching from nothing, or from a bare key, stops nothing`() = runBlocking { val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it })