Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
2 changes: 2 additions & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()

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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading