Files
mantra-kmp/composeApp/build.gradle.kts

298 lines
12 KiB
Kotlin
Raw Normal View History

2026-03-23 01:41:39 +02:00
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
2026-05-02 00:05:38 +02:00
// alias(libs.plugins.androidx.room)
alias(libs.plugins.androidx.room3)
2026-03-24 04:47:19 +02:00
alias(libs.plugins.kotlinMultiplatform)
2026-03-23 01:41:39 +02:00
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
2026-03-23 02:21:18 +02:00
alias(libs.plugins.kotlinPluginSerialization)
2026-03-24 04:47:19 +02:00
alias(libs.plugins.ksp)
fix: build a release apk, by dropping the app's copies of what the library already ships `:composeApp:assembleRelease` dies in `mergeDexRelease`: Type com.machankura.compose.ui.composable.widgets.nfc.ComposableSingletons$HceMonitorKt is defined multiple times: composeApp/build/intermediates/project_dex_archive/release/dexBuilderRelease/out/... lightning-kmp-app/library/build/.transforms/.../bundleLibRuntimeToDirAndroidMain_dex/... Both paths are ours. composeApp compiles a class, lightning-kmp-app's `:library` compiles the same fully-qualified name, and D8 will not merge the two into one apk. Debug never objected, because it packages the per-project dex archives as they stand and only the release merge walks the whole set looking for collisions -- so this has been true for a while and surfaced the first time anyone asked for a release. There were two such copies, and fixing the first only uncovered the second. **The NFC widgets were renamed by directory, not by package.** 9abdf42 ("Refactor torch to mantra", 2026-07-15) moved three files under `composeApp/src/androidMain/kotlin/press/mantra/compose/ui/composable/widgets/nfc/` and left their `package com.machankura.compose.ui.composable.widgets.nfc` line alone. The library holds the same three at `fr/acinq/phoenix/compose/ui/widgets/nfc/`, also declaring `com.machankura...`. So three directories say three different things and the package -- the only one of them D8 reads -- says one. `NfcState.kt` is byte-identical across the two; `HceMonitor.kt` and `NfcReaderMonitor.kt` differ in a single import line, `press.mantra` against `fr.acinq.phoenix` for `ModalBottomSheet`. Deleted the app's three rather than renaming their package to `press.mantra`, which was the other way out and is the worse one. `NfcStateRepository` is an `object`. The library's own `HceService` and `NfcReaderCallback` import it under the `com.machankura` name, and `MainActivity:50-52` reaches for it fully qualified. Renaming the app's copy would have compiled, dexed and run -- with two singletons: one that `MainActivity` sets back to `Inactive` on a new intent, and a different one the HCE service is collecting. Nothing would warn, because by then the names genuinely differ; the tag emulation would just not stop. Deleting leaves one object, and `MainActivity`'s existing fully-qualified references land on it untouched. The two composables went along with it as dead weight -- nothing outside their own files names `HceMonitor` or `NfcReaderMonitor` anywhere in composeApp. **The sqldelight databases were a second copy of the same kind.** With the NFC clash gone, `mergeDexRelease` came back with `fr.acinq.phoenix.db.sqldelight.AppDatabase$Companion`, out of the same pair of directories. composeApp's build.gradle.kts declared `ChannelsDatabase`, `PaymentsDatabase` and `AppDatabase` at packageName `fr.acinq.phoenix.db.sqldelight` from `.sq` sources under `src/commonMain/sqldelight`; the library's build.gradle.kts declares the same three names, the same package and the same layout, over a tree `diff -rq` reports as identical file for file. Both plugins ran, and both generated the same classes. Removed the plugin alias and the whole `sqldelight { }` block from composeApp and deleted its 32 `.sq`/`.sqm` files, rather than moving the app's generated package to `press.mantra`. Nothing under `press.mantra` imports `fr.acinq.phoenix.db` -- grep finds no file -- and nothing there touches `app.cash.sqldelight` either, against 18 files in the library that do. The app was generating a database layer it has never opened. Renaming would have kept generating it, and kept a second identical schema in the tree for someone to edit and then wonder why nothing moved. A comment sits where the plugin alias was, since the absence is the load-bearing part and an alias is a one-line thing to add back by reflex. **The sqldelight driver dependencies stay.** `-android-driver`, `-sqlite-driver`, `-native-driver`, `-runtime` and `-coroutines-extensions` are still declared in composeApp. They are runtime drivers rather than code generation, and are very likely redundant -- the library declares the same five as `implementation`, which still carries them onto the runtime classpath. But they are not what D8 objected to, and a missing driver fails when the app opens a database on one platform, not when it builds, so retiring them wants more evidence than a green build and is its own change. **What this does not fix.** Anything else copied into both trees fails exactly this way, and only on release. A scan of the two source sets for colliding fully-qualified top-level types is clean now -- the three NFC files were the only ones -- but that scan reads `.kt`, and the sqldelight collision had no `.kt` to find. Generated code is where the next one hides: Room and compose-resources generate into composeApp, and the library generates compose-resources too, which is why settings.gradle.kts keeps the submodule's project named `:library` and renames only the coordinate. **Verified.** `:composeApp:assembleRelease` produces the unsigned 36MB apk; `:composeApp:assembleDebug` builds. 906 tests pass, 576 jvm over 69 classes and 330 android over 41 classes, unchanged from before the change since it adds none. The apk was not installed, so that NFC still works on a device is read from the code -- one `NfcStateRepository`, the one both sides were already naming -- rather than watched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:13:32 +02:00
// No sqldelight plugin here on purpose. The fr.acinq.phoenix.db.sqldelight databases this
// module used to generate are the same three lightning-kmp-app's :library generates from an
// identical copy of the .sq sources, and nothing under press.mantra touches them. Generating
// them on both sides put every generated class in the apk twice, which debug tolerates and
// mergeDexRelease rejects as duplicate types. The library's copies are the ones that count.
2026-03-23 01:41:39 +02:00
}
kotlin {
2026-03-26 00:20:20 +02:00
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
2026-03-23 01:41:39 +02:00
androidTarget {
compilerOptions {
2026-06-23 13:59:17 +02:00
jvmTarget.set(JvmTarget.JVM_21)
2026-03-23 01:41:39 +02:00
}
}
build: bump lightning-kmp-app to bbba08b, building lightning-kmp from source lightning-kmp-app moves 6745d88 -> bbba08b, four commits: 57353cf Minor updates 9f3cd72 Build lightning-kmp from the experimental submodule via a composite build f22c30a Follow the submodule chain onto Gradle 9.7.1 bbba08b Declare the ios targets only on a mac, so the IDE can import the project 9f3cd72 is the substantive one. lightning-kmp no longer comes from maven central: the submodule now carries its own experimental/lightning-kmp submodule on branch `threshold` -- the branch with the FROST/prefractal signers -- and substitutes fr.acinq.lightning:lightning-kmp-core for that build's project. That build includes bitcoin-kmp, which includes secp256k1-kmp, which compiles the C library from a secp256k1-zkp fork. So this repo's build tree is now four levels deep and compiles native code. Cloning therefore needs `git submodule update --init --recursive`, and because each included build resolves the android SDK from its own local.properties rather than inheriting the root's, the three new nested builds each need a (gitignored) local.properties with sdk.dir. 57353cf changed two signatures that MantraApplication implements -- LightningApplication gained getApplicationContext(), and BusinessManager.initialize now takes the application rather than a Context. Neither needed a source change: MantraApplication extends android.app.Application, which already supplies getApplicationContext(), and it was already passing `this`, which satisfies the narrowed parameter type. The rest of this commit is what the update forces on the outer build. gradle-wrapper.properties, 9.3.1 -> 9.7.1: f22c30a moved every build in the chain onto 9.7.1. An included build does not use its own wrapper -- the root build's gradle version runs the whole tree -- so this repo has to follow for the chain to build at all. gradle.properties, configuration cache off: secp256k1-kmp's `:jni:generateHeaders` and `:native:buildSecp256k1<target>` both hold gradle script object references and cannot be serialized. The configuration cache covers a whole build tree and has no per-build opt-out, so an included build's incompatibility is this build's problem. The comment records how to undo this once those tasks are fixed upstream. composeApp/build.gradle.kts, ios targets gated on the host: secp256k1-kmp declares a libsecp256k1 cinterop, which makes gradle switch off klib cross compilation for apple targets. On linux nothing in the tree then offers an ios variant of fr.acinq.phoenix:lightning-kmp-app, and the ios compilations failed with "No matching variant of project ':lightning-kmp-app:library'" -- not a warning, a build failure. The gate covers the target declarations, the iosMain dependencies (the source set only exists when the targets do) and the kspIos* configurations (likewise). This mirrors bbba08b, which applied the same gate inside the submodule for the same reason. secp256k1-frost-kmp d3b294d applies that gate there too. Substitution rules apply across a whole build tree, so that project's own lightning-kmp-core coordinate started resolving to the source project without it asking, and every apple source set stopped resolving. The android build masked it -- ios compilations are not in its task graph -- but kmpPartiallyResolvedDependenciesChecker reported it and compileAppleMainKotlinMetadata failed outright, which would have broken IDE import. Note that `:composeApp:compileCommonMainKotlinMetadata` no longer exists on a linux host. With ios gated off, androidTarget is the only declared target (jvm() is still commented out), and KMP does not generate a commonMain metadata compilation for a single-target project. `:composeApp:compileDebugKotlinAndroid` is the check now; it passes clean, with none of the resolution errors above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:50:59 +02:00
// Declared only on a mac, mirroring the gate lightning-kmp-app's :library applies to its own ios
// targets. Down the composite chain secp256k1-kmp declares a libsecp256k1 cinterop, which makes
// gradle switch off klib cross compilation for apple targets, so on a linux/windows host nothing
// in the build tree offers an ios variant of fr.acinq.phoenix:lightning-kmp-app. Declaring these
// targets anyway leaves every ios compilation unable to resolve it and fails the build outright.
// Building an ios binary needs a mac regardless.
if (org.gradle.internal.os.OperatingSystem.current().isMacOsX) {
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
2026-03-23 01:41:39 +02:00
}
}
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>
2026-09-06 01:23:42 +02:00
jvm()
2026-03-23 01:41:39 +02:00
sourceSets {
androidMain.dependencies {
build: package the android secp256k1 natives, with ChillDKG in them The android app had no android secp256k1 natives at all, and had not had any for as long as lightning-kmp-core has been a dependency. `dependencyInsight` on debugRuntimeClasspath resolved every secp256k1 coordinate to `:secp256k1-kmp:jni:jvm:{darwin,linux,mingw}` -- desktop .so/.dylib/.dll files, loaded by extracting them from a jar, which cannot work on a device. The cause is a chain of individually reasonable decisions. lightning-kmp-core publishes no android variant, so an android consumer resolves it to the jvm one; the jvm variant asks for `secp256k1-kmp-jni-jvm`; and nothing anywhere asks for `secp256k1-kmp-jni-android`. lightning-kmp-app names it only in androidDeviceTest, so consumers of the published library do not get it. It has to be named here. Naming it alone would not have been enough. bitcoin-kmp's settings.gradle.kts substituted five of secp256k1-kmp's coordinates to the fork's projects but not -jni-android, so it would have resolved from Maven Central to stock 0.24.0 -- built from upstream libsecp256k1, with no ChillDKG module. Because the kotlin API comes from the substituted root project, that combination type-checks and links and then fails with UnsatisfiedLinkError at the first native call. The rule is added in the submodule commits this carries. Submodule commits carried here: lightning-kmp-app ce1ed01 -> 6434282 build: carry the jni-android substitution down from bitcoin-kmp experimental/lightning-kmp 77be7b78 -> 5103b79e experimental/bitcoin-kmp 196a479 -> 65c4aab build: substitute secp256k1-kmp-jni-android to the included build too Verified through the artifact rather than the graph: composeApp-debug.apk now carries lib/{arm64-v8a,armeabi-v7a,x86,x86_64}/libsecp256k1-jni.so, and `nm -D` on the arm64 one exports all twelve Java_..._chilldkg_... JNI entry points -- hostpubkey_gen, params_hash, participant_step1/step2/finalize, coordinator_step1/finalize, and the recover and investigate calls. The three builds in the chain sit on `build/substitute-jni-android` branches (lightning-kmp-app on `build/agp-9.4.0`, which now carries two commits) and none are pushed, so a fresh clone cannot resolve these pointers yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:25:23 +02:00
// The android secp256k1 natives. Nothing pulls these in transitively: lightning-kmp-core
// publishes no android variant, so the android target resolves it to the jvm one, which
// asks for secp256k1-kmp-jni-jvm and gets desktop .so/.dylib/.dll files that cannot load
// on a device. The app has to name the android artifact itself.
//
// Resolved from source via the composite build, not from Maven Central -- bitcoin-kmp's
// settings.gradle.kts substitutes this coordinate for secp256k1-kmp's :jni:android. That
// matters: the stock artifact has no ChillDKG module, so ChillDKG would compile and then
// fail with UnsatisfiedLinkError. The version is never resolved; substitution matches on
// group:name.
implementation("fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.24.0")
2026-03-23 01:41:39 +02:00
implementation(libs.androidx.activity.compose)
2026-05-02 00:05:38 +02:00
// implementation(libs.androidx.room.sqlite.wrapper)
2026-03-30 13:14:37 +02:00
implementation(libs.compose.uiToolingPreview)
2026-06-15 14:39:46 +02:00
implementation(libs.sqldelight.android.driver)
2026-03-23 01:41:39 +02:00
}
commonMain.dependencies {
2026-04-19 04:30:29 +02:00
implementation(libs.androidx.datastore)
2026-04-18 21:58:32 +02:00
implementation(libs.androidx.datastore.preferences)
2026-03-30 13:14:37 +02:00
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
2026-05-02 00:05:38 +02:00
// implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room3.runtime)
2026-03-30 13:14:37 +02:00
implementation(libs.androidx.sqlite.bundled)
2026-04-21 23:49:54 +02:00
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
2026-04-20 22:57:15 +02:00
2026-03-23 01:41:39 +02:00
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
feat: give the app the five breakpoints, and let the screen margin follow them Phase 6, steps 1 and 2. The app had no notion of window width at all -- two `BoxWithConstraints` in 30,000 lines of UI, both inside a view model -- so every layout decision in it was made once, for a phone, and then rendered unchanged into a 1800dp desktop window. **The dependency question the plan asked to settle first.** `material3-adaptive` publishes multiplatform under `org.jetbrains.compose.material3.adaptive`, with android, desktop and ios variants; the ios ones carry `ios_arm64` and `ios_simulator_arm64` attributes despite the `uikit*` artifact names, so the targets this project declares on a mac resolve. Version **1.2.0**, not the newer 1.3.0-beta02, because that is the version the pinned material3 itself resolves: `material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned material3 compiled against. Nothing is lost by staying: 1.2.0 already computes the large and extra-large breakpoints through `supportLargeAndXLargeWidth`, and carries `ListDetailPaneScaffold` for the pane work. So steps 3-4 can use the library scaffolds rather than a hand-rolled equivalent. **`Breakpoint`** is the five-value enum -- compact / medium / expanded / large / extra-large at 0 / 600 / 840 / 1200 / 1600dp -- with `ofWidth` as a pure function so the thresholds are assertable without a Compose runtime. `TorchTheme` classifies once and provides `LocalBreakpoint`, so no two screens can disagree about the window they are both in. It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`. The latter also computes a `Posture` from the platform's fold state, which on android reaches for `WindowInfoTracker` and an activity; this call sits in `TorchTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview context is not an activity. The pane scaffolds ask for posture themselves, at the one place a fold changes the answer. **Spacing now adapts, and exactly one value moves.** M3 publishes a margin per breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else that varies with window width. The scale itself is absolute: `space200` is 16dp on a phone and 16dp on a desktop, and what adapts is which token a job reaches for, not the token. So `screenMargin` goes 16 -> 24 at medium and holds there, and `containerPadding`, `itemGap` and the rest do not move -- a card does not become a different component because the window grew. Widening all of them is the "everything breathes on a big screen" instinct, and it reads as a zoomed phone rather than as a layout. A test asserts the non-movement, because that is the edit a later reviewer would wave through. Mechanically this made the eight semantic names constructor parameters instead of `get()`s over the scale, so a breakpoint can reassign one without moving the stop underneath it. Kotlin resolves a default expression against the parameters before it, so each still reads its stop by name and still follows it when the scale is overridden -- phase 2's `Spacing(space200 = 24.dp)` assertion holds unchanged. The two instances are singletons because `LocalSpacing` is a `staticCompositionLocalOf` and invalidates on identity, not equality. **A test found a real defect while being written.** `ofWidth` was `entries.last { width >= it.minWidth }`, which throws `NoSuchElementException` below 0dp. A desktop window reports a zero size for the frame before its first layout pass, and this is called from the theme on every composition, so the crash would have arrived on a resize rather than on anything a user did. Now total. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` both green; 28 theme tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:15:43 +02:00
implementation(libs.compose.material3.adaptive)
feat: give the app a navigation component, and stop the app bar duplicating it Phase 6, step 3. The app had no navigation component of any kind: 43 screens reached by pushing a route, and one home screen whose top app bar carried the only two peer surfaces -- a profile avatar in the leading slot, a search icon in the trailing one. **This is an information-architecture change and was taken as one.** With a single top-level destination, a navigation bar would have held one item and been strictly worse than the app bar it replaced -- M3's caution is to swap only functionally equivalent components. Promoting search and profile to peer destinations is what makes a navigation component mean anything here, and it was put to the product owner rather than inferred. Answered: promote them. The consequence is in `HomeScreen`: the app bar now carries a title and nothing else. Two routes to one destination is the thing the caution is about, and the navigation component is now the one route, at every breakpoint. **Which component, at which breakpoint**, straight from the layout foundation: | compact | navigation bar | | medium, expanded | collapsed rail | | large, extra-large | expanded rail | `NavigationSuiteScaffoldDefaults.navigationSuiteType` is not used, and the difference is the last row -- it stops at the collapsed rail, because it classifies with the three-value window size class rather than the five breakpoints the May 2026 revision published. Deriving from `Breakpoint` reaches the row the library's default cannot, and keeps one source of truth for window width in the app. `NavigationSuiteType.None` on everything else. A navigation bar belongs on the destinations it switches between; on a chat room, a signing screen or an onboarding step -- pushed to and left by coming back -- it is a permanent invitation to lose your place. **Two things the wiring needed.** `ActiveProfileRoute` is addressed by metadata event id, not by public key, and only the home screen ever had one. The nav host now observes it for as long as a key is signed in, and the profile item is *disabled* until it arrives rather than absent -- an item that appears late moves the two beside it, and a bar whose items move under a thumb is worse than one briefly unavailable. The item click pops to `HomeRoute`, not to the graph's start destination. The android docs give the second shape and it would be wrong here: this graph starts at `LoadingRoute`, and onboarding clears the stack with `popUpTo(0)` on its way to home, so by the time these items exist the start destination is not on the stack at all -- popping to it would leave the loading screen underneath as the thing back returns to. **Tests, and one that could not be written.** The breakpoint-to-component table is a pure function so all five rows are asserted; the two rail rows differ only in whether labels are drawn, and nobody opens a 1200dp window on purpose. Four more compose the component around a real nav graph, because `TopLevelDestination.of` matches by `hasRoute` -- reflection over the serialized route -- and a renamed route would fail by never showing the component at all. Navigation in those is driven through the controller rather than by tapping an item. That is a harness limitation, established rather than assumed: a click handler that navigates trips navigation-compose's own main-thread assertion under `runDesktopComposeUiTest`, reproducible in twenty lines containing no app code -- a `NavHost`, two routes and a `TextButton`. What an item's `onClick` builds is asserted where it is a pure function instead. Also `material3-adaptive-navigation-suite`, versioned with material3 rather than with the adaptive library: it is published by the material3 group, and its 1.10.0-alpha05 is what names adaptive 1.2.0 in the first place. Most of the `MantraNavHost` diff is indentation -- the `NavHost` call gained an enclosing composable. `git diff -w` shows the 38 lines that are not. 626 jvm tests green; android compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:53:27 +02:00
implementation(libs.compose.material3.adaptive.navigation.suite)
2026-03-23 01:41:39 +02:00
implementation (libs.compose.material.icons.core)
implementation (libs.compose.material.icons.extended)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
2026-04-18 21:58:32 +02:00
2026-03-23 01:41:39 +02:00
implementation(libs.kermit)
2026-03-30 13:09:49 +02:00
implementation(libs.kotlinx.datetime)
2026-03-30 13:14:37 +02:00
implementation(libs.kotlinx.serialization.json)
2026-06-15 14:39:46 +02:00
implementation(libs.kotlinx.serialization.cbor)
2026-03-30 13:09:49 +02:00
2026-03-27 13:35:29 +02:00
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
2026-03-30 13:14:37 +02:00
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.serialization.kotlinx.json)
2026-06-15 14:39:46 +02:00
build: consume secp256k1-frost-kmp and lightning-kmp-app as composite-build submodules Add both libraries as git submodules and resolve them through Gradle composite builds instead of remote publications, so local changes to either library are picked up by the app build directly. Submodules: * secp256k1-frost-kmp (github.com/ac-cord-ac/secp256k1-frost-kmp) at 2478403 -- BIP-327 FROTH/FROST signing over secp256k1, KMP. * lightning-kmp-app (github.com/kngako/lightning-kmp-app) at 6745d88 -- phoenix business logic on top of lightning-kmp-core. Both submodules carry a local commit aligning them with this build's AGP 9.1.1 / Kotlin 2.4.10 (AGP version must match across a composite build or the android variants fail attribute matching). settings.gradle.kts: * includeBuild() both submodule checkouts. * lightning-kmp-app's project stays named ':library' (compose-resources derives its Res package from the project name), so an explicit dependencySubstitution maps the coordinate the app declares, fr.acinq.phoenix:lightning-kmp-app, onto that project. * Drop the jitpack.io repository: it only served com.github.kngako, which is no longer consumed as a binary. composeApp/build.gradle.kts: * commonMain gains ac.cord.auxiliary:library:1.0.0 (secp256k1-frost-kmp, substituted by the composite build). * commonMain replaces com.github.kngako.lightning-kmp-app:library (via JitPack) with fr.acinq.phoenix:lightning-kmp-app:1.0.0 (substituted by the composite build). lightning-kmp-core still comes from Maven Central. * Remove the RestoreJitPackClassifier component-metadata rule and the javax.inject import: it only existed to repair the artifact classifiers JitPack drops from the apple metadata variants, which no longer applies. gradle/libs.versions.toml: * Remove the now-unused lightningKmpApp version and the com.github.kngako lightning-kmp-app catalog entry. Verified: ./gradlew :composeApp:compileCommonMainKotlinMetadata :composeApp:compileDebugKotlinAndroid
2026-08-15 17:07:31 +02:00
// implementation(libs.lightning.kmp.core)
// resolved via the lightning-kmp-app composite build (see settings.gradle.kts)
implementation("fr.acinq.phoenix:lightning-kmp-app:1.0.0")
2026-06-15 14:39:46 +02:00
implementation(libs.navigation.compose)
2026-04-18 02:41:31 +02:00
implementation(libs.okio)
2026-07-01 15:11:31 +02:00
implementation(libs.qrose)
2026-06-15 14:39:46 +02:00
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)
2026-03-30 13:14:37 +02:00
implementation(libs.vitorpamplona.quartz)
2026-03-23 01:41:39 +02:00
}
commonTest.dependencies {
implementation(libs.kotlin.test)
feat: read a room's group events again when they arrived out of order Relays impose no ordering, so a kind:445 can turn up before the group can read it: an application message encrypted under an epoch whose commit has not landed, or a commit for an epoch ahead of the local one. Both are stored and then dropped -- MarmotInboundManager refuses an out-of-epoch commit precisely so it does not half-mutate the group -- and nothing goes back for them once the missing event fills the gap. The message is on disk, readable, and never read. A "Reindex Events" button at the bottom of the group's detail screen is that second look. Only events with nothing to show for them are replayed: no chat line at all, or one of the two placeholder types. A room where nothing went wrong is left exactly as it was, which is what makes the button safe to press on a hunch. Passes repeat while a pass recovers something, because created_at order is not epoch order and a commit recovered by one pass is what lets the next read the messages that were waiting on it. **Replaying was not safe as it stood.** Every row the path writes is keyed on an event id and upserts in place -- MarmotGroupEvent, MarmotInnerEvent, and the nip30303 entities -- with one exception. ChatMessage's primary key is autogenerated, so writing a freshly built line always inserts, and a re-read would have left the room showing each recovered message twice, once as "Undecryptable Message" and once as itself. ChatMessage.reconcileMarmotLine matches on the group event id instead, so a re-read is an update, and refuses to let a placeholder overwrite a line that says something. That last rule is what protects the line this device wrote on the way out for a message it sent: our own kind:445 cannot be read back, since the sender ratchet has consumed the generation, and without the rule a replay would have replaced our words with "Undecryptable Message". The MLS group itself was already safe to replay against, which is worth saying because it is the part that looks dangerous: a commit behind the current epoch is rejected as a duplicate before it touches the group, one ahead is refused, and a consumed ratchet generation throws before mutating anything. The exception was quartz's EpochCommitTracker, which does not dedupe and only empties when a commit applies -- so replaying a held commit just grew the list and left it pending forever. forgetPendingCommits drops the room's entries first, and the sweep feeds the events back in the order CommitOrdering picks a winner in, so a contested epoch resolves the same way it would have on every other device. **What is testable, and what is not.** The DAO is not: testDebugUnitTest is plain JVM and Room's in-memory builder wants an Android Context. So the two pieces carrying decisions are lifted out where they can be run without one -- MarmotReindexSweep for the stopping rule, and reconcileMarmotLine for which of two lines wins -- and the DAO is left as query, sweep, write. The filter tests pin why the query's `tags LIKE` is a prefilter and not a test: an event belonging to another room can mention this one in a q tag, and its own h tag is what rejects it. **Not recovered by any of this.** A message whose key is gone -- one the ratchet has already advanced past, or one from an epoch predating this device's join. And events that never reached disk at all: storeNostrEvent is a single transaction, so a kind:445 arriving before its room exists rolls back its own insert along with the failed indexing, and only a re-sync brings it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:49:57 +02:00
// runTest: the DAO, model and relay layers are all suspending, and there is no
// runBlocking in a common source set — so anything worth asserting about them
// needs a coroutine, and a scheduler, to assert it in.
implementation(libs.kotlinx.coroutinesTest)
2026-03-23 01:41:39 +02:00
}
feat: hold every screen's content to a readable line, and centre it in the window Phase 6, step 5, first half. Every one of the 40 screens rendered a single column that filled whatever width it was given, so on a 1800dp desktop window a paragraph became a 1800dp line -- long enough that the eye loses the start of the next one -- and a six-character text field stretched to 1700dp. M3: *"across all breakpoints, adjust margins and type styles to keep text between 40–60 characters per line."* **The measure is derived, not written down.** `readableContentWidth()` is `bodyLarge`'s font size converted through the current density, times half an em per character, times sixty: 480dp at the default text size. Writing `480.dp` instead would be the same number today and wrong for anybody who has turned text size up -- at 200% the same column holds thirty characters, silently, because the text still fits. Deriving it means the column widens with the type and keeps its sixty. `AverageCharacterAdvance` is the one estimate in it, named and documented, because a proportional face has no character width and half an em is the standard figure for mixed-case Latin prose. Only the ceiling is enforced. The floor needs nothing: a 400dp compact window less its two 16dp margins holds about 46 characters, which is inside the range, and no cap can add characters to a window that has none. There is a test for exactly that, so the claim is checked rather than asserted in a comment. **The column is centred; the text is not.** Those are opposite things and it is worth being explicit, because "centre it" is how the second one gets done by accident. A centred column still has one straight leading edge for every row, avatar and icon to align to, which is what the grids-and-spacing page asks for. Centred text has none. The 91 `TextAlign.Center` uses are a separate question and a separate commit. **Applied at 49 sites in one pass**, at the point every screen consumes its `Scaffold`'s padding -- the one place in each file that is reliably the top of the content. Below 480dp it is not a cap, an inset or a centring; it is nothing, so no phone layout moves. **Verified by measuring a real composition, not by reading the code.** `readableContent()` is `fillMaxWidth` then `wrapContentWidth` then `widthIn`, and every permutation of those three compiles and renders something that looks right in a phone-width preview. This needed `compose.desktop.uiTestJUnit4` in `jvmTest` -- pinned to the same 1.11.1 as the rest of Compose Multiplatform, test-only -- and `runDesktopComposeUiTest(width = 1400)`, which gives a window that genuinely is 1400 pixels across at density 1. Four assertions, and they bite: swapping the last two modifiers makes the 1400dp case report `Actual width is 1400.0.dp, expected 480.0.dp`, which is the "centred but never capped" failure the doc comment names. The same test also pins `currentBreakpoint()` to the real window at all five widths -- 400, 700, 1000, 1400, 1800 -- with the screen margin following. A version of it that measured the parent's constraints rather than the window would answer `Compact` everywhere and pass every unit test in the suite. 37 theme tests green; android and desktop both compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:31:33 +02:00
jvmTest.dependencies {
// A layout modifier cannot be asserted by reading it. `readableContent()` is
// three modifiers whose order decides whether the content is capped, centred,
// both or neither, and every ordering compiles and renders something -- so the
// check has to be a measurement of a real composition. Pinned to the same
// version as the rest of Compose Multiplatform; test-only.
implementation(compose.desktop.uiTestJUnit4)
implementation(libs.kotlin.testJunit)
}
2026-03-23 01:41:39 +02:00
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
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>
2026-09-06 01:23:42 +02:00
// The jvm counterpart to sqldelight-android-driver / -native-driver above.
implementation(libs.sqldelight.sqlite.driver)
2026-03-23 01:41:39 +02:00
}
build: bump lightning-kmp-app to bbba08b, building lightning-kmp from source lightning-kmp-app moves 6745d88 -> bbba08b, four commits: 57353cf Minor updates 9f3cd72 Build lightning-kmp from the experimental submodule via a composite build f22c30a Follow the submodule chain onto Gradle 9.7.1 bbba08b Declare the ios targets only on a mac, so the IDE can import the project 9f3cd72 is the substantive one. lightning-kmp no longer comes from maven central: the submodule now carries its own experimental/lightning-kmp submodule on branch `threshold` -- the branch with the FROST/prefractal signers -- and substitutes fr.acinq.lightning:lightning-kmp-core for that build's project. That build includes bitcoin-kmp, which includes secp256k1-kmp, which compiles the C library from a secp256k1-zkp fork. So this repo's build tree is now four levels deep and compiles native code. Cloning therefore needs `git submodule update --init --recursive`, and because each included build resolves the android SDK from its own local.properties rather than inheriting the root's, the three new nested builds each need a (gitignored) local.properties with sdk.dir. 57353cf changed two signatures that MantraApplication implements -- LightningApplication gained getApplicationContext(), and BusinessManager.initialize now takes the application rather than a Context. Neither needed a source change: MantraApplication extends android.app.Application, which already supplies getApplicationContext(), and it was already passing `this`, which satisfies the narrowed parameter type. The rest of this commit is what the update forces on the outer build. gradle-wrapper.properties, 9.3.1 -> 9.7.1: f22c30a moved every build in the chain onto 9.7.1. An included build does not use its own wrapper -- the root build's gradle version runs the whole tree -- so this repo has to follow for the chain to build at all. gradle.properties, configuration cache off: secp256k1-kmp's `:jni:generateHeaders` and `:native:buildSecp256k1<target>` both hold gradle script object references and cannot be serialized. The configuration cache covers a whole build tree and has no per-build opt-out, so an included build's incompatibility is this build's problem. The comment records how to undo this once those tasks are fixed upstream. composeApp/build.gradle.kts, ios targets gated on the host: secp256k1-kmp declares a libsecp256k1 cinterop, which makes gradle switch off klib cross compilation for apple targets. On linux nothing in the tree then offers an ios variant of fr.acinq.phoenix:lightning-kmp-app, and the ios compilations failed with "No matching variant of project ':lightning-kmp-app:library'" -- not a warning, a build failure. The gate covers the target declarations, the iosMain dependencies (the source set only exists when the targets do) and the kspIos* configurations (likewise). This mirrors bbba08b, which applied the same gate inside the submodule for the same reason. secp256k1-frost-kmp d3b294d applies that gate there too. Substitution rules apply across a whole build tree, so that project's own lightning-kmp-core coordinate started resolving to the source project without it asking, and every apple source set stopped resolving. The android build masked it -- ios compilations are not in its task graph -- but kmpPartiallyResolvedDependenciesChecker reported it and compileAppleMainKotlinMetadata failed outright, which would have broken IDE import. Note that `:composeApp:compileCommonMainKotlinMetadata` no longer exists on a linux host. With ios gated off, androidTarget is the only declared target (jvm() is still commented out), and KMP does not generate a commonMain metadata compilation for a single-target project. `:composeApp:compileDebugKotlinAndroid` is the check now; it passes clean, with none of the resolution errors above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:50:59 +02:00
// Only exists when the ios targets above were declared; the default hierarchy template
// creates this source set from them.
if (org.gradle.internal.os.OperatingSystem.current().isMacOsX) {
iosMain.dependencies {
implementation(libs.sqldelight.native.driver)
}
2026-03-27 13:35:29 +02:00
}
2026-03-23 01:41:39 +02:00
}
}
android {
2026-07-15 01:14:46 +02:00
namespace = "press.mantra.android"
2026-03-23 01:41:39 +02:00
compileSdk = libs.versions.android.compileSdk.get().toInt()
2026-06-15 14:39:46 +02:00
buildFeatures {
buildConfig = true
}
2026-03-23 01:41:39 +02:00
defaultConfig {
2026-07-15 01:14:46 +02:00
applicationId = "press.mantra.android"
2026-03-23 01:41:39 +02:00
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
2026-09-08 09:11:13 +02:00
versionCode = 2
2026-09-09 17:04:09 +02:00
versionName = "0.1.2"
2026-03-23 01:41:39 +02:00
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
2026-03-23 02:21:18 +02:00
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
2026-03-23 01:41:39 +02:00
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
2026-03-23 02:21:18 +02:00
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}
2026-03-23 01:41:39 +02:00
}
compileOptions {
2026-06-23 13:59:17 +02:00
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
2026-03-23 01:41:39 +02:00
}
feat: add a SubmissionEvent that carries a nip30303 event as its payload Every nip30303 kind so far describes a thing: an artifact, a dialect, a chapter, a translated chunk. None of them describes the act of putting one in front of a group, and until now nothing needed to -- a group event's sender was the author of the event inside it, so the two questions had one answer by construction. That construction is also the limit. It means a group can only ever hold work written by its own members under their own keys. A translation lifted from a public archive, a chapter transcribed by an outside contributor, an artifact somebody published years ago: none of it can go in without a member re-authoring it and taking the byline. Kind 30312 is the envelope that separates them. Its content is the payload event's JSON, whole -- same id, same pubKey, same signature, nothing rewritten to look like the submitter's work. The submitter signs for the envelope; the author still signs for the event. Two tags name what is inside so a client can decide whether it can apply a submission without parsing the content first: payloadKind the payload's kind payloadId the payload's id, with the author slot carrying the payload's author -- who, unusually for an id tag in this package, is often not the event's sender Kinds 30300-30311 are taken (30305 and 30307 by contributor lists), so 30312 is the next free one. A submission is not an endorsement and grants nothing. Who may submit is the group's business; this only makes the question expressible. The test covers the property the whole thing rests on: an event written by an outsider goes into an envelope, comes out of a JSON round trip with its id, author and signature intact, and does not acquire the submitter as its author. It also pins payload() returning null rather than something empty when the content will not parse -- which needed android.util.Log stubbing, since quartz logs on that path and unmocked Log methods throw, failing the test on the log line rather than on what it came to check. Nothing sends or reads one yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:21:00 +02:00
testOptions {
unitTests {
// Quartz logs through android.util.Log on the error paths -- parsing a
// malformed event, for one. Unmocked, those methods throw, so a test
// covering such a path fails on the log line rather than on what it
// came to check. Default values let the code under test carry on.
isReturnDefaultValues = true
}
}
2026-03-23 01:41:39 +02:00
}
dependencies {
debugImplementation(libs.compose.uiTooling)
2026-05-02 00:05:38 +02:00
// ksp(libs.androidx.room3.compiler)
add("kspAndroid", libs.androidx.room3.compiler)
build: bump lightning-kmp-app to bbba08b, building lightning-kmp from source lightning-kmp-app moves 6745d88 -> bbba08b, four commits: 57353cf Minor updates 9f3cd72 Build lightning-kmp from the experimental submodule via a composite build f22c30a Follow the submodule chain onto Gradle 9.7.1 bbba08b Declare the ios targets only on a mac, so the IDE can import the project 9f3cd72 is the substantive one. lightning-kmp no longer comes from maven central: the submodule now carries its own experimental/lightning-kmp submodule on branch `threshold` -- the branch with the FROST/prefractal signers -- and substitutes fr.acinq.lightning:lightning-kmp-core for that build's project. That build includes bitcoin-kmp, which includes secp256k1-kmp, which compiles the C library from a secp256k1-zkp fork. So this repo's build tree is now four levels deep and compiles native code. Cloning therefore needs `git submodule update --init --recursive`, and because each included build resolves the android SDK from its own local.properties rather than inheriting the root's, the three new nested builds each need a (gitignored) local.properties with sdk.dir. 57353cf changed two signatures that MantraApplication implements -- LightningApplication gained getApplicationContext(), and BusinessManager.initialize now takes the application rather than a Context. Neither needed a source change: MantraApplication extends android.app.Application, which already supplies getApplicationContext(), and it was already passing `this`, which satisfies the narrowed parameter type. The rest of this commit is what the update forces on the outer build. gradle-wrapper.properties, 9.3.1 -> 9.7.1: f22c30a moved every build in the chain onto 9.7.1. An included build does not use its own wrapper -- the root build's gradle version runs the whole tree -- so this repo has to follow for the chain to build at all. gradle.properties, configuration cache off: secp256k1-kmp's `:jni:generateHeaders` and `:native:buildSecp256k1<target>` both hold gradle script object references and cannot be serialized. The configuration cache covers a whole build tree and has no per-build opt-out, so an included build's incompatibility is this build's problem. The comment records how to undo this once those tasks are fixed upstream. composeApp/build.gradle.kts, ios targets gated on the host: secp256k1-kmp declares a libsecp256k1 cinterop, which makes gradle switch off klib cross compilation for apple targets. On linux nothing in the tree then offers an ios variant of fr.acinq.phoenix:lightning-kmp-app, and the ios compilations failed with "No matching variant of project ':lightning-kmp-app:library'" -- not a warning, a build failure. The gate covers the target declarations, the iosMain dependencies (the source set only exists when the targets do) and the kspIos* configurations (likewise). This mirrors bbba08b, which applied the same gate inside the submodule for the same reason. secp256k1-frost-kmp d3b294d applies that gate there too. Substitution rules apply across a whole build tree, so that project's own lightning-kmp-core coordinate started resolving to the source project without it asking, and every apple source set stopped resolving. The android build masked it -- ios compilations are not in its task graph -- but kmpPartiallyResolvedDependenciesChecker reported it and compileAppleMainKotlinMetadata failed outright, which would have broken IDE import. Note that `:composeApp:compileCommonMainKotlinMetadata` no longer exists on a linux host. With ios gated off, androidTarget is the only declared target (jvm() is still commented out), and KMP does not generate a commonMain metadata compilation for a single-target project. `:composeApp:compileDebugKotlinAndroid` is the check now; it passes clean, with none of the resolution errors above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:50:59 +02:00
// These configurations only exist when the ios targets are declared, which the kotlin block
// above does only on a mac.
if (org.gradle.internal.os.OperatingSystem.current().isMacOsX) {
add("kspIosSimulatorArm64", libs.androidx.room3.compiler)
// add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room3.compiler)
}
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>
2026-09-06 01:23:42 +02:00
add("kspJvm", libs.androidx.room3.compiler)
2026-03-24 04:47:19 +02:00
// Add any other platform target you use in your project, for example kspDesktop
}
2026-05-02 00:05:38 +02:00
room3 {
2026-03-24 04:47:19 +02:00
schemaDirectory("$projectDir/schemas")
2026-03-23 01:41:39 +02:00
}
build: make the conformance audit part of `check`, and gate the branch on it Phase 8, the first two items. The audit has existed since phase 0 and has been run by hand at the end of every phase since, which is exactly the arrangement it was written to end: a budget nobody checks at the moment the number moves is a number that drifts. **`:composeApp:m3Audit`, wired into `check`.** It shells out to `docs/scripts/m3-audit.sh --check` and fails the build when a budget is exceeded or a floor is undercut. Verified to bite: adding one `Color(0xFFAABBCC)` to `LoadingScreen.kt` reports `hardcoded Color outside theme/ 1 over budget 0` and takes the build down with it. The task declares the script and the ui source tree as inputs and a marker file as its output, so it is up-to-date-able rather than re-running on every `check`. On a machine with no bash it warns and skips instead of failing, because a build that dies for a reason unrelated to the change under it teaches people to pass `-x`. **A Gitea Actions workflow**, since the remote is a Gitea 1.25 instance. Two jobs, deliberately: - `budgets` is grep over the source tree -- no gradle, no android SDK, no submodules, no network. This job is the reason the audit is a shell script rather than a gradle plugin, and it should stay runnable on a bare container. - `tests` needs a compiler and therefore the whole composite chain: four levels of submodule and a cross-compile of secp256k1's C sources, so a cold run is minutes rather than seconds. Split out so a runner can be pointed at `budgets` alone where that is all the capacity there is. Its two non-obvious steps carry the reasons at the site -- `submodules: recursive` or configuration fails with `Project with path ':library' not found`, and the android SDK is needed even for a jvm-only test run because `:secp256k1-kmp:jni:android` is in the graph. **The workflow is unverified**, and that is worth saying plainly: this repository has had no CI of any kind, so there is no runner registered to try it against. The syntax is valid and the commands are the ones used by hand throughout this work. The gradle task is the half that is proven, and it is the half that runs on every developer machine regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:19:57 +02:00
/**
* The Material Design conformance audit, as a build task.
*
* `docs/scripts/m3-audit.sh --check` counts what the phases in
* docs/material-design-conformance.md drove to zero -- hardcoded colours, dp literals in
* spacing positions, bare `.clickable`, title case, untriaged `contentDescription = null`
* -- and exits 1 when one of them has come back. It also holds two floors, for the
* adaptive and navigation work, which regress by being *removed*.
*
* Wired into `check` rather than left as a script somebody remembers to run: the whole
* point of a budget is that it is enforced at the moment the number moves, and a number
* that is only checked when a person thinks to look is a number that drifts.
*
* It reads the source tree with grep and needs no gradle, no android SDK and no
* submodules, so it is also the one part of this build that a bare CI runner can do.
*/
val m3Audit = tasks.register("m3Audit") {
group = "verification"
description = "Checks the Material Design conformance budgets in docs/material-design-conformance.md."
val script = rootProject.layout.projectDirectory.file("docs/scripts/m3-audit.sh").asFile
val uiSources = rootProject.layout.projectDirectory
.dir("composeApp/src/commonMain/kotlin/press/mantra/compose/ui")
val projectDirectory = rootProject.layout.projectDirectory.asFile
inputs.file(script).withPropertyName("auditScript")
inputs.dir(uiSources).withPropertyName("uiSources")
// No outputs, so this would run every time. A marker file is what makes it
// up-to-date-able, and it is the only thing the task writes.
val marker = layout.buildDirectory.file("m3-audit/passed.txt")
outputs.file(marker)
doLast {
// Windows has no bash unless somebody installed one. Skipping loudly beats
// failing a build for a reason that has nothing to do with the change under it;
// the CI runner and every developer machine here are unix.
val bash = listOf("/bin/bash", "/usr/bin/bash").firstOrNull { File(it).canExecute() }
if (bash == null) {
logger.warn("m3Audit: no bash found, skipping. Run docs/scripts/m3-audit.sh --check by hand.")
marker.get().asFile.apply { parentFile.mkdirs() }.writeText("skipped: no bash\n")
return@doLast
}
val result = providers.exec {
commandLine(bash, script.absolutePath, "--check")
workingDir = projectDirectory
isIgnoreExitValue = true
}
val text = result.standardOutput.asText.get()
val exit = result.result.get().exitValue
logger.lifecycle(text)
if (exit != 0) {
throw GradleException(
"Material Design conformance budgets exceeded. See the report above and " +
"docs/material-design-conformance.md."
)
}
marker.get().asFile.apply { parentFile.mkdirs() }.writeText(text)
}
}
tasks.named("check") { dependsOn(m3Audit) }
2026-03-23 01:41:39 +02:00
compose.desktop {
application {
2026-07-15 01:14:46 +02:00
mainClass = "press.mantra.desktop.MainKt"
2026-03-23 01:41:39 +02:00
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
2026-07-15 01:14:46 +02:00
packageName = "press.mantra.desktop"
2026-03-23 01:41:39 +02:00
packageVersion = "1.0.0"
}
}
}