Files
mantra-kmp/docs/jvm-target.md
Kgothatso Ngako 5abc37e463 docs: scope the jvm target, and separate it from testing the daos
Two questions arrived together -- whether Room's own testing guidance
applies to this project, and what desktop support would cost -- and they
turned out to have opposite answers. Both are now in docs/jvm-target.md,
phased, with the blocking work separated from the mechanical work.

**The expensive part is already done.** The four-deep native chain --
secp256k1 -> bitcoin-kmp -> lightning-kmp -> lightning-kmp-app -- already
builds for JVM, on every android build we do. The comment at
composeApp/build.gradle.kts:50 records the mechanism without drawing the
conclusion: lightning-kmp-core publishes no android variant, so our
android target resolves it to the *jvm* one, which pulls
secp256k1-kmp-jni-jvm desktop natives, which is exactly why the build has
to name the android artifact by hand. Read the other way round, every JVM
artifact in the chain is already compiled from source by the composite
build. A jvm target adds no cinterop, no C compilation and no new native
constraints. That was the part worth being afraid of, and it is finished.

**The blocker is one level down, and smaller than it looks.**
lightning-kmp-app/library declares 25 expects and implements them across
35 androidMain files. Its jvmMain holds exactly one: fibiprops.jvm.kt, the
Kotlin multiplatform library template's Fibonacci boilerplate, satisfying
two of the 25 -- both of them the template's own. So 23 actuals are
missing, which is why jvm() is commented out there
(library/build.gradle.kts:18), which is why it is commented out here
(composeApp/build.gradle.kts:46). Mantra cannot declare the target until
the fork does.

Six phases, ordered by that dependency. 0 build config; 1 the fourteen
mechanical phoenix actuals; 2 the three SQLDelight JDBC drivers and
NetworkMonitor; 3 key storage; 4 mantra's own sixteen expects; 5 the
desktop entry point. 1-3 are independent and parallelisable, 4 is where
the compiler finally checks the whole thing. Roughly a week to a
launchable build.

**Phase 3 has no day estimate, deliberately.** keyStoreEncryption /
keyStoreDecryption and their two graceful* wrappers delegate on android to
KeystoreHelper.kt -- 116 lines against AndroidKeyStore, StrongBox
attempted first and fallen back from, key material never leaving hardware.
Desktop JVM has no equivalent, so this is a decision rather than a port,
and the doc gives the three real options against what each actually
protects. A fixed-key JCEKS file is named there as a liability rather than
a stopgap: this is wallet seed material, and it lands on top of the
plaintext-key finding already open against this codebase. Recommended
sequencing is a passphrase-derived KEK with the desktop build marked
unsuitable for real funds, so phases 4 and 5 can proceed without the
security question being quietly treated as answered.

Two inherited mistakes are called out rather than carried forward. The old
Aux jvmMain put the database in java.io.tmpdir behind a TODO -- the doc
says not to inherit that in either phase that touches it. And
schedulePlatformLogic goes through WorkManager on android with no desktop
counterpart, so the doc asks for an explicit choice between a no-op and an
in-process coroutine, written down.

**The DAO answer is an appendix, because it is the opposite answer.** None
of the above is needed to test the DAOs, and burying that would have been
misleading. room3-runtime-android:3.0.1 already exposes the no-Context
inMemoryDatabaseBuilder(Function0<T>) overload, and
MantraDatabaseConstructor already supplies what it needs, so Room's
recommended host-machine form compiles in commonTest and runs under
testDebugUnitTest today. The one trap is native and is the secp256k1
problem mirrored: sqlite-bundled-android ships only android-ABI .so under
jni/, so a local unit test's JVM cannot load it and BundledSQLiteDriver
fails at construction; sqlite-bundled-jvm on the androidUnitTest classpath
is the fix. Robolectric neither helps nor is needed -- it cannot load
android .so on the host either.

Everything structural here was checked against the artifacts rather than
recalled: the Room builder overloads by javap on room3-runtime-android,
the two sqlite-bundled native layouts by unzipping both, and the
availability of room3-runtime-jvm, room3-testing, quartz-jvm and the two
SQLDelight drivers by request against the repositories this build actually
resolves from. The absence of android.* and java.* imports in commonMain,
and of any NFC reference from it, was likewise grepped rather than
assumed.

**Not verified: anything that requires compiling.** No jvm target was
turned on, nothing was built, and the day estimates are estimates. Phase 4
is where dependency-substitution surprises would surface if there are any,
and it is precisely the phase nothing here exercises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:38:11 +02:00

17 KiB
Raw Blame History

Bringing up the JVM target

What it would actually take to build Mantra for desktop, phased, with the blocking work separated from the mechanical work.

The headline is not what you would expect. The four-deep native chain — secp256k1 → bitcoin-kmp → lightning-kmp → lightning-kmp-app — is already building for JVM, and has been all along. The thing standing in the way is an empty source set in our own phoenix fork.

One scoping note before anything else: none of this is needed to test the DAOs. Host-speed Room tests run under androidUnitTest today, given one extra dependency. The JVM target is a product decision — a desktop Mantra — not a testing prerequisite. See Room DAO tests at the end.

What is already done for you

The native chain is already JVM. This is the expensive part, and it is finished. The comment at composeApp/build.gradle.kts:50 records why: lightning-kmp-core publishes no android variant, so our android target resolves it to the jvm one, which in turn pulls secp256k1-kmp-jni-jvm — desktop .so/.dylib/.dll files. That is why the build has to name secp256k1-kmp-jni-android by hand.

Read that the other way round and it is good news: every JVM artifact in the chain is already compiled from source, by the composite build, on every android build we do. Turning on a JVM target adds no cinterop, no C compilation, and no new native constraints.

The third-party dependencies all have JVM variants. Verified against the repositories the build actually resolves from:

dependency JVM artifact status
quartz 1.14.0 com.vitorpamplona.quartz:quartz-jvm on Maven Central
room3 3.0.1 androidx.room3:room3-runtime-jvm on Google Maven
sqlite 2.7.0 androidx.sqlite:sqlite-bundled-jvm on Google Maven
sqldelight 2.3.2 app.cash.sqldelight:jdbc-driver, :sqlite-driver on Maven Central

commonMain is clean. No android.* and no java.* imports anywhere in it. The three NFC files in androidMain are not referenced from common code either, so there is nothing to stub out and no android-only API to route around. The shared tree will compile for JVM as-is.

What actually blocks it

lightning-kmp-app/library declares 25 expect symbols in commonMain and implements them across 35 files in androidMain. Its jvmMain contains exactly one file:

// lightning-kmp-app/library/src/jvmMain/kotlin/fibiprops.jvm.kt
package io.github.kotlin.fibonacci

actual val firstElement: Int = 2
actual val secondElement: Int = 3

That is the Kotlin multiplatform library template's Fibonacci boilerplate, left over from whenever the module was scaffolded. It implements two of the 25 expects, and both of them are the template's own.

So 23 JVM actuals are missing, one level down from us, and lightning-kmp-app/library/build.gradle.kts:18 has jvm() commented out because of it. Mantra cannot declare jvm() — commented out in turn at composeApp/build.gradle.kts:46 — until phoenix does.

Everything below is ordered by that dependency.


Phase 0 — build configuration

~half a day. No blockers.

Nothing here needs a decision; it is the groundwork the later phases assume.

  1. Delete the stale jvmMain tree. Six files under composeApp/src/jvmMain/kotlin/ac/cord/auxiliary/ survive from the old Aux project. They are an orphan source set that nothing currently compiles, which is why they have gone unnoticed — they use the wrong package, import androidx.room (Room 2), and reference a long-gone AuxDatabase. The moment jvm() is declared they become compile errors.

    Keep them open in a scratch buffer while doing Phase 4: five of them are a usable skeleton for the actuals we still need.

  2. Add the missing catalog entries to gradle/libs.versions.toml:

    androidx-room3-runtime-jvm  = { module = "androidx.room3:room3-runtime-jvm", version.ref = "room3" }
    androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" }
    sqldelight-jdbc-driver      = { module = "app.cash.sqldelight:jdbc-driver",   version.ref = "sqldelight" }
    sqldelight-sqlite-driver    = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
    
  3. Wire KSP for the JVM target. Uncomment composeApp/build.gradle.kts:194. Without it there is no generated MantraDatabase_Impl or DAO implementation for the host, and MantraDatabaseConstructor has no JVM actual — Room's KSP generates that one, so it costs nothing once the processor runs.

  4. Leave jvm() commented out for now, in both builds. It goes on at the start of Phase 4, once there is something for it to resolve against. Turning it on earlier just means living with a broken build through Phases 13.

Verification: ./gradlew :composeApp:compileDebugKotlinAndroid still passes. This phase changes nothing observable; the point is that it changes nothing observable.


Phase 1 — phoenix: the mechanical actuals

~12 days. Blocked by nothing. Do this first.

Fourteen of the 23. None of them requires a decision — each is either a direct copy of the android implementation or a handful of lines of JVM file handling.

DbHooks.jvm.kt — six functions, and this one is free. The android implementation is 15 lines and every function is an empty body; the hooks only do work on Apple platforms, where they drive CloudKit sync. Copy the file, change the suffix.

actual fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) {}
actual fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) {}
actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) {}
actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) {}
actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) {}
actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? = null

PlatformContext.jvm.kt — the class plus four directory paths. On android these come off a Context; on desktop there is no context object, so PlatformContext becomes either an empty class or one holding an explicit root directory. Prefer the latter — it makes tests and multi-profile desktop installs possible later, and it costs nothing now.

The four paths (getApplicationFilesDirectoryPath, getDatabaseFilesDirectoryPath, getApplicationCacheDirectoryPath, getTemporaryDirectoryPath) should resolve to a per-OS application data directory, not java.io.tmpdir. The old Aux code used tmpdir and left a TODO about it; do not inherit that.

The remaining singles: platformElectrumRegtestConf, AppVersion, phoenixLogWriters (a console writer is fine), computePreferencePath, and the two Fibonacci template properties — which should be deleted along with the template file rather than reimplemented, assuming nothing references them.

Verification: still nothing compiles for JVM at this point, because Phases 2 and 3 are outstanding. Work against the expect list, not the compiler.


Phase 2 — phoenix: drivers and connectivity

~23 days. Blocked by nothing, but do it after Phase 1.

Three of the 23 are database drivers and one is the network monitor. These are real implementations, but they are bounded — the shape is known and the failure modes are ordinary.

DbFactory.jvm.ktcreateChannelsDbDriver, createPaymentsDbDriver, createAppDbDriver. Use SQLDelight's sqlite-driver (the JDBC driver specialised for SQLite). The one real difference from android: the android driver creates and migrates the schema for you via AndroidSqliteDriver's callback, and the JDBC driver does not. You call Schema.create(driver) and the migration path explicitly, and you have to track the applied version yourself. Budget for that, not for the driver construction.

createPaymentsDbDriver also takes an onError: (String) -> Unit — make sure corruption and migration failures actually reach it rather than throwing past it, because on android that callback is what surfaces the problem to the user.

NetworkMonitor.jvm.kt. The android implementation is 90 lines built on ConnectivityManager and its NetworkCallback — genuine push notification of connectivity changes. The JVM has no equivalent. The options are a polling reachability check, or treating the connection as always-available and letting the lightning stack's own reconnect logic handle reality.

Start with polling on a slow interval. It is worse than the android behaviour and that is acceptable — the alternative is pretending the network never changes, which produces confusing UI on a laptop that gets closed and reopened.

Verification: none of this is exercisable until Phase 4. Write the SQLDelight schema-creation path against a scratch main() if you want feedback sooner.


Phase 3 — phoenix: key storage

A decision, not a port. Unbounded until the decision is made.

Four of the 23: keyStoreEncryption, keyStoreDecryption, and the two inline wrappers gracefulSingleSeedDecryption and gracefulMultiSeedDecryption that translate keystore exceptions into a DecryptSeedResult.

The android implementation delegates to KeystoreHelper.kt — 116 lines against AndroidKeyStore, with KeyGenParameterSpec, and setIsStrongBoxBacked(true) attempted first and fallen back from when the device has no secure element. The key material never leaves hardware.

Desktop JVM has no equivalent. There is no portable, hardware-backed keystore on the JVM. The realistic options:

approach protects against cost
passphrase-derived KEK (Argon2id → AES-GCM) disk theft, if the passphrase is strong low; but prompts the user on every launch
OS keychain via JNA (Keychain / DPAPI / libsecret) other users on the machine, at rest three separate platform integrations, three failure modes
JCEKS/PKCS12 file with a fixed key nothing meaningful low, and misleading

This is wallet seed material. The third option is not a stopgap, it is a liability, and it interacts directly with the plaintext-key finding already open against this codebase — do not let a desktop build quietly become the weakest place the seed lives.

Recommendation for sequencing: implement the passphrase-derived KEK, mark the desktop build clearly as unsuitable for real funds, and treat OS-keychain integration as its own piece of work with its own review. That unblocks Phases 4 and 5 without pretending the security question is answered.

This phase is the only one in the plan with no honest day estimate, because the estimate is a function of which row of that table gets chosen and how much review it attracts.


Phase 4 — mantra's own actuals

~12 days. Blocked by Phases 13.

Now turn on jvm()composeApp/build.gradle.kts:46 and lightning-kmp-app/library/build.gradle.kts:18 — and let the compiler drive.

Mantra declares 16 expects across 8 files. They split cleanly:

Six platform basics. getPlatform (Platform.kt), PlatformContext, AppVersion, themeColorScheme (Theme.kt), and PlatformDatabaseBuilder's two functions. The deleted Aux files from Phase 0 are a working skeleton for five of these — repackage to press.mantra.compose, update Room 2 → Room 3 (androidx.roomandroidx.room3), and point at MantraDatabase instead of AuxDatabase.

MantraDatabaseConstructor needs no hand-written actual; Room's KSP generates it once Phase 0 wired kspJvm.

For PlatformDatabaseBuilder.getDatabaseBuilder, use the real application data directory from Phase 1 — not java.io.tmpdir, which is what the old Aux implementation did and which silently loses the database on reboot on most systems.

Nine lightning wrappers. Four in Phoenix.kt (platformStartupLogic, schedulePlatformLogic, getShowIntroFlow, getGlobalPrefs) and five declared in SovereignWalletViewModel.kt (updateBusinessActiveInUI, loadAndDecryptSeed, getAvailableWalletsMeta, and the two saveAvailableWalletMeta overloads, plus platformWriteSeed) whose android actuals live in NavigationViewModel.android.kt.

These are thin — they mostly forward into the phoenix library. They are thin because Phases 13 did the work, which is why they are last.

schedulePlatformLogic is the one to look at properly: on android it schedules background work through WorkManager. On desktop there is no equivalent and no process that outlives the window. Decide explicitly whether it becomes a no-op or an in-process coroutine, and write down which.

Verification: ./gradlew :composeApp:compileKotlinJvm. This is the first point in the plan where the JVM target has to actually resolve, so expect the dependency-substitution surprises to land here rather than earlier.


Phase 5 — desktop entry point and shakeout

~12 days. Blocked by Phase 4.

composeApp/build.gradle.kts:221 already names press.mantra.desktop.MainKt as the desktop main class. That file does not exist. Write it: a application { Window { ... } } entry point constructing PlatformContext and handing it to the same root composable android uses.

The UI itself is Compose Multiplatform and should largely come up as-is. What to expect anyway:

  • Window sizing. The layouts have only ever been laid out at phone widths. Nothing will crash; plenty will look wrong.
  • Back handling. Android's system back has no desktop counterpart.
  • NFC. The three androidMain NFC files are correctly android-only and are not referenced from commonMain — but any UI that offers an NFC affordance needs to not offer it here.
  • Dispatchers.IO. Used in getRoomDatabase and available on JVM, so no change; noted because it is not available on all KMP targets and is easy to trip over later.

Verification: ./gradlew :composeApp:run.


Estimate

phase work days blocked by
0 build configuration 0.5
1 phoenix: mechanical actuals (14) 12
2 phoenix: drivers + network (4) 23
3 phoenix: key storage (4) decision
4 mantra actuals (16) 12 1, 2, 3
5 desktop entry point + shakeout 12 4

Roughly one focused week to a launchable desktop build, assuming Phase 3 takes the passphrase-derived KEK and the build is marked dev-only. Real desktop key storage is separate work that should not be folded into this estimate, and should land before anyone holds funds on a desktop Mantra.

Phases 1, 2 and 3 are independent of each other and can go in parallel if more than one person is on it. Phase 4 cannot start until all three are done, because it is where the compiler finally checks the whole thing.

Out of scope

  • linuxX64() — commented out in the phoenix library at line 53. A native Linux target is a different problem from a JVM one and buys nothing here.
  • iOS on a Linux host — still impossible, for the reasons already documented in both build files. The JVM target does not change that.
  • Publishing desktop distributables — the compose.desktop block already declares Dmg/Msi/Deb formats, but signing, notarisation and update channels are untouched by this plan.

Appendix: Room DAO tests do not need this

Worth stating plainly, because the two questions arrived together and the answer to one is not the answer to the other.

Room's own testing guidance recommends host-machine tests over instrumented ones. We can have those today, without a JVM target, because room3-runtime-android:3.0.1 exposes the no-Context builder overload:

inMemoryDatabaseBuilder(kotlin.jvm.functions.Function0<? extends T>)

and MantraDatabaseConstructor.kt already supplies what it needs. So Room.inMemoryDatabaseBuilder<MantraDatabase>() compiles in commonTest and runs under testDebugUnitTest.

The one trap is native, and it is the same shape as the secp256k1 problem documented in the build file — in the opposite direction:

artifact ships
sqlite-bundled-android jni/{arm64-v8a,armeabi-v7a,x86,x86_64}/libsqliteJni.so
sqlite-bundled-jvm natives/{linux_x64,linux_arm64,osx_*,windows_x64}/

A local unit test resolves the android variant, whose .so files the host JVM cannot load, so BundledSQLiteDriver() fails at construction. Naming sqlite-bundled-jvm on the androidUnitTest classpath fixes it.

Robolectric does not help and is not needed — it cannot load android .so on the host either, and Room's guidance advises against it regardless.