Merge branch 'mantra' into claude/proposals-signature-card-090e46

This commit is contained in:
Kgothatso Ngako
2026-09-06 21:21:55 +02:00
4 changed files with 190 additions and 30 deletions

View File

@@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.first
@Composable
fun SovereignWalletStartupScreen(
sovereignWalletViewModel: press.mantra.compose.ui.view.model.SovereignWalletViewModel,
onNavigateToWalletIntroPage: () -> Unit,
onNavigateToWalletLandingPage: () -> Unit,
onSuccessfulStartup: () -> Unit,
forceWalletId: WalletId?,
@@ -41,10 +40,11 @@ fun SovereignWalletStartupScreen(
)
)
val showIntro = sovereignWalletStartupViewModel.getShowIntroFlow().collectAsState(initial = null)
if (showIntro.value == true) {
LaunchedEffect(Unit) { onNavigateToWalletIntroPage.invoke() }
}
// Deliberately no intro gate. `getShowIntro` defaults to true and nothing in this app ever
// writes it back to false, so gating on it navigated away from this screen on every cold boot
// — and a screen that has been left behind never gets to start the wallet. Startup has to run
// to completion here: everything downstream reads the node's key manager. An intro screen has
// to be a step inside this flow, not a detour around it.
Box(
modifier = Modifier

View File

@@ -372,21 +372,14 @@ fun MantraNavHost(
SovereignWalletStartupScreen(
sovereignWalletViewModel = sovereignWalletViewModel,
// No seed on this device, so there is no wallet to start: the user has to make or
// restore one, and that lives behind the landing screen. This used to park them on
// a loading screen with nothing left to load.
onNavigateToWalletLandingPage = {
navController.navigate(
route = LoadingRoute(
text = "Loading... Torch"
)
)
},
onNavigateToWalletIntroPage = {
navController.navigate(
route = LoadingRoute(
text = "Introducing... Torch"
)
)
applicationIOScope.launch {
navigationViewModel.loadNostrProfile(route)
route = LandingRoute
) {
popUpTo(0)
}
},
onSuccessfulStartup = {

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch
import press.mantra.compose.extensions.nostrPublicKey
@@ -76,19 +77,21 @@ class NavigationViewModel(
NavigationUIState.StartupPhoenix
}
}
} else if (startupRoute != null) {
// This branch was commented out when `observeProfile` was renamed to
// `observeLocalAccount`, which turned the whole call into a no-op for anyone who
// already had an account: the caller had navigated to a loading screen first and then
// nothing ever moved the user off it. Every path out of here must leave the navigation
// state pointing somewhere.
processLocalAccount(
nostrRepository.observeLocalAccount(
publicKey = activeUserPublicKey
).firstOrNull()
)
} else {
// if (startupRoute != null) {
// val localAccount = nostrRepository.observeProfile(
// publicKey = activeUserPublicKey
// ).firstOrNull()
//
// processLocalAccount(localAccount)
//
// } else {
// _navigationUIState.getAndUpdate {
// NavigationUIState.StartupPhoenix
// }
// }
_navigationUIState.getAndUpdate {
NavigationUIState.StartupPhoenix
}
}
}

View File

@@ -0,0 +1,164 @@
package press.mantra.compose.ui.view.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.UnsignedNostrEvent
import press.mantra.compose.database.model.intermdiate.LocalAccount
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute
import press.mantra.compose.ui.view.state.NavigationUIState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.time.Instant
/**
* Where a boot goes once it knows whose device it is on.
*
* `loadNostrProfile` is the only exit from a loading screen. Its callers
* navigate to one first and then ask it where the user actually belongs, so a
* pass through it that decides nothing does not fail -- it strands. The user is
* left on a placeholder with nothing still running that would move them: the
* startup screen has been navigated away from, so no wallet comes up, so the
* flow that watches the wallet never emits again.
*
* That is what happened. The branch for a device that already had an account was
* commented out when `NostrRepository.observeProfile` was renamed
* `observeLocalAccount`, leaving only the no-account case doing any work. Anyone
* who had ever made a profile could boot to "Introducing... Torch" and stay.
*
* So each of these starts the view model on exactly that screen and asks whether
* the answer moved. Asserting the destination is the point, but the weaker
* assertion underneath it -- that the state is no longer the one it started on
* -- is the one the bug broke, and it is what makes a silently skipped branch
* fail here rather than on someone's phone.
*/
class NavigationRoutingTest {
private val publicKey: HexKey = "a".repeat(64)
/** The screen the bug left people on, used as the starting state throughout. */
private val stranded = NavigationUIState.Loading("Introducing... Torch")
/**
* Everything `loadNostrProfile` reads, and nothing else. The no-op
* repository throws from all 41 members, so any call this does not name is a
* failure rather than a quiet default.
*/
private class Device(
private val accounts: List<LocalAccount>
) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
override suspend fun getLocalAccounts(): List<LocalAccount> = accounts
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> =
flowOf(accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == publicKey })
}
/**
* Both entities default their timestamps to `Clock.System.now()` and both
* compare them in `equals`, so the fixture pins them: two builds of the same
* account have to be the same account for an assertion to mean anything.
*/
private val at = Instant.fromEpochSeconds(1_700_000_000)
private fun account(signed: Boolean) = LocalAccount(
unsignedNostrEvent = UnsignedNostrEvent(
id = 1,
pubKey = publicKey,
kind = 0,
tags = emptyArray(),
content = "{}",
signedAt = if (signed) at else null,
createdAt = at,
updatedAt = at,
savedAt = at,
),
nostrEvent = null,
profile = Profile(
publicKey = publicKey,
nostrEventId = "e".repeat(64),
createdAt = at,
updatedAt = at,
savedAt = at,
),
broadcastNostrEventRequest = null,
broadcastNostrEventReceipt = null,
synchronizeNostrEventRequests = emptyList(),
)
/**
* The view model's `init` starts a wallet observer that would write to the
* same state after its 2.1s wait. Cancelling the scope leaves that observer
* parked in the wait, so what these read back came from the call under test.
*/
private fun bootedOn(vararg accounts: LocalAccount): NavigationViewModel {
val scope = CoroutineScope(Job())
return NavigationViewModel(
activeWalletStateFlow = MutableStateFlow<ActiveWallet?>(null),
initialNavigationUIState = stranded,
nostrRepository = Device(accounts.toList()),
scope = scope,
).also { scope.cancel() }
}
@Test
fun `an account already on the device does not stay on the loading screen`() = runTest {
val viewModel = bootedOn(account(signed = true))
viewModel.loadNostrProfile(SovereignWalletStartupRoute)
assertNotEquals(
stranded,
viewModel.navigationUIState.value,
"boot stopped on the screen it was asked to move off",
)
assertEquals(
NavigationUIState.ProfileLoaded(publicKey = publicKey),
viewModel.navigationUIState.value,
)
}
@Test
fun `a profile not yet queued for broadcast is routed by what it is missing`() = runTest {
val viewModel = bootedOn(account(signed = false))
viewModel.loadNostrProfile(SovereignWalletStartupRoute)
// Same device, same call, different answer: what comes back is read off
// the account rather than being one fixed destination for "has an
// account", which is what makes the assertion above worth anything.
assertEquals(
NavigationUIState.UnqueuedProfile(profile = account(signed = false).profile!!),
viewModel.navigationUIState.value,
)
}
@Test
fun `a device with no account is sent to go and make one`() = runTest {
val viewModel = bootedOn()
viewModel.loadNostrProfile(SovereignWalletStartupRoute)
assertEquals(NavigationUIState.Landing, viewModel.navigationUIState.value)
}
@Test
fun `asked from anywhere but startup, it sends the wallet up first`() = runTest {
val viewModel = bootedOn(account(signed = true))
viewModel.loadNostrProfile(startupRoute = null)
// The default-argument contract: no startup route means the caller is
// not the startup screen, and nothing downstream works before the node
// is up, so the answer is to go and start it.
assertEquals(NavigationUIState.StartupPhoenix, viewModel.navigationUIState.value)
}
}