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 82317a90..924f6002 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 @@ -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 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 dd08385b..f4a39fe4 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 @@ -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 = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index f4bcfd12..041f89dd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -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 + } } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt new file mode 100644 index 00000000..bde74339 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -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 + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun getLocalAccounts(): List = accounts + + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + 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(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) + } +}