feat(identity): which profile opens, on launch and after a switch

Phase 3 of docs/multiple-profiles.md. The startup screen learns to agree with
the switch.

The table that picks what to open is lifted out of the screen's remember into
StartupChoice.resolve, where it can be read and tested, because its order was
the whole decision and it was wrong in the one way a switch would hit:
"!startWalletImmediately -> null" sat above "desiredWalletId != null", and
nothing ever set the flag back to true. So after the first visit to the
selector, every sign-in on that device landed on the selector instead of the
profile it had just signed in. Two rows move: what a switch or a sign-in
named goes above the user's earlier "show me the list", since it is the more
specific instruction; and the single-identity row drops below it, since when
both are set they name the same thing.

The default is written. GlobalPrefs.getDefaultWallet has been read by startup
since Phoenix and saved by nothing in this app, so a cold boot with two
profiles was the selector every time with no memory of which one was open.
setActiveIdentity now saves the id it activates -- every activation goes
through it, startup for all three kinds and the two tails through startup --
so the default is always the profile most recently open. The two forget tails
clear it, since the default is the one memory of an identity that would
otherwise outlive it. Both writes are wrapped: a default that could not be
recorded is a selector on the next boot, not a crash now. The screen reads
the saved value as a default only when it names a wallet, which is the one
thing left to decide at the call site.

The startup screen's literals go to the catalogue, and "wallet" goes with
them except in the one place a wallet is what is starting: "Starting wallet"
is shown from StartupViewState.StartingBusiness, which only the branch with
a wallet attached reaches, and stays. The rest become "Preparing profiles",
"Opening profile", "Decrypting", "Loading preferences", "Unlock to continue"
and "Could not load the profiles on this device"; the selector's title is
"Choose a profile", and select_a_wallet goes.

Tests: StartupChoiceTest in commonTest, one case per row and the two
orderings this phase is for -- a desired id opens even after the user once
asked for the list, and one identity with the list asked for still shows it.
IdentitySwitchJvmTest gains the cold boot: activate A then B, and a fresh
view model over the same directory resolves B without asking; forget the
default, and it resolves nothing. Found on the way and recorded in the test:
DataStoreManager caches the global preferences once per process, bound to
whichever directory was current when the first test in the JVM asked -- the
same trap the nsec plan recorded for user preferences -- so the test reads
the default through the view model's own instance rather than one of its
own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@00fc996995
This commit is contained in:
Kgothatso Ngako
2026-09-13 00:43:21 +02:00
parent 84ac84ccaf
commit 73a0e5b9c2
7 changed files with 233 additions and 22 deletions

View File

@@ -209,7 +209,6 @@
<string name="search_member_functionality">Search member functionality</string>
<string name="search_message_functionality_will_be_here">Search message functionality will be here.</string>
<string name="see_how_deep_the_rabbit_hole_goes">See how deep the rabbit hole goes</string>
<string name="select_a_wallet">Select a wallet</string>
<string name="send_message">Send message</string>
<string name="share_profile">Share profile</string>
<string name="shared_key">Shared key</string>
@@ -470,4 +469,13 @@
<string name="you_said_you_stored_it">You said you stored it</string>
<!-- The bar on the note composer, which had no bar before it had a back button. -->
<string name="new_note">New note</string>
<!-- Several profiles on a device, docs/multiple-profiles.md. The startup screen's literals,
with "wallet" kept only where a wallet is what is starting. -->
<string name="choose_a_profile">Choose a profile</string>
<string name="could_not_load_the_profiles_on_this_device">Could not load the profiles on this device</string>
<string name="decrypting">Decrypting…</string>
<string name="opening_profile">Opening profile…</string>
<string name="preparing_profiles">Preparing profiles…</string>
<string name="starting_wallet">Starting wallet…</string>
<string name="unlock_to_continue">Unlock to continue</string>
</resources>

View File

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

View File

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

View File

@@ -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.
*

View File

@@ -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<WalletId, StoredIdentity>,
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
}
}

View File

@@ -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<WalletId, StoredIdentity> = 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))))
}
}

View File

@@ -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<Unit> {
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<StoredIdentity.NostrSecret>(first.availableIdentities.value[idA])
val storedB = assertIs<StoredIdentity.NostrSecret>(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<Unit> {
val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it })