From b6cde85dbce1b619e6b1227fc70f4498fa9e3f8c Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 12 Jan 2026 15:08:37 +0800 Subject: [PATCH 1/3] add nostr service --- gradle/libs.versions.toml | 1 + shared/build.gradle.kts | 2 + .../data/network/nostr/BasicKtorWebSocket.kt | 122 ++++++++++++++++++ .../flare/data/network/nostr/NostrService.kt | 37 ++++++ 4 files changed, 162 insertions(+) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04debd342..46a4b0863 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -161,6 +161,7 @@ ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.r ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-curl = { group = "io.ktor", name = "ktor-client-curl", version.ref = "ktor" } ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } +ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } twitter-parser = { group = "moe.tlaster", name = "twitter-parser", version.ref = "twitter-parser" } swiper = { group = "com.github.tlaster", name = "swiper", version = "0.7.1" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7f74fc771..07fd084f3 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -82,6 +82,8 @@ kotlin { implementation(projects.shared.api) implementation(libs.ktor.client.resources) implementation("dev.whyoleg.cryptography:cryptography-provider-optimal:0.5.0") + implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation(libs.ktor.client.websockets) } } val commonTest by getting { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt new file mode 100644 index 000000000..a6feafe13 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt @@ -0,0 +1,122 @@ +package dev.dimension.flare.data.network.nostr + +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 com.vitorpamplona.quartz.utils.Log +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Clock + +internal class BasicKtorWebSocket( + val url: NormalizedRelayUrl, + val httpClient: (NormalizedRelayUrl) -> HttpClient, + val out: WebSocketListener, +) : WebSocket { + companion object { + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Log.e("BasicKtorWebSocket", "Coroutine Exception: ${throwable.message}", throwable) + } + } + + private val scope = CoroutineScope(Dispatchers.IO + exceptionHandler) + private var socketJob: Job? = null + private var session: DefaultClientWebSocketSession? = null + + override fun needsReconnect() = socketJob == null || socketJob?.isActive == false + + override fun connect() { + if (socketJob?.isActive == true) return + + socketJob = + scope.launch { + try { + val startTime = Clock.System.now().toEpochMilliseconds() // 或者 System.currentTimeMillis() + + httpClient(url).webSocket(urlString = url.url) { + session = this + val endTime = Clock.System.now().toEpochMilliseconds() + + val extensions = call.response.headers["Sec-WebSocket-Extensions"] + val compression = extensions?.contains("permessage-deflate") ?: false + + out.onOpen((endTime - startTime).toInt(), compression) + + try { + for (frame in incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + out.onMessage(text) + } + else -> { + } + } + } + + val reason = closeReason.await() + out.onClosed(reason?.code?.toInt() ?: 1000, reason?.message ?: "Closed normally") + } catch (e: Exception) { + throw e + } + } + } catch (e: CancellationException) { + out.onClosed(1000, "Cancelled") + } catch (t: Throwable) { + out.onFailure(t, null, t.message) + } finally { + session = null + } + } + } + + override fun disconnect() { + scope.launch { + try { + session?.close(CloseReason(CloseReason.Codes.NORMAL, "User disconnect")) + } catch (e: Exception) { + } + socketJob?.cancel() + session = null + } + } + + override fun send(msg: String): Boolean { + val currentSession = session + if (currentSession != null && currentSession.isActive) { + scope.launch { + try { + currentSession.send(Frame.Text(msg)) + } catch (e: Exception) { + Log.e("BasicKtorWebSocket", "Failed to send message", e) + } + } + return true + } + return false + } + + class Builder( + val httpClient: (NormalizedRelayUrl) -> HttpClient, + ) : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = BasicKtorWebSocket(url, httpClient, out) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt new file mode 100644 index 000000000..d0a4f8621 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -0,0 +1,37 @@ +package dev.dimension.flare.data.network.nostr + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import dev.dimension.flare.data.network.ktorClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob + +internal class NostrService( + private val keyPair: KeyPair, + private val appScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), +) : AutoCloseable { + private val client by lazy { + val socketBuilder = + BasicKtorWebSocket.Builder { + ktorClient() + } + NostrClient(socketBuilder, appScope) + } + + private val authCoordinator by lazy { + val signer = NostrSignerInternal(keyPair) + RelayAuthenticator(client, appScope) { authTemplate -> + listOf( + signer.sign(authTemplate), + ) + } + } + + override fun close() { + authCoordinator.destroy() + } +} From 6313933416909303292bb9650d3656e57a4caf38 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 12 Jan 2026 15:15:02 +0800 Subject: [PATCH 2/3] clean up --- .../dimension/flare/data/network/nostr/BasicKtorWebSocket.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt index a6feafe13..cd68542f6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/BasicKtorWebSocket.kt @@ -46,7 +46,7 @@ internal class BasicKtorWebSocket( socketJob = scope.launch { try { - val startTime = Clock.System.now().toEpochMilliseconds() // 或者 System.currentTimeMillis() + val startTime = Clock.System.now().toEpochMilliseconds() httpClient(url).webSocket(urlString = url.url) { session = this From d001a34cb88d5fe8e80783c48d8990e1ef84c21e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 12 Jan 2026 20:32:27 +0800 Subject: [PATCH 3/3] bump min sdk --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46a4b0863..f0015cf24 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ collection = "1.5.0" compileSdk = "36" jna = "5.18.1" lifecycleViewmodelComposeVersion = "2.9.6" -minSdk = "23" +minSdk = "26" java = "21" agp = "8.13.2" kotlin = "2.3.0"