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

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