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