Kgothatso Ngako 11ab8892f0 fix: let startup finish, rather than leave it for an intro that was never built
Sometimes the app boots to "Introducing... Torch" and stays there. That string
is not a screen. It is the `text` of a LoadingRoute that
SovereignWalletStartupScreen navigated to whenever a preference called
showIntro was true, and the one thing that was supposed to move the user off it
had been commented out since 0a5a219 (2026-07-02).

**The gate is always open.** GlobalPrefs.kt, in the lightning-kmp-app submodule:

    /** True if the intro screen must be shown. True by default. */
    val getShowIntro: Flow<Boolean> = safeData.map { it[SHOW_INTRO] ?: true }
    suspend fun saveShowIntro(showIntro: Boolean) = data.edit { it[SHOW_INTRO] = showIntro }

`saveShowIntro` has no caller anywhere in this repository -- its definition is
its only occurrence. SHOW_INTRO is therefore never written, `?: true` is the
answer every time, and the gate fired on every cold boot rather than once ever.
Phoenix clears it when its onboarding carousel finishes. Mantra has no
onboarding carousel; the destination is a loading placeholder. Nothing clears
it because there is nothing to have been shown.

**Leaving the screen is what stops the wallet.** The gate was the first thing in
the composable, ahead of the Box that does the actual work:

    val showIntro = sovereignWalletStartupViewModel.getShowIntroFlow().collectAsState(initial = null)
    if (showIntro.value == true) {
        LaunchedEffect(Unit) { onNavigateToWalletIntroPage.invoke() }
    }

Startup is not something that happens behind this screen; it *is* this screen.
Reaching setActiveWallet means descending ListWalletState.Success -> a non-empty
availableWallets -> wallet metadata and default wallet -> StartupViewState.Init
-> LoadWallet, whose produceState reads getLockBiometricsEnabled and
getLockPinEnabled before its LaunchedEffect calls doLoadWallet -> startupNode.
Navigating away disposes that composition and cancels the LaunchedEffect that
was going to make the call. A screen that has been left behind does not start a
wallet, so activeWalletInUI stayed null.

**And the state machine had already gone quiet.** NavigationViewModel.
observeProfile collects activeWalletStateFlow with collectLatest. Null wallet,
so it had already put StartupPhoenix into _navigationUIState -- which is how the
user arrived at the startup screen in the first place. With activeWalletInUI
pinned at null the flow never emits again, so collectLatest never re-runs; and
even a re-run would `getAndUpdate { NavigationUIState.StartupPhoenix }` onto a
MutableStateFlow already holding that same `data object`, which conflates. No
emission, so MantraNavHost's collector never fires. Nothing was watching
anything any more.

**The one remaining exit was commented out.** onNavigateToWalletIntroPage
launched loadNostrProfile(route) alongside the navigate, and that was the whole
plan: park on a placeholder, work out where the user actually belongs, go
there. In 0a5a219, NostrRepository.observeProfile was renamed
observeLocalAccount and the call stopped compiling, so the branch was commented
out where it stood:

    } else {
    //            if (startupRoute != null) {
    //                val localAccount = nostrRepository.observeProfile(
    //                    publicKey = activeUserPublicKey
    //                ).firstOrNull()
    //
    //                processLocalAccount(localAccount)
    //  ...
    }

Note where the comment markers fall. The surviving `if` covers only
`activeUserPublicKey == null`. For anyone who already had an account,
loadNostrProfile read the database, decided nothing, and returned -- a suspend
function whose entire contract is to leave the navigation state pointing
somewhere, doing so for exactly one caller out of two.

**Why "sometimes".** Two axes.

The first is a race between DataStore reads. The gate is a single read; the
startup path is a chain of them. Usually the gate wins and nothing starts. When
doLoadWallet did fire first, startupNode runs on
SovereignWalletStartupViewModel.viewModelScope, scoped to the back stack entry
-- and the intro navigate used no popUpTo, so the entry survived the departure.
The node came up anyway, setActiveWallet fired, activeWalletInUI went non-null,
collectLatest re-ran and routed properly. Boot looked fine.

The second is whether there is an account. With none,
`getLocalAccounts().firstOrNull()?.profile?.publicKey` is null, the surviving
branch returns Landing, and the user lands on the create-a-profile screen. Only
a device that already had a profile could reach the dead end. Fresh installs
looked healthy, which is a good way for a bug to stay hidden.

**The other dead end, which this one was hiding.** availableWallets.isEmpty()
means no seed on the device, and it called onNavigateToWalletLandingPage, which
navigated to LoadingRoute("Loading... Torch") and launched nothing at all. Not a
race, not a rename -- just a loading screen with nothing left to load. It was
never noticed because on a device with no seed the intro gate got there first
and reached Landing by the account check above. So the accidental path was the
only working route a new user had to creating a wallet, and removing the gate
without fixing this would have broken first run.

**The change.** Three files.

- SovereignWalletStartupScreen: the gate and the onNavigateToWalletIntroPage
  parameter are gone. Startup runs to completion here; everything downstream
  reads the node's key manager, so departing before the node is up cannot work
  regardless of where it departs to.
- MantraNavHost: onNavigateToWalletLandingPage navigates to LandingRoute with
  popUpTo(0), which is where making or restoring a seed lives, and matches how
  every state-driven navigation in this file clears the stack.
- NavigationViewModel: the branch restored against observeLocalAccount, as
  `else if (startupRoute != null)`, so every path out of loadNostrProfile leaves
  the navigation state somewhere.

Neither "... Torch" loading string exists any more.

**Left in place.** getShowIntroFlow -- the expect/actual across android, ios and
jvm, and the accessor on SovereignWalletStartupViewModel -- now has no callers.
It is kept because a real intro screen will want it, but it has to be a step
*inside* this flow, arriving before the wallet is needed and continuing to
startup, not a detour around it. Wiring it back where it was would restore this
bug exactly.

**Not covered, deliberately.** loadNostrProfile still keys off
`getLocalAccounts().firstOrNull()?.profile?.publicKey`, so an account whose
kind-0 is not yet indexed reads as no account and answers Landing, while
observeProfile -- which uses the key manager's pubkey and does not need a
Profile row -- answers with the real state a moment later. Last write wins,
self-correcting, and unchanged by this commit; it predates it and wants the
account lookup rethought rather than patched here.

**Tests.** NavigationRoutingTest, four of them in commonTest, against
loadNostrProfile directly. NostrRepository.NO_OP_NOSTR_REPOSITORY throws from
all 41 of its members, so `by` delegation over it gives a device that answers
getLocalAccounts and observeLocalAccount and fails loudly on anything the call
was not supposed to touch. Each test starts the view model on
NavigationUIState.Loading("Introducing... Torch") -- the screen people were
stranded on -- and asks whether the answer moved.

Three of the four fail with the branch commented back out, and the headline one
fails saying "boot stopped on the screen it was asked to move off. Actual:
Loading(text=Introducing... Torch)", which is the bug report. The fourth, a
device with no account reaching Landing, passes either way: that branch was
never broken, and it is here so that losing it would not be free.

The unqueued-profile case earns its place because the headline assertion is weak
alone -- a constant would satisfy it. Two accounts differing only in signedAt
come back ProfileLoaded and UnqueuedProfile, so what is pinned is that the
destination is read off the account rather than being one fixed answer for "has
an account".

Both entities default their timestamps to Clock.System.now() and compare them in
equals, so the fixture pins them. Without that, building the expected Profile a
second time builds a different Profile, which is how the first run of these
failed.

**Not tested.** The other two files. Removing the showIntro gate and pointing
onNavigateToWalletLandingPage at LandingRoute are Compose and NavHost wiring,
and there is no Compose UI test infrastructure here to hang them on. Note where
that leaves the coverage: the race that decides whether a given boot hangs lives
in the untested half. What these tests hold is that the boot has somewhere to
land once it arrives -- the half that turned a lost race into a dead end rather
than a delay.

Verified: :composeApp:compileDebugKotlinAndroid succeeds; 515 jvm tests, 511
before these four, 0 failed. ChronicleApplyJvmTest "an answered catch-up leaves
one line, whatever it took to deliver" is flaky independently of this change --
it failed with these files reverted to their committed state, and has both
passed and failed on identical code since. Filed separately, not touched here.

The two untested files are read, not run: I did not put the app on a device.
What is asserted about them is the code -- that saveShowIntro has no caller,
that the commented branch was the only exit from that route, and that both
replacements compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:39:33 +02:00
2026-07-05 23:21:02 +02:00
2026-07-28 09:10:20 +02:00
2026-03-24 04:47:19 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00

This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).

  • /composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:

    • commonMain is for code thats common for all targets.
    • Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
  • /iosApp contains iOS applications. Even if youre sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.

Build and Run Android Application

To build and run the development version of the Android app, use the run configuration from the run widget in your IDEs toolbar or build it directly from the terminal:

  • on macOS/Linux
    ./gradlew :composeApp:assembleDebug
    
  • on Windows
    .\gradlew.bat :composeApp:assembleDebug
    

Build and Run Desktop (JVM) Application

To build and run the development version of the desktop app, use the run configuration from the run widget in your IDEs toolbar or run it directly from the terminal:

  • on macOS/Linux
    ./gradlew :composeApp:run
    
  • on Windows
    .\gradlew.bat :composeApp:run
    

Build and Run iOS Application

To build and run the development version of the iOS app, use the run configuration from the run widget in your IDEs toolbar or open the /iosApp directory in Xcode and run it from there.


Learn more about Kotlin Multiplatform

Description
Mantra as a kotlin multiplatform project
https://mantra.press
Readme 8.2 MiB
Languages
Kotlin 100%