From f4cd036f51a6e415dd4fc013122b9edaf7d638ae Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 27 Mar 2026 13:35:29 +0200 Subject: [PATCH] Broadcast the things --- composeApp/build.gradle.kts | 9 ++ .../src/androidMain/AndroidManifest.xml | 3 + .../dao/BroadcastNostrEventRequestDao.kt | 7 +- .../LocalBroadcastNostrEventRequest.kt | 19 ++++ .../repository/DatabaseNostrRepository.kt | 16 +++ .../aux/compose/network/KTorHttpWebSocket.kt | 98 +++++++++++++++++++ .../aux/compose/repository/NostrRepository.kt | 14 +++ .../ui/composable/navigation/AuxNavHost.kt | 24 ++++- .../ui/view/model/NavigationViewModel.kt | 81 +++++++++++++-- gradle/libs.versions.toml | 4 + 10 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/intermdiate/LocalBroadcastNostrEventRequest.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/network/KTorHttpWebSocket.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1a980bc4..73b0ccd4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -41,6 +41,8 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.okhttp.coroutines) implementation(libs.androidx.room.sqlite.wrapper) + + implementation(libs.ktor.client.okhttp) } commonMain.dependencies { implementation(libs.compose.runtime) @@ -65,14 +67,21 @@ kotlin { implementation(libs.kermit) implementation("com.vitorpamplona.quartz:quartz:1.05.1") + + implementation(libs.ktor.client.core) + implementation("io.ktor:ktor-client-websockets:3.4.1") } commonTest.dependencies { implementation(libs.kotlin.test) } jvmMain.dependencies { + implementation(libs.ktor.client.okhttp) implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } } } diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 26403a75..5fa9fe45 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + - @Query("SELECT * FROM BroadcastNostrEventRequest WHERE id = :id") - fun getBroadcastNostrEventRequestById(id: Long): Flow + @Query("SELECT * FROM BroadcastNostrEventRequest WHERE status = :status") + fun observeBroadcastNostrEventRequestByStatus(status: String): Flow @Query("SELECT * FROM BroadcastNostrEventRequest WHERE nostrEventId = :nostrEventId") - fun getBroadcastNostrEventRequestByNostrEventId(nostrEventId: String): Flow + fun observeBroadcastNostrEventRequestByNostrEventId(nostrEventId: String): Flow @Upsert suspend fun upsert(broadcastNostrEventRequest: BroadcastNostrEventRequest) diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/intermdiate/LocalBroadcastNostrEventRequest.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/intermdiate/LocalBroadcastNostrEventRequest.kt new file mode 100644 index 00000000..f9621ea4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/intermdiate/LocalBroadcastNostrEventRequest.kt @@ -0,0 +1,19 @@ +package ac.aux.compose.database.model.intermdiate + +import ac.aux.compose.database.model.BroadcastNostrEventReceipt +import ac.aux.compose.database.model.BroadcastNostrEventRequest +import ac.aux.compose.database.model.NostrEvent +import ac.aux.compose.database.model.Profile +import ac.aux.compose.database.model.UnsignedNostrEvent +import androidx.room.Embedded +import androidx.room.Relation + +data class LocalBroadcastNostrEventRequest( + @Embedded val broadcastNostrEventRequest: BroadcastNostrEventRequest, + + @Relation( + parentColumn = "nostrEventId", + entityColumn = "id" + ) + val nostrEvent: NostrEvent, +) diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt index cd35d636..2815335c 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt @@ -1,9 +1,11 @@ package ac.aux.compose.database.repository import ac.aux.compose.database.AuxDatabase +import ac.aux.compose.database.model.BroadcastNostrEventRequest import ac.aux.compose.database.model.NostrEvent import ac.aux.compose.database.model.Profile import ac.aux.compose.database.model.UnsignedNostrEvent +import ac.aux.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest import ac.aux.compose.database.model.intermdiate.LocalProfile import ac.aux.compose.repository.NostrRepository import co.touchlab.kermit.Logger @@ -30,6 +32,10 @@ class DatabaseNostrRepository( return database.unsignedNostrEventDao().observeUnsignedNostrEvents(publicKey) } + override suspend fun observePendingBroadcastNostrEventRequests(): Flow { + return database.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestByStatus("pending") + } + override suspend fun createNewProfile( publicKey: HexKey, name: String, @@ -83,4 +89,14 @@ class DatabaseNostrRepository( ) } + override suspend fun broadcastProcessed(broadcastNostrEventRequest: BroadcastNostrEventRequest) { + logger.d("Update local reference: $broadcastNostrEventRequest") + database.broadcastNostrEventRequestDao().upsert( + broadcastNostrEventRequest.copy( + status = "sent" + ) + ) + logger.d("Processed: $broadcastNostrEventRequest") + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/KTorHttpWebSocket.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/KTorHttpWebSocket.kt new file mode 100644 index 00000000..4535b883 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/KTorHttpWebSocket.kt @@ -0,0 +1,98 @@ +package ac.aux.compose.network + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +class KTorHttpWebSocket( + val url: NormalizedRelayUrl, + val httpClientBuilder: (url: NormalizedRelayUrl) -> HttpClient, + val out: WebSocketListener +) : WebSocket { + val scope = CoroutineScope(Dispatchers.IO) + + val logger = Logger.withTag("KTorHttpWebSocket") + + private var httpClient: HttpClient? = null + + private var webSocketSession: DefaultClientWebSocketSession? = null + + override fun needsReconnect(): Boolean { + TODO("Not yet implemented") + } + + override fun connect() { + runBlocking { + httpClient = httpClientBuilder(url) + webSocketSession = httpClient?.webSocketSession( + urlString = url.url + ) { + + } + + webSocketSession?.let { socketSession -> + + val job = scope.launch { + for (frame in socketSession.incoming) { + val frameText = frame as? Frame.Text + + frameText?.readText()?.let { out.onMessage(it) } + } + } + + job.join() + + } + + } + + } + + override fun disconnect() { + runBlocking { + webSocketSession?.close() + } + } + + override fun send(msg: String): Boolean = try { + runBlocking { + + webSocketSession?.send( + Frame.Text(msg) + ) + + return@runBlocking true + } + } catch(e: Throwable) { + logger.e("Failed to send $msg", e) + return false + } + + class Builder( + val httpClientBuilder: (NormalizedRelayUrl) -> HttpClient + ): WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener + ): WebSocket = KTorHttpWebSocket( + url = url, + httpClientBuilder = httpClientBuilder, + out = out + ) + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt index 6d12bab9..43e2da79 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt @@ -1,8 +1,10 @@ package ac.aux.compose.repository +import ac.aux.compose.database.model.BroadcastNostrEventRequest import ac.aux.compose.database.model.NostrEvent import ac.aux.compose.database.model.Profile import ac.aux.compose.database.model.UnsignedNostrEvent +import ac.aux.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest import ac.aux.compose.database.model.intermdiate.LocalProfile import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow @@ -12,6 +14,8 @@ interface NostrRepository { suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow + suspend fun observePendingBroadcastNostrEventRequests(): Flow + suspend fun createNewProfile( publicKey: HexKey, name: String, @@ -31,6 +35,8 @@ interface NostrRepository { relayURLs: List = emptyList() ) + suspend fun broadcastProcessed(broadcastNostrEventRequest: BroadcastNostrEventRequest) + companion object { val NO_OP_NOSTR_REPOSITORY = object : NostrRepository { override suspend fun observeProfile(publicKey: HexKey): Flow { @@ -41,6 +47,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun observePendingBroadcastNostrEventRequests(): Flow { + TODO("Not yet implemented") + } + override suspend fun createNewProfile( publicKey: HexKey, name: String, @@ -60,6 +70,10 @@ interface NostrRepository { override suspend fun publishNostrEvent(nostrEvent: NostrEvent, relayURLs: List) { } + + override suspend fun broadcastProcessed(broadcastNostrEventRequest: BroadcastNostrEventRequest) { + TODO("Not yet implemented") + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt index 0f3f38f5..77040ebe 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt @@ -41,12 +41,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob @Composable fun AuxNavHost( auxGlobal: AuxGlobal, navController: NavHostController ) { + val logger = Logger.withTag("AuxNavHost") val lifecycleOwner = LocalLifecycleOwner.current val databaseManager = DatabaseManager(auxGlobal) @@ -55,10 +62,19 @@ fun AuxNavHost( database = databaseManager.auxDatabase ) + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + logger.e("Caught exception: ${throwable.message}", throwable) + } + + val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + + val navigationViewModel: NavigationViewModel = viewModel ( factory = NavigationViewModel.factory( - NavigationUIState.Loading, - nostrRepository + initialNavigationUIState = NavigationUIState.Loading, + nostrRepository = nostrRepository, + scope = applicationIOScope ) ) @@ -164,9 +180,7 @@ fun AuxNavHost( } ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - + composable { CreateProfileScreen( onNavigateToSocialPreconditionRoute = { navController.navigate( diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt index 17c0e13f..422ebe1e 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt @@ -2,6 +2,7 @@ package ac.aux.compose.ui.view.model import ac.aux.compose.database.model.NostrEvent import ac.aux.compose.managers.SeedManager +import ac.aux.compose.network.KTorHttpWebSocket import ac.aux.compose.nostr.Relays import ac.aux.compose.repository.NostrRepository import ac.aux.compose.ui.view.state.NavigationUIState @@ -9,11 +10,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.WebSockets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,19 +31,23 @@ import kotlin.time.Instant class NavigationViewModel( initialNavigationUIState: NavigationUIState, - val nostrRepository: NostrRepository + val nostrRepository: NostrRepository, + val scope: CoroutineScope, ): ViewModel() { + companion object { private const val TAG = "NavigationViewModel" fun factory( initialNavigationUIState: NavigationUIState, - nostrRepository: NostrRepository + nostrRepository: NostrRepository, + scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { NavigationViewModel( initialNavigationUIState = initialNavigationUIState, - nostrRepository = nostrRepository + nostrRepository = nostrRepository, + scope = scope ) } } @@ -43,6 +55,24 @@ class NavigationViewModel( private val logger = Logger.withTag(TAG) + + + val httpClient = HttpClient() { + install(WebSockets) { + pingIntervalMillis = 20_000 + } + } + + val websocketBuilder = KTorHttpWebSocket.Builder { url -> + // TODO: Figure out if we need a tor client + httpClient + } + val nostrClient: INostrClient = NostrClient( + websocketBuilder = websocketBuilder, + scope = scope + ) + + private val _navigationUIState = MutableStateFlow( initialNavigationUIState ) @@ -50,18 +80,17 @@ class NavigationViewModel( init { observeUnsignedNostrEvents() + observePendingBroadcastNostrEventRequests() observeProfile() } - - private fun observeUnsignedNostrEvents() { val tempSigner = NostrSignerSync( SeedManager.activeKeyPair() ) logger.i { "observeUnsignedNostrEvents" } - viewModelScope.launch { + scope.launch(Dispatchers.IO) { nostrRepository.observeUnsignedNostrEvents( publicKey = SeedManager.activePublicKey().toHexKey() ).collect { unsignedNostrEventOrNull -> @@ -92,9 +121,45 @@ class NavigationViewModel( } } } + + private fun observePendingBroadcastNostrEventRequests() { + logger.i { "observePendingBroadcastNostrEventRequests" } + + scope.launch(Dispatchers.IO) { + + nostrRepository.observePendingBroadcastNostrEventRequests().collect { + it?.let { localBroadcastNostrEventRequest -> + val event: Event = localBroadcastNostrEventRequest.nostrEvent.let { nostrEvent -> + Event( + id = nostrEvent.id, + kind = nostrEvent.kind, + pubKey = nostrEvent.pubKey, + content = nostrEvent.content, + tags = nostrEvent.tags, + sig = nostrEvent.sig, + createdAt = nostrEvent.createdAt.toEpochMilliseconds() + ) + } + logger.d("Broadcasting: ${event.toJson()}") + nostrClient.send( + event = event, + relayList = setOf( + RelayUrlNormalizer.normalize(localBroadcastNostrEventRequest.broadcastNostrEventRequest.relayURL) + ) + ) + logger.d("Update with result") + + nostrRepository.broadcastProcessed( + localBroadcastNostrEventRequest.broadcastNostrEventRequest + ) + } + + } + } + } private fun observeProfile() { logger.i("observeProfile") - viewModelScope.launch { + scope.launch(Dispatchers.IO) { delay(2_100) // Looking busy... logger.i("Navigation UI State is Landing") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa2f80de..b88c3132 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ kermit = "2.0.8" kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" ksp = "2.3.6" +ktor = "3.4.1" material3 = "1.10.0-alpha05" materialIconsCore = "1.7.3" materialIconsExtended = "1.7.3" @@ -50,6 +51,9 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-coroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }