feat: mantra compiles for the jvm

Phase 4. Declares jvm(), implements all 16 expects, and bumps the
submodule to the fork branch carrying phases 1-3.
:composeApp:compileKotlinJvm is green.

**The actuals were the small half. Room was the blocker.** The first jvm
compile failed with 58 copies of "Only suspend functions are allowed in
DAOs declared in source sets targeting non-Android platforms". Room
permits blocking query methods on android and nowhere else, so every @Dao
function that was neither suspend nor Flow-returning had to change --
58 of them across 24 files. KSP reports these in alphabetical batches, so
the count shrinks in stages and looks bottomless; scanning the dao package
directly for abstract funs with no suspend and no Flow return finds them
all at once.

It stops there, which is the only reason this is a 58-line change rather
than a refactor. Every one of the 15 call sites outside the dao package
was already inside a suspend function -- the repositories were written
that way throughout -- so nothing needed rewriting. One private helper,
DatabaseNostrRepository.matchNegentropicNostrEvents, had to become
suspend, and its single caller was already suspend, so the cascade
terminated immediately. Zero call-site edits.

**The cost lands on android, not on the jvm.** A blocking DAO method runs
on its caller's thread; a suspend one is dispatched to the query coroutine
context, which getRoomDatabase sets to Dispatchers.IO. That is the better
behaviour -- it is what stops a query running on the main thread -- but it
is a real change to the shipping platform, made for a target that does not
run yet. Hence the unit tests below rather than a compile alone.

**BusinessManager was not an expect**, so nothing warned about it. It is
now ported to the fork's jvmMain (05ce7eb); Phoenix.jvm.kt and
NavigationViewModel.jvm.kt are otherwise the ios actuals with one changed
import, since those files use no ios API.

**schedulePlatformLogic schedules nothing, and logs that it does not.**
Android starts two WorkManager jobs here, one of which is ChannelsWatcher
-- it wakes periodically to notice a channel force-closed while the app
was shut. A desktop application has no process once its window closes, so
there is nothing to wake, and running the watcher in-process would be
strictly worse than not running it: it would only fire while the app was
already open and watching. The exposure is real and belongs in release
notes rather than a comment -- a desktop wallet left closed past a
force-close deadline does not notice.

Smaller calls. PlatformContext carries an application directory, since
there is no Context to read one from, and PlatformDatabaseBuilder puts
aux.db under it rather than in java.io.tmpdir, which is what the abandoned
Aux implementation did behind a TODO and which most systems clear on
reboot. themeColorScheme ignores dynamicColor, which means Material You
and has no desktop counterpart. AppVersion reads the jar manifest that
compose.desktop writes, falling back when running from a class directory.

Verified: :composeApp:compileKotlinJvm green,
:composeApp:compileDebugKotlinAndroid green, and
:composeApp:testDebugUnitTest 52 passing -- the one that matters, since
this commit changes shared code every android query path goes through.

Not verified: nothing has run. No jvm entry point exists yet, so the
database has never been opened on this platform and no business has been
started. That is phase 5, which also has to unlock JvmKeyStore before the
wallet starts -- a passphrase prompt, not just a window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-06 01:23:42 +02:00
1 parent 670f87a609
commit bf4041a5b2
35 files changed
+437 -66

No files matched your search

+5 -2
View File
@@ -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
}
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface BroadcastNostrEventReceiptDao {
@Query("SELECT * FROM BroadcastNostrEventReceipt")
fun getAllBroadcastNostrEventReceipts(): List<press.mantra.compose.database.model.BroadcastNostrEventReceipt>
suspend fun getAllBroadcastNostrEventReceipts(): List<press.mantra.compose.database.model.BroadcastNostrEventReceipt>
@Query("SELECT * FROM BroadcastNostrEventReceipt WHERE id = :id")
fun getBroadcastNostrEventReceiptById(id: Long): Flow<press.mantra.compose.database.model.BroadcastNostrEventReceipt?>
@@ -12,10 +12,10 @@ import kotlin.time.Instant
@Dao
interface BroadcastNostrEventRequestDao {
@Query("SELECT * FROM BroadcastNostrEventRequest")
fun getAllBroadcastNostrEventRequests(): List<BroadcastNostrEventRequest>
suspend fun getAllBroadcastNostrEventRequests(): List<BroadcastNostrEventRequest>
@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
@@ -16,19 +16,19 @@ interface ChatMessageDao {
@Transaction
@Query("SELECT * FROM ChatMessage WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
fun getChatMessagesByChatRoomId(chatRoomId: String): List<press.mantra.compose.database.model.intermdiate.LocalChatMessage>
suspend fun getChatMessagesByChatRoomId(chatRoomId: String): List<press.mantra.compose.database.model.intermdiate.LocalChatMessage>
@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
@@ -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<LocalChatRoom>
suspend fun getChatRoomListByUserPublicKey(userPublicKey: String): List<LocalChatRoom>
@Transaction
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL")
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface ConnectionDao {
@Query("SELECT * FROM Connection LIMIT :limit")
fun getAllConnections(limit: Int = 21): List<press.mantra.compose.database.model.Connection>
suspend fun getAllConnections(limit: Int = 21): List<press.mantra.compose.database.model.Connection>
@Query("SELECT * FROM Connection WHERE (sourcePublicKey = :alicePublicKey AND destinationPublicKey = :bobPublicKey) OR (sourcePublicKey = :bobPublicKey AND destinationPublicKey = :alicePublicKey)")
@@ -7,7 +7,7 @@ import androidx.room3.Upsert
@Dao
interface GiftWrapMessageDao {
@Query("SELECT * FROM GiftWrapMessage")
fun getAllGiftWrapMessages(): List<press.mantra.compose.database.model.GiftWrapMessage>
suspend fun getAllGiftWrapMessages(): List<press.mantra.compose.database.model.GiftWrapMessage>
@Upsert
suspend fun upsert(giftWrapMessage: press.mantra.compose.database.model.GiftWrapMessage)
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface GiftWrapPayloadDao {
@Query("SELECT * FROM GiftWrapPayload")
fun getAllGiftWrapUnsigned(): List<GiftWrapPayload>
suspend fun getAllGiftWrapUnsigned(): List<GiftWrapPayload>
@Query("SELECT * FROM GiftWrapPayload WHERE publicKey = :publicKey AND giftWrapSealId IS NULL")
fun observeUnsealedGiftWrapPayloads(publicKey: String): Flow<GiftWrapPayload?>
@@ -7,7 +7,7 @@ import androidx.room3.Upsert
@Dao
interface GiftWrapSealDao {
@Query("SELECT * FROM GiftWrapSeal")
fun getAllGiftWrapSeals(): List<press.mantra.compose.database.model.GiftWrapSeal>
suspend fun getAllGiftWrapSeals(): List<press.mantra.compose.database.model.GiftWrapSeal>
@Upsert
suspend fun upsert(giftWrapSeal: press.mantra.compose.database.model.GiftWrapSeal)
@@ -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)
@@ -16,10 +16,10 @@ interface MarmotKeyPackageBundleDao {
fun observeAllMarmotKeyPackageBundles(publicKey: HexKey): Flow<List<MarmotKeyPackageBundle>>
@Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey ORDER BY createdAt DESC")
fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle>
suspend fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle>
@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)
@@ -18,7 +18,7 @@ interface NegentropySynchronizeRequestDao {
fun observeNegentropySynchronizeRequestByPurposeAndStatusCount(purpose: String, status: List<String>): Flow<Int>
@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<NegentropySynchronizeRequest>)
suspend fun insert(negentropySynchronizeRequests: List<NegentropySynchronizeRequest>)
}
@@ -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)
}
@@ -73,13 +73,13 @@ interface NostrEventDao {
* constraints they had no parameter for.
*/
@RawQuery
fun getNostrEventsMatchingFilter(
suspend fun getNostrEventsMatchingFilter(
query: RoomRawQuery
): List<NostrEvent>
@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<Kind>,
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<Kind>,
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<Kind>,
ids: Array<HexKey>,
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<HexKey>,
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<NostrEvent>
@@ -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<HexKey>,
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<Kind>,
authors: Array<HexKey>,
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<Kind>,
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<Kind>,
eventId: HexKey,
since: Instant,
@@ -171,17 +171,17 @@ interface NostrEventDao {
): List<NostrEvent>
@Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) ORDER BY savedAt DESC")
fun getAllNostrEvents(kinds: Array<Kind>): List<NostrEvent>
suspend fun getAllNostrEvents(kinds: Array<Kind>): List<NostrEvent>
@Transaction
@Query("SELECT * FROM NostrEvent WHERE id = :id")
fun observeNostrEventById(id: String): Flow<press.mantra.compose.database.model.intermdiate.LocalNostrEvent?>
@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)
@@ -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<Participant>
suspend fun findParticipantByPublicKey(participantPublicKey: String): List<Participant>
@Query("SELECT * FROM Participant WHERE chatRoomId = :chatRoomId")
fun findParticipantsByChatRoomId(chatRoomId: String): List<Participant>
suspend fun findParticipantsByChatRoomId(chatRoomId: String): List<Participant>
@Upsert
suspend fun upsert(participants: List<Participant>)
@@ -8,10 +8,10 @@ import androidx.room3.Upsert
@Dao
interface PostDao {
@Query("SELECT * FROM Post")
fun getAllPosts(): List<press.mantra.compose.database.model.intermdiate.LocalPost>
suspend fun getAllPosts(): List<press.mantra.compose.database.model.intermdiate.LocalPost>
@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)
@@ -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<Profile>
suspend fun getAllProfiles(limit: Int = 21, createdAt: Instant = GENESIS_AT): List<Profile>
@Query("SELECT * FROM Profile WHERE publicKey NOT IN (:excludedPublicKeys) AND createdAt > :createdAt LIMIT :limit")
fun getAllProfilesExcludingOnesWithThesePublicKeys(
suspend fun getAllProfilesExcludingOnesWithThesePublicKeys(
excludedPublicKeys: Array<HexKey>,
limit: Int = 21, createdAt: Instant = GENESIS_AT
): List<Profile>
@Query("SELECT * FROM NostrEvent WHERE kind = 0 AND content LIKE '%' || :searchTerm || '%' LIMIT :limit")
fun filterProfiles(searchTerm: String, limit: Int = 21): List<press.mantra.compose.database.model.intermdiate.LocalProfile>
suspend fun filterProfiles(searchTerm: String, limit: Int = 21): List<press.mantra.compose.database.model.intermdiate.LocalProfile>
@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<Profile?>
@Query("SELECT * FROM Profile WHERE publicKey IN (:publicKeys)")
fun getProfileByPublicKeys(publicKeys: List<String>): List<Profile>
suspend fun getProfileByPublicKeys(publicKeys: List<String>): List<Profile>
@Query("SELECT * FROM NostrEvent WHERE kind = 0 AND pubKey = :publicKey")
fun observeProfileWithFollowersByPublicKey(publicKey: String): Flow<press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowers?>
@@ -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)
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface ReactionDao {
@Query("SELECT * FROM Reaction")
fun getAllReactions(): List<press.mantra.compose.database.model.Reaction>
suspend fun getAllReactions(): List<press.mantra.compose.database.model.Reaction>
@Query("SELECT * FROM Reaction WHERE id = :id")
fun getReactionById(id: String): Flow<press.mantra.compose.database.model.Reaction?>
@@ -12,10 +12,10 @@ interface RecentSearchDao {
fun observeRecentSearchByQuery(query: String): Flow<press.mantra.compose.database.model.RecentSearch?>
@Query("SELECT * FROM RecentSearch ORDER BY createdAt DESC LIMIT :limit ")
fun getAllRecentSearches(limit: Int = 21): List<press.mantra.compose.database.model.RecentSearch>
suspend fun getAllRecentSearches(limit: Int = 21): List<press.mantra.compose.database.model.RecentSearch>
@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)
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface RelayDao {
@Query("SELECT * FROM Relay")
fun getAllPosts(): List<press.mantra.compose.database.model.Relay>
suspend fun getAllPosts(): List<press.mantra.compose.database.model.Relay>
@Query("SELECT * FROM Relay WHERE publicKey = :publicKey")
fun observePublicKeyRelays(publicKey: String): Flow<List<press.mantra.compose.database.model.Relay>>
@@ -7,13 +7,13 @@ import androidx.room3.Upsert
@Dao
interface RepostedRelationDao {
@Query("SELECT * FROM RepostedRelation")
fun getAllReposts(): List<press.mantra.compose.database.model.RepostedRelation>
suspend fun getAllReposts(): List<press.mantra.compose.database.model.RepostedRelation>
@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)
@@ -18,8 +18,8 @@ interface SynchronizeNostrEventRequestDao {
fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String, status: List<String>): Flow<Int>
@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<press.mantra.compose.database.model.SynchronizeNostrEventRequest>)
suspend fun insert(synchronizeNostrEventRequests: List<press.mantra.compose.database.model.SynchronizeNostrEventRequest>)
}
@@ -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)
}
@@ -12,10 +12,10 @@ import press.mantra.compose.database.model.UnsignedNostrEvent
@Dao
interface UnsignedNostrEventDao {
@Query("SELECT * FROM UnsignedNostrEvent")
fun getAUnsignedNostrEvents(): List<UnsignedNostrEvent>
suspend fun getAUnsignedNostrEvents(): List<UnsignedNostrEvent>
@Query("SELECT * FROM UnsignedNostrEvent") // TODO: where nostrEvent.unsignedNostrEventId is 0
fun getAllUnprocessedUnsignedNostrEvents(): List<UnsignedNostrEvent>
suspend fun getAllUnprocessedUnsignedNostrEvents(): List<UnsignedNostrEvent>
@Query("SELECT * FROM UnsignedNostrEvent WHERE id = :id")
fun getUnsignedNostrEventById(id: Long): Flow<UnsignedNostrEvent?>
@@ -32,7 +32,7 @@ interface UnsignedNostrEventDao {
@Transaction
@Query("SELECT * FROM UnsignedNostrEvent WHERE kind = 0")
fun getLocalAccounts(): List<LocalAccount>
suspend fun getLocalAccounts(): List<LocalAccount>
@Query("SELECT * FROM UnsignedNostrEvent WHERE pubKey = :publicKey AND signedAt IS NULL ORDER BY kind ASC")
fun observeUnsignedNostrEvents(publicKey: String): Flow<UnsignedNostrEvent?>
@@ -647,7 +647,7 @@ class DatabaseNostrRepository(
.distinctBy { it.id }
}
private fun matchNegentropicNostrEvents(
private suspend fun matchNegentropicNostrEvents(
synchronizationFilter: SynchronizationFilter,
applyLimits: Boolean,
): List<NostrEvent> {
@@ -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"
}
@@ -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()
@@ -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"
@@ -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<MantraDatabase> {
// 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<MantraDatabase>(name = dbFile.absolutePath)
}
actual fun getInMemoryDatabaseBuilder(platform: PlatformContext): RoomDatabase.Builder<MantraDatabase> {
return Room.inMemoryDatabaseBuilder()
}
}
@@ -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<String>): 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<Boolean> {
return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro
}
actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs {
return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet()
}
@@ -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
@@ -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<Map<WalletId, UserWalletMetadata>> {
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<String>,
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)
}
}
}
+55 -4
View File
@@ -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