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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user