diff --git a/README.md b/README.md index 27e371412..9ea88b624 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,80 @@ Add the dependency implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1') ``` +Manage logged in users with the `KeyPair` class + +```kt +val keys = KeyPair() // creates a random key +val keys = KeyPair("hex...".hexToByteArray()) +val keys = KeyPair("nsec1...".bechToBytes()) +val keys = KeyPair(Nip06().privateKeyFromMnemonic("")) +val readOnly = KeyPair(pubKey = "hex...".hexToByteArray()) +val readOnly = KeyPair(pubKey = "npub1...".bechToBytes()) +``` + +Create signers that can be internal, when you have the private key or when it is a read-only user +or external, when it is controlled by Amber in NIP-55 + +the `NostrSignerInternal` and `NostrSignerExternal` classes. + +```kt +val signer = NostrSignerInternal(keyPair) +val amberSigner = NostrSignerExternal( + pubKey = keyPair.pubKey.toHexKey(), + packageName = signerPackageName, + contentResolver = appContext.contentResolver, +) +``` + +Create a single NostrClient for the entire application and control which relays it will access by +registering subscriptions and sending events. The pool will automatically changed based on filters + +outbox events. + +You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic +wrapper to create the socket connections themselves. + +```kt +val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) +val rootClient = OkHttpClient.Builder().build() +val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } + +val client = NostrClient(socketBuilder, appScope) +``` + +If you want to auth, given a logged-in `signer`: + +```kt +val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay -> + val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer) + client.sendIfExists(authedEvent, relay.url) +} +``` + +To manage subscriptions, the suggested approach is to use subscriptions in the Application class. + +```kt +val metadataSub = RelayClientSubscription( + client = client, + filter = { + val filters = listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(signer.pubkey) + ) + ) + + val signerOutboxRelays = listOfNotNull( + RelayUrlNormalizer.normalizeOrNull("wss://relay1.com"), + RelayUrlNormalizer.normalizeOrNull("wss://relay2.com") + ) + + signerOutboxRelays.associateWith { filters } + } +) { event -> + /* consume event */ +} +``` + ## Contributing Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst) diff --git a/quartz/build.gradle b/quartz/build.gradle index 59399786a..60c5ab93c 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -76,6 +76,9 @@ dependencies { // Normalizes URLs api libs.rfc3986.normalizer + // Websockets API + implementation libs.okhttp + testImplementation libs.junit testImplementation libs.secp256k1.kmp.jni.jvm androidTestImplementation platform(libs.androidx.compose.bom) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt new file mode 100644 index 000000000..e06da52a9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.RandomInstance + +class NostrClientSubscription( + val client: NostrClient, + val filter: () -> Map> = { emptyMap() }, + val onEvent: (event: Event) -> Unit = {}, +) : IRelayClientListener { + private val subId = RandomInstance.randomChars(10) + + init { + client.subscribe(this) + } + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (this.subId == subId) { + onEvent(event) + } + } + + /** + * Creates or Updates the filter with relays. This method should be called + * everytime the filter changes. + */ + fun updateFilter() = client.openReqSubscription(subId, filter()) + + fun closeSubscription() = client.close(subId) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt new file mode 100644 index 000000000..434abb021 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp + +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 okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +class BasicOkHttpWebSocket( + val url: NormalizedRelayUrl, + val httpClient: OkHttpClient, + val out: WebSocketListener, +) : WebSocket { + private var socket: okhttp3.WebSocket? = null + + override fun needsReconnect() = socket == null + + override fun connect() { + val request = Request.Builder().url(url.url).build() + + val listener = + object : okhttp3.WebSocketListener() { + override fun onOpen( + webSocket: okhttp3.WebSocket, + response: Response, + ) = out.onOpen( + response.receivedResponseAtMillis - response.sentRequestAtMillis, + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, + ) + + override fun onMessage( + webSocket: okhttp3.WebSocket, + text: String, + ) = out.onMessage(text) + + override fun onClosing( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) = out.onClosing(code, reason) + + override fun onClosed( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) = out.onClosed(code, reason) + + override fun onFailure( + webSocket: okhttp3.WebSocket, + t: Throwable, + r: Response?, + ) = out.onFailure(t, r?.code, r?.message) + } + + socket = httpClient.newWebSocket(request, listener) + } + + override fun disconnect() { + socket?.cancel() + socket = null + } + + override fun send(msg: String): Boolean = socket?.send(msg) ?: false + + class Builder( + val httpClient: OkHttpClient, + ) : WebsocketBuilder { + // Called when connecting. + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = BasicOkHttpWebSocket(url, httpClient, out) + } +}