Phase 2 is implemented and verified in the fork on claude/jvm-target-actuals (d1a82ea). Three corrections and one omission. **Schema handling does not need hand-rolling.** The plan said "you call Schema.create(driver) and the migration path explicitly, and you have to track the applied version yourself". SQLDelight 2.x ships a factory function that shadows the constructor -- JdbcSqliteDriver(url, properties, schema, migrateEmptySchema, vararg callbacks) -- which does all three, user_version included. The same-named constructor does none of it, which is the trap worth naming rather than the work that was budgeted for. **Foreign keys were the actual work, and the plan never mentioned them.** Off by default in SQLite, and the pragma is per connection while JdbcSqliteDriver opens one per thread, so it has to go through the connection Properties rather than be issued once against the driver. Recorded along with why that needs no compile dependency on org.xerial:sqlite-jdbc, which arrives at runtime scope only. **commonTest has an expect too.** The 23 counted at the top of this document are commonMain's. Declaring jvm() also creates jvmTest, which inherits commonTest, so `connect` in ElectrumServersTest blocks every jvm test from compiling. Noted along with the reason not to stub it empty the way ios does: the class is @Ignore'd everywhere, so an empty body looks harmless right up until somebody removes the @Ignore and connect_to_mainnet_servers starts passing without connecting to anything. **Phase 2 is the first phase that can be run, and the plan told you not to bother.** It said "none of this is exercisable until Phase 4. Write the SQLDelight schema-creation path against a scratch main() if you want feedback sooner." That was wrong twice: library/src/jvmTest/ already exists, and the two properties worth checking are exactly the ones a compiler cannot see. Schema creation and the foreign-key pragma both fail silently in production -- a missing table only shows up at first query, and foreign keys being off means cascading deletes quietly do not happen. The phase now carries a real exit condition, and DbFactoryJvmTest meets it with five passing tests. Recorded with it: the two KeyStoreFunctions actuals have to exist before phase 3 decides anything, because nothing jvm compiles without them, and they should throw rather than do something plausible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
25 KiB
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.
-
Delete the stale
jvmMaintree. Six files undercomposeApp/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, importandroidx.room(Room 2), and reference a long-goneAuxDatabase. The momentjvm()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.
-
Add the missing catalog entries to
gradle/libs.versions.toml:androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" } sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }Only these two, and only because neither can be reached any other way.
sqlite-bundled-jvmhas to be named explicitly because variant-aware resolution hands the android artifact to anything running on the host — that is the whole trap described in the appendix.sqlite-driveris the jvm counterpart to theandroid-driverandnative-driverentries already here.No
room3-runtime-jvmentry: oncejvm()exists,commonMain's existingandroidx-room3-runtimeresolves to the-jvmvariant on its own. And the SQLDelight drivers Phase 2 needs belong in lightning-kmp-app's own catalog, not this one — that is a separate gradle build with a separate version catalog, and putting them here would not make them visible there. -
Do not wire
kspJvmyet. It cannot be done at this point, and the reason is already written down a few lines above it in the build file:These configurations only exist when the ios targets are declared, which the kotlin block above does only on a mac.
The same rule governs
kspJvm—dependencies { add("kspJvm", ...) }throwsUnknownConfigurationExceptionuntil ajvm()target creates that configuration. So uncommenting composeApp/build.gradle.kts:194 belongs in Phase 4, in the same edit that turns the target on, not here. -
Leave
jvm()commented out, 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 1–3.
Verification: ./gradlew :composeApp:compileDebugKotlinAndroid still passes.
This phase changes nothing observable; the point is that it changes nothing
observable — a deleted orphan source set and two unreferenced catalog entries
cannot alter a build.
If you are working in a git worktree, no gradle task will run at all until
the submodule is checked out there. Worktrees do not get submodules
automatically, so lightning-kmp-app/ is empty and the composite build fails
during configuration:
Project with path ':library' not found in build ':lightning-kmp-app'
git submodule update --init --recursive fixes it, but note that a linked
worktree shares .git/modules/ with the main checkout, so both trees end up
sharing one submodule git dir. That is fine while both want the same commit —
check with git submodule status in each — and worth being careful about when
they do not.
Then the fresh clones need local.properties, which is gitignored and therefore
absent, at five levels — the mantra root and each of the four nested builds
down to secp256k1-kmp. Without it configuration fails at
:lightning-kmp-app:lightning-kmp:bitcoin-kmp:secp256k1-kmp:jni:android with
"SDK location not found":
for d in . lightning-kmp-app \
lightning-kmp-app/experimental/lightning-kmp \
lightning-kmp-app/experimental/lightning-kmp/experimental/bitcoin-kmp \
lightning-kmp-app/experimental/lightning-kmp/experimental/bitcoin-kmp/experimental/secp256k1-kmp; do
echo "sdk.dir=$HOME/Android/Sdk" > "$d/local.properties"
done
Phase 1 — phoenix: the mechanical actuals
~1–2 days. Blocked by nothing. Do this first.
Start by turning on jvm() in the library —
lightning-kmp-app/library/build.gradle.kts:18, not mantra's, which still waits
for Phase 4. Without it library/src/jvmMain/ is an orphan source set that
nothing compiles, and every actual in Phases 1–3 would be written blind. With it,
./gradlew :library:compileKotlinJvm prints the remaining expects by name, and
that list is a better worklist than any grep — it shrinks by exactly what you
implement and cannot drift from the truth.
Fifteen of the 23 are mechanical. None requires a decision — each is either a direct copy of the android implementation or a few 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 (not a copy — android
uses 10.0.2.2, the emulator's alias for the host loopback, and a jvm process is
already on the host), AppVersion, phoenixLogWriters (kermit's CommonWriter;
android routes into slf4j because android tooling reads that, and the jvm has no
equivalent convention), and computePreferencePath.
Leave the Fibonacci template alone. fibiprops.jvm.kt looks like stray
scaffolding, but it is the jvm half of a pair: generateFibi is exercised by
template tests in commonTest, androidHostTest, iosTest, jvmTest and
linuxX64Test, and JvmFibiTest asserts a value that depends on exactly the two
properties that file defines. It already satisfies two of the 25 expects, which
is why 23 are missing rather than 25. Deleting the template is a reasonable
cleanup of a lightning wallet library, but it is five test files plus four
fibiprops.* actuals, and it is not this work.
Verification: ./gradlew :library:compileKotlinJvm from inside
lightning-kmp-app/. It still fails at the end of this phase — that is expected,
and the failure is the point. It should report exactly eight remaining
expects, and they should be exactly the contents of Phases 2 and 3:
DbFactory.kt createChannelsDbDriver, createPaymentsDbDriver, createAppDbDriver
NetworkMonitor.kt NetworkMonitor
KeyStoreFunctions.kt keyStoreDecryption, keyStoreEncryption
TechnicalExtensions.kt gracefulSingleSeedDecryption, gracefulMultiSeedDecryption
Anything else in that list means something in this phase is wrong. Also re-run
./gradlew :composeApp:compileDebugKotlinAndroid from the mantra root: adding a
jvm target to the library must not disturb how the android target resolves it.
Phase 2 — phoenix: drivers and connectivity
~2–3 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.kt — createChannelsDbDriver, createPaymentsDbDriver,
createAppDbDriver. Use SQLDelight's sqlite-driver. Structurally, follow the
ios actual rather than the android one: an explicit directory plus a file
name, because the jvm has no Context to hand a bare name to.
Schema handling does not need hand-rolling, contrary to what this plan first said. SQLDelight 2.x ships a factory function that shadows the constructor:
JdbcSqliteDriver(url, properties, schema, migrateEmptySchema = false, vararg callbacks)
It creates the schema on an empty file, migrates an existing one, and maintains
PRAGMA user_version itself. The same-named constructor — JdbcSqliteDriver(url, properties) — does none of that, and reaching for it by accident is the easy
mistake here. AfterVersion10/AfterVersion11 go in as the trailing callbacks,
exactly as ios passes them to schema.migrate.
Foreign keys are the part worth budgeting for. They are off by default in
SQLite, and the pragma is per connection — JdbcSqliteDriver opens one per
thread, so issuing it once against the driver is not enough. Pass it as a
connection property instead:
Properties().apply { setProperty("foreign_keys", "true") }
xerial reads pragma-named properties back through SQLiteConfig(Properties) and
applies them as each connection opens, so this needs no compile-time dependency on
org.xerial:sqlite-jdbc — which is just as well, since sqlite-driver brings it
in at runtime scope only. Android gets the same effect from
setForeignKeyConstraintsEnabled in its driver callback and ios from
DatabaseConfiguration.Extended(foreignKeyConstraints = true). All three platforms
state it separately; none inherits it from the schema.
One incidental discrepancy to be aware of: the app database is named
appdb.sqlite on android and app.sqlite on ios.
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: this is the first phase that can actually be run, and it should
be. Everything before it is checked by the compiler alone. Two properties of the
drivers are not, and both fail silently in production if wrong — an uncreated
schema looks like a missing table at first query, and foreign keys being off means
cascading deletes quietly do not happen. library/src/jvmTest/ already exists;
DbFactoryJvmTest covers schema creation for all three databases, the foreign-key
pragma on each, and that reopening an existing file migrates-or-noops rather than
re-creating.
Running any jvm test needs the module to compile, which means the two
KeyStoreFunctions actuals must exist before Phase 3 has decided anything. Give
them bodies that throw, with a message naming this document. A loud failure is
the right placeholder: the alternative is something that appears to work while
storing a seed weakly, which is the one outcome worth ruling out.
And commonTest has an expect of its own, which is easy to miss because the
23 counted at the top of this document are commonMain's. Declaring jvm() also
creates jvmTest, which inherits commonTest, so connect in
ElectrumServersTest.kt needs a jvm actual before any jvm test compiles. Copy the
androidHostTest one — despite the name it contains no android API, only ktor,
javax.net.ssl and lightning-kmp's JvmTcpSocket.
Do not satisfy it with an empty body the way ios does. The class is @Ignored
on every platform, so an empty actual compiles and looks harmless, but it turns
connect_to_mainnet_servers into an assertion that passes without connecting to
anything the moment somebody removes the @Ignore. The tidier long-term fix is a
shared source set that androidHostTest and jvmTest both depend on, which is a
change to how the module is wired rather than to what it does.
Phase 3 — phoenix: key storage
A decision, not a port. Unbounded until the decision is made.
Four of the 23, but only two of them are actually a decision.
gracefulSingleSeedDecryption and gracefulMultiSeedDecryption are not. They
are pure exception mapping into a DecryptSeedResult, and the exception they
branch on is java.security.KeyStoreException — which exists on the jvm, since
KeyStore is a plain JCA type. Both are a near-copy of the android actuals and
can be written before any of the below is settled. Do them with Phase 2 and leave
two errors outstanding rather than four.
The decision is keyStoreEncryption and keyStoreDecryption.
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
~1–2 days. Blocked by Phases 1–3.
Now turn on jvm() — composeApp/build.gradle.kts:46
and lightning-kmp-app/library/build.gradle.kts:18 — and, in the same edit,
uncomment kspJvm at composeApp/build.gradle.kts:194.
Those two go together: the KSP configuration does not exist until the target
does, which is why Phase 0 deliberately left it alone. Then 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.room → androidx.room3), and point at
MantraDatabase instead of AuxDatabase.
MantraDatabaseConstructor needs no hand-written actual; Room's KSP generates it
once kspJvm is wired above.
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 1–3 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
~1–2 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
androidMainNFC files are correctly android-only and are not referenced fromcommonMain— but any UI that offers an NFC affordance needs to not offer it here. Dispatchers.IO. Used ingetRoomDatabaseand 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) | 1–2 | — |
| 2 | phoenix: drivers + network (4) | 2–3 | — |
| 3 | phoenix: key storage (4) | decision | — |
| 4 | mantra actuals (16) | 1–2 | 1, 2, 3 |
| 5 | desktop entry point + shakeout | 1–2 | 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.desktopblock 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.