Merge branch 'mantra' into claude/proposals-signature-card-090e46
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user