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 since0a5a219(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. In0a5a219, 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>
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 that’s 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 Apple’s 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 you’re 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 IDE’s 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 IDE’s 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 IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…