From bf4041a5b2445313df3166301c74900115f91a66 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:23:42 +0200 Subject: [PATCH] feat: mantra compiles for the jvm Phase 4. Declares jvm(), implements all 16 expects, and bumps the submodule to the fork branch carrying phases 1-3. :composeApp:compileKotlinJvm is green. **The actuals were the small half. Room was the blocker.** The first jvm compile failed with 58 copies of "Only suspend functions are allowed in DAOs declared in source sets targeting non-Android platforms". Room permits blocking query methods on android and nowhere else, so every @Dao function that was neither suspend nor Flow-returning had to change -- 58 of them across 24 files. KSP reports these in alphabetical batches, so the count shrinks in stages and looks bottomless; scanning the dao package directly for abstract funs with no suspend and no Flow return finds them all at once. It stops there, which is the only reason this is a 58-line change rather than a refactor. Every one of the 15 call sites outside the dao package was already inside a suspend function -- the repositories were written that way throughout -- so nothing needed rewriting. One private helper, DatabaseNostrRepository.matchNegentropicNostrEvents, had to become suspend, and its single caller was already suspend, so the cascade terminated immediately. Zero call-site edits. **The cost lands on android, not on the jvm.** A blocking DAO method runs on its caller's thread; a suspend one is dispatched to the query coroutine context, which getRoomDatabase sets to Dispatchers.IO. That is the better behaviour -- it is what stops a query running on the main thread -- but it is a real change to the shipping platform, made for a target that does not run yet. Hence the unit tests below rather than a compile alone. **BusinessManager was not an expect**, so nothing warned about it. It is now ported to the fork's jvmMain (05ce7eb); Phoenix.jvm.kt and NavigationViewModel.jvm.kt are otherwise the ios actuals with one changed import, since those files use no ios API. **schedulePlatformLogic schedules nothing, and logs that it does not.** Android starts two WorkManager jobs here, one of which is ChannelsWatcher -- it wakes periodically to notice a channel force-closed while the app was shut. A desktop application has no process once its window closes, so there is nothing to wake, and running the watcher in-process would be strictly worse than not running it: it would only fire while the app was already open and watching. The exposure is real and belongs in release notes rather than a comment -- a desktop wallet left closed past a force-close deadline does not notice. Smaller calls. PlatformContext carries an application directory, since there is no Context to read one from, and PlatformDatabaseBuilder puts aux.db under it rather than in java.io.tmpdir, which is what the abandoned Aux implementation did behind a TODO and which most systems clear on reboot. themeColorScheme ignores dynamicColor, which means Material You and has no desktop counterpart. AppVersion reads the jar manifest that compose.desktop writes, falling back when running from a class directory. Verified: :composeApp:compileKotlinJvm green, :composeApp:compileDebugKotlinAndroid green, and :composeApp:testDebugUnitTest 52 passing -- the one that matters, since this commit changes shared code every android query path goes through. Not verified: nothing has run. No jvm entry point exists yet, so the database has never been opened on this platform and no business has been started. That is phase 5, which also has to unlock JvmKeyStore before the wallet starts -- a passphrase prompt, not just a window. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 7 +- .../dao/BroadcastNostrEventReceiptDao.kt | 2 +- .../dao/BroadcastNostrEventRequestDao.kt | 4 +- .../compose/database/dao/ChatMessageDao.kt | 10 +- .../compose/database/dao/ChatRoomDao.kt | 4 +- .../compose/database/dao/ConnectionDao.kt | 2 +- .../database/dao/GiftWrapMessageDao.kt | 2 +- .../database/dao/GiftWrapPayloadDao.kt | 2 +- .../compose/database/dao/GiftWrapSealDao.kt | 2 +- .../database/dao/InReplyToRelationDao.kt | 4 +- .../database/dao/MarmotKeyPackageBundleDao.kt | 4 +- .../dao/NegentropySynchronizeRequestDao.kt | 4 +- .../dao/NegentropySynchronizeResultDao.kt | 2 +- .../compose/database/dao/NostrEventDao.kt | 26 +-- .../compose/database/dao/ParticipantDao.kt | 4 +- .../mantra/compose/database/dao/PostDao.kt | 4 +- .../mantra/compose/database/dao/ProfileDao.kt | 10 +- .../compose/database/dao/QuotedRelationDao.kt | 4 +- .../compose/database/dao/ReactionDao.kt | 2 +- .../compose/database/dao/RecentSearchDao.kt | 4 +- .../mantra/compose/database/dao/RelayDao.kt | 2 +- .../database/dao/RepostedRelationDao.kt | 6 +- .../dao/SynchronizeNostrEventRequestDao.kt | 4 +- .../dao/SynchronizeNostrEventResultDao.kt | 2 +- .../database/dao/UnsignedNostrEventDao.kt | 6 +- .../repository/DatabaseNostrRepository.kt | 2 +- .../press/mantra/compose/AppVersion.jvm.kt | 16 ++ .../press/mantra/compose/Platform.jvm.kt | 7 + .../mantra/compose/PlatformContext.jvm.kt | 47 ++++++ .../builder/PlatformDatabaseBuilder.jvm.kt | 21 +++ .../mantra/compose/extensions/Phoenix.jvm.kt | 52 ++++++ .../mantra/compose/ui/theme/Theme.jvm.kt | 17 ++ .../ui/view/model/NavigationViewModel.jvm.kt | 157 ++++++++++++++++++ docs/jvm-target.md | 59 ++++++- lightning-kmp-app | 2 +- 35 files changed, 437 insertions(+), 66 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/AppVersion.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/Platform.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/PlatformContext.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 71501a81..cf291845 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -43,7 +43,7 @@ kotlin { } } -// jvm() + jvm() sourceSets { androidMain.dependencies { @@ -125,6 +125,9 @@ kotlin { jvmMain.dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) + + // The jvm counterpart to sqldelight-android-driver / -native-driver above. + implementation(libs.sqldelight.sqlite.driver) } // Only exists when the ios targets above were declared; the default hierarchy template // creates this source set from them. @@ -191,7 +194,7 @@ dependencies { // add("kspIosX64", libs.androidx.room.compiler) add("kspIosArm64", libs.androidx.room3.compiler) } -// add("kspJvm", libs.androidx.room3.compiler) + add("kspJvm", libs.androidx.room3.compiler) // Add any other platform target you use in your project, for example kspDesktop } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventReceiptDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventReceiptDao.kt index 730db07c..07fde1f6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventReceiptDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventReceiptDao.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface BroadcastNostrEventReceiptDao { @Query("SELECT * FROM BroadcastNostrEventReceipt") - fun getAllBroadcastNostrEventReceipts(): List + suspend fun getAllBroadcastNostrEventReceipts(): List @Query("SELECT * FROM BroadcastNostrEventReceipt WHERE id = :id") fun getBroadcastNostrEventReceiptById(id: Long): Flow diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventRequestDao.kt index 0f625335..97175d4e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/BroadcastNostrEventRequestDao.kt @@ -12,10 +12,10 @@ import kotlin.time.Instant @Dao interface BroadcastNostrEventRequestDao { @Query("SELECT * FROM BroadcastNostrEventRequest") - fun getAllBroadcastNostrEventRequests(): List + suspend fun getAllBroadcastNostrEventRequests(): List @Query("SELECT * FROM BroadcastNostrEventRequest WHERE nostrEventId = :nostrEventId ORDER BY createdAt ASC") - fun getFirstBroadcastNostrEventRequestByNostrEventId(nostrEventId: String): BroadcastNostrEventRequest? + suspend fun getFirstBroadcastNostrEventRequestByNostrEventId(nostrEventId: String): BroadcastNostrEventRequest? // See NegentropySynchronizeRequestDao: the old `createdAt > :createdAt` bound a // once-evaluated `Clock.System.now()` for the life of the Flow. Because Instants are diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt index daa45b01..1758a1a9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt @@ -16,19 +16,19 @@ interface ChatMessageDao { @Transaction @Query("SELECT * FROM ChatMessage WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") - fun getChatMessagesByChatRoomId(chatRoomId: String): List + suspend fun getChatMessagesByChatRoomId(chatRoomId: String): List @Query("SELECT * FROM ChatMessage WHERE giftWrapPayloadId = :giftWrapPayloadId ORDER BY createdAt DESC") - fun getChatMessagesByGiftWrapPayloadId(giftWrapPayloadId: HexKey): ChatMessage? + suspend fun getChatMessagesByGiftWrapPayloadId(giftWrapPayloadId: HexKey): ChatMessage? @Query("SELECT * FROM ChatMessage WHERE marmotInnerEventId = :marmotInnerEventId ORDER BY createdAt DESC") - fun getChatMessagesByMarmotInnerEventId(marmotInnerEventId: HexKey): ChatMessage? + suspend fun getChatMessagesByMarmotInnerEventId(marmotInnerEventId: HexKey): ChatMessage? @Query("SELECT * FROM ChatMessage WHERE marmotGroupEventId = :marmotGroupEventId ORDER BY createdAt DESC") - fun getChatMessagesByMarmotGroupEventId(marmotGroupEventId: HexKey): ChatMessage? + suspend fun getChatMessagesByMarmotGroupEventId(marmotGroupEventId: HexKey): ChatMessage? @Query("SELECT COUNT(*) FROM ChatMessage WHERE chatRoomId = :chatRoomId AND senderPublicKey = :senderPublicKey") - fun countChatMessagesBySenderPublicKey(chatRoomId: HexKey, senderPublicKey: HexKey): Int + suspend fun countChatMessagesBySenderPublicKey(chatRoomId: HexKey, senderPublicKey: HexKey): Int @Upsert suspend fun upsert(chatMessage: press.mantra.compose.database.model.ChatMessage): Long diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt index cfa6006b..ba65001c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt @@ -13,11 +13,11 @@ import kotlinx.coroutines.flow.Flow interface ChatRoomDao { @Transaction @Query("SELECT * FROM ChatRoom WHERE id = :id AND deletedAt IS NULL") - fun findChatRoomById(id: String): LocalChatRoom? + suspend fun findChatRoomById(id: String): LocalChatRoom? @Transaction @Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL") - fun getChatRoomListByUserPublicKey(userPublicKey: String): List + suspend fun getChatRoomListByUserPublicKey(userPublicKey: String): List @Transaction @Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ConnectionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ConnectionDao.kt index 4399eabd..2c73acb2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ConnectionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ConnectionDao.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface ConnectionDao { @Query("SELECT * FROM Connection LIMIT :limit") - fun getAllConnections(limit: Int = 21): List + suspend fun getAllConnections(limit: Int = 21): List @Query("SELECT * FROM Connection WHERE (sourcePublicKey = :alicePublicKey AND destinationPublicKey = :bobPublicKey) OR (sourcePublicKey = :bobPublicKey AND destinationPublicKey = :alicePublicKey)") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt index 7b71babd..19ff15f2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt @@ -7,7 +7,7 @@ import androidx.room3.Upsert @Dao interface GiftWrapMessageDao { @Query("SELECT * FROM GiftWrapMessage") - fun getAllGiftWrapMessages(): List + suspend fun getAllGiftWrapMessages(): List @Upsert suspend fun upsert(giftWrapMessage: press.mantra.compose.database.model.GiftWrapMessage) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt index f881170d..1ff37322 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface GiftWrapPayloadDao { @Query("SELECT * FROM GiftWrapPayload") - fun getAllGiftWrapUnsigned(): List + suspend fun getAllGiftWrapUnsigned(): List @Query("SELECT * FROM GiftWrapPayload WHERE publicKey = :publicKey AND giftWrapSealId IS NULL") fun observeUnsealedGiftWrapPayloads(publicKey: String): Flow diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt index e939d480..a551c40a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt @@ -7,7 +7,7 @@ import androidx.room3.Upsert @Dao interface GiftWrapSealDao { @Query("SELECT * FROM GiftWrapSeal") - fun getAllGiftWrapSeals(): List + suspend fun getAllGiftWrapSeals(): List @Upsert suspend fun upsert(giftWrapSeal: press.mantra.compose.database.model.GiftWrapSeal) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/InReplyToRelationDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/InReplyToRelationDao.kt index a447a22f..df8c2ad8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/InReplyToRelationDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/InReplyToRelationDao.kt @@ -8,10 +8,10 @@ import androidx.room3.Upsert @Dao interface InReplyToRelationDao { @Query("SELECT * FROM InReplyToRelation WHERE replyingNostrEventId = :replyingNostrEventId") - fun getRepostByReplyingNostrEventId(replyingNostrEventId: String): press.mantra.compose.database.model.InReplyToRelation? + suspend fun getRepostByReplyingNostrEventId(replyingNostrEventId: String): press.mantra.compose.database.model.InReplyToRelation? @Query("SELECT * FROM InReplyToRelation WHERE inReplyToNostrEventId = :inReplyToNostrEventId") - fun getRepostByInReplyToNostrEventId(inReplyToNostrEventId: String): press.mantra.compose.database.model.InReplyToRelation? + suspend fun getRepostByInReplyToNostrEventId(inReplyToNostrEventId: String): press.mantra.compose.database.model.InReplyToRelation? @Insert suspend fun insert(inReplyToRelation: press.mantra.compose.database.model.InReplyToRelation) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageBundleDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageBundleDao.kt index a42223d1..9fd408ae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageBundleDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageBundleDao.kt @@ -16,10 +16,10 @@ interface MarmotKeyPackageBundleDao { fun observeAllMarmotKeyPackageBundles(publicKey: HexKey): Flow> @Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey ORDER BY createdAt DESC") - fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List + suspend fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List @Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :id ORDER BY createdAt DESC") - fun getMarmotKeyPackageBundleById(id: String): MarmotKeyPackageBundle? + suspend fun getMarmotKeyPackageBundleById(id: String): MarmotKeyPackageBundle? @Upsert suspend fun upsert(marmotKeyPackageBundle: MarmotKeyPackageBundle) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt index cb0ee4cd..04298523 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt @@ -18,7 +18,7 @@ interface NegentropySynchronizeRequestDao { fun observeNegentropySynchronizeRequestByPurposeAndStatusCount(purpose: String, status: List): Flow @Upsert - fun upsert(negentropySynchronizeRequest: NegentropySynchronizeRequest) + suspend fun upsert(negentropySynchronizeRequest: NegentropySynchronizeRequest) /** * Upsert rather than insert-or-ignore. `computeId` buckets by minute, so an identical @@ -28,5 +28,5 @@ interface NegentropySynchronizeRequestDao { * hit the network, while still collapsing duplicates into a single row. */ @Upsert - fun insert(negentropySynchronizeRequests: List) + suspend fun insert(negentropySynchronizeRequests: List) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeResultDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeResultDao.kt index bb51e37f..dea93bec 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeResultDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeResultDao.kt @@ -6,5 +6,5 @@ import androidx.room3.Upsert @Dao interface NegentropySynchronizeResultDao { @Upsert - fun upsert(negentropySynchronizeResult: press.mantra.compose.database.model.NegentropySynchronizeResult) + suspend fun upsert(negentropySynchronizeResult: press.mantra.compose.database.model.NegentropySynchronizeResult) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index 1066c814..e813241b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -73,13 +73,13 @@ interface NostrEventDao { * constraints they had no parameter for. */ @RawQuery - fun getNostrEventsMatchingFilter( + suspend fun getNostrEventsMatchingFilter( query: RoomRawQuery ): List @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getNostrEvents( + suspend fun getNostrEvents( kinds: Array, since: Instant, limit: Int @@ -87,7 +87,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE content LIKE '%' || :search || '%' AND kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getFilteredNostrEvents( + suspend fun getFilteredNostrEvents( kinds: Array, search: String, since: Instant, @@ -96,7 +96,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND id in (:ids) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getFilteredNostrEvents( + suspend fun getFilteredNostrEvents( kinds: Array, ids: Array, since: Instant, @@ -105,7 +105,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE id in (:ids) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getNostrEventsByIds( + suspend fun getNostrEventsByIds( ids: Array, since: Instant, limit: Int @@ -114,7 +114,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE kind = ${GroupEvent.KIND} AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getMLSGroupEvents( + suspend fun getMLSGroupEvents( since: Instant, limit: Int ): List @@ -135,7 +135,7 @@ interface NostrEventDao { "AND (expiresAt IS NULL OR expiresAt > :now) " + "ORDER BY createdAt DESC LIMIT :limit" ) - fun getMarmotGroupEvents( + suspend fun getMarmotGroupEvents( chatRoomIds: Array, since: Instant, until: Instant, @@ -145,7 +145,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND pubKey in (:authors) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getAuthoredNostrEvents( + suspend fun getAuthoredNostrEvents( kinds: Array, authors: Array, since: Instant, @@ -154,7 +154,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE tags LIKE '%' || :publicKey || '%' AND kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getPublicKeyMentionedNostrEvents( + suspend fun getPublicKeyMentionedNostrEvents( kinds: Array, publicKey: HexKey, since: Instant, @@ -163,7 +163,7 @@ interface NostrEventDao { @Transaction @Query("SELECT * FROM NostrEvent WHERE tags LIKE '%' || :eventId || '%reply%' AND kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") - fun getNostrEventReplies( + suspend fun getNostrEventReplies( kinds: Array, eventId: HexKey, since: Instant, @@ -171,17 +171,17 @@ interface NostrEventDao { ): List @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) ORDER BY savedAt DESC") - fun getAllNostrEvents(kinds: Array): List + suspend fun getAllNostrEvents(kinds: Array): List @Transaction @Query("SELECT * FROM NostrEvent WHERE id = :id") fun observeNostrEventById(id: String): Flow @Query("SELECT * FROM NostrEvent WHERE id = :id") - fun getNostrEventById(id: String): NostrEvent? + suspend fun getNostrEventById(id: String): NostrEvent? @Query("SELECT * FROM NostrEvent WHERE pubKey = :publicKey AND kind = :kind ORDER BY createdAt DESC") - fun getNostrEventByPublicKeyAndKind(publicKey: HexKey, kind: Kind): NostrEvent? + suspend fun getNostrEventByPublicKeyAndKind(publicKey: HexKey, kind: Kind): NostrEvent? @Upsert suspend fun upsert(nostrEvent: NostrEvent) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ParticipantDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ParticipantDao.kt index fc681765..85c21cfb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ParticipantDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ParticipantDao.kt @@ -9,10 +9,10 @@ import press.mantra.compose.database.model.Participant @Dao interface ParticipantDao { @Query("SELECT * FROM Participant WHERE participantPublicKey = :participantPublicKey") - fun findParticipantByPublicKey(participantPublicKey: String): List + suspend fun findParticipantByPublicKey(participantPublicKey: String): List @Query("SELECT * FROM Participant WHERE chatRoomId = :chatRoomId") - fun findParticipantsByChatRoomId(chatRoomId: String): List + suspend fun findParticipantsByChatRoomId(chatRoomId: String): List @Upsert suspend fun upsert(participants: List) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/PostDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/PostDao.kt index 77ef09e6..2beedae8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/PostDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/PostDao.kt @@ -8,10 +8,10 @@ import androidx.room3.Upsert @Dao interface PostDao { @Query("SELECT * FROM Post") - fun getAllPosts(): List + suspend fun getAllPosts(): List @Query("SELECT * FROM Post WHERE id = :id") - fun getPostById(id: String): press.mantra.compose.database.model.Post? + suspend fun getPostById(id: String): press.mantra.compose.database.model.Post? @Insert suspend fun insert(post: press.mantra.compose.database.model.Post) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ProfileDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ProfileDao.kt index 91ba8e5c..13fbad43 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ProfileDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ProfileDao.kt @@ -13,26 +13,26 @@ import kotlin.time.Instant @Dao interface ProfileDao { @Query("SELECT * FROM Profile WHERE createdAt > :createdAt LIMIT :limit") - fun getAllProfiles(limit: Int = 21, createdAt: Instant = GENESIS_AT): List + suspend fun getAllProfiles(limit: Int = 21, createdAt: Instant = GENESIS_AT): List @Query("SELECT * FROM Profile WHERE publicKey NOT IN (:excludedPublicKeys) AND createdAt > :createdAt LIMIT :limit") - fun getAllProfilesExcludingOnesWithThesePublicKeys( + suspend fun getAllProfilesExcludingOnesWithThesePublicKeys( excludedPublicKeys: Array, limit: Int = 21, createdAt: Instant = GENESIS_AT ): List @Query("SELECT * FROM NostrEvent WHERE kind = 0 AND content LIKE '%' || :searchTerm || '%' LIMIT :limit") - fun filterProfiles(searchTerm: String, limit: Int = 21): List + suspend fun filterProfiles(searchTerm: String, limit: Int = 21): List @Query("SELECT * FROM Profile WHERE publicKey = :publicKey") - fun getProfileByPublicKey(publicKey: String): Profile? + suspend fun getProfileByPublicKey(publicKey: String): Profile? @Query("SELECT * FROM Profile WHERE publicKey = :publicKey") fun observeProfileByPublicKey(publicKey: String): Flow @Query("SELECT * FROM Profile WHERE publicKey IN (:publicKeys)") - fun getProfileByPublicKeys(publicKeys: List): List + suspend fun getProfileByPublicKeys(publicKeys: List): List @Query("SELECT * FROM NostrEvent WHERE kind = 0 AND pubKey = :publicKey") fun observeProfileWithFollowersByPublicKey(publicKey: String): Flow diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/QuotedRelationDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/QuotedRelationDao.kt index 0913b55f..f29bc86d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/QuotedRelationDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/QuotedRelationDao.kt @@ -8,10 +8,10 @@ import androidx.room3.Upsert @Dao interface QuotedRelationDao { @Query("SELECT * FROM QuotedRelation WHERE quotingNostrEventId = :quotingNostrEventId") - fun getRepostByQuotingNostrEventId(quotingNostrEventId: String): press.mantra.compose.database.model.QuotedRelation? + suspend fun getRepostByQuotingNostrEventId(quotingNostrEventId: String): press.mantra.compose.database.model.QuotedRelation? @Query("SELECT * FROM QuotedRelation WHERE quotedNostrEventId = :quotedNostrEventId") - fun getRepostByQuotedNostrEventId(quotedNostrEventId: String): press.mantra.compose.database.model.QuotedRelation? + suspend fun getRepostByQuotedNostrEventId(quotedNostrEventId: String): press.mantra.compose.database.model.QuotedRelation? @Insert suspend fun insert(quotedRelation: press.mantra.compose.database.model.QuotedRelation) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ReactionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ReactionDao.kt index 7f6c26c9..f4c41430 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ReactionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ReactionDao.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface ReactionDao { @Query("SELECT * FROM Reaction") - fun getAllReactions(): List + suspend fun getAllReactions(): List @Query("SELECT * FROM Reaction WHERE id = :id") fun getReactionById(id: String): Flow diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RecentSearchDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RecentSearchDao.kt index dc87b1aa..07d4be44 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RecentSearchDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RecentSearchDao.kt @@ -12,10 +12,10 @@ interface RecentSearchDao { fun observeRecentSearchByQuery(query: String): Flow @Query("SELECT * FROM RecentSearch ORDER BY createdAt DESC LIMIT :limit ") - fun getAllRecentSearches(limit: Int = 21): List + suspend fun getAllRecentSearches(limit: Int = 21): List @Delete - fun deleteRecentSearch(recentSearch: press.mantra.compose.database.model.RecentSearch) + suspend fun deleteRecentSearch(recentSearch: press.mantra.compose.database.model.RecentSearch) @Upsert suspend fun upsert(recentSearch: press.mantra.compose.database.model.RecentSearch) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RelayDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RelayDao.kt index 629616e9..65feae1a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RelayDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RelayDao.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface RelayDao { @Query("SELECT * FROM Relay") - fun getAllPosts(): List + suspend fun getAllPosts(): List @Query("SELECT * FROM Relay WHERE publicKey = :publicKey") fun observePublicKeyRelays(publicKey: String): Flow> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RepostedRelationDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RepostedRelationDao.kt index 7a47218f..bab0d88b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RepostedRelationDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/RepostedRelationDao.kt @@ -7,13 +7,13 @@ import androidx.room3.Upsert @Dao interface RepostedRelationDao { @Query("SELECT * FROM RepostedRelation") - fun getAllReposts(): List + suspend fun getAllReposts(): List @Query("SELECT * FROM RepostedRelation WHERE repostingNostrEventId = :repostingNostrEventId") - fun getRepostByRepostingId(repostingNostrEventId: String): press.mantra.compose.database.model.RepostedRelation? + suspend fun getRepostByRepostingId(repostingNostrEventId: String): press.mantra.compose.database.model.RepostedRelation? @Query("SELECT * FROM RepostedRelation WHERE repostedNostrEventId = :repostedNostrEventId") - fun getRepostByRepostedId(repostedNostrEventId: String): press.mantra.compose.database.model.RepostedRelation? + suspend fun getRepostByRepostedId(repostedNostrEventId: String): press.mantra.compose.database.model.RepostedRelation? @Upsert suspend fun upsert(repostedRelation: press.mantra.compose.database.model.RepostedRelation) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt index 36e2e00c..8620b38d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt @@ -18,8 +18,8 @@ interface SynchronizeNostrEventRequestDao { fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String, status: List): Flow @Upsert - fun upsert(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) + suspend fun upsert(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) @Insert - fun insert(synchronizeNostrEventRequests: List) + suspend fun insert(synchronizeNostrEventRequests: List) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventResultDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventResultDao.kt index 9e5fa4f1..036efaa6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventResultDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventResultDao.kt @@ -6,5 +6,5 @@ import androidx.room3.Upsert @Dao interface SynchronizeNostrEventResultDao { @Upsert - fun upsert(synchronizeNostrEventResult: press.mantra.compose.database.model.SynchronizeNostrEventResult) + suspend fun upsert(synchronizeNostrEventResult: press.mantra.compose.database.model.SynchronizeNostrEventResult) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt index 926231cd..3a23775b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt @@ -12,10 +12,10 @@ import press.mantra.compose.database.model.UnsignedNostrEvent @Dao interface UnsignedNostrEventDao { @Query("SELECT * FROM UnsignedNostrEvent") - fun getAUnsignedNostrEvents(): List + suspend fun getAUnsignedNostrEvents(): List @Query("SELECT * FROM UnsignedNostrEvent") // TODO: where nostrEvent.unsignedNostrEventId is 0 - fun getAllUnprocessedUnsignedNostrEvents(): List + suspend fun getAllUnprocessedUnsignedNostrEvents(): List @Query("SELECT * FROM UnsignedNostrEvent WHERE id = :id") fun getUnsignedNostrEventById(id: Long): Flow @@ -32,7 +32,7 @@ interface UnsignedNostrEventDao { @Transaction @Query("SELECT * FROM UnsignedNostrEvent WHERE kind = 0") - fun getLocalAccounts(): List + suspend fun getLocalAccounts(): List @Query("SELECT * FROM UnsignedNostrEvent WHERE pubKey = :publicKey AND signedAt IS NULL ORDER BY kind ASC") fun observeUnsignedNostrEvents(publicKey: String): Flow diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 27cfca37..39581e60 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -647,7 +647,7 @@ class DatabaseNostrRepository( .distinctBy { it.id } } - private fun matchNegentropicNostrEvents( + private suspend fun matchNegentropicNostrEvents( synchronizationFilter: SynchronizationFilter, applyLimits: Boolean, ): List { diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/AppVersion.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/AppVersion.jvm.kt new file mode 100644 index 00000000..a5e0d987 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/AppVersion.jvm.kt @@ -0,0 +1,16 @@ +package press.mantra.compose + +/** + * Android reads these from `BuildConfig`, which the android plugin generates. There is no + * equivalent for the jvm target, so this reads the jar manifest that `compose.desktop` + * writes when it packages a distribution, and falls back when running from a class + * directory -- from gradle, or a test -- where no manifest exists. + */ +actual object AppVersion { + actual val versionName: String + get() = AppVersion::class.java.`package`?.implementationVersion ?: "0.0.0-dev" + + /** No monotonic build number exists here the way `versionCode` does on android. */ + actual val versionCode: String + get() = "0" +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/Platform.jvm.kt new file mode 100644 index 00000000..8763703e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/Platform.jvm.kt @@ -0,0 +1,7 @@ +package press.mantra.compose + +class JvmPlatform : Platform { + override val name: String = "Java ${System.getProperty("java.version")}" +} + +actual fun getPlatform(): Platform = JvmPlatform() diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/PlatformContext.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/PlatformContext.jvm.kt new file mode 100644 index 00000000..208a1a97 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/PlatformContext.jvm.kt @@ -0,0 +1,47 @@ +package press.mantra.compose + +import java.io.File +import java.util.Locale + +/** + * Android wraps a `Context`, which is where the database and files directories come from. + * The jvm has no such object, so the location is carried explicitly. + * + * This is deliberately *not* the same class as `fr.acinq.phoenix.utils.PlatformContext`, + * matching how android and ios keep the two apart -- `MainActivity` and + * `MainViewController` each construct both. A desktop entry point should hand the same + * directory to both so one install keeps one place on disk. + */ +actual class PlatformContext( + val applicationDir: File = defaultMantraDir(), +) + +/** + * The conventional per-user application data directory for the host os. + * + * Pointedly not `java.io.tmpdir`, which is what the abandoned Aux implementation used + * behind a TODO: most systems clear it on reboot, and the chat history and marmot group + * state live here. + */ +internal fun defaultMantraDir(): File { + val home = File(System.getProperty("user.home")) + val os = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) + return when { + os.contains("win") -> + (System.getenv("LOCALAPPDATA") ?: System.getenv("APPDATA")) + ?.takeIf { it.isNotBlank() } + ?.let { File(it, APP_DIR_NAME) } + ?: File(home, "AppData/Local/$APP_DIR_NAME") + + os.contains("mac") || os.contains("darwin") -> + File(home, "Library/Application Support/$APP_DIR_NAME") + + else -> + System.getenv("XDG_DATA_HOME") + ?.takeIf { it.isNotBlank() } + ?.let { File(it, APP_DIR_NAME) } + ?: File(home, ".local/share/$APP_DIR_NAME") + } +} + +private const val APP_DIR_NAME = "mantra" diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.jvm.kt new file mode 100644 index 00000000..423ff6f3 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.jvm.kt @@ -0,0 +1,21 @@ +package press.mantra.compose.database.builder + +import androidx.room3.Room +import androidx.room3.RoomDatabase +import press.mantra.compose.PlatformContext +import press.mantra.compose.database.MantraDatabase +import java.io.File + +actual object PlatformDatabaseBuilder { + actual fun getDatabaseBuilder(platformContext: PlatformContext): RoomDatabase.Builder { + // Under the context's application directory, not java.io.tmpdir -- which is what the + // abandoned Aux implementation did behind a TODO, and which most systems clear on + // reboot. The file keeps android's name so one app does not have two on disk. + val dbFile = File(platformContext.applicationDir.apply { mkdirs() }, "aux.db") + return Room.databaseBuilder(name = dbFile.absolutePath) + } + + actual fun getInMemoryDatabaseBuilder(platform: PlatformContext): RoomDatabase.Builder { + return Room.inMemoryDatabaseBuilder() + } +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt new file mode 100644 index 00000000..d47aa7ec --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt @@ -0,0 +1,52 @@ +package press.mantra.compose.extensions + +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.Chain +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.StartBusinessResult +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +/** + * `Dispatchers.Default`, following android rather than ios -- ios hops to Main, and there is + * no reason for the jvm to start a lightning node on the ui thread. + */ +actual suspend fun platformStartupLogic(words: List): StartBusinessResult { + return withContext(Dispatchers.Default) { + BusinessManager.startNewBusiness(words, isHeadless = false) + } +} + +/** + * **Nothing is scheduled, and that has a consequence worth stating.** + * + * Android schedules two WorkManager jobs here: `ChannelsWatcher`, which wakes periodically + * to check whether a channel has been force-closed while the app was shut, and + * `ContactsPhotoCleaner`. Ios schedules neither and logs that it does not. + * + * The jvm has no equivalent, and not for want of an api -- a desktop application simply has + * no process once its window closes, so there is nothing for a scheduler to wake. Running + * the watcher in-process would be worse than not running it: it would only ever fire while + * the app was already open and watching anyway, which is the case that needs no help. + * + * The exposure is real. A desktop wallet that stays closed past a force-close deadline does + * not notice. Anything that closes it -- a system daemon, a scheduled task -- has to live + * outside this process, which is why this logs rather than pretending. + */ +actual fun schedulePlatformLogic(phoenixGlobal: PhoenixGlobal) { + Logger.withTag("schedulePlatformLogic").w { + "no background scheduling on the jvm target: channels are only watched while the app is open" + } +} + +actual fun getShowIntroFlow(phoenixGlobal: PhoenixGlobal): Flow { + return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro +} + +actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs { + return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet() +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt new file mode 100644 index 00000000..c5d5e191 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt @@ -0,0 +1,17 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** + * `dynamicColor` is ignored: it means Material You, which reads a wallpaper-derived palette + * from the android system and has no desktop counterpart. The app's own schemes are used + * whatever the caller asks for. + */ +@Composable +actual fun themeColorScheme( + darkTheme: Boolean, + dynamicColor: Boolean, + darkScheme: ColorScheme, + lightScheme: ColorScheme +): ColorScheme = if (darkTheme) darkScheme else lightScheme diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt new file mode 100644 index 00000000..62d6645a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -0,0 +1,157 @@ +// The ios actuals with one import changed. That file uses no ios API -- SeedManager, +// DataStoreManager, EncryptedSeed and LocalKeyManager are all commonMain -- so the only +// thing that had to move was BusinessManager, which is now ported to the library's jvmMain. +// +// Kept as a copy for the same reason the ported BusinessManager is: sharing it means an +// intermediate source set between iosMain and jvmMain, which is a change to how the module +// is wired rather than to what it does. + +package press.mantra.compose.ui.view.model + +import press.mantra.compose.AppVersion +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.DecryptSeedResult +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + + +actual fun updateBusinessActiveInUI(walletId: WalletId) { + BusinessManager.updateBusinessActiveInUI(walletId) +} + +actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { + return SeedManager.loadAndDecrypt( + phoenixGlobal + ) +} + +actual fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> { + return DataStoreManager( + phoenixGlobal.ctx, + chain = Chain.Mainnet + ).loadGlobalPrefsForWallet().getAvailableWalletsMeta +} + +actual suspend fun saveAvailableWalletMeta( + phoenixGlobal: PhoenixGlobal, + metadata: UserWalletMetadata +) { + DataStoreManager( + phoenixGlobal.ctx, + chain = Chain.Mainnet + ).loadGlobalPrefsForWallet().saveAvailableWalletMeta(metadata) +} + +actual suspend fun saveAvailableWalletMeta( + phoenixGlobal: PhoenixGlobal, + walletId: WalletId, + name: String?, + avatar: String, + isHidden: Boolean +) { + DataStoreManager( + phoenixGlobal.ctx, + chain = Chain.Mainnet + ).loadGlobalPrefsForWallet().saveAvailableWalletMeta( + walletId = walletId, + name = name, + avatar = avatar, + isHidden = isHidden + ) +} + +actual fun platformWriteSeed( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + writingState: WritingSeedState, + viewModelScope: CoroutineScope, + mnemonics: List, + onWritingSeedError: (WritingSeedState.Error) -> Unit, + onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, + isRestoringWallet: Boolean, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + onSeedWritten: (WalletId) -> Unit +) { + if (writingState !is WritingSeedState.Init) return + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.e("failed to write mnemonics to disk: ${e.message}") + onWritingSeedError.invoke( + WritingSeedState.Error.Generic(e) + ) + }) { + log.d("writing mnemonics to disk...") + + onWritingSeedStateWriting.invoke( + WritingSeedState.Writing(mnemonics) + ) + val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { + it.key to it.value.words + }?.toMap() + + val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() + val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) + val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + + when { + existingSeeds == null -> { + log.e("could not load the existing seed map, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.CannotLoadSeedMap + ) + return@launch + } + existingSeeds.containsKey(newWalletId) -> { + log.i("attempting to import a seed that already exists, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.SeedAlreadyExists + ) + return@launch + } + else -> { + val newSeedMap = existingSeeds + (newWalletId to mnemonics) + val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) + SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) + if (isRestoringWallet) { + log.i("successfully restored wallet=$newWalletId") + } else { + log.i("successfully created wallet=$newWalletId") + } + } + } + + globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) + val dataStoreManager = DataStoreManager( + phoenixGlobal.ctx, + chain = NodeParamsManager.chain, + ) + val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) + userPrefs.saveIsTorEnabled(isTorEnabled) + userPrefs.saveElectrumServer(customElectrumServer) + + viewModelScope.launch(Dispatchers.Main) { + delay(1000) + onSeedWritten(newWalletId) + } + } +} \ No newline at end of file diff --git a/docs/jvm-target.md b/docs/jvm-target.md index a9314379..e5368ddc 100644 --- a/docs/jvm-target.md +++ b/docs/jvm-target.md @@ -424,6 +424,37 @@ Those two go together: the KSP configuration does not exist until the target does, which is why Phase 0 deliberately left it alone. Then let the compiler drive. +### Before any of that: Room will not generate a DAO for a non-android target + +**This is the real content of Phase 4, and it is not the actuals.** The first jvm +compile fails with 58 copies of: + +``` +Only suspend functions are allowed in DAOs declared in source sets targeting +non-Android platforms. +``` + +Room permits blocking query methods **only** on android. Every `@Dao` function that +is neither `suspend` nor `Flow`-returning has to change, and there were 58 of them +across 25 files in `database/dao/`. KSP reports them in alphabetical batches, so the +count shrinks in stages and looks endless; scan for them directly instead — an +abstract `fun` in a `@Dao` that has no `suspend` and no `Flow<...>` return. + +The saving grace is that it stops there. All 15 call sites outside the DAO layer +were **already inside `suspend` functions** — the repositories were written that way +throughout — so the change is `suspend` on 58 declarations, plus exactly one private +helper (`DatabaseNostrRepository.matchNegentropicNostrEvents`), whose single caller +was already suspend. Zero call-site edits. + +It is not free, though, and the cost lands on **android**. A blocking DAO method runs +on its caller's thread; a `suspend` one is dispatched to the query coroutine context, +which `getRoomDatabase` sets to `Dispatchers.IO`. That is the better behaviour — it is +what stops a query running on the main thread — but it is a real change to a shipping +platform, made for the benefit of a target that does not exist yet. Run the android +unit tests, not just the compile. + +### Then the actuals + Mantra declares 16 expects across 8 files. They split cleanly: **Six platform basics.** `getPlatform` ([Platform.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/Platform.kt)), @@ -442,6 +473,19 @@ directory from Phase 1 — not `java.io.tmpdir`, which is what the old Aux implementation did and which silently loses the database on reboot on most systems. +**The library's android- and ios-only classes are not all expects.** The 23 counted +at the top of this document are `expect` declarations, and the compiler lists those +for you. `BusinessManager` is not one — it exists as a plain object in `androidMain` +and again in `iosMain`, with no common declaration, so nothing flags its absence until +mantra's own actual tries to import it. + +The ios one turns out to contain no ios API whatever: no `platform.*`, no cinterop, no +`NSObject`. It ports to `jvmMain` on a package rename alone and compiles unchanged. It +is also the right one to start from — the android manager is built around an android +`Application` it holds, while the ios one constructs its own `PlatformContext`, which +is exactly what the jvm can do. `NavigationViewModel.ios.kt` is likewise portable on +one changed import. + **Nine lightning wrappers.** Four in [Phoenix.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Phoenix.kt) (`platformStartupLogic`, `schedulePlatformLogic`, `getShowIntroFlow`, @@ -454,10 +498,17 @@ android actuals live in `NavigationViewModel.android.kt`. These are thin — they mostly forward into the phoenix library. They are thin *because* Phases 1–3 did the work, which is why they are last. -`schedulePlatformLogic` is the one to look at properly: on android it schedules -background work through WorkManager. On desktop there is no equivalent and no -process that outlives the window. Decide explicitly whether it becomes a no-op or -an in-process coroutine, and write down which. +`schedulePlatformLogic` is the one to look at properly, and the answer is that it +should schedule **nothing** and say so. On android it starts two WorkManager jobs, one +of which is `ChannelsWatcher` — it wakes periodically to notice a channel force-closed +while the app was shut. A desktop application has no process once its window closes, +so there is nothing for a scheduler to wake, and running the watcher in-process would +be strictly worse than not running it: it would only ever fire while the app was +already open and watching anyway. + +The exposure is real and belongs in the release notes, not just a comment: a desktop +wallet left closed past a force-close deadline does not notice. Covering it needs +something outside this process, which is a separate piece of work from this plan. **Verification:** `./gradlew :composeApp:compileKotlinJvm`. This is the first point in the plan where the JVM target has to actually resolve, so expect the diff --git a/lightning-kmp-app b/lightning-kmp-app index 64342823..05ce7eb3 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 6434282380a14a997a2ac91227bb4d787a6f2c76 +Subproject commit 05ce7eb3ffbe0e7dfd7e0b2b31bbfde84cf0efa8