feat: mantra compiles for the jvm

Phase 4. Declares jvm(), implements all 16 expects, and bumps the
submodule to the fork branch carrying phases 1-3.
:composeApp:compileKotlinJvm is green.

**The actuals were the small half. Room was the blocker.** The first jvm
compile failed with 58 copies of "Only suspend functions are allowed in
DAOs declared in source sets targeting non-Android platforms". Room
permits blocking query methods on android and nowhere else, so every @Dao
function that was neither suspend nor Flow-returning had to change --
58 of them across 24 files. KSP reports these in alphabetical batches, so
the count shrinks in stages and looks bottomless; scanning the dao package
directly for abstract funs with no suspend and no Flow return finds them
all at once.

It stops there, which is the only reason this is a 58-line change rather
than a refactor. Every one of the 15 call sites outside the dao package
was already inside a suspend function -- the repositories were written
that way throughout -- so nothing needed rewriting. One private helper,
DatabaseNostrRepository.matchNegentropicNostrEvents, had to become
suspend, and its single caller was already suspend, so the cascade
terminated immediately. Zero call-site edits.

**The cost lands on android, not on the jvm.** A blocking DAO method runs
on its caller's thread; a suspend one is dispatched to the query coroutine
context, which getRoomDatabase sets to Dispatchers.IO. That is the better
behaviour -- it is what stops a query running on the main thread -- but it
is a real change to the shipping platform, made for a target that does not
run yet. Hence the unit tests below rather than a compile alone.

**BusinessManager was not an expect**, so nothing warned about it. It is
now ported to the fork's jvmMain (05ce7eb); Phoenix.jvm.kt and
NavigationViewModel.jvm.kt are otherwise the ios actuals with one changed
import, since those files use no ios API.

**schedulePlatformLogic schedules nothing, and logs that it does not.**
Android starts two WorkManager jobs here, one of which is ChannelsWatcher
-- it wakes periodically to notice a channel force-closed while the app
was shut. A desktop application has no process once its window closes, so
there is nothing to wake, and running the watcher in-process would be
strictly worse than not running it: it would only fire while the app was
already open and watching. The exposure is real and belongs in release
notes rather than a comment -- a desktop wallet left closed past a
force-close deadline does not notice.

Smaller calls. PlatformContext carries an application directory, since
there is no Context to read one from, and PlatformDatabaseBuilder puts
aux.db under it rather than in java.io.tmpdir, which is what the abandoned
Aux implementation did behind a TODO and which most systems clear on
reboot. themeColorScheme ignores dynamicColor, which means Material You
and has no desktop counterpart. AppVersion reads the jar manifest that
compose.desktop writes, falling back when running from a class directory.

Verified: :composeApp:compileKotlinJvm green,
:composeApp:compileDebugKotlinAndroid green, and
:composeApp:testDebugUnitTest 52 passing -- the one that matters, since
this commit changes shared code every android query path goes through.

Not verified: nothing has run. No jvm entry point exists yet, so the
database has never been opened on this platform and no business has been
started. That is phase 5, which also has to unlock JvmKeyStore before the
wallet starts -- a passphrase prompt, not just a window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-06 01:23:42 +02:00
1 parent 670f87a609
commit bf4041a5b2
35 files changed
+437 -66

No files matched your search

@@ -0,0 +1,16 @@
package press.mantra.compose
/**
* Android reads these from `BuildConfig`, which the android plugin generates. There is no
* equivalent for the jvm target, so this reads the jar manifest that `compose.desktop`
* writes when it packages a distribution, and falls back when running from a class
* directory -- from gradle, or a test -- where no manifest exists.
*/
actual object AppVersion {
actual val versionName: String
get() = AppVersion::class.java.`package`?.implementationVersion ?: "0.0.0-dev"
/** No monotonic build number exists here the way `versionCode` does on android. */
actual val versionCode: String
get() = "0"
}
@@ -0,0 +1,7 @@
package press.mantra.compose
class JvmPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
}
actual fun getPlatform(): Platform = JvmPlatform()
@@ -0,0 +1,47 @@
package press.mantra.compose
import java.io.File
import java.util.Locale
/**
* Android wraps a `Context`, which is where the database and files directories come from.
* The jvm has no such object, so the location is carried explicitly.
*
* This is deliberately *not* the same class as `fr.acinq.phoenix.utils.PlatformContext`,
* matching how android and ios keep the two apart -- `MainActivity` and
* `MainViewController` each construct both. A desktop entry point should hand the same
* directory to both so one install keeps one place on disk.
*/
actual class PlatformContext(
val applicationDir: File = defaultMantraDir(),
)
/**
* The conventional per-user application data directory for the host os.
*
* Pointedly not `java.io.tmpdir`, which is what the abandoned Aux implementation used
* behind a TODO: most systems clear it on reboot, and the chat history and marmot group
* state live here.
*/
internal fun defaultMantraDir(): File {
val home = File(System.getProperty("user.home"))
val os = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT)
return when {
os.contains("win") ->
(System.getenv("LOCALAPPDATA") ?: System.getenv("APPDATA"))
?.takeIf { it.isNotBlank() }
?.let { File(it, APP_DIR_NAME) }
?: File(home, "AppData/Local/$APP_DIR_NAME")
os.contains("mac") || os.contains("darwin") ->
File(home, "Library/Application Support/$APP_DIR_NAME")
else ->
System.getenv("XDG_DATA_HOME")
?.takeIf { it.isNotBlank() }
?.let { File(it, APP_DIR_NAME) }
?: File(home, ".local/share/$APP_DIR_NAME")
}
}
private const val APP_DIR_NAME = "mantra"
@@ -0,0 +1,21 @@
package press.mantra.compose.database.builder
import androidx.room3.Room
import androidx.room3.RoomDatabase
import press.mantra.compose.PlatformContext
import press.mantra.compose.database.MantraDatabase
import java.io.File
actual object PlatformDatabaseBuilder {
actual fun getDatabaseBuilder(platformContext: PlatformContext): RoomDatabase.Builder<MantraDatabase> {
// Under the context's application directory, not java.io.tmpdir -- which is what the
// abandoned Aux implementation did behind a TODO, and which most systems clear on
// reboot. The file keeps android's name so one app does not have two on disk.
val dbFile = File(platformContext.applicationDir.apply { mkdirs() }, "aux.db")
return Room.databaseBuilder<MantraDatabase>(name = dbFile.absolutePath)
}
actual fun getInMemoryDatabaseBuilder(platform: PlatformContext): RoomDatabase.Builder<MantraDatabase> {
return Room.inMemoryDatabaseBuilder()
}
}
@@ -0,0 +1,52 @@
package press.mantra.compose.extensions
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.Chain
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.jvm.BusinessManager
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
/**
* `Dispatchers.Default`, following android rather than ios -- ios hops to Main, and there is
* no reason for the jvm to start a lightning node on the ui thread.
*/
actual suspend fun platformStartupLogic(words: List<String>): StartBusinessResult {
return withContext(Dispatchers.Default) {
BusinessManager.startNewBusiness(words, isHeadless = false)
}
}
/**
* **Nothing is scheduled, and that has a consequence worth stating.**
*
* Android schedules two WorkManager jobs here: `ChannelsWatcher`, which wakes periodically
* to check whether a channel has been force-closed while the app was shut, and
* `ContactsPhotoCleaner`. Ios schedules neither and logs that it does not.
*
* The jvm has no equivalent, and not for want of an api -- a desktop application simply has
* no process once its window closes, so there is nothing for a scheduler to wake. Running
* the watcher in-process would be worse than not running it: it would only ever fire while
* the app was already open and watching anyway, which is the case that needs no help.
*
* The exposure is real. A desktop wallet that stays closed past a force-close deadline does
* not notice. Anything that closes it -- a system daemon, a scheduled task -- has to live
* outside this process, which is why this logs rather than pretending.
*/
actual fun schedulePlatformLogic(phoenixGlobal: PhoenixGlobal) {
Logger.withTag("schedulePlatformLogic").w {
"no background scheduling on the jvm target: channels are only watched while the app is open"
}
}
actual fun getShowIntroFlow(phoenixGlobal: PhoenixGlobal): Flow<Boolean> {
return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro
}
actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs {
return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet()
}
@@ -0,0 +1,17 @@
package press.mantra.compose.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
/**
* `dynamicColor` is ignored: it means Material You, which reads a wallpaper-derived palette
* from the android system and has no desktop counterpart. The app's own schemes are used
* whatever the caller asks for.
*/
@Composable
actual fun themeColorScheme(
darkTheme: Boolean,
dynamicColor: Boolean,
darkScheme: ColorScheme,
lightScheme: ColorScheme
): ColorScheme = if (darkTheme) darkScheme else lightScheme
@@ -0,0 +1,157 @@
// The ios actuals with one import changed. That file uses no ios API -- SeedManager,
// DataStoreManager, EncryptedSeed and LocalKeyManager are all commonMain -- so the only
// thing that had to move was BusinessManager, which is now ported to the library's jvmMain.
//
// Kept as a copy for the same reason the ported BusinessManager is: sharing it means an
// intermediate source set between iosMain and jvmMain, which is a change to how the module
// is wired rather than to what it does.
package press.mantra.compose.ui.view.model
import press.mantra.compose.AppVersion
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.DecryptSeedResult
import fr.acinq.phoenix.data.ElectrumConfig
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.jvm.BusinessManager
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.security.EncryptedSeed
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
actual fun updateBusinessActiveInUI(walletId: WalletId) {
BusinessManager.updateBusinessActiveInUI(walletId)
}
actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult {
return SeedManager.loadAndDecrypt(
phoenixGlobal
)
}
actual fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow<Map<WalletId, UserWalletMetadata>> {
return DataStoreManager(
phoenixGlobal.ctx,
chain = Chain.Mainnet
).loadGlobalPrefsForWallet().getAvailableWalletsMeta
}
actual suspend fun saveAvailableWalletMeta(
phoenixGlobal: PhoenixGlobal,
metadata: UserWalletMetadata
) {
DataStoreManager(
phoenixGlobal.ctx,
chain = Chain.Mainnet
).loadGlobalPrefsForWallet().saveAvailableWalletMeta(metadata)
}
actual suspend fun saveAvailableWalletMeta(
phoenixGlobal: PhoenixGlobal,
walletId: WalletId,
name: String?,
avatar: String,
isHidden: Boolean
) {
DataStoreManager(
phoenixGlobal.ctx,
chain = Chain.Mainnet
).loadGlobalPrefsForWallet().saveAvailableWalletMeta(
walletId = walletId,
name = name,
avatar = avatar,
isHidden = isHidden
)
}
actual fun platformWriteSeed(
log: Logger,
phoenixGlobal: PhoenixGlobal,
globalPrefs: GlobalPrefs,
writingState: WritingSeedState,
viewModelScope: CoroutineScope,
mnemonics: List<String>,
onWritingSeedError: (WritingSeedState.Error) -> Unit,
onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit,
isRestoringWallet: Boolean,
isTorEnabled: Boolean,
customElectrumServer: ElectrumConfig.Custom?,
onSeedWritten: (WalletId) -> Unit
) {
if (writingState !is WritingSeedState.Init) return
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
log.e("failed to write mnemonics to disk: ${e.message}")
onWritingSeedError.invoke(
WritingSeedState.Error.Generic(e)
)
}) {
log.d("writing mnemonics to disk...")
onWritingSeedStateWriting.invoke(
WritingSeedState.Writing(mnemonics)
)
val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map {
it.key to it.value.words
}?.toMap()
val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector()
val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub)
val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey)
when {
existingSeeds == null -> {
log.e("could not load the existing seed map, aborting...")
onWritingSeedError.invoke(
WritingSeedState.Error.CannotLoadSeedMap
)
return@launch
}
existingSeeds.containsKey(newWalletId) -> {
log.i("attempting to import a seed that already exists, aborting...")
onWritingSeedError.invoke(
WritingSeedState.Error.SeedAlreadyExists
)
return@launch
}
else -> {
val newSeedMap = existingSeeds + (newWalletId to mnemonics)
val encrypted = EncryptedSeed.V2.encrypt(newSeedMap)
SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true)
if (isRestoringWallet) {
log.i("successfully restored wallet=$newWalletId")
} else {
log.i("successfully created wallet=$newWalletId")
}
}
}
globalPrefs.saveLastUsedAppCode(AppVersion.versionCode)
val dataStoreManager = DataStoreManager(
phoenixGlobal.ctx,
chain = NodeParamsManager.chain,
)
val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId)
userPrefs.saveIsTorEnabled(isTorEnabled)
userPrefs.saveElectrumServer(customElectrumServer)
viewModelScope.launch(Dispatchers.Main) {
delay(1000)
onSeedWritten(newWalletId)
}
}
}