From a998cf20a9f6f0d3e411c6b60e5932696159a6f0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:37:27 +0200 Subject: [PATCH 1/9] docs: the plan for an ios build, phased against the route Curated walked Curated reached a device in eight commits. Five defects stand between this tree and the same place -- the native chain not configuring on a mac, Room with no way to construct the database on native, a cancellation Swift cannot see, two separate ios link failures, and CIO having no TLS on kotlin/native. Each is diagnosed upstream, so the work is porting a known answer. Three places this tree cannot simply cherry-pick: the test-name sweep has to run over Mantra's own 84 names in 35 files (ten of Curated's 38 files are from lines Mantra never pulled, and Mantra has eight Curated does not); the fork's "linux cinterop" fix is `if (!currentOs.isLinux)` and is really the fix that makes a mac work; and `@ConstructedBy` changes what Room's KSP generates for every target, not only the one it unblocks. Co-Authored-By: Claude Opus 5 --- docs/README.md | 1 + docs/ios-build.md | 462 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 docs/ios-build.md diff --git a/docs/README.md b/docs/README.md index f47439a6..50ebb0bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles | | [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | +| [ios-build.md](./ios-build.md) | building for an iPhone, following the route the Curated fork walked — the five defects between here and a device, why the fork's "linux cinterop" fix is the one that makes a mac work, and the one annotation that unblocks native by changing every other target | | [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes | | [chapter-uploads.md](./chapter-uploads.md) | picking files off a device and having the group sign each one in as a chapter — why N files can never be one signing session, and the four things the single-chapter path gets away with | | [project-feature.md](./project-feature.md) | bringing the server schema's `project` and its two join models into a group that has no accounts and no server — a creator tag of its own and owners as a signed set of `p` tags, the role the parser has to be told about or every project lands ownerless, why a membership is its own signed event rather than a list, and the kind band that is already double-booked | diff --git a/docs/ios-build.md b/docs/ios-build.md new file mode 100644 index 00000000..55e56c38 --- /dev/null +++ b/docs/ios-build.md @@ -0,0 +1,462 @@ +# Bringing up the iOS target + +What it takes to build and run Mantra on an iPhone, phased, following the route +the Curated fork already walked. Every one of its findings is diagnosed and +reproducible here, so the work is porting a known answer rather than +re-discovering it. + +The headline is that almost nothing is missing. The `iosArm64` and +`iosSimulatorArm64` targets are **already declared** in +[composeApp/build.gradle.kts:36](../composeApp/build.gradle.kts), gated behind +`OperatingSystem.current().isMacOsX` the way everything in the composite chain is; +`composeApp/src/iosMain` holds ten `actual` files that match `jvmMain`'s ten +one-for-one, so no `expect` is unimplemented; `commonMain` has no `java.*` or +`javax.*` import anywhere; quartz 1.14.0 publishes `iosArm64` and +`iosSimulatorArm64` klibs; and `iosApp/` is byte-identical to Curated's apart from +brand tokens and one icon file, Xcode build phase and all. + +What stands in the way is five concrete defects, which Curated hit in the order a +build hits them — configure, compile, link, connect, sign — and fixed in eight +commits. + +## The eight commits, and what each one is here + +| Curated | what it did | how it lands here | +|---|---|---| +| `94de86a3`, `cbf23640`, `64b37447` | three gitlink bumps of `lightning-kmp-app`, carrying a secp256k1-kmp build fix | the same three bumps, verbatim — Mantra is pinned at their exact parent | +| `987df2c8` | `@ConstructedBy`, `@Throws(CancellationException)`, an unused import, `TEAM_ID` | the same, minus `TEAM_ID`, which is account-specific | +| `aa9312f0` | comma-free `commonTest` names, plus a disabled compiler phase | **rewritten**, not cherry-picked — see Phase 3 | +| `b9140c6a` | the Darwin ktor engine | near-verbatim; the shape it assumes is the shape here | +| `bd47c841` | app icon and `MARKETING_VERSION` | needs Mantra artwork | +| `a673c85e` | `versionName` | a release decision, not a port | + +## What is already done for you + +**The pin lines up exactly.** Mantra records `lightning-kmp-app` at +`84cc44c855e3d173e26016b807c449153a3af597`. That is the *parent* of Curated's +`94de86a3`. The three bumps are therefore a straight walk along the same gitlinks — +`84cc44c` → `2dcd4fb` → `db15e59` → `e0d2b82` — with no rebase and no divergence +to reconcile. + +**The library's own iOS gating is already right.** `lightning-kmp-app/library` +declares `iosArm64()` and `iosSimulatorArm64()` under the same `isMacOsX` guard, +and sets `kotlin.native.enableKlibsCrossCompilation=true`. Nothing in the outer +build has to teach it about apple targets. + +**`commonMain` is clean, and the publish surface is small.** One `HttpClient {` +construction in the whole module +([NostrSocketClientFactory.kt:10](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt)), +no explicit Coil `ImageLoader` anywhere, six `@Throws` annotations on the publish +path. That is the same shape Curated's fixes were written against, so Phases 2 and +4 are small and exact rather than a search. + +**The audit does not look at tests.** `docs/scripts/m3-audit.sh` scans +`composeApp/src/commonMain/kotlin/press/mantra/compose/ui` only, so Phase 3's sweep +over test names cannot trip `./gradlew check`. + +## What actually blocks it + +Five things, independent of each other except for the order the build reveals them +in: + +1. **The chain will not configure on a mac.** secp256k1-kmp's `.def` declares + `staticLibraries` for every target and the cinterop tool refuses to emit a klib + while the archive is absent. Off a Linux host the Linux archives are never + built, so the IDE sync and configuration fail with `cinterop file ... does not + exist`. The fix — `9bcae25` in secp256k1-kmp, hardened by `8adf691` — stubs those + archives under `if (!currentOs.isLinux)`. Its commit subject says "linux + cinterop", which reads backwards from here: **it is the fix that makes the chain + work on a mac**, and it is a strict no-op on Linux. + +2. **Room cannot construct the database on native.** + `MantraDatabaseConstructor` already exists in `commonMain`, `expect`, + `@Suppress`ed and referenced by nothing. Without `@ConstructedBy` naming it, + Room's KSP generates no `actual`, and on a target with no reflection there is no + other way in. + +3. **Swift cannot see a cancellation as a cancellation.** Six `@Throws` sites on + the publish path name `NostrPublishException` and not + `kotlin.coroutines.cancellation.CancellationException`, so a cancelled publish + crosses the ObjC boundary as an unexpected throw. + +4. **The iOS link tasks fail, twice over.** `linkDebugTestIosArm64` is rejected by + Kotlin/Native over backticked test names containing a comma (`Name contains + illegal characters: ','`), and `linkReleaseFrameworkIosArm64` crashes Kotlin + 2.4.10's release backend inside `StaticInitializersOptimization`. + +5. **Nothing secure connects on a device.** CIO has no TLS implementation on + Kotlin/Native — every `wss://` and `https://` call dies at the handshake with + `TLS sessions are not supported on Native platform`. The relay sockets and + Coil's image fetcher both sit on a default-engine client. + +Everything below is ordered by that list. + +--- + +## Phase 0 — bootstrap, and a recorded baseline + +Nothing is changed in this phase. Its output is a green build to compare against, +because four of the five defects above are invisible until a mac is in the loop and +you do not want to be guessing afterwards which failure you introduced. + +**In this worktree, initialise the chain first.** Four nested submodules — +`lightning-kmp-app` → `experimental/lightning-kmp` → `experimental/bitcoin-kmp` → +`experimental/secp256k1-kmp` (→ `native/secp256k1`) — are unpopulated, and gradle +dies early and confusingly with `Project with path ':library' not found`: + +```bash +git -c protocol.file.allow=always submodule update --init --recursive +``` + +`protocol.file.allow=always` is required: the recorded url for `lightning-kmp-app` +is the main checkout's path, and git refuses `file` transport for submodules by +default. The last level (`native/secp256k1`, fetched from GitHub) can stall for +ten minutes or more; let the recursive call do what it can and finish that one +level from the main checkout's copy. Trust `git -C rev-parse HEAD` per +level rather than `git submodule status --recursive`, which lies after a +per-level url override. + +Then record the baseline: + +```bash +ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest +``` + +Note the jvmTest count and that it is green. `IdentitySwitchJvmTest`'s "last +profile opened" case fails about one run in nine on a 5s DataStore timeout on a +clean tree too — if you see exactly that, re-run before believing it. + +**Deliverable:** no commit. A noted baseline. + +--- + +## Phase 1 — advance the library chain + +The three gitlink bumps, kept as three commits so each one says what it carries. +Walk the outer pin, and let the nested gitlinks come with it. + +```bash +git -C lightning-kmp-app fetch https://github.com/kngako/lightning-kmp-app.git 'refs/heads/*:refs/remotes/github/*' +git -C lightning-kmp-app checkout --detach 2dcd4fbe1513421d100a4485583f167b1fdcd47c +git -c protocol.file.allow=always -C lightning-kmp-app submodule update --init --recursive +git add lightning-kmp-app && git commit +``` + +In a worktree the submodule's `origin` is the **main checkout's copy**, not GitHub, +so `git fetch origin` shows nothing new — fetch the `.gitmodules` url directly, as +above. Each bump moves `experimental/lightning-kmp` too, which moves `bitcoin-kmp`, +which moves `secp256k1-kmp`, so re-run `submodule update --init --recursive` inside +`lightning-kmp-app` after each checkout or the chain is left mid-walk. + +The three, in order: + +| commit | pin | what it carries | +|---|---|---| +| 1 | `2dcd4fb` | secp256k1-kmp `9bcae25` — the stub archives that let the chain configure off Linux | +| 2 | `db15e59` | the same fix merged into the `threshold` branches, so the nested gitlinks name branch tips rather than side commits | +| 3 | `e0d2b82` | secp256k1-kmp `8adf691` — registers the stub task with `tasks.register` instead of the delegate provider, which Gradle 9 requires | + +Commits 2 and 3 look like bookkeeping and are not: without 3 the stub task is +never registered under a name, and without 2 the pins sit on commits that were +rebased away. + +**Verify, on Linux.** The whole fix is behind `if (!currentOs.isLinux)`, so Linux +must be *unchanged*: + +```bash +ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest +``` + +Same test count, same green, as Phase 0. The library's own ~159 tests run inside +the composite build and are the thing that proves the wiring still gives it what it +expects. + +**Verify, on a mac.** `./gradlew :composeApp:tasks` configures at all, and +`:composeApp:compileKotlinIosArm64` resolves `fr.acinq.phoenix:lightning-kmp-app`. +That is the phase's actual purpose. + +> Suggested message, commit 1: +> `build: carry secp256k1-kmp's fix for configuring off a linux host` +> — and say in the body that the upstream subject reads the other way round. + +--- + +## Phase 2 — declare the Room constructor and cancellation to Swift + +Four small edits, one commit. All of it compiles on Linux. + +**`@ConstructedBy`.** Add the import and the annotation above +[MantraDatabase.kt:369](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt)'s +`@ColumnTypeConverters`: + +```kotlin +import androidx.room3.ConstructedBy +… +@ConstructedBy(MantraDatabaseConstructor::class) +@ColumnTypeConverters(MantraConverters::class) +abstract class MantraDatabase: RoomDatabase() { +``` + +The `expect object` it names already exists and needs no edit. This is the one +change in the whole plan that **also changes the JVM and Android builds**: Room's +KSP starts generating an `actual` for every target rather than none, so the +annotation that unblocks iOS is the annotation most likely to move something +underneath `:composeApp:jvmTest`. Run it and read the output rather than assuming. + +**Cancellation on the publish path.** Add +`kotlin.coroutines.cancellation.CancellationException::class` to the six existing +`@Throws`, and give `EventPublishTransport.publishEvent` one it does not have: + +- [RelayPool.kt:289](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt) +- [RelaysSocketManager.kt:103](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) and `:118` +- [NostrPublisherRepository.kt:33](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt), `:55` and `:66` +- [EventPublishTransport.kt:31](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt) — new, naming `NostrPublishException` as well + +Keep the fully-qualified style the surrounding annotations already use. + +**One unused import.** `import kotlin.native.Platform` in +[Platform.ios.kt](../composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt) +shadows the app's own `Platform` interface. Delete the line. Nothing on Linux +compiles `iosMain`, so this one is carried on Curated's word and the compiler's, +later. + +**Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest`. +**On a mac:** `:composeApp:compileKotlinIosArm64`. + +> `feat(ios): declare the Room constructor and publish cancellation to swift` + +--- + +## Phase 3 — unbreak the iOS link tasks + +Two unrelated link failures, one commit, because they are the same symptom from the +outside: `./gradlew :composeApp:linkDebugTestIosArm64` and +`:composeApp:linkReleaseFrameworkIosArm64` both fail. + +### The comma names — rewrite, do not cherry-pick + +Curated's `aa9312f0` touched 38 `commonTest` files. **Ten of them do not exist +here** (`GroupEventProposalTest`, `CuratedSchemaEventTest`, `CuratedEntryEventTest`, +`CuratedSchemaTemplateTest`, `GroupCuratedEntryTest`, `GroupCuratedSchemaTest`, +`GroupPostTest`, `CuratedSuggestionTest`, `MarmotRoomIdentityTest`, +`MarmotPayloadBoundsTest`, `FrostSigningMembersTest`) — they belong to the fork's +curated-list and Marmot-v2 lines, neither of which Mantra has pulled. Meanwhile +Mantra has test files Curated does not: `ProjectEventTest`, +`ChapterProposalSizeTest`, `UploadChaptersStateTest`, `UploadReviewTest`, +`SubgroupLineageTest`, `MarkdownBlockTest`, `MarkdownProducerTest`, +`ChapterTextTest`. + +So do the sweep over **Mantra's own tree**: 84 backticked `fun` names across 35 +files in `composeApp/src/commonTest`. Every comma in every one of them is followed +by exactly one space — verified, no exceptions — so the rewrite is total and +unambiguous: + +```bash +grep -rl 'fun `[^`]*,[^`]*`' composeApp/src/commonTest --include='*.kt' \ + | xargs perl -i -pe 's/(fun `[^`]*`)/($x=$1)=~s{, }{ }g; $x/ge' +``` + +Then read the diff. A name losing its comma loses a little of its rhythm and none +of its meaning (`read as its kind, tags and content` → `read as its kind tags and +content`); if any of the 84 reads badly afterwards, reword that one by hand rather +than leaving a comma behind. + +**Leave `jvmTest` alone.** It has 138 comma names and Kotlin/Native never compiles +it. Curated left it alone for the same reason, and widening the sweep would put 138 +cosmetic edits in a commit that is about a link failure. + +### The release backend crash + +Append to `composeApp/build.gradle.kts`, after the `kotlin { }` block and before +`android {` at line 161 — the same place Curated put it: + +```kotlin +tasks.withType().configureEach { + if (name.startsWith("linkRelease")) { + toolOptions.freeCompilerArgs.add("-Xdisable-phases=RemoveRedundantCallsToStaticInitializersPhase") + } +} +``` + +With the comment Curated wrote, which is the valuable part: the pass is an LTO +optimization, so only optimized links reach it (which is why the debug test binary +links fine); it assumes every function in the call graph has at least one statement +in its body; something in this module's graph arrives with an empty one; and +disabling it costs the redundant initializer calls it would have removed and +nothing else. + +`tasks.withType()` matches nothing on Linux, so this is inert +there — but the class resolves from the KMP plugin's classpath on any host, so the +script still has to compile. `:composeApp:compileDebugKotlinAndroid` proves that +much. + +**Expect this one not to be the end of it.** The crash is a property of the call +graph, and Mantra's graph is a superset of Curated's — projects, chapter uploads, +multi-parent subgroups, the markdown producer. The same phase may crash somewhere +else, or a different phase may crash. Treat the stack trace as the instruction: +disable the named phase, and write down why. + +**Verify, on a mac:** `:composeApp:linkDebugTestIosArm64` and +`:composeApp:linkReleaseFrameworkIosArm64`. +**On Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest` — the +renamed tests must still run, and the count must not move. + +> `build(ios): unbreak the ios link tasks` + +--- + +## Phase 4 — put every ktor client on Darwin + +CIO has no TLS on Kotlin/Native. Pick the engine per platform instead of letting +the classpath pick it. + +**Catalogue entry**, beside `ktor-client-cio` in +[gradle/libs.versions.toml:82](../gradle/libs.versions.toml): + +```toml +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +``` + +**Dependency**, inside the existing `isMacOsX`-gated `iosMain.dependencies` block at +[composeApp/build.gradle.kts:154](../composeApp/build.gradle.kts). It goes inside +the gate, which is why Linux resolution is untouched by this phase. + +**Three new files**, an `expect` and two `actual`s, in +`press.mantra.compose.network`: + +- `commonMain/.../PlatformHttpClientEngine.kt` — `internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*>` +- `androidMain/.../PlatformHttpClientEngine.android.kt` — `= CIO` +- `jvmMain/.../PlatformHttpClientEngine.jvm.kt` — `= CIO` +- `iosMain/.../PlatformHttpClientEngine.ios.kt` — `= Darwin` + +**Two call sites.** `defaultSocketsHttpClient` becomes +`HttpClient(platformHttpClientEngineFactory()) { … }`, keeping its `WebSockets` +block as-is. And `MantraApp` gains the singleton Coil loader, because Coil's +default ktor fetcher builds a bare `HttpClient()` and would fail on `https://` +images exactly as the sockets did: + +```kotlin +private val imageHttpClient by lazy { HttpClient(platformHttpClientEngineFactory()) } + +@Composable +fun MantraApp(…) { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { add(KtorNetworkFetcherFactory(httpClient = { imageHttpClient })) } + .build() + } + + MantraTheme { … } +} +``` + +`setSingletonImageLoaderFactory` goes *above* `MantraTheme`, at the top of the +composable's body, so it is installed before the first `AsyncImage` composes. +`MantraApp` is the only place both call sites are reachable from, and Mantra has no +`ImageLoader` configuration today to reconcile with. + +**Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinJvm :composeApp:jvmTest` — the android and jvm `actual`s and both call sites compile here, which is most of the phase. +**On a mac:** `:composeApp:compileKotlinIosArm64`, then the real test — a device or +simulator that reaches a relay over `wss://` and renders an avatar over `https://`. + +> `fix(ios): build ktor clients on Darwin so wss/https work on a device` + +--- + +## Phase 5 — signing, icon, version + +Three things Curated's last two commits did that **cannot be ported**, only +decided. Each needs an answer from you before it can be written. + +**`TEAM_ID`.** [iosApp/Configuration/Config.xcconfig](../iosApp/Configuration/Config.xcconfig) +has `TEAM_ID=` empty, and the bundle identifier is built from it +(`press.mantra.ios$(TEAM_ID)`). Curated committed its development team id in the +clear, which is normal for an xcconfig — a team id is not a secret — but it is +*their* team id and signs builds to *their* account. Mantra needs its own, or an +`xcconfig` that stays out of git. + +**The app icon.** Curated replaced its single `app-icon-1024.png` with a 1024 +light variant and a 1024 dark one, adding the `luminosity: dark` appearance to +`Contents.json` and leaving the `tinted` slot filenameless so iOS derives it. +Mantra's `AppIcon.appiconset` still holds one 1024 file and one entry. The +*structure* ports; the artwork does not. Two 1024×1024 PNGs are needed. + +**`MARKETING_VERSION`.** Mantra's xcconfig says `1.0` while +`android { versionName }` says `0.1.2` — the two have simply never been reconciled +because nothing shipped on iOS. Curated set both to the `0.1.x` line. Pick one +number for both; the mismatch is the bug, not either value. + +Curated's `a673c85e` moved `versionName` from `0.1.2` to `0.1.1`, which is a +release-bookkeeping step on their tree and means nothing on this one. Do not port +it. + +> `feat(ios): the app icon, the signing team, and one version across both stores` + +--- + +## Phase 6 — the part only a mac can find + +Phases 1 to 4 fix everything Curated hit. They do not fix what Curated never had, +and Mantra has pulled four lines of work the fork does not carry: projects, chapter +uploads, multi-parent subgroups, and the markdown producer. None of it has ever +been compiled for `iosArm64`. + +So the honest shape of this phase is a loop, not a list: + +```bash +./gradlew :composeApp:compileKotlinIosArm64 +./gradlew :composeApp:linkDebugTestIosArm64 +./gradlew :composeApp:iosSimulatorArm64Test +./gradlew :composeApp:linkReleaseFrameworkIosArm64 +``` + +then Xcode, whose build phase already runs +`:composeApp:embedAndSignAppleFrameworkForXcode` against the `ComposeApp` +framework, deployment target 18.2. + +Known places to look first, each a guess with a reason: + +- **`FilePicker.ios.kt`** is the newest `iosMain` actual and the one behind chapter + uploads. It exists, so it compiles in principle; whether `UIDocumentPicker` + actually returns bytes on a device is untested anywhere. +- **Anything reached only from the new screens** — the `StaticInitializers` crash in + Phase 3 is call-graph-shaped, and these four lines are what widened the graph. +- **`kotlinx.datetime` and `okio` edges** in the markdown and chapter paths, which + are the usual source of a jvm-only overload that compiles everywhere but native. + +Each fix found here is its own commit, in the voice of what broke rather than +"ios fixes". + +--- + +## Phase 7 — write down what it cost + +This document, revised from a plan into a record: which of the five defects +behaved as predicted, what Phase 6 actually found, and which compiler phase is +disabled with which stack trace behind it. Then revise its row in +[docs/README.md](./README.md), which this document already carries, so it describes +what happened rather than what was planned. + +The `-Xdisable-phases` flag in particular needs a home in prose. It is a load- +bearing workaround for an upstream compiler bug, it is invisible from the source, +and the next person to bump Kotlin should be able to find out in one grep whether +they can take it out again. + +--- + +## What to do if there is no mac + +Phases 1 to 4 are worth landing anyway, and all four are verifiable on Linux for +the half that Linux compiles: + +| phase | Linux-verifiable | needs a mac | +|---|---|---| +| 1 | the fix is `if (!currentOs.isLinux)`, so "unchanged" is the whole check | that it configures at all | +| 2 | `@ConstructedBy` and all seven `@Throws` | the unused-import removal | +| 3 | the renamed tests still run; the build script still compiles | both link tasks | +| 4 | the `expect`, the android and jvm `actual`s, both call sites | the Darwin `actual`, and TLS actually working | +| 5 | nothing | nothing — it needs decisions, not a host | + +Phase 2's `@ConstructedBy` is the one with real Linux-side risk, since it changes +what Room's KSP generates for every target. Land it on its own and read +`:composeApp:jvmTest` carefully. From a8f0e26f2ae7329bdffb34ceb8d608ca25192caa Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:43:32 +0200 Subject: [PATCH 2/9] build: carry secp256k1-kmp's fix for configuring off a linux host `84cc44c` -> `2dcd4fb`, which carries secp256k1-kmp `9bcae25` down the chain. Its upstream subject says "linux cinterop sync fix" and reads backwards from here: the whole of it is behind `if (!currentOs.isLinux)`, so on this host it is a no-op, and what it actually fixes is configuring the chain on a mac. secp256k1-kmp's .def declares `staticLibraries` for every target and the cinterop tool refuses to emit a klib while the archive is absent from libraryPaths. Off a linux host the linux archives are never built -- buildSecp256k1LinuxArm64 is onlyIf(isLinux), and the linuxX64 cinterop points at native/build/linux, which only a linux host fills -- so the cinterop commonization an IDE sync resolves for linuxMain fails with "cinterop file ... does not exist". Cinterop only parses headers, so the fix leaves it enabled and satisfies the archive check with placeholder ones; compile, link and publication for the linux targets stay off. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index 84cc44c8..2dcd4fbe 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 84cc44c855e3d173e26016b807c449153a3af597 +Subproject commit 2dcd4fbe1513421d100a4485583f167b1fdcd47c From 1b8df6b44646c5c4aa763483b37ebafdff3c3cea Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:43:54 +0200 Subject: [PATCH 3/9] build: follow the chain onto the merged threshold tips `2dcd4fb` -> `db15e59`. The previous commit's fix reached this tree as a side commit on each of the three nested repositories; this moves all of them onto the `threshold` branch tips that merged it -- secp256k1-kmp `9bcae25` -> `6c7b1e4`, and bitcoin-kmp and lightning-kmp with it. Bookkeeping in appearance only. The pins the previous commit named were rebased away upstream, so a later fetch would not have found them. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index 2dcd4fbe..db15e593 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 2dcd4fbe1513421d100a4485583f167b1fdcd47c +Subproject commit db15e593864581dbe13191efd571b8657012b020 From dda2f95c1b18634b88a05d10bf80aedb2cd71417 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:44:08 +0200 Subject: [PATCH 4/9] build: register the stub archive task under a name Gradle 9 accepts `db15e59` -> `e0d2b82`, carrying secp256k1-kmp `8adf691`. The stub-archive task the previous two commits brought in was declared with `val createLinuxStubStaticLibs = tasks.registering(...)`, whose delegate provider Gradle 9 no longer takes outside a `by` delegation -- so the task was never registered under a name and the cinterop tasks had nothing to depend on. `tasks.register("createLinuxStubStaticLibs")` instead. With this the chain's five levels are where Curated's are: `e0d2b82` -> `e5b22f5d` -> `9e3c4f8` -> `8adf691` -> `7a35fde1`. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index db15e593..e0d2b823 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit db15e593864581dbe13191efd571b8657012b020 +Subproject commit e0d2b823dad95d56816da56116575e7564938d90 From 73b2c8ae9af46a7a4d1d3290d4b6b61856aa3064 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:48:43 +0200 Subject: [PATCH 5/9] feat(ios): give Room a way to construct the database without reflection `MantraDatabaseConstructor` has been in commonMain all along -- `expect`, `@Suppress`ed for having no actual, and referenced by nothing. Room's KSP only generates the actual when `@ConstructedBy` names the object, so on a target with no reflection there was no way into the database at all. This is the one annotation in the ios work that changes every other target too: the generator now emits an `actual object` for android and jvm as well, each returning `MantraDatabase_Impl()`, where before it emitted none. jvmTest is unmoved at 1174, and MantraDatabaseJvmTest's comment about the no-Context builder overload being available "because MantraDatabaseConstructor gives Room a way to instantiate without reflection" is now true rather than aspirational. Co-Authored-By: Claude Opus 5 --- .../kotlin/press/mantra/compose/database/MantraDatabase.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index c0c690ba..f7cd28cc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -2,6 +2,7 @@ package press.mantra.compose.database import androidx.room3.ColumnTypeConverters import androidx.room3.AutoMigration +import androidx.room3.ConstructedBy import androidx.room3.Database import androidx.room3.RoomDatabase import androidx.room3.immediateTransaction @@ -366,6 +367,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) AutoMigration(from = 21, to = 22), ] ) +@ConstructedBy(MantraDatabaseConstructor::class) @ColumnTypeConverters(MantraConverters::class) abstract class MantraDatabase: RoomDatabase() { abstract fun broadcastNostrEventReceiptDao(): BroadcastNostrEventReceiptDao From fc19583225fb111233d2f852e4358c8fac9bea29 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:51:13 +0200 Subject: [PATCH 6/9] feat(ios): declare publish cancellation to swift, and unshadow Platform Seven `@Throws` on the publish path now name `kotlin.coroutines.cancellation.CancellationException` alongside `NostrPublishException`. Without it a publish cancelled by the screen navigating away crosses the ObjC boundary as an unexpected throw rather than as the cancellation it is, and swift has no way to tell the two apart. Six of the seven already existed; `EventPublishTransport` had none, so the interface the group-signed path publishes through said less about itself than the implementation behind it. `Platform.ios.kt` also imported `kotlin.native.Platform`, which shadows the app's own `Platform` interface the file is there to implement. It was unused and nothing on this host compiles iosMain, so the compiler never said so. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/network/relays/EventPublishTransport.kt | 1 + .../kotlin/press/mantra/compose/network/relays/RelayPool.kt | 2 +- .../mantra/compose/network/relays/RelaysSocketManager.kt | 4 ++-- .../mantra/compose/repository/NostrPublisherRepository.kt | 6 +++--- .../src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt | 1 - 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt index a15fd5db..f269f013 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt @@ -28,5 +28,6 @@ interface EventPublishTransport { * * @see RelayPool.publishEvent */ + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishEvent(nostrEvent: NostrEvent, relays: Set): Flow } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index a260a1d7..42c3e99f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -286,7 +286,7 @@ class RelayPool( ) } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set = emptySet()): Flow { val relayUrls = relays.map { NormalizedRelayUrl(it.url).displayUrl() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index c79e7f7c..81db3293 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -100,7 +100,7 @@ class RelaysSocketManager( relayPool.closePool() } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent): Flow { return if (userRelays.isNotEmpty()) { relayPool.publishEvent( @@ -115,7 +115,7 @@ class RelaysSocketManager( } } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) override suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow { logger.d("publishEvent: $nostrEvent") return relayPool.publishEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt index ef911ca7..93bc0bfd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt @@ -30,7 +30,7 @@ class NostrPublisherRepository( } } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) private suspend fun publishAndImportEvent(signedNostrEvent: press.mantra.compose.database.model.NostrEvent, outboxRelays: List = emptyList()) { relaysSocketManager.publishEvent(signedNostrEvent) importEvent(signedNostrEvent) @@ -52,7 +52,7 @@ class NostrPublisherRepository( } } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun signPublishImportNostrEvent( unsignedNostrEvent: press.mantra.compose.database.model.UnsignedNostrEvent, outboxRelays: List, @@ -63,7 +63,7 @@ class NostrPublisherRepository( } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishRelayList(userId: String, relays: List): press.mantra.compose.database.model.NostrEvent { val signedNostrEvent = nostrNotary.signRelayListMetadata(userId = userId, relays = relays).unwrapOrThrow() relaysSocketManager.publishEvent(nostrEvent = signedNostrEvent, relays = relays.toSet()) diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt index cfaf67f8..37ba5f43 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt @@ -1,7 +1,6 @@ package press.mantra.compose import platform.UIKit.UIDevice -import kotlin.native.Platform class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion From d024838bae268a7fb2d7c69038d6aa23452ec9e3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:54:27 +0200 Subject: [PATCH 7/9] build(ios): unbreak the ios link tasks Two unrelated failures with one symptom from outside: neither `linkDebugTestIosArm64` nor `linkReleaseFrameworkIosArm64` completes. linkDebugTestIosArm64: Kotlin/Native rejects backticked test names containing a comma ("Name contains illegal characters: ','"), so the commas come out of the commonTest names -- 84 of them across 35 files. Every one was a `, `, so the sweep is a deletion and nothing else; two names where the comma was doing real work are reworded rather than jammed together ("EOSE does not end a live subscription, CLOSED does" became "a live subscription ends on CLOSED and not on EOSE"). jvmTest's own 138 are left alone: Kotlin/Native never compiles that source set, and 138 cosmetic edits do not belong in a commit about a link failure. linkReleaseFrameworkIosArm64: Kotlin 2.4.10's release backend crashes in StaticInitializersOptimization on this module's call graph, so RemoveRedundantCallsToStaticInitializersPhase is disabled for the release native links. The pass is an optimization only. It matches no task on this host, where no ios target is declared at all. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 22 +++++++++++++++++++ .../query/NostrEventFilterQueryTest.kt | 2 +- .../compose/managers/FrostSigningRoundTest.kt | 4 ++-- .../compose/managers/GroupKeyStateTest.kt | 10 ++++----- .../compose/managers/LiveInstanceCacheTest.kt | 2 +- .../managers/LiveSubscriptionPlanTest.kt | 4 ++-- .../managers/MarmotMip03CarveOutTest.kt | 2 +- .../managers/SharedKeyDerivationTest.kt | 4 ++-- .../compose/managers/SignedArtifactTest.kt | 4 ++-- .../compose/managers/SignedChapterTest.kt | 2 +- .../managers/TranslationScaffoldTest.kt | 4 ++-- .../relays/RelayPoolSubscriptionTest.kt | 4 ++-- .../network/sockets/ReconnectBackoffTest.kt | 2 +- .../nostr/DirectMessagePeerSyncTest.kt | 4 ++-- .../compose/nostr/MarmotDeliveryTest.kt | 6 ++--- .../nostr/MarmotDirectMessageDeliveryTest.kt | 2 +- .../compose/nostr/MarmotDirectMessageTest.kt | 4 ++-- .../compose/nostr/MemberProfileSyncTest.kt | 4 ++-- .../mantra/compose/nostr/SignInSyncTest.kt | 6 ++--- .../nostr/chronicle/ChronicleEventTest.kt | 18 +++++++-------- .../nostr/frost/FrostProposalCodecTest.kt | 6 ++--- .../nostr/nip30303/ArtifactEventTest.kt | 4 ++-- .../nostr/nip30303/ChapterProposalSizeTest.kt | 6 ++--- .../nostr/nip30303/ProjectEventTest.kt | 2 +- .../SubgroupBirthCertificateEventTest.kt | 14 ++++++------ .../nostr/subgroup/SubgroupLineageTest.kt | 8 +++---- .../mantra/compose/text/ChapterTextTest.kt | 4 ++-- .../mantra/compose/text/MarkdownBlockTest.kt | 2 +- .../compose/text/MarkdownProducerTest.kt | 2 +- .../mantra/compose/text/ProposedEventTest.kt | 12 +++++----- .../navigation/NavigationSuiteTest.kt | 2 +- .../ui/view/model/NavigationRoutingTest.kt | 4 ++-- .../view/model/UnsyncedProfileDecisionTest.kt | 4 ++-- .../ui/view/state/StartupChoiceTest.kt | 4 ++-- .../ui/view/state/UploadChaptersStateTest.kt | 2 +- .../compose/ui/view/state/UploadReviewTest.kt | 4 ++-- 36 files changed, 106 insertions(+), 84 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 65f10bea..2c6a928e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -158,6 +158,28 @@ kotlin { } } +/** + * Kotlin 2.4.10's release backend crashes linking this module's ios binaries: + * + * e: java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0 + * at org.jetbrains.kotlin.backend.konan.optimizations.StaticInitializersOptimization + * $InterproceduralAnalysis.getCalledInitializer(StaticInitializersOptimization.kt:244) + * + * `RemoveRedundantCallsToStaticInitializersPhase` -- an LTO pass, so only optimized links hit + * it, which is why the debug test binary links fine -- assumes every function in the call + * graph has at least one statement in its body, and something in this module's graph arrives + * with an empty one. The pass is an optimization only; disabling it costs the redundant + * initializer calls it would have removed and nothing else. + * + * Nothing matches this on a non-mac host, where no ios target is declared and so no + * KotlinNativeLink task exists. + */ +tasks.withType().configureEach { + if (name.startsWith("linkRelease")) { + toolOptions.freeCompilerArgs.add("-Xdisable-phases=RemoveRedundantCallsToStaticInitializersPhase") + } +} + android { namespace = "press.mantra.android" compileSdk = libs.versions.android.compileSdk.get().toInt() diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt index f950561a..5b79b735 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt @@ -61,7 +61,7 @@ class NostrEventFilterQueryTest { /** AND between tag names, OR between the values of one name — as `FilterMatcher` does. */ @Test - fun `tags are matched by name and value, not by substring`() { + fun `tags are matched by name and value not by substring`() { val query = NostrEventFilterQuery.build( SynchronizationFilter( kinds = arrayOf(1059), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 5a34e8aa..aa19c07f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -161,7 +161,7 @@ class FrostSigningRoundTest { } @Test - fun `one signer set signs a batch of three, and every signature verifies`() { + fun `one signer set signs a batch of three and every signature verifies`() { // The manager's batch, with the database taken out of the way: one signer // set and one tweak cache shared, and a nonce, a Session and a signature // per event. If any of that is wired up wrongly the aggregate simply @@ -571,7 +571,7 @@ class FrostSigningCompletionTest { } @Test - fun `the finished event is the one that was proposed, with a signature on it`() { + fun `the finished event is the one that was proposed with a signature on it`() { // Not rebuilt and not rehashed: the id a session is pinned to is the id // the signature is over, so anything that changed here would produce an // event whose signature verifies against nothing. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index 48892ee3..f43caf63 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -697,7 +697,7 @@ class GroupKeyStateTest { } @Test - fun `an event edited after signing fails on the id, not on the signature`() { + fun `an event edited after signing fails on the id not on the signature`() { val signed = signedByRoom() val tampered = Event( @@ -951,7 +951,7 @@ class GroupKeyStateTest { } @Test - fun `two parents land as adjacent pairs, each id before its own certificate`() { + fun `two parents land as adjacent pairs each id before its own certificate`() { // The one thing in this design that exists for *other people's* builds. A // reader that predates several parents takes the first `parent_group` and // the first `birth_certificate` and checks them against each other, so @@ -989,7 +989,7 @@ class GroupKeyStateTest { } @Test - fun `a state claiming two parents keeps both, each with its own certificate`() { + fun `a state claiming two parents keeps both each with its own certificate`() { // Two groups, two quorums, two signatures over the same child's id. The // second one signed later and could not have named the first as a // co-parent, which is why nothing requires the certificates to agree on @@ -1071,7 +1071,7 @@ class GroupKeyStateTest { } @Test - fun `an uneven number of halves is refused, not completed from the other`() { + fun `an uneven number of halves is refused not completed from the other`() { val certificate = birthCertificate() val extraParent = GroupKeyStateEvent.assembleTags( @@ -1094,7 +1094,7 @@ class GroupKeyStateTest { } @Test - fun `a half that is present and broken shifts nothing, because it is counted`() { + fun `a half that is present and broken shifts nothing because it is counted`() { // Counted by tag name as well as by successful parse, so a broken half is // told from one that was never written. Only the first would slide the // pairing along by one and pair an id with somebody else's certificate. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt index c2931f4d..c58616db 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt @@ -128,7 +128,7 @@ class LiveInstanceCacheTest { } @Test - fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking { + fun `does not run the block or cache anything when there is nothing to build`() = runBlocking { val cache = cache() var ran = false diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt index 8761f337..f0145864 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt @@ -57,7 +57,7 @@ class LiveSubscriptionPlanTest { * costs; it does not bound what arrives live. */ @Test - fun `the group filter asks for the given groups, and also carries no since`() { + fun `the group filter asks for the given groups and also carries no since`() { val filter = groupFilter(listOf("group-a", "group-b")) assertEquals(listOf(GroupEvent.KIND), filter.kinds) @@ -145,7 +145,7 @@ class LiveSubscriptionPlanTest { // --- what ends a live subscription ------------------------------------------------- @Test - fun `EOSE does not end a live subscription, CLOSED does`() { + fun `a live subscription ends on CLOSED and not on EOSE`() { assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription()) assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription()) assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription()) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt index f6059a92..75f69eb0 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt @@ -72,7 +72,7 @@ class MarmotMip03CarveOutTest { } @Test - fun `a sender with no credential identity is rejected, wrap or not`() { + fun `a sender with no credential identity is rejected wrap or not`() { // Required rather than merely compared: with no identity there is nothing to // attribute a line to, and for a gift wrap there is nothing to check the seal // against either. Both kinds must fail, not just the one that does a comparison. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt index 1e78e49c..8c027933 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt @@ -58,7 +58,7 @@ class SharedKeyDerivationTest { } @Test - fun `each step commits to the key it tweaks, so depth is not interchangeable`() { + fun `each step commits to the key it tweaks so depth is not interchangeable`() { // Index 0 applied once is not index 0 applied three times: the scalar binds // the key being tweaked, so a shorter path cannot collide with a deeper one // that happens to end on the same indices. @@ -107,7 +107,7 @@ class SharedKeyDerivationTest { } @Test - fun `a hardened path is refused, not silently walked`() { + fun `a hardened path is refused not silently walked`() { // Hardened derivation needs the parent private key, which nobody in a // threshold group has. A path claiming one was never walked, so acting on it // would mean deriving something different from what the room says. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 5cccb1dc..5a1054cc 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -157,7 +157,7 @@ class SignedArtifactTest { } @Test - fun `the artifact the group signs is authored by the group, not the proposer`() { + fun `the artifact the group signs is authored by the group not the proposer`() { val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) assertNotNull(artifact) @@ -282,7 +282,7 @@ class SignedArtifactTest { } @Test - fun `the version is a second message, signed under its own nonce`() { + fun `the version is a second message signed under its own nonce`() { // Two items of a batch are two independent FROST instances: k signatures // over k ids, never one signature stretched over both. A version whose // signature verified against the artifact's id would mean a nonce had diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt index 5397cb69..6eb24317 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt @@ -210,7 +210,7 @@ class SignedChapterTest { } @Test - fun `the chapter the group signs is authored by the group, not the proposer`() { + fun `the chapter the group signs is authored by the group not the proposer`() { val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId) assertNotNull(chapter) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt index 3beec9f7..f9e96494 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt @@ -103,7 +103,7 @@ class TranslationScaffoldTest { } @Test - fun `a catch-up keeps each chapter's own position, not the position in the gap`() { + fun `a catch-up keeps each chapter's own position not the position in the gap`() { val chapters = (0..5).map(::sourceChapter) // A catch-up proposes a subset, and the subset is not contiguous. @@ -151,7 +151,7 @@ class TranslationScaffoldTest { } @Test - fun `what is missing is matched on the chapter named, not on how many there are`() { + fun `what is missing is matched on the chapter named not on how many there are`() { val chapters = (0..3).map(::sourceChapter) // Chapter 1 never landed but chapter 2 did, which a count would read as diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt index decc69cd..f2d7ea0b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt @@ -48,7 +48,7 @@ class RelayPoolSubscriptionTest { ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind)))) @Test - fun `a query is retained, and replayed when its socket comes back`() = runTest { + fun `a query is retained and replayed when its socket comes back`() = runTest { val factory = FakeSocketClientFactory() val pool = pool(factory) @@ -66,7 +66,7 @@ class RelayPoolSubscriptionTest { } @Test - fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest { + fun `a closed query is forgotten and the socket stops reconnecting for it`() = runTest { val factory = FakeSocketClientFactory() val pool = pool(factory) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt index 0c4cb389..929471bb 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt @@ -53,7 +53,7 @@ class ReconnectBackoffTest { } @Test - fun `adds at most the jitter fraction on top, never subtracts`() { + fun `adds at most the jitter fraction on top never subtracts`() { assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0)) assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0)) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt index 1cafb423..244b199f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt @@ -22,7 +22,7 @@ class DirectMessagePeerSyncTest { // --- the DM relays: everything a chat needs ---------------------------------------- @Test - fun `one request per DM relay, asking for the three kinds a chat needs`() { + fun `one request per DM relay asking for the three kinds a chat needs`() { val requests = DirectMessagePeerSync.requests(alice) assertEquals(Relays.DefaultDMRelayList.size, requests.size) @@ -57,7 +57,7 @@ class DirectMessagePeerSyncTest { // --- the indexers: the kind 0 alone ------------------------------------------------- @Test - fun `one request per bootstrap relay, asking for the kind 0 alone`() { + fun `one request per bootstrap relay asking for the kind 0 alone`() { val requests = DirectMessagePeerSync.profileRequests(alice) assertEquals(SignInSync.bootstrapRelays.size, requests.size) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt index 5df982dc..91a265fd 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt @@ -38,7 +38,7 @@ class MarmotDeliveryTest { ) @Test - fun `a signing proposal goes out, though nothing in the chat points at it`() { + fun `a signing proposal goes out though nothing in the chat points at it`() { // The regression. A FROST message has no ChatMessage by design -- the manager // writes its own transcript lines from what arrives, so a row here would be a // second, worse account of the same thing -- and that must not be the reason the @@ -65,7 +65,7 @@ class MarmotDeliveryTest { } @Test - fun `every relay gets a request, naming the event`() { + fun `every relay gets a request naming the event`() { val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) assertContentEquals( @@ -76,7 +76,7 @@ class MarmotDeliveryTest { } @Test - fun `requests are queued pending, which is all the broadcaster looks at`() { + fun `requests are queued pending which is all the broadcaster looks at`() { // `observeBroadcastNostrEventRequestsByStatus("pending")` is the only thing that // picks these up. A request written in any other state is as unsent as no request. val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt index ee4b8473..c24402d7 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt @@ -75,7 +75,7 @@ class MarmotDirectMessageDeliveryTest { } @Test - fun `our own message is ours, even though we cannot open it`() { + fun `our own message is ours even though we cannot open it`() { // The case that destroys data if it is missed. Alice cannot decrypt the wrap she // sent -- its key was discarded -- so nothing about the payload distinguishes this // from a bystander's view. Only the MLS sender identity does. Were it to come back diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt index 1d3d2474..207246ef 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt @@ -67,7 +67,7 @@ class MarmotDirectMessageTest { } @Test - fun `a bystander gets nothing, and no exception`() { + fun `a bystander gets nothing and no exception`() { // Null rather than a throw: the caller is midway through processing a kind:445 // that carries a real message for somebody, and an exception would abandon it. assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve)) @@ -103,7 +103,7 @@ class MarmotDirectMessageTest { } @Test - fun `a seal from somebody else is still opened, and is caught by its pubkey`() { + fun `a seal from somebody else is still opened and is caught by its pubkey`() { // open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and // wrap it, and Bob's key will open it -- what stops it being rendered as Alice's // is the inbound check that the seal's pubkey is the MLS sender's identity. This diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt index b3ac1227..394af299 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt @@ -29,7 +29,7 @@ class MemberProfileSyncTest { * once their single ask comes back empty. */ @Test - fun `a placeholder counts as unknown, the same as no row at all`() { + fun `a placeholder counts as unknown the same as no row at all`() { val rooms = listOf( room( members = listOf( @@ -81,7 +81,7 @@ class MemberProfileSyncTest { } @Test - fun `one request per relay, asking those authors for kind zero`() { + fun `one request per relay asking those authors for kind zero`() { val requests = MemberProfileSync.requests(listOf(alice, bob)) assertEquals(Relays.DefaultDMRelayList.size, requests.size) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt index 9db84af3..f5e41c2f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt @@ -19,7 +19,7 @@ class SignInSyncTest { private fun relay(url: String) = RelayUrlNormalizer.normalize(url) @Test - fun `the bootstrap asks every indexer and our own relay, once each`() { + fun `the bootstrap asks every indexer and our own relay once each`() { val bootstrap = SignInSync.bootstrapRelays assertTrue(bootstrap.containsAll(Relays.DefaultIndexerRelayList), "the indexers hold everyone's kinds 0, 3 and 10002") @@ -29,7 +29,7 @@ class SignInSyncTest { } @Test - fun `one request per relay, all asking for the same things about the same key`() { + fun `one request per relay all asking for the same things about the same key`() { val relays = listOf(relay("wss://one.example"), relay("wss://two.example")) val requests = SignInSync.requests(unsignedNostrEventId = 7, publicKey = publicKey, relays = relays, level = 1) @@ -47,7 +47,7 @@ class SignInSyncTest { } @Test - fun `a relay list yields its write relays, less those already asked`() { + fun `a relay list yields its write relays less those already asked`() { val tags = arrayOf( arrayOf("r", "wss://write.example", "write"), arrayOf("r", "wss://both.example"), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt index 0bb7fa12..fec97a88 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt @@ -60,7 +60,7 @@ class ChronicleEventTest { // ---- The codec ------------------------------------------------------- @Test - fun `what is encoded is what comes back, whole`() { + fun `what is encoded is what comes back whole`() { val events = listOf( payload(DialectEvent.KIND, "isiZulu"), payload(ArtifactEvent.KIND, "an artifact"), @@ -168,7 +168,7 @@ class ChronicleEventTest { } @Test - fun `the byte cap counts bytes, not characters`() { + fun `the byte cap counts bytes not characters`() { // A multi-byte payload that fits as characters and does not as bytes. If // this were measured in characters the cap would let through up to four // times what it says. @@ -183,7 +183,7 @@ class ChronicleEventTest { // ---- What a chronicle may carry --------------------------------------- @Test - fun `the allowlist is every kind the group signs, and only those`() { + fun `the allowlist is every kind the group signs and only those`() { listOf( DialectEvent.KIND, ArtifactEvent.KIND, @@ -246,7 +246,7 @@ class ChronicleEventTest { } @Test - fun `the translated text is chroniclable, now that the group signs it`() { + fun `the translated text is chroniclable now that the group signs it`() { // Worth its own case because it was the feature's headline limitation for // as long as `saveTranslation` wrote the row and queued the member's own // rumor. A chronicle can only carry what the receiver can check, so a @@ -269,7 +269,7 @@ class ChronicleEventTest { } @Test - fun `a chronicle may not carry a key state, however well signed`() { + fun `a chronicle may not carry a key state however well signed`() { // The attack the allowlist exists for, and the reason it is an allowlist // rather than a denylist. A GroupKeyStateEvent is signed by the room and // passes verification perfectly, so a chronicle carrying an old one is a @@ -298,7 +298,7 @@ class ChronicleEventTest { } @Test - fun `a chronicle carries documents, not envelopes or protocol`() { + fun `a chronicle carries documents not envelopes or protocol`() { assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND)) assertFalse(ChronicleEvent.isChroniclable(ChronicleEvent.KIND)) assertFalse(ChronicleEvent.isChroniclable(ChronicleRequestEvent.KIND)) @@ -320,7 +320,7 @@ class ChronicleEventTest { // ---- Dependency order ------------------------------------------------ @Test - fun `the order is the foreign keys, not the kind numbers`() { + fun `the order is the foreign keys not the kind numbers`() { fun rank(kind: Int) = assertNotNull(ChronicleEvent.applyRank(kind), "kind $kind has no rank") // Every one of these is a foreign key in Room, so a chronicle applied the @@ -393,7 +393,7 @@ class ChronicleEventTest { // ---- The tags -------------------------------------------------------- @Test - fun `a page says which chronicle it is, where in it, and who for`() { + fun `a page says which chronicle it is and where in it and who for`() { val template = ChronicleEvent.build( payloads = listOf(payload(DialectEvent.KIND)), chronicleId = chronicleId, @@ -451,7 +451,7 @@ class ChronicleEventTest { // ---- What build refuses to make -------------------------------------- @Test - fun `a page is not built over either cap, or out of what it may not carry`() { + fun `a page is not built over either cap or out of what it may not carry`() { // The outbound half of both checks. It is politeness rather than // security -- the inbound one is the boundary -- but a page nobody can // read back is better failed where it was made. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt index f63474ad..4f5527d3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt @@ -28,7 +28,7 @@ class FrostProposalCodecTest { ) @Test - fun `a batch of one is the bare event, exactly as it always was`() { + fun `a batch of one is the bare event exactly as it always was`() { val one = event("just this") // The load-bearing assertion. A device on a build that predates batching @@ -49,7 +49,7 @@ class FrostProposalCodecTest { } @Test - fun `what is encoded is what comes back, in order`() { + fun `what is encoded is what comes back in order`() { val events = listOf(event("first"), event("second"), event("third")) val decoded = FrostSigningEvents.decodeProposal( @@ -65,7 +65,7 @@ class FrostProposalCodecTest { } @Test - fun `a bare event decodes as a batch of one, forever`() { + fun `a bare event decodes as a batch of one forever`() { // Proposals in the old shape do not stop arriving just because this build // stopped writing them. val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson()) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt index db212a4a..0678548e 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt @@ -61,7 +61,7 @@ class ArtifactEventTest { } @Test - fun `the proposal is the artifact's kind, so the signing screen can describe it`() { + fun `the proposal is the artifact's kind so the signing screen can describe it`() { // The session carries a kind and nothing else to go on. Get this wrong // and members are asked to sign "event of kind 30300" -- a question // nobody can answer. @@ -69,7 +69,7 @@ class ArtifactEventTest { } @Test - fun `an artifact declares one version, whatever an initializer adds`() { + fun `an artifact declares one version whatever an initializer adds`() { // addUnique, not add: two labels would leave receivers deriving two // different first versions depending on which one they read first. val template = ArtifactEvent.build( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt index ec1080fe..7ad1db41 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt @@ -144,7 +144,7 @@ class ChapterProposalSizeTest { } @Test - fun `where the fit runs out, for a chapter-shaped chapter`() { + fun `where the fit runs out for a chapter-shaped chapter`() { // Pinned so the number is on the record rather than in a comment, and so // that a change to either event's tags shows up as a moved boundary // instead of as a quorum that never arrives. Forty paragraphs is the @@ -169,7 +169,7 @@ class ChapterProposalSizeTest { } @Test - fun `the text is measured in bytes, not in characters`() { + fun `the text is measured in bytes not in characters`() { // The languages this app exists for are full of characters that are one // UTF-16 unit and three bytes. Measuring by length would under-count a // Setswana or Amharic chapter and the cap would stop binding exactly @@ -191,7 +191,7 @@ class ChapterProposalSizeTest { private val AddChapterMaxChunks: Int get() = FrostSigningManager.MAX_BATCH_SIZE - 1 @Test - fun `the chunk cap is one less than the batch, because the chapter takes a place`() { + fun `the chunk cap is one less than the batch because the chapter takes a place`() { assertEquals(FrostSigningManager.MAX_BATCH_SIZE - 1, AddChapterMaxChunks) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt index e0c2fea9..a3c2de28 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt @@ -77,7 +77,7 @@ class ProjectEventTest { // --- owners are a set, and all of them survive --- @Test - fun `three owners come back, sorted`() { + fun `three owners come back sorted`() { val event = project(setOf(owner3, owner1, owner2)) // `addUnique` here would leave one. Sorted because the id is a hash over diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index ada04fb2..5a569840 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -243,7 +243,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `a certificate carrying no parent, no key or no path is refused`() { + fun `a certificate carrying no parent no key or no path is refused`() { listOf("parent_group", "subgroup_key", "frost_path").forEach { dropped -> val incomplete = certificate( tags = SubgroupBirthCertificateEvent.assembleTags( @@ -359,7 +359,7 @@ class SubgroupBirthCertificateEventTest { // signature against a room id, and a room id is a public key. @Test - fun `in the parent's room, a certificate is about the room's own child`() { + fun `in the parent's room a certificate is about the room's own child`() { assertEquals( SubgroupManager.ArrivingCertificate.OurChild(subgroupChatRoomId), SubgroupManager.classify(parentChatRoomId, certificate()) @@ -367,7 +367,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `in the child's room, the same certificate is an offer to adopt it`() { + fun `in the child's room the same certificate is an offer to adopt it`() { // The half of an adoption the child's members would otherwise never hear // about until a proposal asked for their signature. One event, two // meanings, and the room it arrived in is the whole of the difference. @@ -398,7 +398,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `a group is not its own child, however truthfully it says so`() { + fun `a group is not its own child however truthfully it says so`() { // The one self-referential certificate `certifies` waves through: author, // subject and named parent all one room, the id deriving from the key, // the signature verifying. Refused before either branch -- read as an @@ -446,7 +446,7 @@ class SubgroupBirthCertificateEventTest { // asserting about it is where it stops. @Test - fun `an unsigned proposal addresses its child, and certifies nothing`() { + fun `an unsigned proposal addresses its child and certifies nothing`() { // What the ceremony screen is actually holding: the event as it was // assembled, before anybody signed it. Any string in `sig` serves -- // the point is that no quorum has put one there. @@ -472,7 +472,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `a forgery by another group addresses the pair it names, and certifies nothing`() { + fun `a forgery by another group addresses the pair it names and certifies nothing`() { // The reason `addresses` must never decide a lineage: anybody can author // an event naming any pair, and this says yes to it. The guard is that // nothing downstream of a lineage asks `addresses` -- only the screen @@ -560,7 +560,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `everything certifies addresses, so the weaker one cannot be the stricter`() { + fun `everything certifies addresses so the weaker one cannot be the stricter`() { // `certifies` calls `addresses` and adds to it, and this is what keeps // that true: a change that let one pass where the other refuses would // mean two different answers to "which child is this about". diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt index 7c8c31df..dd7cac9b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt @@ -29,7 +29,7 @@ class SubgroupLineageTest { } @Test - fun `one parent cannot fail, because it has nothing to repeat`() { + fun `one parent cannot fail because it has nothing to repeat`() { val lineage = SubgroupLineage.of(parentage(alpha)) assertEquals(1, lineage.size) @@ -53,14 +53,14 @@ class SubgroupLineageTest { } @Test - fun `a room id is matched case-insensitively, because it is hex`() { + fun `a room id is matched case-insensitively because it is hex`() { val lineage = SubgroupLineage.of(parentage(alpha)) assertTrue(lineage.names(alpha.uppercase())) } @Test - fun `the order is the parents' ids, whatever order they arrived in`() { + fun `the order is the parents' ids whatever order they arrived in`() { // The whole reason this class sorts: two devices assembling one lineage // from the same certificates have to emit the same bytes, or a reader // that pairs the first tag of each name gets a mismatched pair. @@ -82,7 +82,7 @@ class SubgroupLineageTest { } @Test - fun `adding a parent is an addition, not a replacement`() { + fun `adding a parent is an addition not a replacement`() { // A key state is replaceable and the newest wins wholesale, so an // adoption that carried only the new parent would silently drop the old // one. Expressing it as an addition is what stops a caller assembling diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt index aa99eaed..43709f78 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt @@ -99,7 +99,7 @@ class ChapterTextTest { } @Test - fun `utf16 is refused, because it is not supported rather than because it is binary`() { + fun `utf16 is refused because it is not supported rather than because it is binary`() { // FF FE is a UTF-16LE byte order mark and an illegal UTF-8 lead byte, so // this lands on NotText either way. Asserted so that a future lenient // decode cannot quietly turn it into a chapter of interleaved NULs. @@ -118,7 +118,7 @@ class ChapterTextTest { } @Test - fun `an empty file is text, not a failure`() { + fun `an empty file is text not a failure`() { // Whether an empty chapter may be proposed is the form's decision; decode // only says what the bytes were. assertEquals("", decoded(ByteArray(0))) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt index d272bad3..90e795b3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt @@ -30,7 +30,7 @@ class MarkdownBlockTest { } @Test - fun `the corpus is not empty, so a generator that wrote nothing fails`() { + fun `the corpus is not empty so a generator that wrote nothing fails`() { assertEquals(true, MARKDOWN_BLOCK_FIXTURES.size >= 25) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt index 455ed8a1..9be32aeb 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt @@ -76,7 +76,7 @@ class MarkdownProducerTest { } @Test - fun `a blockquote is quoted again, line by line`() { + fun `a blockquote is quoted again line by line`() { val source = "> line one\n> line two\n" // Two lines in, three out: the marker goes in front of every line, so a diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 1915ed16..b9309634 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -42,7 +42,7 @@ class ProposedEventTest { * contains and so the rest of what is being agreed to. */ @Test - fun `a chapter is named, measured, and counted`() { + fun `a chapter is named measured and counted`() { val summary = ProposedEvent.summarize(chapter(name = "The wreck"), chunkCount = 4) assertEquals("New chapter", summary.label) @@ -67,7 +67,7 @@ class ProposedEventTest { * handed -- the text here splits into two, and the batch says five. */ @Test - fun `the chunk count is the batch's, not a re-reading of the text`() { + fun `the chunk count is the batch's not a re-reading of the text`() { val summary = ProposedEvent.summarize( chapter(originalText = "One.\n\nTwo."), chunkCount = 5, @@ -77,7 +77,7 @@ class ProposedEventTest { } @Test - fun `with no batch to count, a chapter says nothing about chunks`() { + fun `with no batch to count a chapter says nothing about chunks`() { // A transcript line, or a signed event on its own: there is no batch to // ask, and guessing is what this parameter exists to stop. val summary = ProposedEvent.summarize(chapter(name = "The wreck")) @@ -86,7 +86,7 @@ class ProposedEventTest { } @Test - fun `a chunk is said as its own words, in its own place`() { + fun `a chunk is said as its own words in its own place`() { // The chunks travel in the same batch as their chapter and are read // under it, so each has to be checkable on its own. val summary = ProposedEvent.summarize( @@ -179,7 +179,7 @@ class ProposedEventTest { } @Test - fun `a project owned by somebody else says who, and not the room`() { + fun `a project owned by somebody else says who and not the room`() { val summary = ProposedEvent.summarize( project(setOf(roomId, outsider)), chatRoomId = roomId @@ -191,7 +191,7 @@ class ProposedEventTest { } @Test - fun `with no room to compare against, every owner is named`() { + fun `with no room to compare against every owner is named`() { val summary = ProposedEvent.summarize(project(setOf(roomId, outsider))) assertTrue(summary.detail.contains(roomId.take(12)), summary.detail) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt index f899c5cc..4d619abc 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt @@ -45,7 +45,7 @@ class NavigationSuiteTest { } @Test - fun `nothing is drawn off a top-level destination, at any width`() { + fun `nothing is drawn off a top-level destination at any width`() { // The component belongs on the destinations it switches between. On a chat room, a // signing screen or an onboarding step -- reached by pushing a route and left by // coming back -- a persistent bar is an invitation to lose your place. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index a8aef044..8b06cb9b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -176,7 +176,7 @@ class NavigationRoutingTest { } @Test - fun `asked from anywhere but startup, it sends the wallet up first`() = runTest { + fun `asked from anywhere but startup it sends the wallet up first`() = runTest { val viewModel = bootedOn(account(signed = true)) viewModel.loadNostrProfile(startupRoute = null) @@ -195,7 +195,7 @@ class NavigationRoutingTest { * to one state, and Landing pops the back stack. */ @Test - fun `a key just imported is sent to fetch its profile, not to make one`() = runTest { + fun `a key just imported is sent to fetch its profile not to make one`() = runTest { val viewModel = bootedOn(placeholderAccount()) viewModel.loadNostrProfile(SovereignWalletStartupRoute) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt index c543e8f4..ba6ebb11 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt @@ -99,7 +99,7 @@ class UnsyncedProfileDecisionTest { } @Test - fun `a kind 0 that has arrived, indexed or not, is never not-found`() { + fun `a kind 0 that has arrived indexed or not is never not-found`() { val event = NostrEvent( id = "e".repeat(64), pubKey = publicKey, @@ -117,7 +117,7 @@ class UnsyncedProfileDecisionTest { } @Test - fun `no account, or no request yet, is still searching`() { + fun `no account or no request yet is still searching`() { assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(null)) assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account())) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt index 1715d159..886e3ba8 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt @@ -32,7 +32,7 @@ class StartupChoiceTest { } @Test - fun `what a switch or a sign-in named is opened, even after the user once asked for the list`() { + fun `what a switch or a sign-in named is opened even after the user once asked for the list`() { assertEquals(b, resolve(desired = b.id, startImmediately = false)) } @@ -53,7 +53,7 @@ class StartupChoiceTest { } @Test - fun `a default the list no longer has, or no default, shows the list`() { + fun `a default the list no longer has or no default shows the list`() { assertNull(resolve(default = WalletId("cc".repeat(20)))) assertNull(resolve()) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt index bafb8d18..b13f9c55 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt @@ -141,7 +141,7 @@ class UploadChaptersStateTest { } @Test - fun `a failed proposal is still proposable, because a retry is a new proposal`() { + fun `a failed proposal is still proposable because a retry is a new proposal`() { val chapters = listOf(row("tried", problem = UploadProblem.ProposalFailed)) assertEquals(listOf("tried"), state(chapters).proposable.map { it.id }) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt index d910d905..5e6989a3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt @@ -49,7 +49,7 @@ class UploadReviewTest { } @Test - fun `counts are of the decoded text, not of the file`() { + fun `counts are of the decoded text not of the file`() { // CRLF in, LF counted: the row shows what would be signed. val rows = reviewed(file("a.md", "One two.\r\n\r\nThree.\r\n")) @@ -58,7 +58,7 @@ class UploadReviewTest { } @Test - fun `a file that is not text is refused, and blocks only its own row`() { + fun `a file that is not text is refused and blocks only its own row`() { val rows = reviewed( file("good.md", "Fine.\n"), PickedFile("cover.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x00, 0x0D)), From e6c3c8a2441cbd3c0f4859e1f8aa945c982efc32 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:57:59 +0200 Subject: [PATCH 8/9] fix(ios): build ktor clients on Darwin so wss/https work on a device CIO has no TLS implementation on kotlin/native, so every secure connection died at the handshake with "TLS sessions are not supported on Native platform". Pick the engine per platform -- CIO on android and the desktop jvm, Darwin on ios -- and pin both the relay socket client and coil's image fetcher to it. Coil is the half that is easy to miss. Nothing in the app configured an ImageLoader, so its default ktor fetcher built a bare `HttpClient()` and took whichever engine the classpath served first; every avatar and preview over https would have failed the same way the sockets did, with nothing in the app's own code to point at. `MantraApp` is the one place both call sites are reachable from, and the factory is installed above `MantraTheme` so it is in place before the first AsyncImage composes. The darwin dependency goes inside the existing isMacOsX gate, so nothing about resolution on a linux or windows host changes. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 6 ++++++ .../PlatformHttpClientEngine.android.kt | 6 ++++++ .../kotlin/press/mantra/compose/MantraApp.kt | 21 +++++++++++++++++++ .../network/PlatformHttpClientEngine.kt | 11 ++++++++++ .../sockets/NostrSocketClientFactory.kt | 3 ++- .../network/PlatformHttpClientEngine.ios.kt | 6 ++++++ .../network/PlatformHttpClientEngine.jvm.kt | 6 ++++++ gradle/libs.versions.toml | 1 + 8 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt create mode 100644 composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2c6a928e..9f70db05 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -153,6 +153,12 @@ kotlin { if (org.gradle.internal.os.OperatingSystem.current().isMacOsX) { iosMain.dependencies { implementation(libs.sqldelight.native.driver) + + // CIO has no TLS implementation on native ("TLS sessions are not + // supported on Native platform"), so every wss/https call it makes on + // a device fails at the handshake. Darwin (NSURLSession) is the engine + // apple targets must use. + implementation(libs.ktor.client.darwin) } } } diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt new file mode 100644 index 00000000..a1cfbe1b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO + +internal actual fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> = CIO diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt index 321545fa..324ef5da 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt @@ -5,17 +5,38 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.network.ktor3.KtorNetworkFetcherFactory +import io.ktor.client.HttpClient +import press.mantra.compose.network.platformHttpClientEngineFactory import press.mantra.compose.ui.composable.navigation.MantraNavHost import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import fr.acinq.phoenix.PhoenixGlobal +/** + * Coil's default ktor fetcher builds a bare `HttpClient()`, whose engine is whichever the + * classpath happens to serve first. With CIO that means no TLS on ios, so images over + * https fail exactly like the relay sockets did. Pin the singleton loader to the same + * platform engine the sockets use. + */ +private val imageHttpClient by lazy { HttpClient(platformHttpClientEngineFactory()) } + @Composable fun MantraApp( navController: NavHostController, mantraGlobal: MantraGlobal, phoenixGlobal: PhoenixGlobal ) { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(httpClient = { imageHttpClient })) + } + .build() + } + MantraTheme { Surface( modifier = Modifier.fillMaxSize() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt new file mode 100644 index 00000000..11d09c53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory + +/** + * The engine every Ktor client in the app is built on. CIO serves android and the desktop + * jvm; on apple targets its TLS layer does not exist (`TLS sessions are not supported on + * Native platform`), so every wss/https call fails at the handshake and ios uses Darwin + * (NSURLSession) instead. + */ +internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt index 99262b0d..860702c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt @@ -4,10 +4,11 @@ import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.WebSockets import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter import kotlinx.serialization.json.Json +import press.mantra.compose.network.platformHttpClientEngineFactory internal val defaultSocketsHttpClient by lazy { - HttpClient { + HttpClient(platformHttpClientEngineFactory()) { install(WebSockets) { contentConverter = KotlinxWebsocketSerializationConverter(Json { isLenient = true diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt new file mode 100644 index 00000000..6bbe3a40 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.darwin.Darwin + +internal actual fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> = Darwin diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt new file mode 100644 index 00000000..a1cfbe1b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO + +internal actual fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> = CIO diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34c59f78..5e6f2132 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,6 +80,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } lightning-kmp-core = { module = "fr.acinq.lightning:lightning-kmp-core", version.ref = "lightningKmpCore" } From 55c8f92cd33541e9fd131d14b44e3841ac086066 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 00:00:15 +0200 Subject: [PATCH 9/9] docs: record what the first four ios phases actually did Phases 0 to 4 are on this branch and verified on linux; 5 needs two decisions and an asset, 6 and 7 need a mac. Each phase's text is left as it was planned, with a Landed note where what happened is worth knowing. Three findings the plan did not predict. The three library bumps touch no Kotlin source anywhere in the chain -- eleven build scripts in secp256k1-kmp and nothing else -- so gradle reports jvmTest UP-TO-DATE afterwards, which reads like a stale cache and is not one. That bump also carries four fixes beyond the stub archives, the only consequential one being compileSdk 35 -> 36 in secp256k1-kmp's jni/android, below Mantra's own 37. And two of the 84 test names needed rewording by hand, where the comma had been carrying real work rather than pausing. Also records that `-Xdisable-phases` is in but unproven -- no KotlinNativeLink task exists here for it to match -- and the team id Phase 5 is expected to use, which still wants confirming. Co-Authored-By: Claude Opus 5 --- docs/ios-build.md | 86 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/ios-build.md b/docs/ios-build.md index 55e56c38..d58e0472 100644 --- a/docs/ios-build.md +++ b/docs/ios-build.md @@ -19,6 +19,29 @@ What stands in the way is five concrete defects, which Curated hit in the order build hits them — configure, compile, link, connect, sign — and fixed in eight commits. +## Where this stands + +Phases 0 to 4 are **done and on this branch**, verified on a linux host; Phase 5 +needs two decisions and an asset, and Phases 6 and 7 need a mac. Nothing below was +rewritten after the fact — each phase's text is as it was planned, with a +**Landed** note where what happened is worth knowing. + +| phase | state | commit | +|---|---|---| +| 0 bootstrap and baseline | done — 1174 + 159 tests green | (no commit) | +| 1 advance the library chain | done — linux unchanged, as predicted | `a8f0e26f`, `1b8df6b4`, `dda2f95c` | +| 2 Room constructor and cancellation | done | `73b2c8ae`, `fc195832` | +| 3 unbreak the ios link tasks | done — linux half only | `d024838b` | +| 4 ktor on Darwin | done — android and jvm halves only | `e6c3c8a2` | +| 5 signing, icon, version | **not started** — needs decisions | — | +| 6 what only a mac can find | **not started** — needs a mac | — | +| 7 write down what it cost | in progress — this section | — | + +`:composeApp:check` passes at the tip: lint, the m3 audit's ten counts, and all +three test suites — `jvmTest` 1174, `testDebugUnitTest` 563, the library's own 159. +Every count is the Phase 0 baseline, unmoved. + + ## The eight commits, and what each one is here | Curated | what it did | how it lands here | @@ -179,6 +202,24 @@ That is the phase's actual purpose. > `build: carry secp256k1-kmp's fix for configuring off a linux host` > — and say in the body that the upstream subject reads the other way round. +**Landed** as `a8f0e26f`, `1b8df6b4`, `dda2f95c`. All five levels now sit exactly +where Curated's do: `e0d2b82` → `e5b22f5d` → `9e3c4f8` → `8adf691` → `7a35fde1`. + +Two things worth knowing. First, the walk across all three bumps touches **no +Kotlin source anywhere in the chain** — the entire payload is eleven +`build.gradle.kts` and `settings.gradle.kts` files in secp256k1-kmp — so gradle +correctly reports `jvmTest UP-TO-DATE` afterwards. That reads like a stale cache +and is not one; a forced `--rerun` returns the same 1174 and 159. + +Second, the bump carries four fixes beyond the stub archives, because +`a8cc3ec..8adf691` is seven commits rather than three: the `generateHeaders` +output paths (which were `generated./jni`, with a stray dot, and whose temp +directory was being cleared with a `delete()` that silently does nothing on a +non-empty directory), Gradle 10's removal of the delegated-property DSL, `Project` +objects passed as dependency notation, and `compileSdk 35` → `36` in +secp256k1-kmp's `jni/android`. The last of those is the only one that can touch an +android build here, and it is below Mantra's own 37. + --- ## Phase 2 — declare the Room constructor and cancellation to Swift @@ -225,6 +266,19 @@ later. > `feat(ios): declare the Room constructor and publish cancellation to swift` +**Landed** as two commits rather than one — `73b2c8ae` for `@ConstructedBy` alone, +`fc195832` for the seven annotations and the import — because the first was the +only change in the plan with real linux-side risk and deserved to be readable on +its own. + +The risk was real and benign. Room's KSP now emits an `actual object +MantraDatabaseConstructor` for **android and jvm as well**, each returning +`MantraDatabase_Impl()`, where before it emitted none at all and the `expect` sat +`@Suppress`ed and unreferenced. `jvmTest` is unmoved at 1174. A side effect worth +noting: `MantraDatabaseJvmTest`'s comment about the no-`Context` builder overload +being available "because `MantraDatabaseConstructor` gives Room a way to +instantiate without reflection" was aspirational when written and is now true. + --- ## Phase 3 — unbreak the iOS link tasks @@ -303,6 +357,23 @@ renamed tests must still run, and the count must not move. > `build(ios): unbreak the ios link tasks` +**Landed** as `d024838b`, the linux-verifiable half. The sweep ran exactly as +predicted: 84 names in 35 files, 84 changed lines, every one of them a `fun` line +and no other line in the tree touched. Both renamed tests show up in the results +under their new names, under `jvmTest` and `testDebugUnitTest` alike, and the 138 +commas left in the results are `jvmTest`'s own — the set the plan leaves alone. + +Two of the 84 needed rewording by hand, where the comma had been carrying real +work rather than pausing: `EOSE does not end a live subscription, CLOSED does` +became `a live subscription ends on CLOSED and not on EOSE`, and `a page says +which chronicle it is, where in it, and who for` became `... and where in it and +who for`. The other 82 lost only a pause. + +The `-Xdisable-phases` block is in but **unproven** — no `KotlinNativeLink` task +exists on this host for it to match, so all that is verified is that the build +script still compiles. Whether this module's call graph crashes the same phase +Curated's did is Phase 6's question. + --- ## Phase 4 — put every ktor client on Darwin @@ -361,6 +432,13 @@ simulator that reaches a relay over `wss://` and renders an avatar over `https:/ > `fix(ios): build ktor clients on Darwin so wss/https work on a device` +**Landed** as `e6c3c8a2`. The android and jvm `actual`s and both call sites +compile; the Darwin `actual` and the thing the phase is actually for do not, here. +`ktor-client-darwin` 3.5.2 has never been resolved on this host — the gradle cache +holds 3.3.3 and 3.4.1 from older work and nothing newer — because the dependency +sits inside the `isMacOsX` gate. It resolves on the mac at the same `ktor` version +ref Curated built against. + --- ## Phase 5 — signing, icon, version @@ -371,9 +449,11 @@ decided. Each needs an answer from you before it can be written. **`TEAM_ID`.** [iosApp/Configuration/Config.xcconfig](../iosApp/Configuration/Config.xcconfig) has `TEAM_ID=` empty, and the bundle identifier is built from it (`press.mantra.ios$(TEAM_ID)`). Curated committed its development team id in the -clear, which is normal for an xcconfig — a team id is not a secret — but it is -*their* team id and signs builds to *their* account. Mantra needs its own, or an -`xcconfig` that stays out of git. +clear, which is normal for an xcconfig — a team id is not a secret — but it signs +builds to a particular account. The expectation is that Mantra uses **the same one +Curare does, `ZK3TQ662GB`**; that still wants confirming before it is written, +since it decides whose account the build belongs to and the bundle identifier is +derived from it. **The app icon.** Curated replaced its single `app-icon-1024.png` with a 1024 light variant and a 1024 dark one, adding the `luminosity: dark` appearance to