From 37e2a2c62fe04a56c9ed1f156f6006a71b4810c8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Jun 2026 11:45:17 +0200 Subject: [PATCH 01/17] fix(cast): request ACCESS_LOCAL_NETWORK for Android 17 device discovery Android 17 (API 37) introduced Local Network Protection, which gates the mDNS/multicast discovery the Cast SDK relies on behind the new dangerous ACCESS_LOCAL_NETWORK runtime permission. Declare ACCESS_LOCAL_NETWORK in the Play manifest (cast is Play-only; F-Droid ships no casting) and request it at runtime when the user opens the Cast picker, guarded by SDK_INT >= 37 so older OSes are unaffected. --- .../composable/controls/RenderTopButtons.kt | 10 +- .../amethyst/ui/cast/CastPermissions.kt | 122 ++++++++++++++++++ amethyst/src/play/AndroidManifest.xml | 7 + .../composeResources/values/strings.xml | 3 + 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 79b381c4be..db06db131e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaIte import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog +import com.vitorpamplona.amethyst.ui.cast.rememberCastWithLocalNetworkPermission import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -313,6 +314,7 @@ fun RenderTopButtons( val captionsContentDescription = stringRes(if (captionsEnabled) Res.string.captions_turn_off else Res.string.captions_turn_on) val shareDialogVisible = remember { mutableStateOf(false) } + val context = LocalContext.current val castDialogVisible = remember { mutableStateOf(false) } val castSessionState by Amethyst.instance.castRegistry.sessionState .collectAsStateWithLifecycle() @@ -321,15 +323,19 @@ fun RenderTopButtons( val castIcon = if (isThisVideoCasting) MaterialSymbols.CastConnected else MaterialSymbols.Cast val castContentDescription = stringRes(if (isThisVideoCasting) Res.string.cast_stop_casting else Res.string.cast_to_device) + // Android 17 Local Network Protection blocks Cast device discovery until the + // user grants ACCESS_LOCAL_NETWORK, so gate the picker behind the request. + val showCastPicker = remember { { castDialogVisible.value = true } } + val openCastPicker = rememberCastWithLocalNetworkPermission(context, showCastPicker) val onCastButtonClick = - remember(isThisVideoCasting) { + remember(isThisVideoCasting, openCastPicker) { { if (isThisVideoCasting) { Amethyst.instance.applicationIOScope.launch { Amethyst.instance.castRegistry.stopCasting() } } else { - castDialogVisible.value = true + openCastPicker() } Unit } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt new file mode 100644 index 0000000000..a2903564d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt @@ -0,0 +1,122 @@ +/* + * 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.amethyst.ui.cast + +import android.Manifest +import android.content.Context +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_message +import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_open_settings +import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_title +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.ui.call.hasPermission +import com.vitorpamplona.amethyst.ui.call.openAppSettings + +// Android 17 (API 37) Local Network Protection gates the mDNS/multicast device +// discovery the Cast SDK relies on behind the dangerous ACCESS_LOCAL_NETWORK +// runtime permission. Without it the picker silently finds zero receivers. +// Older OSes have no LNP and never define the permission, so the gate is a +// no-op there. +private const val LOCAL_NETWORK_PROTECTION_SDK = 37 + +private fun needsLocalNetworkPermission(): Boolean = Build.VERSION.SDK_INT >= LOCAL_NETWORK_PROTECTION_SDK + +fun hasLocalNetworkPermission(context: Context): Boolean = !needsLocalNetworkPermission() || hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) + +/** + * Returns a click handler that ensures [Manifest.permission.ACCESS_LOCAL_NETWORK] + * is granted before running [onGranted] (which opens the Cast device picker). + * + * On Android 17+ the permission is requested on first tap; if the user denies + * it, a dialog deep-links to app settings. On older OSes the permission does + * not exist, so [onGranted] runs immediately. + */ +@Composable +fun rememberCastWithLocalNetworkPermission( + context: Context, + onGranted: () -> Unit, +): () -> Unit { + var showDeniedDialog by remember { mutableStateOf(false) } + + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + // A silent deny (permanently-denied, where Android skips the dialog) + // also lands here with granted=false, so surface the deep-link + // dialog rather than failing silently with an empty picker. + if (granted) onGranted() else showDeniedDialog = true + } + + if (showDeniedDialog) { + LocalNetworkPermissionDeniedDialog( + onDismiss = { showDeniedDialog = false }, + onOpenSettings = { + showDeniedDialog = false + openAppSettings(context) + }, + ) + } + + return remember(onGranted) { + { + if (hasLocalNetworkPermission(context)) { + onGranted() + } else { + launcher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + } + } +} + +@Composable +private fun LocalNetworkPermissionDeniedDialog( + onDismiss: () -> Unit, + onOpenSettings: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.cast_local_network_permission_title)) }, + text = { Text(stringRes(Res.string.cast_local_network_permission_message)) }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text(stringRes(Res.string.cast_local_network_permission_open_settings)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(Res.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 60c2dfb7a1..1ead319210 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 3782cc7eb2..3ef72ba264 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2694,6 +2694,9 @@ Only visible to you Cast to… Searching for devices on your Wi-Fi… + Permission needed + Amethyst needs local network access to find Cast devices on your Wi-Fi. Please enable it in the app settings. + Open settings Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From 5ab25b89d20c905c264bf3bbf66d24383954ac39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:16:24 +0000 Subject: [PATCH 02/17] feat: serve NIP-FE from geode and add the client side to quartz geode (KtorRelay): - POST /req, /count, /event on the engine through quartz's HttpRelayHandler, answered as application/x-ndjson and streamed chunk by chunk; one refusal line with its status otherwise. - WWW-Authenticate: Nostr on 401, Retry-After on 429/503, CORS preflight (any origin, Authorization + Content-Type), X-Accel-Buffering: no. - Per-client and global concurrency caps (429 / 503), counted per peer address or, behind a listed trusted proxy, per the address it forwards. - 413 for bodies over [http].max_body_bytes; the NIP-86 route shares the bounded body reader. - --auth / --optional-auth now accept a NIP-98-proved key: stock FullAuthPolicy refuses transport sign-in, which left every signed HTTP request on an AUTH-gated geode at 401. - [http] config section (enabled, caps, deadline, body cap, alternate_urls for a .onion, trusted_proxies); FE in the default NIP-11 list, dropped when disabled; supported_nips accepts "FE". quartz: - HttpRelayAnswerReader: turns NIP-FE lines back into frames and tells a complete answer from a cut-off one, as the NIP requires of clients. - HttpRelayClient (jvmAndroid, OkHttp): req/count/publish, reads the answer incrementally, signs a NIP-98 token and retries on 401. - HttpRelayCommand.url()/body() and HttpRelayHandler.refusal() for hosts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/README.md | 29 +- geode/config.example.toml | 32 +- .../com/vitorpamplona/geode/KtorRelay.kt | 33 ++ .../kotlin/com/vitorpamplona/geode/Main.kt | 7 +- .../com/vitorpamplona/geode/RelayInfo.kt | 3 +- .../com/vitorpamplona/geode/SignInPolicies.kt | 43 +++ .../geode/config/StaticConfig.kt | 76 ++++- .../vitorpamplona/geode/server/BoundedBody.kt | 48 +++ .../geode/server/HttpAdmission.kt | 89 +++++ .../geode/server/HttpCommandSettings.kt | 53 +++ .../geode/server/Nip86HttpRoute.kt | 46 +-- .../geode/server/NipFEHttpRoute.kt | 168 ++++++++++ .../com/vitorpamplona/geode/NipFEHttpTest.kt | 307 ++++++++++++++++++ .../geode/config/StaticConfigTest.kt | 53 ++- .../geode/server/HttpAdmissionTest.kt | 71 ++++ .../nipFERelayOverHttp/HttpRelayAnswer.kt | 100 ++++++ .../nipFERelayOverHttp/HttpRelayCommand.kt | 9 + .../nipFERelayOverHttp/HttpRelayHandler.kt | 9 +- .../HttpRelayAnswerReaderTest.kt | 127 ++++++++ .../nipFERelayOverHttp/HttpRelayClient.kt | 153 +++++++++ 20 files changed, 1396 insertions(+), 60 deletions(-) create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt diff --git a/geode/README.md b/geode/README.md index 4c45f710fc..07c5c73a20 100644 --- a/geode/README.md +++ b/geode/README.md @@ -3,8 +3,8 @@ A standalone [Nostr](https://github.com/nostr-protocol/nips) relay for the JVM, built on Quartz's relay-server code (Ktor CIO). It speaks the core relay protocol plus NIP-11 (info doc), NIP-42 (AUTH), NIP-45 (COUNT), NIP-50 -(full-text search), NIP-77 (Negentropy sync), and NIP-86 (relay management), -stores events in SQLite (or a filesystem backend), and can mirror upstream +(full-text search), NIP-77 (Negentropy sync), NIP-86 (relay management) and +NIP-FE (REQ/COUNT/EVENT over plain HTTP), stores events in SQLite (or a filesystem backend), and can mirror upstream relays strfry-router style. geode depends only on `:quartz` — no Android, no Compose. `amy serve` (the @@ -97,8 +97,29 @@ geode --version Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools), `[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search), -`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and -`[admin]` (NIP-86 management). See the example file for every knob. +`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), +`[http]` (NIP-FE limits) and `[admin]` (NIP-86 management). See the example +file for every knob. + +### Commands over HTTP (NIP-FE) + +Besides the websocket, geode answers one command per HTTP `POST` beside the +relay path, streamed back as NDJSON — no socket, no subscription left open: + +```bash +curl -N -d '{"kinds":[1],"limit":2}' http://localhost:7447/req +# ["EVENT",{"id":"…","kind":1,…}] +# ["EVENT",{"id":"…","kind":1,…}] +# ["EOSE"] +curl -d '{"kinds":[1]}' http://localhost:7447/count # ["COUNT",{"count":2}] +curl -d @signed-event.json http://localhost:7447/event # ["OK","",true,""] +``` + +A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or +`OK` (EVENT) was cut off. On an AUTH-gated relay the answer is `401` until the +request carries a NIP-98 `Authorization: Nostr …` header whose `payload` is the +body's sha256. Quartz's `HttpRelayClient` does all of this for JVM/Android +clients. ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index 4651105623..2340424c6e 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -19,7 +19,8 @@ contact = "admin@example.com" # pubkey = "..." # Override the supported NIPs advertised on the NIP-11 endpoint. If # omitted, the relay advertises the NIPs it actually implements. -# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62] +# Hex-named NIPs go in as strings. +# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62, "FE"] [network] host = "0.0.0.0" @@ -165,6 +166,35 @@ require_auth = false # backfill_seconds = 3600 # filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}' +[http] +# NIP-FE: relay commands over HTTP. One command per POST to +# /req, /count or /event, answered as NDJSON +# (application/x-ndjson) and streamed as it is found; nothing stays open +# afterwards. NIP-98 `Authorization: Nostr ...` headers sign a request +# in, exactly as NIP-42 AUTH would on the socket. On by default; turning +# it off also drops "FE" from the default NIP-11 list. +enabled = true +# Every request is its own connection, so the websocket's +# per-connection limits don't bound HTTP clients. These do: over the +# per-client cap a request gets 429, over the global cap 503, both with +# Retry-After. 0 = no limit. +max_concurrent_requests = 256 +max_requests_per_client = 16 +# An answer still running after this ends on a CLOSED line. +deadline_seconds = 30 +max_body_bytes = 524288 +retry_after_seconds = 1 +# Other addresses this relay is reachable at: a NIP-98 token may name +# the endpoint under any of them, as well as under [info].relay_url. +# alternate_urls = ["ws://youraddress.onion/"] +# Behind a reverse proxy every request comes from the proxy's address. +# List the proxies here and the per-client cap counts the address the +# proxy writes in client_address_header instead (its last entry). The +# header is ignored from anyone else. Also set `proxy_buffering off` +# (nginx) or equivalent; the relay sends X-Accel-Buffering: no. +# trusted_proxies = ["127.0.0.1"] +# client_address_header = "X-Forwarded-For" + [admin] # NIP-86 relay management API. When `pubkeys` is non-empty, the relay # accepts HTTP POST application/nostr+json+rpc on the same URL, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 0a1af895c1..8c05dbadb4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -20,13 +20,17 @@ */ package com.vitorpamplona.geode +import com.vitorpamplona.geode.server.HttpCommandSettings import com.vitorpamplona.geode.server.Nip11HttpRoute import com.vitorpamplona.geode.server.Nip86HttpRoute +import com.vitorpamplona.geode.server.NipFEHttpRoute import com.vitorpamplona.geode.server.WebSocketSessionPump import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import io.ktor.server.application.install import io.ktor.server.application.serverConfig import io.ktor.server.cio.CIO @@ -34,6 +38,7 @@ import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer import io.ktor.server.routing.get +import io.ktor.server.routing.options import io.ktor.server.routing.post import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets @@ -77,6 +82,11 @@ class KtorRelay( val workerGroupSize: Int? = null, /** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */ val callGroupSize: Int? = null, + /** + * NIP-FE: relay commands over HTTP at `/req`, `/count` and `/event`. On by default; null + * turns the endpoints off (and the operator should then drop `FE` from the NIP-11 doc). + */ + val httpCommands: HttpCommandSettings? = HttpCommandSettings(), ) { /** * NIP-86 HTTP adapter. Wraps the engine's [RelayEngine.nip86Server] @@ -104,6 +114,20 @@ class KtorRelay( private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json }) + /** + * NIP-FE. Each request runs on its own session of the same engine, so the websocket's policies + * and limits apply. A NIP-98 token must name the endpoint under `relay.url` read as http(s), or + * under one of the configured alternate URLs (a .onion). + */ + private val nipFERoute = + httpCommands?.let { settings -> + val origins = (listOf(relay.url) + settings.alternateUrls).map { it.toHttp() } + NipFEHttpRoute( + handler = HttpRelayHandler(relay.server, origins = { origins }, deadline = settings.deadline), + settings = settings, + ) + } + private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -171,6 +195,15 @@ class KtorRelay( post(path) { nip86Route.handle(call) } + // NIP-FE: one command per POST, answered as NDJSON. The paths + // hang off the relay's own path, as the NIP-98 `u` does. + nipFERoute?.let { route -> + HttpRelayCommand.entries.forEach { command -> + val endpoint = path.trimEnd('/') + command.path + post(endpoint) { route.handle(call, command) } + options(endpoint) { route.preflight(call) } + } + } webSocket(path) { if (shuttingDown) { // Just return — Ktor closes the WS for us. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 1ed17bbcdb..e4ddc8bdea 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -35,9 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy @@ -356,6 +354,7 @@ private fun serve(args: Array) { connectionGroupSize = config.network.connection_group_size, workerGroupSize = config.network.worker_group_size, callGroupSize = config.network.call_group_size, + httpCommands = config.http.toSettings(), ).start() // `[[mirror]]` upstreams: dial each configured relay and stream its @@ -526,9 +525,9 @@ private fun composePolicy( val pieces = mutableListOf() if (requireAuth) { - pieces += FullAuthPolicy(advertisedUrl) + pieces += SignInPolicy(advertisedUrl) } else if (optionalAuth) { - pieces += OptionalAuthPolicy(advertisedUrl) + pieces += OptionalSignInPolicy(advertisedUrl) } config.options.reject_future_seconds?.let { secs -> diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index 4170fa14b4..92f294b519 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -62,9 +62,10 @@ data class RelayInfo( * - 62 NIP-62 right to vanish * - 77 NIP-77 negentropy reconciliation * - 86 NIP-86 relay management API (when admin pubkeys configured) + * - FE NIP-FE relay commands over HTTP (KtorRelay; `[http].enabled`) */ val SUPPORTED_NIPS: List = - listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86") + listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86", "FE") /** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */ fun default(url: NormalizedRelayUrl): RelayInfo = diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt new file mode 100644 index 0000000000..b246219bab --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt @@ -0,0 +1,43 @@ +/* + * 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.geode + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy + +/** + * Geode's AUTH decides nothing past the NIP-42 proof, so a key a NIP-98 header proved on a NIP-FE + * command signs in just the same: the transport's proof is all the socket's would have been. + */ +internal class SignInPolicy( + relay: NormalizedRelayUrl, +) : FullAuthPolicy(relay) { + override suspend fun authorizeTransport(pubkey: HexKey): String? = null +} + +/** [SignInPolicy] for optional AUTH. */ +internal class OptionalSignInPolicy( + relay: NormalizedRelayUrl, +) : OptionalAuthPolicy(relay) { + override suspend fun authorizeTransport(pubkey: HexKey): String? = null +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 8cfecb4e41..3528cbc8e4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -21,12 +21,15 @@ package com.vitorpamplona.geode.config import cc.ekblad.toml.decode +import cc.ekblad.toml.model.TomlValue import cc.ekblad.toml.tomlMapper import com.vitorpamplona.geode.RelayInfo +import com.vitorpamplona.geode.server.HttpCommandSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import java.io.File +import kotlin.time.Duration.Companion.seconds /** * Operator-facing **boot-time** configuration. Parsed once from a TOML @@ -45,10 +48,14 @@ data class StaticConfig( val authorization: AuthorizationSection = AuthorizationSection(), val admin: AdminSection = AdminSection(), val negentropy: NegentropySection = NegentropySection(), + val http: HttpSection = HttpSection(), /** `[[mirror]]` entries — upstream relays this relay streams from. */ val mirror: List = emptyList(), ) { - fun resolveInfo(fullTextSearch: Boolean = true): RelayInfo = + fun resolveInfo( + fullTextSearch: Boolean = true, + httpCommands: Boolean = http.enabled, + ): RelayInfo = RelayInfo( Nip11RelayInformation( name = info.name ?: RelayInfo.NAME, @@ -59,10 +66,10 @@ data class StaticConfig( software = info.software ?: RelayInfo.SOFTWARE, version = info.version ?: RelayInfo.VERSION, supported_nips = - info.supported_nips?.map(Int::toString) + info.supported_nips // An explicit [info] nips list is operator-authoritative; - // the default list stays honest about search. - ?: if (fullTextSearch) RelayInfo.SUPPORTED_NIPS else RelayInfo.SUPPORTED_NIPS - "50", + // the default list stays honest about search and HTTP commands. + ?: RelayInfo.SUPPORTED_NIPS.filter { (fullTextSearch || it != "50") && (httpCommands || it != "FE") }, privacy_policy = info.privacy_policy, terms_of_service = info.terms_of_service, relay_countries = info.relay_countries, @@ -80,7 +87,8 @@ data class StaticConfig( val icon: String? = null, val software: String? = null, val version: String? = null, - val supported_nips: List? = null, + /** NIP numbers, and the hex-named NIPs as strings: `[1, 11, 42, "FE"]`. */ + val supported_nips: List? = null, val privacy_policy: String? = null, val terms_of_service: String? = null, val relay_countries: List? = null, @@ -223,6 +231,53 @@ data class StaticConfig( val live_index: Boolean = true, ) + /** + * NIP-FE: relay commands over HTTP — `POST /req`, `/count`, `/event`, one command per + * request, answered as NDJSON. Each request is its own connection, so the concurrency caps here + * stand in for the websocket's per-connection limits. + */ + data class HttpSection( + val enabled: Boolean = true, + /** Requests running at once across all clients; over it, 503. 0 = no limit. */ + val max_concurrent_requests: Int = 256, + /** Requests one client address may run at once; over it, 429. 0 = no limit. */ + val max_requests_per_client: Int = 16, + /** How long one answer may run before it ends on a `CLOSED` line. */ + val deadline_seconds: Long = 30, + /** Largest request body read; larger is 413. */ + val max_body_bytes: Int = 512 * 1024, + /** `Retry-After` on the relay's own 429 and 503. */ + val retry_after_seconds: Int = 1, + /** + * Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name). + * A NIP-98 token's `u` may name the endpoint under any of them or under `[info].relay_url`. + */ + val alternate_urls: List = emptyList(), + /** + * Addresses of the reverse proxies in front of the relay. Only from these peers is + * [client_address_header] believed when counting a client's requests. + */ + val trusted_proxies: List = emptyList(), + val client_address_header: String = "X-Forwarded-For", + ) { + /** The transport settings, or null when the endpoints are off. */ + fun toSettings(): HttpCommandSettings? = + if (!enabled) { + null + } else { + HttpCommandSettings( + maxConcurrent = max_concurrent_requests, + maxPerClient = max_requests_per_client, + deadline = deadline_seconds.seconds, + maxBodyBytes = max_body_bytes, + retryAfterSeconds = retry_after_seconds, + alternateUrls = alternate_urls.map { it.normalizeRelayUrl() }, + trustedProxies = trusted_proxies.toSet(), + clientAddressHeader = client_address_header, + ) + } + } + data class AuthorizationSection( val pubkey_whitelist: List = emptyList(), val pubkey_blacklist: List = emptyList(), @@ -297,6 +352,11 @@ data class StaticConfig( * the store. */ fun validate() { + require(http.max_concurrent_requests >= 0) { "[http].max_concurrent_requests must be >= 0 (0 = no limit), got ${http.max_concurrent_requests}" } + require(http.max_requests_per_client >= 0) { "[http].max_requests_per_client must be >= 0 (0 = no limit), got ${http.max_requests_per_client}" } + require(http.deadline_seconds > 0) { "[http].deadline_seconds must be > 0, got ${http.deadline_seconds}" } + require(http.max_body_bytes > 0) { "[http].max_body_bytes must be > 0, got ${http.max_body_bytes}" } + require(http.retry_after_seconds >= 0) { "[http].retry_after_seconds must be >= 0, got ${http.retry_after_seconds}" } database.readers?.let { require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" } } @@ -308,7 +368,11 @@ data class StaticConfig( } companion object { - private val mapper = tomlMapper { } + private val mapper = + tomlMapper { + // `supported_nips = [1, 11, "FE"]`: numbered NIPs are integers, hex-named ones strings. + decoder { it: TomlValue.Integer -> it.value.toString() } + } fun fromToml(toml: String): StaticConfig = mapper.decode(toml) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt new file mode 100644 index 0000000000..7634d48939 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt @@ -0,0 +1,48 @@ +/* + * 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.geode.server + +import io.ktor.http.HttpHeaders +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receiveChannel +import io.ktor.utils.io.readAvailable + +/** + * Reads the request body up to [cap] bytes. Returns null when it is larger — by its declared + * `Content-Length` or by what actually arrives — without reading past the cap; the caller answers 413. + */ +internal suspend fun readBoundedBody( + call: ApplicationCall, + cap: Int, +): ByteArray? { + val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (declared != null && declared > cap) return null + val ch = call.receiveChannel() + val buf = ByteArray(cap + 1) + var pos = 0 + while (pos <= cap) { + val read = ch.readAvailable(buf, pos, buf.size - pos) + if (read <= 0) break + pos += read + } + if (pos > cap) return null + return buf.copyOfRange(0, pos) +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt new file mode 100644 index 0000000000..939d3ab22d --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt @@ -0,0 +1,89 @@ +/* + * 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.geode.server + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * How many NIP-FE requests run at once, per client and in all. Each HTTP request is its own + * connection, so the per-connection limits the websocket leans on (open subscriptions, one search at + * a time) bound nothing here; this does. A request over its client's share is that client's to + * slow down (429); one over the relay's is everyone's (503). + */ +internal class HttpAdmission( + /** Requests running at once across all clients; 0 is no limit. */ + private val maxConcurrent: Int, + /** Requests one client may run at once; 0 is no limit. */ + private val maxPerClient: Int, +) { + enum class Verdict { ADMITTED, CLIENT_BUSY, RELAY_BUSY } + + private val running = AtomicInteger() + private val perClient = ConcurrentHashMap() + + /** Number of requests running now. */ + val inFlight: Int get() = running.get() + + /** Runs [block] if [client] and the relay have room; otherwise says which of them had none. */ + suspend fun admit( + client: String, + block: suspend () -> Unit, + ): Verdict { + val verdict = enter(client) + if (verdict != Verdict.ADMITTED) return verdict + try { + block() + } finally { + leave(client) + } + return verdict + } + + private fun enter(client: String): Verdict { + var clientFull = false + perClient.compute(client) { _, n -> + val now = n ?: 0 + if (maxPerClient in 1..now) { + clientFull = true + n + } else { + now + 1 + } + } + if (clientFull) return Verdict.CLIENT_BUSY + if (running.incrementAndGet().let { maxConcurrent in 1 until it }) { + running.decrementAndGet() + release(client) + return Verdict.RELAY_BUSY + } + return Verdict.ADMITTED + } + + private fun leave(client: String) { + running.decrementAndGet() + release(client) + } + + private fun release(client: String) { + perClient.computeIfPresent(client) { _, n -> if (n <= 1) null else n - 1 } + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt new file mode 100644 index 0000000000..9955f3884d --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -0,0 +1,53 @@ +/* + * 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.geode.server + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler +import kotlin.time.Duration + +/** + * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: `POST` to + * `/req`, `/count` and `/event`. The engine's policies and limits apply as they do on + * the websocket; these bound what the websocket's per-connection limits cannot, since every request + * is its own connection. + */ +data class HttpCommandSettings( + /** Requests running at once across all clients before the rest get 503; 0 is no limit. */ + val maxConcurrent: Int = 256, + /** Requests one client address may run at once before its next gets 429; 0 is no limit. */ + val maxPerClient: Int = 16, + /** How long one answer may run, first byte to last. */ + val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE, + /** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */ + val maxBodyBytes: Int = 512 * 1024, + /** The `Retry-After` sent with a 429 or 503 the relay decides itself. */ + val retryAfterSeconds: Int = 1, + /** Other URLs this relay answers at (its .onion); a NIP-98 `u` may name any of them. */ + val alternateUrls: List = emptyList(), + /** + * Peers whose [clientAddressHeader] is believed: the reverse proxies in front of the relay. A + * request from anyone else is counted under its own address, whatever the header says. + */ + val trustedProxies: Set = emptySet(), + /** Where a trusted proxy writes the client's address; its last entry is the one the proxy saw. */ + val clientAddressHeader: String = "X-Forwarded-For", +) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt index 5c3e49d800..13dd281efb 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt @@ -26,9 +26,7 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.request.header -import io.ktor.server.request.receiveChannel import io.ktor.server.response.respondText -import io.ktor.utils.io.readAvailable /** * Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by @@ -55,7 +53,12 @@ internal class Nip86HttpRoute( private val handler: Nip86HttpHandler, ) { suspend fun handle(call: ApplicationCall) { - val body = readBoundedBody(call, handler.maxBodyBytes) ?: return // 413 already sent + val body = + readBoundedBody(call, handler.maxBodyBytes) ?: return call.respondText( + "request body exceeds ${handler.maxBodyBytes}-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) val authHeader = call.request.header(HttpHeaders.Authorization) when (val r = handler.handle(authHeader, body)) { @@ -111,43 +114,6 @@ internal class Nip86HttpRoute( } } - /** - * Bounded read using [cap]. Returns null after sending a 413 if - * the request body exceeds the cap — either the declared - * `Content-Length` or what we actually pull off the wire. - */ - private suspend fun readBoundedBody( - call: ApplicationCall, - cap: Int, - ): ByteArray? { - val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() - if (declared != null && declared > cap) { - call.respondText( - "request body exceeds $cap-byte cap", - ContentType.Text.Plain, - HttpStatusCode.PayloadTooLarge, - ) - return null - } - val ch = call.receiveChannel() - val buf = ByteArray(cap + 1) - var pos = 0 - while (pos <= cap) { - val read = ch.readAvailable(buf, pos, buf.size - pos) - if (read <= 0) break - pos += read - } - if (pos > cap) { - call.respondText( - "request body exceeds $cap-byte cap", - ContentType.Text.Plain, - HttpStatusCode.PayloadTooLarge, - ) - return null - } - return buf.copyOfRange(0, pos) - } - /** * Single-line stderr audit. Operators grep on "nip86" / pubkey / * method without needing a logging framework. Best-effort — a diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt new file mode 100644 index 0000000000..fb616548ae --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -0,0 +1,168 @@ +/* + * 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.geode.server + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayLines +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayRequest +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayResponse +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayStatus +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.header +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytesWriter +import io.ktor.server.response.respondText +import io.ktor.utils.io.writeStringUtf8 + +/** + * NIP-FE over Ktor: the host half of [HttpRelayHandler]. It admits the request, reads the body up to + * the cap, and writes the handler's answer as `application/x-ndjson` — one refusal line with its + * status, or a 200 streamed and flushed frame by frame — with the headers the status calls for + * (`WWW-Authenticate` on 401, `Retry-After` on 429 and 503) and the CORS and no-buffering headers + * every answer carries. + */ +internal class NipFEHttpRoute( + private val handler: HttpRelayHandler, + private val settings: HttpCommandSettings, +) { + private val admission = HttpAdmission(settings.maxConcurrent, settings.maxPerClient) + + private val bodyCap: Int = minOf(settings.maxBodyBytes.toLong(), handler.maxBodyBytes ?: Long.MAX_VALUE).toInt() + + /** Requests being answered right now. */ + val inFlight: Int get() = admission.inFlight + + /** A CORS preflight: any origin may POST with a NIP-98 `Authorization`; no cookies are involved. */ + suspend fun preflight(call: ApplicationCall) { + call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") + call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS") + call.response.header(HttpHeaders.AccessControlAllowHeaders, "Authorization, Content-Type") + call.response.header(HttpHeaders.AccessControlMaxAge, PREFLIGHT_MAX_AGE_SECONDS.toString()) + call.respond(HttpStatusCode.NoContent) + } + + /** Answers one [command]. Admission runs first, so a refused request spends no NIP-98 token. */ + suspend fun handle( + call: ApplicationCall, + command: HttpRelayCommand, + ) { + call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") + call.response.header(HttpHeaders.AccessControlExposeHeaders, "${HttpHeaders.WWWAuthenticate}, ${HttpHeaders.RetryAfter}") + call.response.header(HttpHeaders.CacheControl, "no-store") + // nginx and friends buffer responses by default, which holds back the lines streaming delivers. + call.response.header(ACCEL_BUFFERING, "no") + + val verdict = + admission.admit(clientOf(call)) { + val body = + readBoundedBody(call, bodyCap) + ?: return@admit respondLine( + call, + HttpRelayStatus.PAYLOAD_TOO_LARGE, + HttpRelayHandler.refusal(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + ) + handler.handle(HttpRelayRequest(command, call.request.header(HttpHeaders.Authorization), body), Answer(call)) + } + when (verdict) { + HttpAdmission.Verdict.ADMITTED -> {} + + HttpAdmission.Verdict.CLIENT_BUSY -> { + respondLine( + call, + HttpRelayStatus.TOO_MANY_REQUESTS, + HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), + ) + } + + HttpAdmission.Verdict.RELAY_BUSY -> { + respondLine( + call, + HttpRelayStatus.UNAVAILABLE, + HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")), + ) + } + } + } + + /** + * Who the request counts against: the peer's address, or, when the peer is a trusted proxy, the + * last address in its [HttpCommandSettings.clientAddressHeader] — the one that proxy saw. Earlier + * entries are whatever the client claimed and are never believed. + */ + private fun clientOf(call: ApplicationCall): String { + val peer = call.request.local.remoteAddress + if (peer !in settings.trustedProxies) return peer + return call.request + .header(settings.clientAddressHeader) + ?.substringAfterLast(',') + ?.trim() + ?.ifEmpty { null } ?: peer + } + + /** A one-line answer: a refusal, or a command answered at once. */ + private suspend fun respondLine( + call: ApplicationCall, + status: Int, + frame: String, + ) { + when (status) { + HttpRelayStatus.UNAUTHORIZED -> call.response.header(HttpHeaders.WWWAuthenticate, WWW_AUTHENTICATE) + HttpRelayStatus.TOO_MANY_REQUESTS, HttpRelayStatus.UNAVAILABLE -> call.response.header(HttpHeaders.RetryAfter, settings.retryAfterSeconds.toString()) + } + call.respondText(frame + "\n", NDJSON, HttpStatusCode.fromValue(status)) + } + + private inner class Answer( + private val call: ApplicationCall, + ) : HttpRelayResponse { + override suspend fun single( + status: Int, + frame: String, + ) = respondLine(call, status, frame) + + /** Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the writer. */ + override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = + call.respondBytesWriter(NDJSON, HttpStatusCode.OK) { + val out = this + object : HttpRelayLines { + override suspend fun line(frame: String) { + out.writeStringUtf8(frame) + out.writeStringUtf8("\n") + } + + override suspend fun flush() = out.flush() + }.lines() + } + } + + companion object { + val NDJSON = ContentType("application", "x-ndjson") + const val WWW_AUTHENTICATE = "Nostr" + const val ACCEL_BUFFERING = "X-Accel-Buffering" + const val PREFLIGHT_MAX_AGE_SECONDS = 86_400 + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt new file mode 100644 index 0000000000..e9fd20927a --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -0,0 +1,307 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.server.HttpCommandSettings +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.net.ServerSocket +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +/** + * NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay], + * covering the answer shape, the status table, the headers each status carries, NIP-98 sign-in + * on an AUTH-gated relay, and where the endpoints live. + */ +class NipFEHttpTest { + private val http = OkHttpClient.Builder().build() + private val alice = NostrSignerInternal(KeyPair()) + private val running = mutableListOf>() + + @AfterTest + fun teardown() { + running.forEach { (server, relay) -> + server.stop(0, 1_000) + relay.close() + } + } + + /** A relay whose advertised URL is the one it listens on, so a NIP-98 `u` names a reachable endpoint. */ + private fun start( + path: String = "/", + settings: HttpCommandSettings? = HttpCommandSettings(), + policy: ((NormalizedRelayUrl) -> IRelayPolicy)? = null, + ): NormalizedRelayUrl { + val port = ServerSocket(0).use { it.localPort } + val url = "ws://127.0.0.1:$port$path".normalizeRelayUrl() + val relay = if (policy == null) RelayEngine(url) else RelayEngine(url, policyBuilder = { policy(url) }) + val server = KtorRelay(relay, host = "127.0.0.1", port = port, path = path, httpCommands = settings).start() + running += server to relay + return url + } + + private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(http, signer) + + private suspend fun note(text: String): Event = alice.sign(TextNoteEvent.build(text)) + + private fun post( + url: String, + body: String, + authorization: String? = null, + ): Response = + http + .newCall( + Request + .Builder() + .url(url) + .post(body.toRequestBody("text/plain".toMediaType())) + .apply { authorization?.let { header("Authorization", it) } } + .build(), + ).execute() + + private fun Response.lines() = body.string().lines().filter { it.isNotEmpty() } + + @Test + fun aReqStreamsWhatWasPublishedAndEndsOnEose() = + runBlocking { + val relay = start() + val notes = listOf(note("one"), note("two"), note("three")) + notes.forEach { assertTrue(assertIs(client().publish(relay, it).last).success) } + + val got = mutableListOf() + val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), got::add) + assertEquals(200, answer.status) + assertTrue(answer.complete) + assertIs(answer.last) + assertEquals(notes.map { it.id }.toSet(), got.map { it.id }.toSet()) + } + + @Test + fun theWireIsNdjsonWithoutSubscriptionIds() = + runBlocking { + val relay = start() + val n = note("hello") + client().publish(relay, n) + post(HttpRelayCommand.REQ.url(relay), """{"ids":["${n.id}"]}""").use { response -> + assertEquals(200, response.code) + assertTrue(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + assertEquals("no", response.header("X-Accel-Buffering")) + assertEquals("*", response.header("Access-Control-Allow-Origin")) + val lines = response.lines() + assertEquals(listOf("""["EVENT",${n.toJson()}]""", """["EOSE"]"""), lines) + } + } + + @Test + fun aCountIsOneCountLine() = + runBlocking { + val relay = start() + client().publish(relay, note("a")) + client().publish(relay, note("b")) + val answer = client().count(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND)))) + assertTrue(answer.complete) + assertEquals(2, assertIs(answer.last).result.count) + } + + @Test + fun aDuplicateIs200AndAForgeryIs400() = + runBlocking { + val relay = start(policy = { VerifyPolicy }) + val n = note("once") + assertEquals(200, client().publish(relay, n).status) + assertEquals(200, client().publish(relay, n).status) + + val forged = n.toJson().replace("\"once\"", "\"twice\"") + post(HttpRelayCommand.EVENT.url(relay), forged).use { response -> + assertEquals(400, response.code) + val line = response.lines().single() + assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line) + } + } + + @Test + fun aBodyThatIsNotTheCommandIs400AndOneOverTheCapIs413() { + val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64)) + post(HttpRelayCommand.REQ.url(relay), "hello").use { response -> + assertEquals(400, response.code) + assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + } + post(HttpRelayCommand.REQ.url(relay), """{"authors":["${"a".repeat(64)}"]}""").use { response -> + assertEquals(413, response.code) + assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + } + } + + @Test + fun aRateLimitedRefusalIs429WithRetryAfter() { + val relay = + start( + settings = HttpCommandSettings(retryAfterSeconds = 7), + policy = { + object : PassThroughPolicy() { + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Rejected("rate-limited: slow down") + } + }, + ) + post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + assertEquals(429, response.code) + assertEquals("7", response.header("Retry-After")) + assertEquals("""["CLOSED","rate-limited: slow down"]""", response.lines().single()) + } + } + + @Test + fun aPreflightLetsAnyOriginPostWithAuthorization() { + val relay = start() + val preflight = + Request + .Builder() + .url(HttpRelayCommand.REQ.url(relay)) + .method("OPTIONS", null) + .header("Origin", "https://app.example") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "authorization") + .build() + http.newCall(preflight).execute().use { response -> + assertEquals(204, response.code) + assertEquals("*", response.header("Access-Control-Allow-Origin")) + assertTrue(response.header("Access-Control-Allow-Methods")!!.contains("POST")) + assertTrue(response.header("Access-Control-Allow-Headers")!!.contains("Authorization")) + } + } + + @Test + fun anAuthGatedRelayAnswers401UntilANip98TokenSignsTheRequestIn() = + runBlocking { + val relay = start(policy = ::SignInPolicy) + post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + assertEquals(401, response.code) + assertEquals("Nostr", response.header("WWW-Authenticate")) + assertTrue(response.lines().single().startsWith("""["CLOSED","auth-required:""")) + } + + val unsigned = client().req(relay, listOf(Filter(kinds = listOf(1)))) {} + assertEquals(401, unsigned.status) + assertTrue(unsigned.complete) + + val n = note("signed in") + val published = client(alice).publish(relay, n) + assertEquals(200, published.status) + assertTrue(assertIs(published.last).success) + + val got = mutableListOf() + val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), got::add) + assertEquals(200, read.status) + assertTrue(read.complete) + assertEquals(listOf(n.id), got.map { it.id }) + } + + @Test + fun aTokenForAnotherBodyDoesNotSignIn() = + runBlocking { + val relay = start(policy = ::SignInPolicy) + val url = HttpRelayCommand.REQ.url(relay) + val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", """{"kinds":[1]}""".encodeToByteArray())).toAuthToken() + post(url, """{"kinds":[0]}""", token).use { response -> + assertEquals(401, response.code) + assertTrue(response.lines().single().contains("payload")) + } + post(url, """{"kinds":[1]}""", token).use { assertEquals(200, it.code) } + } + + @Test + fun aTokenSignedAtTheOnionAddressVerifies() = + runBlocking { + val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl() + val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion))) + val body = """{"kinds":[1]}""" + val token = alice.sign(HTTPAuthorizationEvent.build(HttpRelayCommand.REQ.url(onion), "POST", body.encodeToByteArray())).toAuthToken() + post(HttpRelayCommand.REQ.url(relay), body, token).use { assertEquals(200, it.code) } + } + + @Test + fun theEndpointsHangOffTheRelayPath() = + runBlocking { + val relay = start(path = "/nostr") + assertTrue(HttpRelayCommand.REQ.url(relay).endsWith("/nostr/req")) + assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete) + post(HttpRelayCommand.REQ.url(relay).replace("/nostr/req", "/req"), "{}").use { assertEquals(404, it.code) } + } + + @Test + fun turnedOffThereAreNoEndpoints() { + val relay = start(settings = null) + post(HttpRelayCommand.REQ.url(relay), "{}").use { assertEquals(404, it.code) } + } + + @Test + fun nip11AdvertisesFE() { + val relay = start() + val request = + Request + .Builder() + .url(relay.url.replace("ws://", "http://")) + .header("Accept", "application/nostr+json") + .build() + http.newCall(request).execute().use { response -> + assertTrue(response.body.string().contains("\"FE\"")) + } + } + + @Test + fun noFirstFrameWithinTheDeadlineIs503WithRetryAfter() = + runBlocking { + val relay = start(settings = HttpCommandSettings(deadline = Duration.ZERO, retryAfterSeconds = 3)) + val answer = client().req(relay, listOf(Filter(kinds = listOf(1)))) {} + assertEquals(503, answer.status) + assertEquals("3", answer.retryAfter) + assertTrue(answer.complete) + assertFalse(answer.last is EoseMessage) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index 2ba0823441..a9469e37aa 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -28,6 +28,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds class StaticConfigTest { @Test @@ -219,7 +220,7 @@ class StaticConfigTest { assertEquals("wss://relay.example.com/", c.info.relay_url) assertEquals("Example", c.info.name) - assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips) + assertEquals(listOf("1", "9", "11", "42"), c.info.supported_nips) assertEquals("127.0.0.1", c.network.host) assertEquals(9988, c.network.port) @@ -249,6 +250,56 @@ class StaticConfigTest { assertEquals(listOf("1", "11", "42"), info.document.supported_nips) } + @Test + fun supportedNipsTakeHexNamedNipsAsStrings() { + val c = + StaticConfig.fromToml( + """ + [info] + supported_nips = [1, 11, "FE"] + """.trimIndent(), + ) + assertEquals(listOf("1", "11", "FE"), c.resolveInfo().document.supported_nips) + } + + @Test + fun httpCommandsAreOnByDefaultAndAdvertised() { + val c = StaticConfig.fromToml("") + assertTrue(c.http.enabled) + assertNotNull(c.http.toSettings()) + assertTrue("FE" in c.resolveInfo().document.supported_nips!!) + } + + @Test + fun httpSectionParsesAndTurningItOffDropsFE() { + val c = + StaticConfig.fromToml( + """ + [http] + enabled = false + max_concurrent_requests = 10 + max_requests_per_client = 2 + deadline_seconds = 5 + alternate_urls = ["ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/"] + trusted_proxies = ["127.0.0.1"] + """.trimIndent(), + ) + assertEquals(10, c.http.max_concurrent_requests) + assertEquals(2, c.http.max_requests_per_client) + assertEquals(listOf("127.0.0.1"), c.http.trusted_proxies) + assertEquals(null, c.http.toSettings()) + assertTrue("FE" !in c.resolveInfo().document.supported_nips!!) + val on = c.copy(http = c.http.copy(enabled = true)).http.toSettings()!! + assertEquals(5.seconds, on.deadline) + assertEquals(1, on.alternateUrls.size) + } + + @Test + fun httpLimitsMustBeSane() { + assertFailsWith { StaticConfig.fromToml("[http]\ndeadline_seconds = 0").validate() } + assertFailsWith { StaticConfig.fromToml("[http]\nmax_requests_per_client = -1").validate() } + } + @Test fun loadsTheBundledExampleConfigCleanly() { // The example file lives at the module root so operators have a diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt new file mode 100644 index 0000000000..ed2cbb77af --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt @@ -0,0 +1,71 @@ +/* + * 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.geode.server + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals + +class HttpAdmissionTest { + @Test + fun aClientOverItsShareIsBusyAndARelayOverItsCapIsTooWhileOthersStillRun() = + runBlocking { + val admission = HttpAdmission(maxConcurrent = 2, maxPerClient = 1) + val release = CompletableDeferred() + val a = async { admission.admit("a") { release.await() } } + val b = async { admission.admit("b") { release.await() } } + while (admission.inFlight < 2) yield() + + assertEquals(HttpAdmission.Verdict.CLIENT_BUSY, admission.admit("a") {}) + assertEquals(HttpAdmission.Verdict.RELAY_BUSY, admission.admit("c") {}) + + release.complete(Unit) + assertEquals(HttpAdmission.Verdict.ADMITTED, a.await()) + assertEquals(HttpAdmission.Verdict.ADMITTED, b.await()) + assertEquals(0, admission.inFlight) + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {}) + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("c") {}) + } + + @Test + fun zeroIsNoLimit() = + runBlocking { + val admission = HttpAdmission(maxConcurrent = 0, maxPerClient = 0) + val release = CompletableDeferred() + val held = List(50) { async { admission.admit("a") { release.await() } } } + while (admission.inFlight < 50) yield() + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {}) + release.complete(Unit) + held.forEach { assertEquals(HttpAdmission.Verdict.ADMITTED, it.await()) } + } + + @Test + fun aFailingRequestStillLeaves() = + runBlocking { + val admission = HttpAdmission(maxConcurrent = 1, maxPerClient = 1) + runCatching { admission.admit("a") { error("boom") } } + assertEquals(0, admission.inFlight) + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {}) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt new file mode 100644 index 0000000000..7af34712f4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt @@ -0,0 +1,100 @@ +/* + * 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.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message + +/** + * A NIP-FE answer as the client got it. [complete] is false when the body stopped before the frame + * that ends the command's answer: what came is a prefix, and a REQ's prefix looks exactly like a + * short result, so a caller MUST NOT read an incomplete answer as the whole one. + */ +class HttpRelayAnswer( + val status: Int, + /** The frame the answer ended on (EOSE, CLOSED, COUNT, OK, NOTICE), or the last one read when cut off. */ + val last: Message?, + /** Whether the answer reached its end, rather than being cut off. */ + val complete: Boolean, + /** The `Retry-After` the relay sent with a 429 or 503. */ + val retryAfter: String? = null, +) + +/** + * NIP-FE, client side: reads an answer one line at a time as it arrives and keeps track of whether + * it ended where [command]'s answer ends. A 200 streams up to that frame; any other status is one + * refusal line, which is the whole answer whatever frame it is. + */ +class HttpRelayAnswerReader( + val command: HttpRelayCommand, + val status: Int, +) { + var last: Message? = null + private set + + private var lines = 0 + private var ended = false + private var broken = false + + /** + * The frame on [line], typed with [HttpRelayCommand.SUB_ID] as its subscription id, or null for + * a blank line. A line that does not parse, or anything after the answer ended, marks the answer + * incomplete: the body is not what this NIP says it is. + */ + fun read(line: String): Message? { + if (line.isBlank()) return null + if (ended || broken) { + broken = true + return null + } + val message = + try { + OptimizedJsonMapper.fromJsonToMessage(withSubId(line.trim())) + } catch (_: Exception) { + broken = true + return null + } + lines++ + last = message + ended = status != HttpRelayStatus.OK || command.ends(message) + return message + } + + /** Whether the body read so far is a whole answer. Asked once the body ends. */ + val complete: Boolean get() = ended && !broken && (status == HttpRelayStatus.OK || lines == 1) + + fun answer(retryAfter: String? = null) = HttpRelayAnswer(status, last, complete, retryAfter) +} + +/** + * [frame] with the subscription id NIP-FE leaves out put back, so the websocket's parser reads it: + * `["EVENT",{…}]` → `["EVENT","http",{…}]`, `["EOSE"]` → `["EOSE","http"]`. The inverse of + * [withoutSubId]; frames that carry no subscription id pass as they are. + */ +internal fun withSubId(frame: String): String { + if (!frame.startsWith('[')) return frame + var open = 1 + while (open < frame.length && frame[open].isWhitespace()) open++ + if (open >= frame.length || frame[open] != '"') return frame + val verbEnd = frame.indexOf('"', open + 1) + if (verbEnd < 0 || frame.substring(open + 1, verbEnd) !in SUBSCRIPTION_FRAMES) return frame + return frame.substring(0, verbEnd + 1) + SUB_ID_FIELD + frame.substring(verbEnd + 1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt index cb5a865ea6..d0f51da283 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt @@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp /** * NIP-FE: the client commands HTTP carries, one path each. A body is the command's arguments @@ -69,6 +72,9 @@ enum class HttpRelayCommand( } } + /** This command's endpoint on [relay]: the relay URL read as http(s), host and path kept, plus [path]. */ + fun url(relay: NormalizedRelayUrl): String = relay.toHttp().trimEnd('/') + path + /** Whether [message] is the last frame of this command's answer. */ fun ends(message: Message): Boolean = message is NoticeMessage || @@ -86,5 +92,8 @@ enum class HttpRelayCommand( const val SUB_ID = "http" fun forPath(path: String): HttpRelayCommand? = entries.firstOrNull { it.path == path } + + /** A REQ or COUNT body: the filters as the array that follows the subscription id. */ + fun body(filters: List): String = filters.joinToString(",", "[", "]") { it.toJson() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt index a4054bbd08..d4333623f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt @@ -309,9 +309,12 @@ class HttpRelayHandler( return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${refusal?.reason}")) } - private fun closed(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + private fun closed(reason: String) = refusal(reason) companion object { + /** A `CLOSED` line with [reason], as NIP-FE sends it: for a host that refuses before the handler runs (413, 429, 503). */ + fun refusal(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + val DEFAULT_DEADLINE = 30_000.milliseconds /** The websocket's slow-consumer bound in the reference relays. */ @@ -322,9 +325,9 @@ class HttpRelayHandler( } /** The frames that carry a subscription id in the engine; NIP-FE sends them without it. */ -private val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") +internal val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") -private const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\"" +internal const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\"" /** * [frame] as NIP-FE sends it: the engine's frame with its `"http"` subscription id taken out, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt new file mode 100644 index 0000000000..3c00b6c9a1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt @@ -0,0 +1,127 @@ +/* + * 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.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-FE, client side: an answer's lines back into frames, and a whole answer told from a cut one. */ +class HttpRelayAnswerReaderTest { + private val event = + """{"id":"a8e0b2f2c1e7e4f5b0a1f9c1b3d2e6a7c8b9d0e1f2a3b4c5d6e7f8091a2b3c4d","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",""" + + """"created_at":1700000000,"kind":1,"tags":[],"content":"hi","sig":"${"0".repeat(128)}"}""" + + private fun read( + command: HttpRelayCommand, + status: Int, + vararg lines: String, + ) = HttpRelayAnswerReader(command, status).also { reader -> lines.forEach { reader.read(it) } } + + @Test + fun theSubscriptionIdGoesBackWhereItWasTakenOut() { + for (frame in listOf("""["EVENT","http",$event]""", """["EOSE","http"]""", """["CLOSED","http","error: x"]""", """["COUNT","http",{"count":3}]""")) { + assertEquals(frame, withSubId(withoutSubId(frame))) + } + assertEquals("""["OK","id",true,""]""", withSubId("""["OK","id",true,""]""")) + assertEquals("""["NOTICE","hi"]""", withSubId("""["NOTICE","hi"]""")) + assertEquals("""[ "EOSE","http"]""", withSubId("""[ "EOSE"]""")) + } + + @Test + fun aReqThatEndsOnEoseIsComplete() { + val reader = read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE"]""") + assertTrue(reader.complete) + assertIs(reader.last) + } + + @Test + fun aReqWithItsTailMissingIsIncomplete() { + val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200) + val message = reader.read("""["EVENT",$event]""") + assertIs(message) + assertEquals("hi", message.event.content) + assertEquals(HttpRelayCommand.SUB_ID, message.subId) + assertFalse(reader.complete) + assertFalse(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).complete, "an empty body is no answer") + } + + @Test + fun aClosedEndsAReqAndACountButNotAnEvent() { + assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["CLOSED","error: the answer ran past 30s"]""").complete) + assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","error: x"]""").complete) + assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","error: x"]""").complete) + } + + @Test + fun aCountAndAnOkAreWholeAnswers() { + val count = read(HttpRelayCommand.COUNT, 200, """["COUNT",{"count":7}]""") + assertTrue(count.complete) + assertEquals(7, assertIs(count.last).result.count) + val ok = read(HttpRelayCommand.EVENT, 200, """["OK","abc",true,""]""") + assertTrue(ok.complete) + assertTrue(assertIs(ok.last).success) + } + + @Test + fun aRefusalIsOneLineWhateverItsFrame() { + val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","auth-required: sign in"]""") + assertTrue(refused.complete) + assertEquals("auth-required: sign in", assertIs(refused.last).message) + assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","blocked: no"]""", """["EOSE"]""").complete) + } + + @Test + fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() { + assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", """["EVENT",$event]""").complete) + assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE""").complete) + assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", "", " ").complete, "blank lines are not frames") + } + + @Test + fun blankLinesAreSkipped() { + assertNull(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).read("")) + } + + @Test + fun theEndpointsHangOffTheRelayUrl() { + assertEquals("https://relay.example/req", HttpRelayCommand.REQ.url(NormalizedRelayUrl("wss://relay.example/"))) + assertEquals("http://127.0.0.1:7447/nostr/count", HttpRelayCommand.COUNT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr"))) + assertEquals("http://127.0.0.1:7447/nostr/event", HttpRelayCommand.EVENT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr/"))) + } + + @Test + fun aFilterBodyIsTheArrayAfterTheSubscriptionId() { + val body = HttpRelayCommand.body(listOf(Filter(kinds = listOf(1), limit = 2), Filter(kinds = listOf(0)))) + assertEquals("""[{"kinds":[1],"limit":2},{"kinds":[0]}]""", body) + assertEquals("""["REQ","http",{"kinds":[1],"limit":2},{"kinds":[0]}]""", HttpRelayCommand.REQ.frameOf(body)) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt new file mode 100644 index 0000000000..fca059543e --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt @@ -0,0 +1,153 @@ +/* + * 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.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync +import okio.IOException + +/** + * NIP-FE over OkHttp: one relay command per request, its answer read line by line as the relay + * writes it. Nothing stays open after a call returns. + * + * With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for + * its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the + * answer. [signFirst] sends the token with the first try instead, saving the round trip against a + * relay known to want it. + */ +class HttpRelayClient( + private val http: OkHttpClient, + private val signer: NostrSigner? = null, + private val signFirst: Boolean = false, +) { + /** Stored events matching [filters], each to [onEvent] as it arrives. Complete only if it ended on EOSE or CLOSED. */ + suspend fun req( + relay: NormalizedRelayUrl, + filters: List, + onEvent: (Event) -> Unit, + ): HttpRelayAnswer = + send(relay, HttpRelayCommand.REQ, HttpRelayCommand.body(filters)) { + if (it is EventMessage) onEvent(it.event) + } + + /** NIP-45: [HttpRelayAnswer.last] is the COUNT, or the refusal. */ + suspend fun count( + relay: NormalizedRelayUrl, + filters: List, + ): HttpRelayAnswer = send(relay, HttpRelayCommand.COUNT, HttpRelayCommand.body(filters)) + + /** Publishes [event]: [HttpRelayAnswer.last] is its OK, or the refusal. */ + suspend fun publish( + relay: NormalizedRelayUrl, + event: Event, + ): HttpRelayAnswer = send(relay, HttpRelayCommand.EVENT, event.toJson()) + + /** Posts [body] to [command]'s endpoint on [relay], handing every frame to [onMessage] as it is read. */ + suspend fun send( + relay: NormalizedRelayUrl, + command: HttpRelayCommand, + body: String, + onMessage: (Message) -> Unit = {}, + ): HttpRelayAnswer { + val url = command.url(relay) + val bytes = body.encodeToByteArray() + if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) + val first = post(command, url, bytes, null, onMessage, retrying = true) + if (first.status != HttpRelayStatus.UNAUTHORIZED) return first + return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) + } + + private suspend fun token( + url: String, + body: ByteArray, + ): String? = signer?.sign(HTTPAuthorizationEvent.build(url, "POST", body))?.toAuthToken() + + private suspend fun post( + command: HttpRelayCommand, + url: String, + body: ByteArray, + authorization: String?, + onMessage: (Message) -> Unit, + /** A signed try follows a 401, so that refusal is not the answer and is not handed on. */ + retrying: Boolean, + ): HttpRelayAnswer = + coroutineScope { + val request = + Request + .Builder() + .url(url) + .post(body.toRequestBody(JSON)) + .header("Accept", NDJSON) + .apply { authorization?.let { header("Authorization", it) } } + .build() + val call = http.newCall(request) + // A blocking read does not see coroutine cancellation; cancelling the call unblocks it. + val watcher = + launch { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + call.executeAsync().use { response -> + withContext(Dispatchers.IO) { + val reader = HttpRelayAnswerReader(command, response.code) + val deliver = !(retrying && response.code == HttpRelayStatus.UNAUTHORIZED) + val source = response.body.source() + try { + while (true) { + val line = source.readUtf8Line() ?: break + val message = reader.read(line) + if (message != null && deliver) onMessage(message) + } + } catch (_: IOException) { + // The connection dropped mid-answer: what came is what the reader says it is. + } + reader.answer(response.header("Retry-After")) + } + } + } finally { + watcher.cancel() + } + } + + companion object { + const val NDJSON = "application/x-ndjson" + private val JSON = "application/json".toMediaType() + } +} From d34e808ed473c2ddf4f4657fc47ff564117f7289 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 19:05:10 +0200 Subject: [PATCH 03/17] chore(cast): surface the receiver's verdict on load(), at INFO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Casting to an LG webOS TV connected but never rendered, and nothing in the log said why. Two gaps hid it: - `RemoteMediaClient.load()` returns a PendingResult carrying the receiver's accept/refuse verdict, and it was being discarded. A refused load was indistinguishable from a successful one: the coroutine reported Casting and the TV sat on its splash screen. Capture the result callback and log the refusal code and message. - Debug builds default to `LogLevel.INFO` (Amethyst.DEFAULT_LOG_LEVEL), so every cast diagnostic — all of them `Log.d` — was invisible on a debug client. Promote the low-volume lifecycle lines (discovery, route counts, session start/end, media status) to INFO and the hard failures to WARN. Also decode Cast's bare int status codes via CastStatusCodes.getStatusCodeString and log the ACCESS_LOCAL_NETWORK grant state at discovery time, since a denied LNP permission produces an empty picker with no error of any kind. `updateRoutes` now logs the unfiltered router total next to the kept count: seen=0 means discovery saw nothing, seen>0 kept=0 means the routes exist but none advertises the Cast control category — two faults that look identical from the UI. --- .../cast/chromecast/ChromecastCaster.kt | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index 9529ef6418..feef14d78f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -20,13 +20,18 @@ */ package com.vitorpamplona.amethyst.service.cast.chromecast +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.os.Handler import android.os.Looper +import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.CastStatusCodes import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata @@ -55,6 +60,9 @@ private const val TAG = "ChromecastCaster" private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L +/** Android 17 — the first release to enforce Local Network Protection. See [ChromecastCaster.localNetworkState]. */ +private const val LOCAL_NETWORK_PROTECTION_SDK = 37 + /** * Google Cast (Chromecast) caster. * @@ -123,7 +131,7 @@ class ChromecastCaster( override fun onStatusUpdated() { val client = currentMediaClient ?: return val status = client.mediaStatus - Log.d(TAG) { + Log.i(TAG) { "media.onStatusUpdated playerState=${playerStateName(client.playerState)} " + "idleReason=${idleReasonName(status?.idleReason ?: -1)} " + "pos=${client.approximateStreamPosition}/${client.streamDuration}ms" @@ -155,6 +163,14 @@ class ChromecastCaster( currentMediaClient = null } + /** + * Cast surfaces failures as bare ints spread across several unrelated ranges (CommonStatusCodes, + * CastStatusCodes, and internal codes documented nowhere). [CastStatusCodes.getStatusCodeString] + * is the SDK's own lookup, so it decodes far more than the public constants do — keep the raw + * number alongside it for the ones it doesn't recognise either. + */ + private fun statusName(code: Int): String = "$code(${CastStatusCodes.getStatusCodeString(code)})" + private fun playerStateName(state: Int): String = when (state) { MediaStatus.PLAYER_STATE_IDLE -> "IDLE" @@ -185,7 +201,7 @@ class ChromecastCaster( session: CastSession, sessionId: String, ) { - Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" } + Log.i(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" } attachMediaClientCallback(session) pendingSessionStart?.complete(true) pendingSessionStart = null @@ -195,7 +211,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG) { "session.onStartFailed error=$error" } + Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } pendingSessionStart?.complete(false) pendingSessionStart = null sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)") @@ -209,7 +225,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.d(TAG) { "session.onEnded error=$error" } + Log.i(TAG) { "session.onEnded error=${statusName(error)}" } // If a cast() was awaiting a session start, this is also a terminal // outcome — the session never reached a usable state. Without // completing here the cast coroutine hangs and the discovery @@ -242,7 +258,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG) { "session.onResumeFailed error=$error" } + Log.w(TAG) { "session.onResumeFailed error=${statusName(error)}" } pendingSessionStart?.complete(false) pendingSessionStart = null } @@ -275,7 +291,7 @@ class ChromecastCaster( val gms = GoogleApiAvailability.getInstance() val status = gms.isGooglePlayServicesAvailable(appContext) if (status != ConnectionResult.SUCCESS) { - Log.d(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." } + Log.w(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." } return null } return try { @@ -301,15 +317,15 @@ class ChromecastCaster( } fun startDiscovery() { - Log.d(TAG) { "startDiscovery (already registered? $registered)" } + Log.i(TAG) { "startDiscovery (already registered? $registered) ${localNetworkState()}" } main.post { if (registered) { - Log.d(TAG) { "startDiscovery: already registered, no-op" } + Log.i(TAG) { "startDiscovery: already registered, no-op" } return@post } val ctx = ensureCastContext() if (ctx == null) { - Log.d(TAG) { "startDiscovery: CastContext unavailable, aborting" } + Log.w(TAG) { "startDiscovery: CastContext unavailable, aborting" } return@post } val router = MediaRouter.getInstance(appContext) @@ -322,11 +338,25 @@ class ChromecastCaster( mediaRouter = router routeSelector = selector registered = true - Log.d(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" } + Log.i(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" } updateRoutes(router) } } + /** + * Android 17 (API 37) Local Network Protection gates the mDNS/multicast traffic the Cast SDK + * uses for discovery behind [Manifest.permission.ACCESS_LOCAL_NETWORK]. When it is denied the + * SDK reports no error at all — the picker simply stays empty forever — so the grant state is + * the single most important thing a discovery log can tell us apart from the route count. + */ + private fun localNetworkState(): String { + if (Build.VERSION.SDK_INT < LOCAL_NETWORK_PROTECTION_SDK) return "lnp=n/a(sdk${Build.VERSION.SDK_INT})" + val granted = + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED + return "lnp=sdk${Build.VERSION.SDK_INT} ACCESS_LOCAL_NETWORK=${if (granted) "GRANTED" else "DENIED"}" + } + fun stopDiscovery() { Log.d(TAG) { "stopDiscovery (registered=$registered)" } main.post { @@ -357,7 +387,11 @@ class ChromecastCaster( name = route.name, ) } - Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" } + // Log the unfiltered router total alongside the kept count: an empty picker with + // seen=0 means discovery itself never saw anything (LNP / Wi-Fi / mDNS), whereas + // seen>0 with count=0 means the routes exist but none advertises the Cast control + // category — two completely different faults that look identical from the UI. + Log.i(TAG) { "updateRoutes: seen=${router.routes.size} kept=${list.size} -> [${list.joinToString { it.name }}]" } devicesFlow.value = list } @@ -365,7 +399,7 @@ class ChromecastCaster( device: CastDevice, request: CastRequest, ) { - Log.d(TAG) { "cast device=${device.name} url=${request.url}" } + Log.i(TAG) { "cast device=${device.name} url=${request.url}" } val ctx = withContext(Dispatchers.Main) { ensureCastContext() } if (ctx == null) { Log.w(TAG, "cast: CastContext unavailable") @@ -438,8 +472,28 @@ class ChromecastCaster( false } else { try { - client.load(buildLoadRequest(request)) - Log.d(TAG) { "cast: load() submitted (status=${client.playerState})" } + val loadRequest = buildLoadRequest(request) + val info = loadRequest.mediaInfo + Log.i(TAG) { + "cast: load() submitting contentType=${info?.contentType} " + + "streamType=${info?.streamType} url=${info?.contentId}" + } + // load() returns a PendingResult carrying the receiver's verdict. Dropping it + // (as this used to) makes a refused load indistinguishable from a successful + // one: the coroutine reports Casting, the TV sits on its splash screen, and + // nothing anywhere records why. This callback is the only place the receiver + // ever tells us what it disliked about the media. + client.load(loadRequest).setResultCallback { result -> + val status = result.status + if (status.isSuccess) { + Log.i(TAG) { "cast: load() accepted by receiver" } + } else { + Log.w(TAG) { + "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + + "msg=${status.statusMessage}" + } + } + } true } catch (t: Throwable) { Log.w(TAG, "cast: remoteMediaClient.load failed", t) From 61d7800391b51198e2e0d56b5ada3f16ee457d02 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 19:34:11 +0200 Subject: [PATCH 04/17] Manual testing fixes fix(cast): stop a receiver switch from cancelling its own cast fix(cast): tell the receiver when a stream is live --- .../amethyst/service/cast/CastDevice.kt | 22 +++++ .../amethyst/service/cast/CastRoutePlan.kt | 65 +++++++++++++++ .../composable/controls/RenderTopButtons.kt | 9 +++ .../cast/chromecast/ChromecastCaster.kt | 57 +++++++++++-- .../amethyst/service/cast/CastLivenessTest.kt | 53 ++++++++++++ .../service/cast/CastRoutePlanTest.kt | 80 +++++++++++++++++++ 6 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index 670d34d339..99d18c6067 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -34,8 +34,30 @@ data class CastRequest( val mimeType: String? = null, val title: String? = null, val artworkUri: String? = null, + /** + * Whether the receiver should treat this as an endless live stream rather than a seekable + * recording. Resolve it with [resolveCastLiveness] — never from the URL, which cannot tell a + * live `.m3u8` from an on-demand one. + */ + val isLive: Boolean = false, ) +/** + * Decides what to tell the Cast receiver about a stream's liveness. + * + * [learned] is ExoPlayer's verdict for this URL (see HlsLivenessCache), recorded once it has parsed + * the playlist — the only signal that actually distinguishes a live `.m3u8` from an on-demand one. + * It wins whenever we have it. [metadataFlag] is the kind:30311 live-activity flag, which is right + * when set but absent for a live stream shared in a plain kind:1 note, so it is only the fallback. + * + * Defaulting to non-live when we know nothing keeps the previous behaviour for the common case + * (progressive MP4), where a live claim would cost the receiver its seek bar and duration. + */ +fun resolveCastLiveness( + learned: Boolean?, + metadataFlag: Boolean, +): Boolean = learned ?: metadataFlag + // Critical for HLS: sending an `.m3u8` URL with `video/mp4` makes the default // Cast receiver try to demux a playlist as MP4 and crash, wiping the TV's Cast // service from the network in the process. Strip query/fragment first so diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt new file mode 100644 index 0000000000..ba6a0a9847 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt @@ -0,0 +1,65 @@ +/* + * 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.amethyst.service.cast + +/** + * How a cast request should reach its target receiver, given what is already connected. + * + * @param needsRouteSelection whether MediaRouter has to be asked to move. + * @param expectsPreviousSessionToEnd whether an existing session will be torn down as a *result* of + * that move. The caller must not read that teardown as the new attempt failing. + */ +enum class CastRoutePlan( + val needsRouteSelection: Boolean, + val expectsPreviousSessionToEnd: Boolean, +) { + /** Already connected to this very receiver — load straight onto the live session. */ + REUSE_SESSION(needsRouteSelection = false, expectsPreviousSessionToEnd = false), + + /** Connected to a *different* receiver — selecting the target ends that session first. */ + SWAP_RECEIVER(needsRouteSelection = true, expectsPreviousSessionToEnd = true), + + /** Nothing connected — select the route and wait for the session to start. */ + SELECT_FRESH(needsRouteSelection = true, expectsPreviousSessionToEnd = false), +} + +/** + * Chooses the [CastRoutePlan] for a cast to [targetRouteId]. + * + * The distinction that matters is between reusing a session and swapping receivers. Treating any + * connected session as reusable — as this used to — means a session on the soundbar is taken as + * proof that a cast to the TV is already connected: the start completes instantly, `selectRoute()` + * then tears that session down, and by the time the media is loaded the session is gone, so the + * load is skipped and the TV sits on its splash screen having "connected" successfully. + * + * [hasConnectedSession] must reflect a session that is actually connected; a stale or suspended one + * cannot take a load and has to go through a fresh start. + */ +fun planRouteSelection( + targetRouteId: String, + selectedRouteId: String?, + hasConnectedSession: Boolean, +): CastRoutePlan = + when { + !hasConnectedSession -> CastRoutePlan.SELECT_FRESH + selectedRouteId == targetRouteId -> CastRoutePlan.REUSE_SESSION + else -> CastRoutePlan.SWAP_RECEIVER + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index db06db131e..0e63c53ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -74,10 +74,12 @@ import com.vitorpamplona.amethyst.model.VideoButtonLocation import com.vitorpamplona.amethyst.model.VideoPlayerAction import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState +import com.vitorpamplona.amethyst.service.cast.resolveCastLiveness import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia +import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog import com.vitorpamplona.amethyst.ui.cast.rememberCastWithLocalNetworkPermission @@ -501,6 +503,13 @@ fun RenderTopButtons( mimeType = mediaData.mimeType, title = mediaData.title, artworkUri = mediaData.artworkUri, + // By the time the cast button is reachable the player has already parsed the + // playlist, so the learned verdict is normally available here. + isLive = + resolveCastLiveness( + learned = HlsLivenessCache.verdict(mediaData.videoUri), + metadataFlag = mediaData.isLiveStream, + ), ), onDismiss = { castDialogVisible.value = false }, ) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index feef14d78f..18f99565a2 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -45,8 +45,10 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage import com.vitorpamplona.amethyst.service.cast.CastDevice import com.vitorpamplona.amethyst.service.cast.CastRequest +import com.vitorpamplona.amethyst.service.cast.CastRoutePlan import com.vitorpamplona.amethyst.service.cast.CastSessionState import com.vitorpamplona.amethyst.service.cast.effectiveMimeType +import com.vitorpamplona.amethyst.service.cast.planRouteSelection import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -225,12 +227,21 @@ class ChromecastCaster( session: CastSession, error: Int, ) { + detachMediaClientCallback() + // Moving to a different receiver ends the outgoing session by design, and that + // callback lands *after* cast() has installed the pending start for the incoming + // one. Failing it here is what made every device-to-device switch report + // "session start refused" and skip the load. + if (expectingSessionSwapEnd) { + expectingSessionSwapEnd = false + Log.i(TAG) { "session.onEnded error=${statusName(error)} — expected teardown while switching receivers" } + return + } Log.i(TAG) { "session.onEnded error=${statusName(error)}" } // If a cast() was awaiting a session start, this is also a terminal // outcome — the session never reached a usable state. Without // completing here the cast coroutine hangs and the discovery // ref-count leaks +1 for every failed attempt. - detachMediaClientCallback() pendingSessionStart?.complete(false) pendingSessionStart = null sessionFlow.value = CastSessionState.Idle @@ -279,6 +290,15 @@ class ChromecastCaster( @Volatile private var pendingSessionStart: CompletableDeferred? = null + /** + * Armed while a [CastRoutePlan.SWAP_RECEIVER] is in flight — between asking MediaRouter to move + * to a different receiver and the outgoing session's teardown callback. That teardown is the + * expected consequence of the move, not the new attempt failing, and must not complete the + * pending start. Cleared as soon as the attempt resolves, so a later genuine end still counts. + */ + @Volatile + private var expectingSessionSwapEnd = false + private fun currentDevice(): CastDevice? = when (val s = sessionFlow.value) { is CastSessionState.Connecting -> s.device @@ -424,21 +444,34 @@ class ChromecastCaster( Log.d(TAG) { "cast: existing session connected=${existing?.isConnected} hasClient=${existing?.remoteMediaClient != null}" } + val plan = + planRouteSelection( + targetRouteId = route.id, + selectedRouteId = mediaRouter?.selectedRoute?.id, + hasConnectedSession = existing?.isConnected == true, + ) + Log.i(TAG) { "cast: plan=$plan target=${route.id} selected=${mediaRouter?.selectedRoute?.id}" } + val pending = CompletableDeferred() // If a previous cast() is still awaiting a callback, fail it // before swapping in our deferred — otherwise the earlier call // hangs to the 30s timeout. pendingSessionStart?.complete(false) pendingSessionStart = pending + // Arm this before selectRoute, not after: the teardown callback for the outgoing + // session can arrive on the very next main-thread tick. + expectingSessionSwapEnd = plan.expectsPreviousSessionToEnd try { - Log.d(TAG) { "cast: selectRoute id=${route.id}" } - mediaRouter?.selectRoute(route) - if (existing?.isConnected == true) { - Log.d(TAG) { "cast: reusing already-connected session, completing immediately" } + if (plan.needsRouteSelection) { + Log.i(TAG) { "cast: selectRoute id=${route.id}" } + mediaRouter?.selectRoute(route) + } else { + Log.i(TAG) { "cast: reusing the session already connected to this receiver" } pending.complete(true) } } catch (t: Throwable) { Log.w(TAG, "cast: selectRoute threw", t) + expectingSessionSwapEnd = false pending.complete(false) } // Defence in depth: if a callback is somehow missed (SDK bug, @@ -451,6 +484,9 @@ class ChromecastCaster( Log.w(TAG) { "cast: session start timed out after ${SESSION_START_TIMEOUT_MS}ms" } pendingSessionStart = null } + // However this attempt ended, the swap it was waiting on is over. Leaving the flag + // armed would make the *next* genuine session end get swallowed as an expected one. + expectingSessionSwapEnd = false outcome ?: false } @@ -516,10 +552,19 @@ class ChromecastCaster( request.artworkUri?.let { runCatching { metadata.addImage(WebImage(it.toUri())) } } + // A live HLS playlist has no #EXT-X-ENDLIST and no duration. Declaring it BUFFERED asks the + // receiver for a seekable stream of known length, which it cannot resolve — the LG webOS + // receiver sits in LOADING forever rather than reporting an error. + val streamType = + if (request.isLive) { + MediaInfo.STREAM_TYPE_LIVE + } else { + MediaInfo.STREAM_TYPE_BUFFERED + } val info = MediaInfo .Builder(request.url) - .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setStreamType(streamType) .setContentType(request.effectiveMimeType()) .setMetadata(metadata) .build() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt new file mode 100644 index 0000000000..7dfad36d1f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt @@ -0,0 +1,53 @@ +/* + * 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.amethyst.service.cast + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CastLivenessTest { + @Test + fun playerVerdictWinsOverMetadataWhenItSaysLive() { + // A live .m3u8 shared in a plain kind:1 note carries no live-activity flag, so only + // ExoPlayer's parsed verdict can save it from being cast as a seekable recording. + assertTrue(resolveCastLiveness(learned = true, metadataFlag = false)) + } + + @Test + fun playerVerdictWinsOverMetadataWhenItSaysOnDemand() { + // A kind:30311 recording stays flagged live long after the broadcast ended; the parsed + // playlist is the ground truth and must override it. + assertFalse(resolveCastLiveness(learned = false, metadataFlag = true)) + } + + @Test + fun fallsBackToMetadataWhileTheUrlIsStillUnclassified() { + assertTrue(resolveCastLiveness(learned = null, metadataFlag = true)) + assertFalse(resolveCastLiveness(learned = null, metadataFlag = false)) + } + + @Test + fun progressiveMediaStaysBufferedWhenNothingIsKnown() { + // The common case: an MP4 with no verdict and no flag must keep its seek bar. + assertFalse(resolveCastLiveness(learned = null, metadataFlag = false)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt new file mode 100644 index 0000000000..51e92b8651 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt @@ -0,0 +1,80 @@ +/* + * 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.amethyst.service.cast + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CastRoutePlanTest { + private val tv = "route-tv" + private val soundbar = "route-soundbar" + + @Test + fun reusesTheSessionOnlyWhenItBelongsToTheTargetRoute() { + assertEquals( + CastRoutePlan.REUSE_SESSION, + planRouteSelection(targetRouteId = tv, selectedRouteId = tv, hasConnectedSession = true), + ) + } + + @Test + fun switchingReceiversIsASwapNotAReuse() { + // The regression: a session connected to the soundbar was treated as reusable for the TV. + // cast() completed its start immediately, selectRoute() then tore that session down, and the + // load was skipped against a dead session — the TV connected and showed nothing. + assertEquals( + CastRoutePlan.SWAP_RECEIVER, + planRouteSelection(targetRouteId = tv, selectedRouteId = soundbar, hasConnectedSession = true), + ) + } + + @Test + fun aDisconnectedSessionOnTheTargetRouteStillNeedsAFreshStart() { + assertEquals( + CastRoutePlan.SELECT_FRESH, + planRouteSelection(targetRouteId = tv, selectedRouteId = tv, hasConnectedSession = false), + ) + } + + @Test + fun theFirstCastOfTheSessionSelectsFresh() { + assertEquals( + CastRoutePlan.SELECT_FRESH, + planRouteSelection(targetRouteId = tv, selectedRouteId = null, hasConnectedSession = false), + ) + } + + @Test + fun onlyASwapExpectsTheOldSessionToEnd() { + // This is what stops the outgoing session's onSessionEnded from failing the incoming + // attempt, which is why switching devices used to report "session start refused". + assertEquals(true, CastRoutePlan.SWAP_RECEIVER.expectsPreviousSessionToEnd) + assertEquals(false, CastRoutePlan.SELECT_FRESH.expectsPreviousSessionToEnd) + assertEquals(false, CastRoutePlan.REUSE_SESSION.expectsPreviousSessionToEnd) + } + + @Test + fun onlyReuseSkipsTheRouteSelection() { + assertEquals(false, CastRoutePlan.REUSE_SESSION.needsRouteSelection) + assertEquals(true, CastRoutePlan.SWAP_RECEIVER.needsRouteSelection) + assertEquals(true, CastRoutePlan.SELECT_FRESH.needsRouteSelection) + } +} From 2dc540294820a807b8bee6ce00ba76c2ca13fc2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 20:33:43 +0200 Subject: [PATCH 05/17] New features feat(cast): time out a load the receiver silently abandons feat(cast): tell the user when the receiver refuses a video --- .../amethyst/service/cast/CastDevice.kt | 24 +++- .../ui/cast/CastDevicePickerDialog.kt | 14 +- .../cast/chromecast/ChromecastCaster.kt | 121 ++++++++++++++++-- .../composeResources/values/strings.xml | 4 + 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index 99d18c6067..a9f1372860 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.cast import androidx.compose.runtime.Immutable +import org.jetbrains.compose.resources.StringResource @Immutable data class CastDevice( @@ -88,6 +89,27 @@ sealed class CastSessionState { data class Error( val device: CastDevice?, - val message: String, + val message: CastErrorMessage, ) : CastSessionState() } + +/** + * What went wrong, in a form the picker resolves in composition. The caster reports failures from + * Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over + * the resource and the UI formats it. + */ +@Immutable +sealed interface CastErrorMessage { + /** Not yet localized. */ + data class Raw( + val text: String, + ) : CastErrorMessage + + /** + * A `%1$s`-shaped message naming the receiver — the picker supplies the [CastSessionState.Error] + * device's name, or a generic noun when the device is unknown. + */ + data class Localized( + val text: StringResource, + ) : CastErrorMessage +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index be65b69133..727ed86cf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -31,12 +31,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cast_generic_receiver import com.vitorpamplona.amethyst.commons.resources.cast_searching_for_devices import com.vitorpamplona.amethyst.commons.resources.cast_to_device_dialog_title import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -105,9 +107,10 @@ fun CastDevicePickerDialog( } } - if (sessionState is CastSessionState.Error) { + val error = sessionState as? CastSessionState.Error + if (error != null) { Text( - text = (sessionState as CastSessionState.Error).message, + text = castErrorText(error), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -115,3 +118,10 @@ fun CastDevicePickerDialog( } } } + +@Composable +private fun castErrorText(error: CastSessionState.Error): String = + when (val message = error.message) { + is CastErrorMessage.Raw -> message.text + is CastErrorMessage.Localized -> stringRes(message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) + } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index 18f99565a2..dbd0f80339 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -32,6 +32,7 @@ import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.CastStatusCodes +import com.google.android.gms.cast.MediaError import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata @@ -43,7 +44,12 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding +import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media import com.vitorpamplona.amethyst.service.cast.CastDevice +import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastRoutePlan import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -62,6 +68,16 @@ private const val TAG = "ChromecastCaster" private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L +/** + * How long the receiver gets to move off LOADING before we call it stalled. + * + * A healthy receiver takes ~1s. A wedged one — the state an LG webOS TV lands in after its Cast + * service crashes, cleared only by power-cycling the TV — accepts the load, reports LOADING, and + * then reports nothing ever again: no progress, no error, no session end. Generous by design, since + * overshooting only delays an error message while undershooting aborts a slow but working load. + */ +private const val LOAD_PROGRESS_TIMEOUT_MS = 20_000L + /** Android 17 — the first release to enforce Local Network Protection. See [ChromecastCaster.localNetworkState]. */ private const val LOCAL_NETWORK_PROTECTION_SDK = 37 @@ -133,15 +149,26 @@ class ChromecastCaster( override fun onStatusUpdated() { val client = currentMediaClient ?: return val status = client.mediaStatus + val idleReason = status?.idleReason ?: -1 Log.i(TAG) { "media.onStatusUpdated playerState=${playerStateName(client.playerState)} " + - "idleReason=${idleReasonName(status?.idleReason ?: -1)} " + + "idleReason=${idleReasonName(idleReason)} " + "pos=${client.approximateStreamPosition}/${client.streamDuration}ms" } + // Any real progress means the receiver is alive and the watchdog has done its job. + if (client.playerState == MediaStatus.PLAYER_STATE_PLAYING || client.playerState == MediaStatus.PLAYER_STATE_BUFFERING) { + cancelLoadWatchdog() + } + // The receiver can also fail without ever calling onMediaError — dropping to IDLE + // with an ERROR reason is the terminal signal in that case. + if (client.playerState == MediaStatus.PLAYER_STATE_IDLE && idleReason == MediaStatus.IDLE_REASON_ERROR) { + reportMediaFailure(null) + } } - override fun onMediaError(mediaError: com.google.android.gms.cast.MediaError) { + override fun onMediaError(mediaError: MediaError) { Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" } + reportMediaFailure(mediaError.detailedErrorCode) } } @@ -216,7 +243,7 @@ class ChromecastCaster( Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } pendingSessionStart?.complete(false) pendingSessionStart = null - sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)") + sessionFlow.value = CastSessionState.Error(currentDevice(), CastErrorMessage.Raw("Cast session failed (code $error)")) } override fun onSessionEnding(session: CastSession) { @@ -238,6 +265,7 @@ class ChromecastCaster( return } Log.i(TAG) { "session.onEnded error=${statusName(error)}" } + cancelLoadWatchdog() // If a cast() was awaiting a session start, this is also a terminal // outcome — the session never reached a usable state. Without // completing here the cast coroutine hangs and the discovery @@ -299,6 +327,68 @@ class ChromecastCaster( @Volatile private var expectingSessionSwapEnd = false + /** + * Fires when the receiver accepted a load and then went quiet — see [LOAD_PROGRESS_TIMEOUT_MS]. + * Nothing else covers this: the media callbacks only speak when the receiver does, and the + * session is still perfectly connected, so without this the picker claims to be casting forever + * while the device sits on its splash screen. + */ + private val loadWatchdog = + Runnable { + val state = currentMediaClient?.playerState + val progressed = state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING + if (progressed || sessionFlow.value is CastSessionState.Error) return@Runnable + + val device = watchedDevice ?: currentDevice() + Log.w(TAG) { + "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" + } + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage.Localized(Res.string.cast_error_receiver_not_responding)) + watchedDevice = null + } + + /** The device a load is currently being watched for, so the message can name it. */ + @Volatile + private var watchedDevice: CastDevice? = null + + private fun armLoadWatchdog(device: CastDevice) { + main.removeCallbacks(loadWatchdog) + watchedDevice = device + main.postDelayed(loadWatchdog, LOAD_PROGRESS_TIMEOUT_MS) + } + + private fun cancelLoadWatchdog() { + main.removeCallbacks(loadWatchdog) + watchedDevice = null + } + + /** + * Turns a receiver-side playback failure into a [CastSessionState.Error] the picker can show. + * + * Without this the UI stays on [CastSessionState.Casting] — set the moment `load()` is + * submitted — while the receiver has already given up, so a rejected video looks exactly like a + * working one: the device sits on its splash screen and nothing ever explains why. + * + * The first report wins. A failure usually arrives twice (onMediaError, then IDLE/ERROR) and the + * earlier one carries the detailed code, so it is the more specific of the two. + */ + private fun reportMediaFailure(detailedErrorCode: Int?) { + cancelLoadWatchdog() + if (sessionFlow.value is CastSessionState.Error) return + val device = currentDevice() + val message = + when (detailedErrorCode) { + MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED, + MediaError.DetailedErrorCode.MEDIA_DECODE, + -> + Res.string.cast_error_unsupported_media + else -> Res.string.cast_error_playback_failed + } + Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" } + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Localized(message)) + } + private fun currentDevice(): CastDevice? = when (val s = sessionFlow.value) { is CastSessionState.Connecting -> s.device @@ -423,7 +513,7 @@ class ChromecastCaster( val ctx = withContext(Dispatchers.Main) { ensureCastContext() } if (ctx == null) { Log.w(TAG, "cast: CastContext unavailable") - sessionFlow.value = CastSessionState.Error(device, "Google Play services unavailable") + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Google Play services unavailable")) return } val route = @@ -432,7 +522,7 @@ class ChromecastCaster( } if (route == null) { Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" } - sessionFlow.value = CastSessionState.Error(device, "Device went offline") + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Device went offline")) return } @@ -492,7 +582,7 @@ class ChromecastCaster( if (!started) { Log.w(TAG, "cast: session start refused") - sessionFlow.value = CastSessionState.Error(device, "Cast session refused") + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Cast session refused")) return } @@ -519,6 +609,7 @@ class ChromecastCaster( // one: the coroutine reports Casting, the TV sits on its splash screen, and // nothing anywhere records why. This callback is the only place the receiver // ever tells us what it disliked about the media. + armLoadWatchdog(device) client.load(loadRequest).setResultCallback { result -> val status = result.status if (status.isSuccess) { @@ -528,21 +619,30 @@ class ChromecastCaster( "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + "msg=${status.statusMessage}" } + // The receiver answered, so the watchdog is moot — but nothing else + // turns a refusal into something the user can read. + reportMediaFailure(null) } } true } catch (t: Throwable) { + cancelLoadWatchdog() Log.w(TAG, "cast: remoteMediaClient.load failed", t) false } } } + // A receiver can reject the media before this coroutine gets here — onMediaError has been + // seen ~150ms after load(). This attempt set Connecting on entry, so any Error sitting here + // now came from its own callbacks and carries the receiver's reason; don't paper over it + // with a Casting state that claims a video is playing when it already failed. + val reportedFailure = sessionFlow.value as? CastSessionState.Error sessionFlow.value = - if (ok) { - CastSessionState.Casting(device, request) - } else { - CastSessionState.Error(device, "Could not load media on receiver") + when { + reportedFailure != null -> reportedFailure + ok -> CastSessionState.Casting(device, request) + else -> CastSessionState.Error(device, CastErrorMessage.Raw("Could not load media on receiver")) } } @@ -577,6 +677,7 @@ class ChromecastCaster( suspend fun stopCasting() { Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } + withContext(Dispatchers.Main) { cancelLoadWatchdog() } // Await MEDIA_STOP before endCurrentSession() — racing them on the // same main-thread tick loses the stop on some receivers (LG webOS). val client = currentMediaClient diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 3ef72ba264..88b63a2a5d 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2697,6 +2697,10 @@ Permission needed Amethyst needs local network access to find Cast devices on your Wi-Fi. Please enable it in the app settings. Open settings + The cast device + %1$s can't play this video's format + %1$s couldn't play this video + %1$s stopped responding. Restarting the device usually fixes it. Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From 68f59acfceb2228f8ac00c4ab8cd7b3c67a176a7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 21:37:46 +0200 Subject: [PATCH 06/17] Code review fixes fix(cast): don't let two teardowns run at once fix(cast): let the session end before unselecting the route fix(cast): stop the receiver app instead of parking it on its splash screen fix(cast): replace raw SDK error codes in the picker with usable messages --- .../amethyst/service/cast/CastDevice.kt | 21 ++-- .../ui/cast/CastDevicePickerDialog.kt | 7 +- .../cast/chromecast/ChromecastCaster.kt | 110 +++++++++++++++--- .../composeResources/values/strings.xml | 4 + 4 files changed, 107 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index a9f1372860..fffadcc5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -97,19 +97,12 @@ sealed class CastSessionState { * What went wrong, in a form the picker resolves in composition. The caster reports failures from * Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over * the resource and the UI formats it. + * + * [text] is `%1$s`-shaped, naming the receiver: the picker supplies the [CastSessionState.Error] + * device's name, or a generic noun when the failure happens before or after we know which device + * it was. */ @Immutable -sealed interface CastErrorMessage { - /** Not yet localized. */ - data class Raw( - val text: String, - ) : CastErrorMessage - - /** - * A `%1$s`-shaped message naming the receiver — the picker supplies the [CastSessionState.Error] - * device's name, or a generic noun when the device is unknown. - */ - data class Localized( - val text: StringResource, - ) : CastErrorMessage -} +data class CastErrorMessage( + val text: StringResource, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index 727ed86cf1..671b3b9f7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -120,8 +119,4 @@ fun CastDevicePickerDialog( } @Composable -private fun castErrorText(error: CastSessionState.Error): String = - when (val message = error.message) { - is CastErrorMessage.Raw -> message.text - is CastErrorMessage.Localized -> stringRes(message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) - } +private fun castErrorText(error: CastSessionState.Error): String = stringRes(error.message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index dbd0f80339..f9c082b772 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -45,6 +45,10 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cast_error_connect_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_device_offline +import com.vitorpamplona.amethyst.commons.resources.cast_error_load_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_play_services_unavailable import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media @@ -61,6 +65,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -68,6 +73,16 @@ private const val TAG = "ChromecastCaster" private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L +/** + * How long to wait for the session to actually end after asking the receiver app to stop. + * + * Unselecting the route drops the connection, so doing it before the receiver has processed + * STOP_APP leaves the app running and the TV parked on the Default Media Receiver splash — the + * "default renderer" screen the user has to leave with the TV remote. Observed at ~25-170ms on a + * webOS TV, longer the more playback there was to flush, so this is generous. + */ +private const val SESSION_END_TIMEOUT_MS = 5_000L + /** * How long the receiver gets to move off LOADING before we call it stalled. * @@ -241,9 +256,21 @@ class ChromecastCaster( error: Int, ) { Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } + cancelLoadWatchdog() pendingSessionStart?.complete(false) pendingSessionStart = null - sessionFlow.value = CastSessionState.Error(currentDevice(), CastErrorMessage.Raw("Cast session failed (code $error)")) + if (error == CastStatusCodes.CANCELED) { + // The user backed out of the connection; that is not a failure to report. + sessionFlow.value = CastSessionState.Idle + return + } + // The raw SDK integer belongs in the log, not in front of the user. Every code that + // reaches here means the same thing to them — the device would not accept a + // connection — and on a webOS TV whose Cast service has died (2252, seen repeatedly + // once it wedges) restarting it is genuinely the fix. + val device = currentDevice() + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_connect_failed)) } override fun onSessionEnding(session: CastSession) { @@ -255,6 +282,9 @@ class ChromecastCaster( error: Int, ) { detachMediaClientCallback() + // Whoever is tearing down gets told first, before any of the swap/failure handling + // below decides to return early — stopCasting() is blocked on this. + pendingSessionEnd?.complete(Unit) // Moving to a different receiver ends the outgoing session by design, and that // callback lands *after* cast() has installed the pending start for the incoming // one. Failing it here is what made every device-to-device switch report @@ -344,7 +374,7 @@ class ChromecastCaster( "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" } sessionFlow.value = - CastSessionState.Error(device, CastErrorMessage.Localized(Res.string.cast_error_receiver_not_responding)) + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_receiver_not_responding)) watchedDevice = null } @@ -352,6 +382,10 @@ class ChromecastCaster( @Volatile private var watchedDevice: CastDevice? = null + /** Set while [stopCasting] waits for the receiver app to actually go away. */ + @Volatile + private var pendingSessionEnd: CompletableDeferred? = null + private fun armLoadWatchdog(device: CastDevice) { main.removeCallbacks(loadWatchdog) watchedDevice = device @@ -386,7 +420,7 @@ class ChromecastCaster( else -> Res.string.cast_error_playback_failed } Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" } - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Localized(message)) + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message)) } private fun currentDevice(): CastDevice? = @@ -513,7 +547,8 @@ class ChromecastCaster( val ctx = withContext(Dispatchers.Main) { ensureCastContext() } if (ctx == null) { Log.w(TAG, "cast: CastContext unavailable") - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Google Play services unavailable")) + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_play_services_unavailable)) return } val route = @@ -522,7 +557,7 @@ class ChromecastCaster( } if (route == null) { Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" } - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Device went offline")) + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_device_offline)) return } @@ -582,7 +617,10 @@ class ChromecastCaster( if (!started) { Log.w(TAG, "cast: session start refused") - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Cast session refused")) + // onSessionStartFailed usually got here first with a better-informed message; keep it. + sessionFlow.value = + sessionFlow.value as? CastSessionState.Error + ?: CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_connect_failed)) return } @@ -642,7 +680,7 @@ class ChromecastCaster( when { reportedFailure != null -> reportedFailure ok -> CastSessionState.Casting(device, request) - else -> CastSessionState.Error(device, CastErrorMessage.Raw("Could not load media on receiver")) + else -> CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_load_failed)) } } @@ -675,8 +713,29 @@ class ChromecastCaster( .build() } + /** + * Serialises teardown. A stop against an unresponsive receiver can take seconds to ack, so the + * user reasonably presses stop again — and two overlapping teardowns wreck each other: both + * call `stop()` (the second erroring), both call `endCurrentSession`, and the later one installs + * its session-end wait after `onSessionEnded` has already fired, so it waits out the full + * timeout for an event that will never come again. + */ + private val stopInFlight = Mutex() + suspend fun stopCasting() { - Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } + if (!stopInFlight.tryLock()) { + Log.i(TAG) { "stopCasting: a teardown is already running; ignoring the repeat request" } + return + } + try { + stopCastingLocked() + } finally { + stopInFlight.unlock() + } + } + + private suspend fun stopCastingLocked() { + Log.i(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } withContext(Dispatchers.Main) { cancelLoadWatchdog() } // Await MEDIA_STOP before endCurrentSession() — racing them on the // same main-thread tick loses the stop on some receivers (LG webOS). @@ -687,8 +746,8 @@ class ChromecastCaster( try { client.stop().setResultCallback { result -> val status = result.status - Log.d(TAG) { - "remoteMediaClient.stop ack code=${status.statusCode} msg=${status.statusMessage}" + Log.i(TAG) { + "remoteMediaClient.stop ack code=${statusName(status.statusCode)} msg=${status.statusMessage}" } stopAck.complete(status.statusCode) } @@ -702,16 +761,37 @@ class ChromecastCaster( Log.w(TAG) { "remoteMediaClient.stop did not ack within ${STOP_AWAIT_TIMEOUT_MS}ms" } } } + val ended = CompletableDeferred() withContext(Dispatchers.Main) { + pendingSessionEnd = ended try { - // false: receiver app already halted media via stop() above. - // true previously triggered an extra teardown that compounded - // the race — keep the receiver running on its splash screen - // so the next cast can reuse the connection cleanly. - castContext?.sessionManager?.endCurrentSession(false) + // true: shut the receiver application down, don't just detach from it. + // + // This was false, to "keep the receiver running on its splash screen so the next + // cast can reuse the connection cleanly". That is what strands an LG webOS TV on the + // Default Media Receiver holding screen instead of returning it to its home screen, + // and the reused connection is not clean: the SDK hands the same session id back to + // later casts, and after a few stop/start cycles the receiver stops accepting + // connections at all (every start fails 2252) until the TV is power-cycled. + // + // The race that motivated false is now handled directly — the MEDIA_STOP ack is + // awaited above before we get here, and a receiver swap no longer mistakes the + // outgoing session's teardown for a failure of the incoming one. + Log.i(TAG) { "stopCasting: ending session and stopping the receiver app" } + castContext?.sessionManager?.endCurrentSession(true) } catch (t: Throwable) { Log.w(TAG, "endCurrentSession failed", t) + ended.complete(Unit) } + } + // Wait for the session to be gone before unselecting. Unselecting drops the connection the + // STOP_APP message travels over, so doing it on the same tick — as this used to — can beat + // the message to the TV and leave the receiver running on its splash screen. + if (withTimeoutOrNull(SESSION_END_TIMEOUT_MS) { ended.await() } == null) { + Log.w(TAG) { "stopCasting: session did not end within ${SESSION_END_TIMEOUT_MS}ms; unselecting anyway" } + } + withContext(Dispatchers.Main) { + pendingSessionEnd = null mediaRouter?.unselect(MediaRouter.UNSELECT_REASON_STOPPED) } sessionFlow.value = CastSessionState.Idle diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 88b63a2a5d..8ff7d6e8de 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2701,6 +2701,10 @@ %1$s can't play this video's format %1$s couldn't play this video %1$s stopped responding. Restarting the device usually fixes it. + Couldn't connect to %1$s. Restarting the device usually fixes it. + %1$s went offline + Couldn't load this video on %1$s + Casting needs Google Play services, which aren't available on this device Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From 9138745e245e35af00441702a8ae8a01ec7f524b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 22:23:25 +0200 Subject: [PATCH 07/17] feat(cast): say what the video was when the receiver won't play it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A receiver that dislikes a video is close to silent about it: the LG webOS TV accepts the load, reports LOADING, then says nothing at all — no error, no reason. "It didn't play" was the entire diagnosis available, for a file that plays fine on the phone and on the soundbar. The phone has already decoded the same media locally, so describe it from the player's own track format: "H.265 (HEVC) 1920x1080". Codec names are the ones from device spec sheets rather than raw mime types, because recognising "HEVC" is what tells the user why their TV refused it and whether another copy would work. Reported in the failure message and logged with the load request. The summary comes from the highest-resolution track in the group, since that is the one adaptive playback climbs to and therefore the one a receiver with limited codec or resolution support fails on. Also logs MediaError.customData, which can carry the receiver's own explanation and was being discarded. --- .../amethyst/service/cast/CastDevice.kt | 13 +++- .../amethyst/service/cast/CastMediaSummary.kt | 64 ++++++++++++++++++ .../composable/controls/RenderTopButtons.kt | 8 +++ .../controls/VideoQualityControls.kt | 19 ++++++ .../ui/cast/CastDevicePickerDialog.kt | 7 +- .../cast/chromecast/ChromecastCaster.kt | 41 +++++++++--- .../service/cast/CastMediaSummaryTest.kt | 66 +++++++++++++++++++ .../composeResources/values/strings.xml | 3 + 8 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index fffadcc5b9..14bb4ee49b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -41,6 +41,12 @@ data class CastRequest( * live `.m3u8` from an on-demand one. */ val isLive: Boolean = false, + /** + * What the video is — "H.265 (HEVC) 1920x1080" — taken from the local player, which has already + * decoded it. Reported when a cast fails, because the receiver itself rarely says why. See + * [summarizeCastFormat]. + */ + val formatSummary: String? = null, ) /** @@ -98,11 +104,12 @@ sealed class CastSessionState { * Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over * the resource and the UI formats it. * - * [text] is `%1$s`-shaped, naming the receiver: the picker supplies the [CastSessionState.Error] - * device's name, or a generic noun when the failure happens before or after we know which device - * it was. + * [text] names the receiver as `%1$s`: the picker supplies the [CastSessionState.Error] device's + * name, or a generic noun when the failure happens before or after we know which device it was. + * A `_detail` resource also takes [detail] — what the video was — as `%2$s`. */ @Immutable data class CastErrorMessage( val text: StringResource, + val detail: String? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt new file mode 100644 index 0000000000..5b0a272230 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt @@ -0,0 +1,64 @@ +/* + * 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.amethyst.service.cast + +/** + * Short human-readable description of what a video actually is — "H.265 (HEVC) 1920x1080". + * + * A Cast receiver that dislikes a video usually says nothing useful: an LG webOS TV accepts the + * load, reports LOADING, and then goes silent, with no error and no reason. The phone, meanwhile, + * has already decoded the same file locally and knows exactly what it is. Reporting that turns "it + * didn't play" into "it's HEVC", which is the fact that explains the failure and tells the user + * whether another device or another copy would do better. + * + * Codec names are the ones people recognise from device spec sheets rather than the raw mime types, + * because the point of the message is to be recognisable. Anything unrecognised passes through + * unchanged — a mime type we don't have a friendly name for is still better than nothing. + */ +fun summarizeCastFormat( + sampleMimeType: String?, + codecs: String?, + width: Int, + height: Int, +): String? { + val codec = friendlyCodecName(sampleMimeType) ?: codecs?.takeIf { it.isNotBlank() } + val resolution = if (width > 0 && height > 0) "${width}x$height" else null + return when { + codec != null && resolution != null -> "$codec $resolution" + codec != null -> codec + resolution != null -> resolution + else -> null + } +} + +private fun friendlyCodecName(sampleMimeType: String?): String? { + val mime = sampleMimeType?.takeIf { it.isNotBlank() } ?: return null + return when (mime.lowercase()) { + "video/avc" -> "H.264" + "video/hevc", "video/dolby-vision" -> "H.265 (HEVC)" + "video/av01" -> "AV1" + "video/x-vnd.on2.vp9" -> "VP9" + "video/x-vnd.on2.vp8" -> "VP8" + "video/mp4v-es" -> "MPEG-4" + "video/mpeg2" -> "MPEG-2" + else -> mime + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 0e63c53ff3..cd0a3d15dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -232,6 +232,7 @@ fun RenderTopButtons( RenderTopButtons( mediaData = mediaData, hasMultipleQualities = hasMultipleQualities, + castFormatSummary = castFormatSummary(videoGroup), qualityButton = { VideoQualityButton( player = player, @@ -297,6 +298,9 @@ fun RenderTopButtons( fun RenderTopButtons( mediaData: MediaItemData, hasMultipleQualities: Boolean, + // What the local player decoded this as. Only the Cast failure messages use it: a receiver that + // refuses a video usually will not say why, so this is the only description of it available. + castFormatSummary: String? = null, qualityButton: @Composable () -> Unit, controllerVisible: MutableState, startingMuteState: Boolean, @@ -510,6 +514,10 @@ fun RenderTopButtons( learned = HlsLivenessCache.verdict(mediaData.videoUri), metadataFlag = mediaData.isLiveStream, ), + // The local player has already decoded this, so it knows what the receiver + // is about to be handed — the only description of the media available when + // the receiver refuses it without saying why. + formatSummary = castFormatSummary, ), onDismiss = { castDialogVisible.value = false }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt index 8acee68d0a..d3030d2f9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt @@ -26,10 +26,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.media3.common.C +import androidx.media3.common.Format import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.service.cast.summarizeCastFormat import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel @@ -37,6 +39,23 @@ import kotlin.math.ceil internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } +// Describes the video being played, for the Cast failure messages. Uses the highest-resolution +// track in the group: that is the one an adaptive stream will climb to, so it is the one a +// receiver with limited codec or resolution support will choke on. +@OptIn(UnstableApi::class) +internal fun castFormatSummary(group: Tracks.Group?): String? { + if (group == null) return null + var best: Format? = null + for (i in 0 until group.length) { + val format = group.getTrackFormat(i) + if (best == null || format.width.toLong() * format.height > best!!.width.toLong() * best!!.height) { + best = format + } + } + val format = best ?: return null + return summarizeCastFormat(format.sampleMimeType, format.codecs, format.width, format.height) +} + /** * Constrains adaptive selection to the area the player is actually drawn in. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index 671b3b9f7f..d1cff911b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -119,4 +119,9 @@ fun CastDevicePickerDialog( } @Composable -private fun castErrorText(error: CastSessionState.Error): String = stringRes(error.message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) +private fun castErrorText(error: CastSessionState.Error): String = + stringRes( + error.message.text, + error.device?.name ?: stringRes(Res.string.cast_generic_receiver), + error.message.detail, + ) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index f9c082b772..b12ebb1d96 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -50,8 +50,11 @@ import com.vitorpamplona.amethyst.commons.resources.cast_error_device_offline import com.vitorpamplona.amethyst.commons.resources.cast_error_load_failed import com.vitorpamplona.amethyst.commons.resources.cast_error_play_services_unavailable import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed_detail import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding +import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding_detail import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media +import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media_detail import com.vitorpamplona.amethyst.service.cast.CastDevice import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRequest @@ -182,7 +185,10 @@ class ChromecastCaster( } override fun onMediaError(mediaError: MediaError) { - Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" } + Log.w(TAG) { + "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} " + + "type=${mediaError.type} media=${castingFormat ?: "unknown"} customData=${mediaError.customData}" + } reportMediaFailure(mediaError.detailedErrorCode) } } @@ -373,8 +379,14 @@ class ChromecastCaster( Log.w(TAG) { "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" } - sessionFlow.value = - CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_receiver_not_responding)) + val format = castingFormat + val message = + if (format != null) { + Res.string.cast_error_receiver_not_responding_detail + } else { + Res.string.cast_error_receiver_not_responding + } + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message, format)) watchedDevice = null } @@ -382,6 +394,10 @@ class ChromecastCaster( @Volatile private var watchedDevice: CastDevice? = null + /** What the in-flight cast is, so a failure can say what the receiver refused. */ + @Volatile + private var castingFormat: String? = null + /** Set while [stopCasting] waits for the receiver app to actually go away. */ @Volatile private var pendingSessionEnd: CompletableDeferred? = null @@ -411,16 +427,19 @@ class ChromecastCaster( cancelLoadWatchdog() if (sessionFlow.value is CastSessionState.Error) return val device = currentDevice() + val unsupported = + detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED || + detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_DECODE + val format = castingFormat val message = - when (detailedErrorCode) { - MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED, - MediaError.DetailedErrorCode.MEDIA_DECODE, - -> - Res.string.cast_error_unsupported_media + when { + unsupported && format != null -> Res.string.cast_error_unsupported_media_detail + unsupported -> Res.string.cast_error_unsupported_media + format != null -> Res.string.cast_error_playback_failed_detail else -> Res.string.cast_error_playback_failed } Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" } - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message)) + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message, format)) } private fun currentDevice(): CastDevice? = @@ -638,9 +657,11 @@ class ChromecastCaster( try { val loadRequest = buildLoadRequest(request) val info = loadRequest.mediaInfo + castingFormat = request.formatSummary Log.i(TAG) { "cast: load() submitting contentType=${info?.contentType} " + - "streamType=${info?.streamType} url=${info?.contentId}" + "streamType=${info?.streamType} media=${request.formatSummary ?: "unknown"} " + + "url=${info?.contentId}" } // load() returns a PendingResult carrying the receiver's verdict. Dropping it // (as this used to) makes a refused load indistinguishable from a successful diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt new file mode 100644 index 0000000000..cdbb588aa2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt @@ -0,0 +1,66 @@ +/* + * 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.amethyst.service.cast + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CastMediaSummaryTest { + @Test + fun namesTheCodecTheReceiverIsMostLikelyToRefuse() { + // The whole point: "HEVC" is the word that explains why a TV took the file and went quiet. + assertEquals( + "H.265 (HEVC) 1920x1080", + summarizeCastFormat("video/hevc", codecs = "hev1.1.6.L93.B0", width = 1920, height = 1080), + ) + } + + @Test + fun namesTheCommonCodecsInTermsPeopleRecognise() { + assertEquals("H.264 640x480", summarizeCastFormat("video/avc", null, 640, 480)) + assertEquals("AV1 3840x2160", summarizeCastFormat("video/av01", null, 3840, 2160)) + assertEquals("VP9 1280x720", summarizeCastFormat("video/x-vnd.on2.vp9", null, 1280, 720)) + } + + @Test + fun fallsBackToTheRawMimeTypeForCodecsWeDoNotNameOurselves() { + assertEquals("video/quirky 100x50", summarizeCastFormat("video/quirky", null, 100, 50)) + } + + @Test + fun omitsTheResolutionWhenItIsNotKnown() { + assertEquals("H.264", summarizeCastFormat("video/avc", null, width = -1, height = -1)) + assertEquals("H.264", summarizeCastFormat("video/avc", null, width = 0, height = 0)) + } + + @Test + fun fallsBackToTheCodecStringWhenTheMimeTypeIsMissing() { + // ExoPlayer can report a codec string without a sample mime type on some containers. + assertEquals("hev1.1.6.L93.B0 1920x1080", summarizeCastFormat(null, "hev1.1.6.L93.B0", 1920, 1080)) + } + + @Test + fun isNullWhenThereIsNothingWorthSaying() { + assertNull(summarizeCastFormat(null, null, -1, -1)) + assertNull(summarizeCastFormat("", "", 0, 0)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 8ff7d6e8de..3cf81df338 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2705,6 +2705,9 @@ %1$s went offline Couldn't load this video on %1$s Casting needs Google Play services, which aren't available on this device + %1$s can't play this video's format (%2$s) + %1$s couldn't play this video (%2$s) + %1$s never started playing this video (%2$s). It may not support that format, or restarting the device may help. Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From a121dd21bc4cafd99c95fd44f49e584fcc758896 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 22:33:07 +0200 Subject: [PATCH 08/17] fix(cast): don't report a stop the user asked for as a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing stop on a cast that had not started playing produced an error message. The receiver answers an abandoned load with CANCELED, and the refusal handler treated that like any other refusal — so the user got told something went wrong immediately after successfully doing what they intended. Skip the report when the refusal is CANCELED, and keep the target device on the caster for the length of the cast. currentDevice() reads the device back off the session state, which a teardown has already reset by the time a late failure arrives, so the message named "the cast device" instead of the TV in front of them — visible in the log as `device=The cast device`. --- .../cast/chromecast/ChromecastCaster.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index b12ebb1d96..314dd81d0e 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -375,7 +375,7 @@ class ChromecastCaster( val progressed = state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING if (progressed || sessionFlow.value is CastSessionState.Error) return@Runnable - val device = watchedDevice ?: currentDevice() + val device = watchedDevice ?: currentDevice() ?: castingDevice Log.w(TAG) { "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" } @@ -398,6 +398,14 @@ class ChromecastCaster( @Volatile private var castingFormat: String? = null + /** + * Which device the in-flight cast targets. [currentDevice] reads it back off the session state, + * which a teardown has usually already reset by the time a late failure arrives — leaving the + * message to name "the cast device" instead of the TV the user was looking at. + */ + @Volatile + private var castingDevice: CastDevice? = null + /** Set while [stopCasting] waits for the receiver app to actually go away. */ @Volatile private var pendingSessionEnd: CompletableDeferred? = null @@ -426,7 +434,7 @@ class ChromecastCaster( private fun reportMediaFailure(detailedErrorCode: Int?) { cancelLoadWatchdog() if (sessionFlow.value is CastSessionState.Error) return - val device = currentDevice() + val device = currentDevice() ?: castingDevice val unsupported = detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED || detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_DECODE @@ -658,6 +666,7 @@ class ChromecastCaster( val loadRequest = buildLoadRequest(request) val info = loadRequest.mediaInfo castingFormat = request.formatSummary + castingDevice = device Log.i(TAG) { "cast: load() submitting contentType=${info?.contentType} " + "streamType=${info?.streamType} media=${request.formatSummary ?: "unknown"} " + @@ -678,9 +687,12 @@ class ChromecastCaster( "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + "msg=${status.statusMessage}" } - // The receiver answered, so the watchdog is moot — but nothing else - // turns a refusal into something the user can read. - reportMediaFailure(null) + // CANCELED is what the receiver answers when the load was + // abandoned because we tore the session down — i.e. the user pressed + // stop. That is the outcome they asked for, not a failure to report. + if (status.statusCode != CastStatusCodes.CANCELED) { + reportMediaFailure(null) + } } } true From bb92054f6538f42aebf28d32774ec59ae539ed95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:51:41 +0000 Subject: [PATCH 09/17] refactor: NIP-FE frames in, frames out, on the relay URL Follows the NIP revision: the body is one REQ, COUNT or EVENT frame as the websocket carries it, POSTed to the relay's own URL, and the answer is the session's frames verbatim, the client's subscription id included. quartz: - HttpRelayHandler parses the body to refuse what HTTP does not carry and to know how the answer ends, then feeds the text to the session as socket text. The body splicing and the subscription-id stripping are gone; refusals are the command's own CLOSED / OK false, and pre-run refusals (400/413/503) a NOTICE. - NIP-98: the token is checked once, against the address its `u` names (any of the relay's, trailing slash or not), within 60 seconds, and may repeat for the same body. - HttpRelayCommand is the three kinds, their end frames and refusals; HttpRelayAnswerReader parses lines with the socket parser; HttpRelayClient sends ReqCmd/CountCmd/EventCmd frames. geode: - One POST on the relay path: application/nostr+json+rpc goes to NIP-86, anything else is a command. One CORS preflight. With [http] off, every POST goes to NIP-86 as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/README.md | 24 +-- geode/config.example.toml | 16 +- .../com/vitorpamplona/geode/KtorRelay.kt | 30 ++- .../geode/config/StaticConfig.kt | 10 +- .../geode/server/HttpCommandSettings.kt | 8 +- .../geode/server/NipFEHttpRoute.kt | 32 +-- .../com/vitorpamplona/geode/NipFEHttpTest.kt | 81 +++++--- .../nipFERelayOverHttp/HttpRelayAnswer.kt | 22 +-- .../nipFERelayOverHttp/HttpRelayCommand.kt | 78 +++----- .../nipFERelayOverHttp/HttpRelayHandler.kt | 153 +++++++------- .../HttpRelayAnswerReaderTest.kt | 50 ++--- .../nipFERelayOverHttp/HttpRelayClient.kt | 29 ++- .../HttpRelayHandlerTest.kt | 187 +++++++++--------- 13 files changed, 350 insertions(+), 370 deletions(-) diff --git a/geode/README.md b/geode/README.md index 07c5c73a20..a32af00068 100644 --- a/geode/README.md +++ b/geode/README.md @@ -103,23 +103,25 @@ file for every knob. ### Commands over HTTP (NIP-FE) -Besides the websocket, geode answers one command per HTTP `POST` beside the -relay path, streamed back as NDJSON — no socket, no subscription left open: +Besides the websocket, geode answers one command per HTTP `POST` to the relay +URL: the body is the frame you would send on the socket, and the answer is the +socket's frames as NDJSON, streamed — no socket, no subscription left open: ```bash -curl -N -d '{"kinds":[1],"limit":2}' http://localhost:7447/req -# ["EVENT",{"id":"…","kind":1,…}] -# ["EVENT",{"id":"…","kind":1,…}] -# ["EOSE"] -curl -d '{"kinds":[1]}' http://localhost:7447/count # ["COUNT",{"count":2}] -curl -d @signed-event.json http://localhost:7447/event # ["OK","",true,""] +curl -N -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/ +# ["EVENT","q",{"id":"…","kind":1,…}] +# ["EVENT","q",{"id":"…","kind":1,…}] +# ["EOSE","q"] +curl -d '["COUNT","c",{"kinds":[1]}]' http://localhost:7447/ # ["COUNT","c",{"count":2}] +curl -d "[\"EVENT\",$(cat signed-event.json)]" http://localhost:7447/ # ["OK","",true,""] ``` A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or `OK` (EVENT) was cut off. On an AUTH-gated relay the answer is `401` until the -request carries a NIP-98 `Authorization: Nostr …` header whose `payload` is the -body's sha256. Quartz's `HttpRelayClient` does all of this for JVM/Android -clients. +request carries a NIP-98 `Authorization: Nostr …` header whose `u` is the +relay's http URL and whose `payload` is the body's sha256. NIP-86 admin calls +share the URL, told apart by `Content-Type: application/nostr+json+rpc`. +Quartz's `HttpRelayClient` does all of this for JVM/Android clients. ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index 2340424c6e..a2a46fe6c7 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -167,12 +167,14 @@ require_auth = false # filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}' [http] -# NIP-FE: relay commands over HTTP. One command per POST to -# /req, /count or /event, answered as NDJSON -# (application/x-ndjson) and streamed as it is found; nothing stays open -# afterwards. NIP-98 `Authorization: Nostr ...` headers sign a request -# in, exactly as NIP-42 AUTH would on the socket. On by default; turning -# it off also drops "FE" from the default NIP-11 list. +# NIP-FE: relay commands over HTTP. One REQ, COUNT or EVENT frame per +# POST to the relay URL, exactly as it would go on the websocket, +# answered as NDJSON (application/x-ndjson) in the socket's own frames and +# streamed as it is found; nothing stays open afterwards. NIP-86 calls +# share the URL, told apart by their application/nostr+json+rpc type. +# NIP-98 `Authorization: Nostr ...` headers sign a request in, exactly as +# NIP-42 AUTH would on the socket. On by default; turning it off also +# drops "FE" from the default NIP-11 list. enabled = true # Every request is its own connection, so the websocket's # per-connection limits don't bound HTTP clients. These do: over the @@ -185,7 +187,7 @@ deadline_seconds = 30 max_body_bytes = 524288 retry_after_seconds = 1 # Other addresses this relay is reachable at: a NIP-98 token may name -# the endpoint under any of them, as well as under [info].relay_url. +# any of them, as well as [info].relay_url. # alternate_urls = ["ws://youraddress.onion/"] # Behind a reverse proxy every request comes from the proxy's address. # List the proxies here and the per-client cap counts the address the diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 8c05dbadb4..4385521331 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler -import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import io.ktor.server.application.install import io.ktor.server.application.serverConfig @@ -83,8 +82,8 @@ class KtorRelay( /** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */ val callGroupSize: Int? = null, /** - * NIP-FE: relay commands over HTTP at `/req`, `/count` and `/event`. On by default; null - * turns the endpoints off (and the operator should then drop `FE` from the NIP-11 doc). + * NIP-FE: relay commands POSTed to the relay's URL, beside NIP-86. On by default; null turns them + * off, every POST going to NIP-86 again (and the operator should then drop `FE` from NIP-11). */ val httpCommands: HttpCommandSettings? = HttpCommandSettings(), ) { @@ -116,8 +115,8 @@ class KtorRelay( /** * NIP-FE. Each request runs on its own session of the same engine, so the websocket's policies - * and limits apply. A NIP-98 token must name the endpoint under `relay.url` read as http(s), or - * under one of the configured alternate URLs (a .onion). + * and limits apply. A NIP-98 token must name `relay.url` read as http(s), or one of the + * configured alternate URLs (a .onion). */ private val nipFERoute = httpCommands?.let { settings -> @@ -188,21 +187,18 @@ class KtorRelay( get(path) { nip11Route.handle(call) } - // NIP-86: POST application/nostr+json+rpc with a NIP-98 - // signed Authorization header → JSON-RPC dispatch. - // Always mounted; an empty admin allow-list on the engine just means - // every request fails the allow-list check (403). + // Two POSTs share the relay URL, told apart by Content-Type: + // - NIP-86: application/nostr+json+rpc with a NIP-98 signed + // Authorization header → JSON-RPC dispatch. Always mounted; an + // empty admin allow-list just means every call fails it (403). + // - NIP-FE: anything else is one REQ/COUNT/EVENT frame, answered + // as NDJSON in the socket's own frames. post(path) { - nip86Route.handle(call) + val commands = nipFERoute + if (commands != null && commands.isCommand(call)) commands.handle(call) else nip86Route.handle(call) } - // NIP-FE: one command per POST, answered as NDJSON. The paths - // hang off the relay's own path, as the NIP-98 `u` does. nipFERoute?.let { route -> - HttpRelayCommand.entries.forEach { command -> - val endpoint = path.trimEnd('/') + command.path - post(endpoint) { route.handle(call, command) } - options(endpoint) { route.preflight(call) } - } + options(path) { route.preflight(call) } } webSocket(path) { if (shuttingDown) { diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 3528cbc8e4..b3d888565c 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -232,9 +232,9 @@ data class StaticConfig( ) /** - * NIP-FE: relay commands over HTTP — `POST /req`, `/count`, `/event`, one command per - * request, answered as NDJSON. Each request is its own connection, so the concurrency caps here - * stand in for the websocket's per-connection limits. + * NIP-FE: relay commands over HTTP — one REQ, COUNT or EVENT frame POSTed to the relay's URL, + * answered as NDJSON in the socket's own frames. Each request is its own connection, so the + * concurrency caps here stand in for the websocket's per-connection limits. */ data class HttpSection( val enabled: Boolean = true, @@ -250,7 +250,7 @@ data class StaticConfig( val retry_after_seconds: Int = 1, /** * Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name). - * A NIP-98 token's `u` may name the endpoint under any of them or under `[info].relay_url`. + * A NIP-98 token's `u` may name any of them, read as http(s), or `[info].relay_url`. */ val alternate_urls: List = emptyList(), /** @@ -260,7 +260,7 @@ data class StaticConfig( val trusted_proxies: List = emptyList(), val client_address_header: String = "X-Forwarded-For", ) { - /** The transport settings, or null when the endpoints are off. */ + /** The transport settings, or null when commands over HTTP are off. */ fun toSettings(): HttpCommandSettings? = if (!enabled) { null diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt index 9955f3884d..5d1017889e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import kotlin.time.Duration /** - * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: `POST` to - * `/req`, `/count` and `/event`. The engine's policies and limits apply as they do on - * the websocket; these bound what the websocket's per-connection limits cannot, since every request - * is its own connection. + * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: a `POST` to + * the relay's URL carrying one REQ, COUNT or EVENT frame. The engine's policies and limits apply as + * they do on the websocket; these bound what the websocket's per-connection limits cannot, since + * every request is its own connection. */ data class HttpCommandSettings( /** Requests running at once across all clients before the rest get 503; 0 is no limit. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt index fb616548ae..77b7ac51b3 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.geode.server import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix -import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayLines import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayRequest @@ -31,6 +31,7 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.response.header import io.ktor.server.response.respond @@ -39,7 +40,8 @@ import io.ktor.server.response.respondText import io.ktor.utils.io.writeStringUtf8 /** - * NIP-FE over Ktor: the host half of [HttpRelayHandler]. It admits the request, reads the body up to + * NIP-FE over Ktor: the host half of [HttpRelayHandler], on POSTs to the relay's URL that are not + * NIP-86 calls (see [isCommand]). It admits the request, reads the body up to * the cap, and writes the handler's answer as `application/x-ndjson` — one refusal line with its * status, or a 200 streamed and flushed frame by frame — with the headers the status calls for * (`WWW-Authenticate` on 401, `Retry-After` on 429 and 503) and the CORS and no-buffering headers @@ -65,11 +67,18 @@ internal class NipFEHttpRoute( call.respond(HttpStatusCode.NoContent) } - /** Answers one [command]. Admission runs first, so a refused request spends no NIP-98 token. */ - suspend fun handle( - call: ApplicationCall, - command: HttpRelayCommand, - ) { + /** + * Whether a POST to the relay's URL is a NIP-FE command: anything but NIP-86's + * `application/nostr+json+rpc`, since commands need no `Content-Type` at all. + */ + fun isCommand(call: ApplicationCall): Boolean = + !call.request + .contentType() + .withoutParameters() + .match(NIP86) + + /** Answers the command in the body. Admission runs first, so a refused request spends no NIP-98 token. */ + suspend fun handle(call: ApplicationCall) { call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") call.response.header(HttpHeaders.AccessControlExposeHeaders, "${HttpHeaders.WWWAuthenticate}, ${HttpHeaders.RetryAfter}") call.response.header(HttpHeaders.CacheControl, "no-store") @@ -83,9 +92,9 @@ internal class NipFEHttpRoute( ?: return@admit respondLine( call, HttpRelayStatus.PAYLOAD_TOO_LARGE, - HttpRelayHandler.refusal(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), ) - handler.handle(HttpRelayRequest(command, call.request.header(HttpHeaders.Authorization), body), Answer(call)) + handler.handle(HttpRelayRequest(call.request.header(HttpHeaders.Authorization), body), Answer(call)) } when (verdict) { HttpAdmission.Verdict.ADMITTED -> {} @@ -94,7 +103,7 @@ internal class NipFEHttpRoute( respondLine( call, HttpRelayStatus.TOO_MANY_REQUESTS, - HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), + HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), ) } @@ -102,7 +111,7 @@ internal class NipFEHttpRoute( respondLine( call, HttpRelayStatus.UNAVAILABLE, - HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")), + HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")), ) } } @@ -161,6 +170,7 @@ internal class NipFEHttpRoute( companion object { val NDJSON = ContentType("application", "x-ndjson") + private val NIP86 = ContentType.parse(Nip86HttpHandler.CONTENT_TYPE) const val WWW_AUTHENTICATE = "Nostr" const val ACCEL_BUFFERING = "X-Accel-Buffering" const val PREFLIGHT_MAX_AGE_SECONDS = 86_400 diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt index e9fd20927a..8650bfb503 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult @@ -38,7 +39,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient -import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -57,7 +57,7 @@ import kotlin.time.Duration /** * NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay], * covering the answer shape, the status table, the headers each status carries, NIP-98 sign-in - * on an AUTH-gated relay, and where the endpoints live. + * on an AUTH-gated relay, and sharing the relay URL with NIP-86. */ class NipFEHttpTest { private val http = OkHttpClient.Builder().build() @@ -94,13 +94,14 @@ class NipFEHttpTest { url: String, body: String, authorization: String? = null, + contentType: String = "text/plain", ): Response = http .newCall( Request .Builder() .url(url) - .post(body.toRequestBody("text/plain".toMediaType())) + .post(body.toRequestBody(contentType.toMediaType())) .apply { authorization?.let { header("Authorization", it) } } .build(), ).execute() @@ -115,7 +116,7 @@ class NipFEHttpTest { notes.forEach { assertTrue(assertIs(client().publish(relay, it).last).success) } val got = mutableListOf() - val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), got::add) + val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), onEvent = got::add) assertEquals(200, answer.status) assertTrue(answer.complete) assertIs(answer.last) @@ -123,18 +124,18 @@ class NipFEHttpTest { } @Test - fun theWireIsNdjsonWithoutSubscriptionIds() = + fun theWireIsNdjsonInTheSocketsOwnFrames() = runBlocking { val relay = start() val n = note("hello") client().publish(relay, n) - post(HttpRelayCommand.REQ.url(relay), """{"ids":["${n.id}"]}""").use { response -> + post(relay.toHttp(), """["REQ","q",{"ids":["${n.id}"]}]""").use { response -> assertEquals(200, response.code) assertTrue(response.header("Content-Type")!!.startsWith("application/x-ndjson")) assertEquals("no", response.header("X-Accel-Buffering")) assertEquals("*", response.header("Access-Control-Allow-Origin")) val lines = response.lines() - assertEquals(listOf("""["EVENT",${n.toJson()}]""", """["EOSE"]"""), lines) + assertEquals(listOf("""["EVENT","q",${n.toJson()}]""", """["EOSE","q"]"""), lines) } } @@ -158,7 +159,7 @@ class NipFEHttpTest { assertEquals(200, client().publish(relay, n).status) val forged = n.toJson().replace("\"once\"", "\"twice\"") - post(HttpRelayCommand.EVENT.url(relay), forged).use { response -> + post(relay.toHttp(), """["EVENT",$forged]""").use { response -> assertEquals(400, response.code) val line = response.lines().single() assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line) @@ -166,15 +167,17 @@ class NipFEHttpTest { } @Test - fun aBodyThatIsNotTheCommandIs400AndOneOverTheCapIs413() { + fun aBodyThatIsNotACommandIs400AndOneOverTheCapIs413() { val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64)) - post(HttpRelayCommand.REQ.url(relay), "hello").use { response -> - assertEquals(400, response.code) - assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + for (body in listOf("hello", """{"kinds":[1]}""", """["CLOSE","q"]""")) { + post(relay.toHttp(), body).use { response -> + assertEquals(400, response.code, body) + assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:""")) + } } - post(HttpRelayCommand.REQ.url(relay), """{"authors":["${"a".repeat(64)}"]}""").use { response -> + post(relay.toHttp(), """["REQ","q",{"authors":["${"a".repeat(64)}"]}]""").use { response -> assertEquals(413, response.code) - assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:""")) } } @@ -189,10 +192,10 @@ class NipFEHttpTest { } }, ) - post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + post(relay.toHttp(), """["REQ","q",{}]""").use { response -> assertEquals(429, response.code) assertEquals("7", response.header("Retry-After")) - assertEquals("""["CLOSED","rate-limited: slow down"]""", response.lines().single()) + assertEquals("""["CLOSED","q","rate-limited: slow down"]""", response.lines().single()) } } @@ -202,7 +205,7 @@ class NipFEHttpTest { val preflight = Request .Builder() - .url(HttpRelayCommand.REQ.url(relay)) + .url(relay.toHttp()) .method("OPTIONS", null) .header("Origin", "https://app.example") .header("Access-Control-Request-Method", "POST") @@ -220,10 +223,10 @@ class NipFEHttpTest { fun anAuthGatedRelayAnswers401UntilANip98TokenSignsTheRequestIn() = runBlocking { val relay = start(policy = ::SignInPolicy) - post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + post(relay.toHttp(), """["REQ","q",{}]""").use { response -> assertEquals(401, response.code) assertEquals("Nostr", response.header("WWW-Authenticate")) - assertTrue(response.lines().single().startsWith("""["CLOSED","auth-required:""")) + assertTrue(response.lines().single().startsWith("""["CLOSED","q","auth-required:""")) } val unsigned = client().req(relay, listOf(Filter(kinds = listOf(1)))) {} @@ -236,7 +239,7 @@ class NipFEHttpTest { assertTrue(assertIs(published.last).success) val got = mutableListOf() - val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), got::add) + val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add) assertEquals(200, read.status) assertTrue(read.complete) assertEquals(listOf(n.id), got.map { it.id }) @@ -246,13 +249,15 @@ class NipFEHttpTest { fun aTokenForAnotherBodyDoesNotSignIn() = runBlocking { val relay = start(policy = ::SignInPolicy) - val url = HttpRelayCommand.REQ.url(relay) - val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", """{"kinds":[1]}""".encodeToByteArray())).toAuthToken() - post(url, """{"kinds":[0]}""", token).use { response -> + val url = relay.toHttp() + val signed = """["REQ","q",{"kinds":[1]}]""" + val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", signed.encodeToByteArray())).toAuthToken() + post(url, """["REQ","q",{"kinds":[0]}]""", token).use { response -> assertEquals(401, response.code) assertTrue(response.lines().single().contains("payload")) } - post(url, """{"kinds":[1]}""", token).use { assertEquals(200, it.code) } + post(url, signed, token).use { assertEquals(200, it.code) } + post(url, signed, token).use { assertEquals(200, it.code, "good again for the same body within its window") } } @Test @@ -260,24 +265,36 @@ class NipFEHttpTest { runBlocking { val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl() val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion))) - val body = """{"kinds":[1]}""" - val token = alice.sign(HTTPAuthorizationEvent.build(HttpRelayCommand.REQ.url(onion), "POST", body.encodeToByteArray())).toAuthToken() - post(HttpRelayCommand.REQ.url(relay), body, token).use { assertEquals(200, it.code) } + val body = """["REQ","q",{"kinds":[1]}]""" + val token = alice.sign(HTTPAuthorizationEvent.build(onion.toHttp(), "POST", body.encodeToByteArray())).toAuthToken() + post(relay.toHttp(), body, token).use { assertEquals(200, it.code) } } @Test - fun theEndpointsHangOffTheRelayPath() = + fun commandsGoToTheRelayUrlPathIncluded() = runBlocking { val relay = start(path = "/nostr") - assertTrue(HttpRelayCommand.REQ.url(relay).endsWith("/nostr/req")) + assertTrue(relay.toHttp().trimEnd('/').endsWith("/nostr")) assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete) - post(HttpRelayCommand.REQ.url(relay).replace("/nostr/req", "/req"), "{}").use { assertEquals(404, it.code) } + post(relay.toHttp().replace("/nostr", ""), """["REQ","q",{}]""").use { assertEquals(404, it.code) } } @Test - fun turnedOffThereAreNoEndpoints() { + fun nip86CallsKeepTheRelayUrlByTheirContentType() { + val relay = start() + post(relay.toHttp(), """{"method":"supportedmethods","params":[]}""", contentType = "application/nostr+json+rpc").use { response -> + assertEquals(401, response.code, "NIP-86 asks for its own NIP-98 token") + assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + } + } + + @Test + fun turnedOffEveryPostIsNip86Again() { val relay = start(settings = null) - post(HttpRelayCommand.REQ.url(relay), "{}").use { assertEquals(404, it.code) } + post(relay.toHttp(), """["REQ","q",{}]""").use { response -> + assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + assertEquals(401, response.code) + } } @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt index 7af34712f4..98b04bfd88 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt @@ -55,9 +55,8 @@ class HttpRelayAnswerReader( private var broken = false /** - * The frame on [line], typed with [HttpRelayCommand.SUB_ID] as its subscription id, or null for - * a blank line. A line that does not parse, or anything after the answer ended, marks the answer - * incomplete: the body is not what this NIP says it is. + * The frame on [line], or null for a blank line. A line that does not parse, or anything after + * the answer ended, marks the answer incomplete: the body is not what this NIP says it is. */ fun read(line: String): Message? { if (line.isBlank()) return null @@ -67,7 +66,7 @@ class HttpRelayAnswerReader( } val message = try { - OptimizedJsonMapper.fromJsonToMessage(withSubId(line.trim())) + OptimizedJsonMapper.fromJsonToMessage(line) } catch (_: Exception) { broken = true return null @@ -83,18 +82,3 @@ class HttpRelayAnswerReader( fun answer(retryAfter: String? = null) = HttpRelayAnswer(status, last, complete, retryAfter) } - -/** - * [frame] with the subscription id NIP-FE leaves out put back, so the websocket's parser reads it: - * `["EVENT",{…}]` → `["EVENT","http",{…}]`, `["EOSE"]` → `["EOSE","http"]`. The inverse of - * [withoutSubId]; frames that carry no subscription id pass as they are. - */ -internal fun withSubId(frame: String): String { - if (!frame.startsWith('[')) return frame - var open = 1 - while (open < frame.length && frame[open].isWhitespace()) open++ - if (open >= frame.length || frame[open] != '"') return frame - val verbEnd = frame.indexOf('"', open + 1) - if (verbEnd < 0 || frame.substring(open + 1, verbEnd) !in SUBSCRIPTION_FRAMES) return frame - return frame.substring(0, verbEnd + 1) + SUB_ID_FIELD + frame.substring(verbEnd + 1) -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt index d0f51da283..35b074fcb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt @@ -26,56 +26,22 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp /** - * NIP-FE: the client commands HTTP carries, one path each. A body is the command's arguments - * after its subscription id (a lone object where the command takes one); the answer ends on the - * first frame [ends] accepts. + * NIP-FE: the client commands an HTTP request may carry, each as the frame a client sends on the + * websocket, and the frame that ends each one's answer. */ -enum class HttpRelayCommand( - val path: String, -) { - REQ("/req"), - COUNT("/count"), - EVENT("/event"), +enum class HttpRelayCommand { + REQ, + COUNT, + EVENT, ; - /** - * The client frame [body] stands for, or null when it plainly is not this command's arguments. - * The body is spliced in as sent and the engine parses the frame, as it parses socket text, so - * any other malformed body is the engine's NOTICE. The verb and subscription id come first and - * the parser reads one value, so nothing a body holds can make it another command. - */ - fun frameOf(body: String): String? { - val text = body.trim() - return when (this) { - REQ, COUNT -> { - val filters = - when { - text.startsWith('{') -> text - text.startsWith('[') && text.endsWith(']') -> text.substring(1, text.length - 1).trim().ifEmpty { return null } - else -> return null - } - "[\"${if (this == REQ) ReqCmd.LABEL else CountCmd.LABEL}\",\"$SUB_ID\",$filters]" - } - - EVENT -> { - if (!text.startsWith('{')) return null - "[\"${EventCmd.LABEL}\",$text]" - } - } - } - - /** This command's endpoint on [relay]: the relay URL read as http(s), host and path kept, plus [path]. */ - fun url(relay: NormalizedRelayUrl): String = relay.toHttp().trimEnd('/') + path - - /** Whether [message] is the last frame of this command's answer. */ + /** Whether [message] is the last frame of this command's answer. A NOTICE ends any: the command never ran. */ fun ends(message: Message): Boolean = message is NoticeMessage || when (this) { @@ -85,15 +51,25 @@ enum class HttpRelayCommand( } companion object { - /** - * The subscription id every HTTP command runs under inside the engine. NIP-FE answers carry - * none, so [HttpRelayHandler] takes it back out of each frame before it goes out. - */ - const val SUB_ID = "http" + /** The kind of [cmd], or null for one HTTP does not carry (AUTH, CLOSE, NEG-*). */ + fun of(cmd: Command): HttpRelayCommand? = + when (cmd) { + is ReqCmd -> REQ + is CountCmd -> COUNT + is EventCmd -> EVENT + else -> null + } - fun forPath(path: String): HttpRelayCommand? = entries.firstOrNull { it.path == path } - - /** A REQ or COUNT body: the filters as the array that follows the subscription id. */ - fun body(filters: List): String = filters.joinToString(",", "[", "]") { it.toJson() } + /** The frame that refuses [cmd] with [reason], as the socket would: CLOSED for a REQ or COUNT, OK false for an EVENT. */ + fun refusal( + cmd: Command, + reason: String, + ): Message = + when (cmd) { + is EventCmd -> OkMessage(cmd.event.id, false, reason) + is ReqCmd -> ClosedMessage(cmd.subId, reason) + is CountCmd -> ClosedMessage(cmd.queryId, reason) + else -> NoticeMessage(reason) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt index d4333623f6..d4c96f7438 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt @@ -21,13 +21,16 @@ package com.vitorpamplona.quartz.nipFERelayOverHttp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.server.RelayServerBase import com.vitorpamplona.quartz.nip01Core.relay.server.SessionSink import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier +import com.vitorpamplona.quartz.nip98HttpAuth.tags.UrlTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException @@ -36,18 +39,19 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource -/** One NIP-FE request as the handler needs it. The host routes by [HttpRelayCommand.path]. */ +/** One NIP-FE request as the handler needs it: a POST to the relay's URL that is not a NIP-86 call. */ class HttpRelayRequest( - val command: HttpRelayCommand, /** The `Authorization` header as sent, or null. */ val authorization: String?, /** - * The body. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the engine measures a - * frame in characters, and a UTF-8 character takes up to three bytes. + * The body: one client frame. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the + * engine measures a frame in characters, and a UTF-8 character takes up to three bytes. */ val body: ByteArray, ) @@ -82,16 +86,17 @@ interface HttpRelayLines { class HttpRelayReaderStalled : Exception("the client stopped reading the answer") /** - * NIP-FE: one relay command per HTTP request, run on its own [RelayServerBase] session, so every - * limit and policy the socket applies applies here, and answered with the relay's own frames up to - * the command's answer, without their subscription id. Nothing outlives the request. + * NIP-FE: one relay command per HTTP request. The body is the frame a client would send on the + * websocket; it runs on its own [RelayServerBase] session, fed as socket text, so every limit and + * policy the socket applies applies here; and the answer is the session's frames as the socket + * would carry them, up to the one that ends the command's answer. Nothing outlives the request. * * Admission (how many requests a client may run) is the host's: gate before calling [handle], so a * refused request does not spend a NIP-98 token the handler would have verified. */ class HttpRelayHandler( private val server: RelayServerBase, - /** The prefixes a NIP-98 `u` may carry (the relay's http origin, its .onion), asked per request; never from the request. */ + /** The URLs a NIP-98 `u` may name (the relay's http URL, its .onion), asked per request; never from the request. */ private val origins: () -> List, /** How long one answer may run, first byte to last. [Duration.INFINITE] turns the deadline off. */ private val deadline: Duration = DEFAULT_DEADLINE, @@ -107,36 +112,35 @@ class HttpRelayHandler( request: HttpRelayRequest, response: HttpRelayResponse, ) { - val command = request.command val max = server.limits?.maxMessageLength + val tooLarge = "invalid: the command exceeds $max characters" maxBodyBytes?.let { cap -> - if (request.body.size > cap) { - return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters")) - } + if (request.body.size > cap) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge)) } - val frame = - command.frameOf(request.body.decodeToString()) - ?: return response.single(HttpRelayStatus.BAD_REQUEST, closed("invalid: the body is not ${command.name}'s arguments")) + val text = request.body.decodeToString() // Characters, as the engine's own limit counts them. - if (max != null && frame.length > max) { - return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters")) - } + if (max != null && text.length > max) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge)) + + // Read here as well as in the engine, to refuse the commands HTTP does not carry and to + // know what ends the answer and how to refuse it. The engine still parses the text itself, + // as socket text, so the policies that judge raw frames run. + val cmd = + try { + OptimizedJsonMapper.fromJsonToCommand(text) + } catch (_: Exception) { + null + } + val command = + cmd?.let { HttpRelayCommand.of(it) } + ?: return response.single(HttpRelayStatus.BAD_REQUEST, notice("invalid: the body is not one REQ, COUNT or EVENT frame")) + val signedIn = when (val proof = proofOf(request)) { - is Proof.Anonymous -> { - null - } - - is Proof.Signed -> { - proof.pubkey - } - - is Proof.Refused -> { - val reason = proof.reason - return response.single(HttpRelayStatus.forReason(reason), closed(reason)) - } + is Proof.Anonymous -> null + is Proof.Signed -> proof.pubkey + is Proof.Refused -> return response.single(HttpRelayStatus.forReason(proof.reason), HttpRelayCommand.refusal(cmd, proof.reason).toJson()) } - exchange(frame, command, signedIn, response) + exchange(text, cmd, command, signedIn, response) } /** A frame as queued: its wire text, its type when the engine built one, and whether it ends the answer. */ @@ -147,7 +151,8 @@ class HttpRelayHandler( ) private suspend fun exchange( - frame: String, + text: String, + cmd: Command, command: HttpRelayCommand, signedIn: HexKey?, response: HttpRelayResponse, @@ -165,23 +170,25 @@ class HttpRelayHandler( } } - fun fail(reason: String) = offer(Frame(closed(reason), ClosedMessage(HttpRelayCommand.SUB_ID, reason), last = true)) + fun refusal(reason: String) = HttpRelayCommand.refusal(cmd, reason) + + fun fail(reason: String) = refusal(reason).let { offer(Frame(it.toJson(), it, last = true)) } val sink = object : SessionSink { override fun message(message: Message) { // The challenge every connection opens with; this one proves its key by NIP-98 instead. if (message is AuthMessage) return - offer(Frame(withoutSubId(message.toJson()), message, command.ends(message))) + offer(Frame(message.toJson(), message, command.ends(message))) } - override fun raw(json: String) = offer(Frame(withoutSubId(json), null, last = false)) + override fun raw(json: String) = offer(Frame(json, null, last = false)) } val session = launch { try { server.serve(sink) { session -> val refused = signedIn?.let { session.authenticateByTransport(it) } - if (refused != null) fail(refused) else session.receive(frame) + if (refused != null) fail(refused) else session.receive(text) ended.await() } } catch (e: CancellationException) { @@ -202,7 +209,7 @@ class HttpRelayHandler( val status = HttpRelayStatus.of(first?.message) when { first == null -> { - single(HttpRelayStatus.UNAVAILABLE, closed("error: no answer within $deadline")) + single(HttpRelayStatus.UNAVAILABLE, notice("error: no answer within $deadline")) } first.last || status != HttpRelayStatus.OK -> { @@ -214,8 +221,8 @@ class HttpRelayHandler( bounded(due) { when (drain(first, frames, due)) { Ending.ANSWERED -> {} - Ending.DEADLINE -> line(closed("error: the answer ran past $deadline")) - Ending.CUT -> line(closed("error: slow reader, over $maxQueuedFrames frames waiting")) + Ending.DEADLINE -> line(refusal("error: the answer ran past $deadline").toJson()) + Ending.CUT -> line(refusal("error: slow reader, over $maxQueuedFrames frames waiting").toJson()) } flush() } @@ -284,36 +291,46 @@ class HttpRelayHandler( } /** - * A NIP-98 header, checked against every address in [origins], so a token signed at the .onion - * verifies there. It must bind the body's hash: it authorizes one command. - * Another scheme (a proxy's Basic, a client's Bearer) is not addressed to the relay and is ignored. + * A NIP-98 header. Its `u` may name any address in [origins], with or without the trailing + * slash, so a token signed at the .onion verifies there; the token is checked once, against the + * address it names. It must bind the body's hash and be within 60 seconds of now; within that + * window it may come again for the same body. Another scheme (a proxy's Basic, a client's + * Bearer) is not addressed to the relay and is ignored. */ private suspend fun proofOf(request: HttpRelayRequest): Proof { val header = request.authorization?.trim().orEmpty() val scheme = Nip98AuthVerifier.SCHEME if (!header.regionMatches(0, scheme, 0, scheme.length, ignoreCase = true)) return Proof.Anonymous val token = scheme + header.substring(scheme.length).trim() - val accepted = origins().map { it.trimEnd('/') + request.command.path } - if (accepted.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign")) + val addresses = origins() + if (addresses.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign")) + // The address the token names, when it is one of ours; otherwise the first, and the + // verifier refuses the mismatch (or whatever else is wrong with the token) itself. + val signed = claimedUrl(token) + val url = signed?.takeIf { u -> addresses.any { it.trimEnd('/') == u.trimEnd('/') } } ?: addresses.first() // A fresh verifier each time: it remembers the tokens it accepts, and a NIP-FE token is not - // single-use. A request can land on any instance, which no one process's memory can follow, - // and the body's hash already limits a captured token to the command it signs, in its window. - var refusal: Nip98AuthVerifier.Result.Malformed? = null - for (url in accepted) { - when (val r = Nip98AuthVerifier().verify(token, "POST", url, request.body)) { - is Nip98AuthVerifier.Result.Verified -> return Proof.Signed(r.pubkey) - is Nip98AuthVerifier.Result.Missing -> return Proof.Anonymous - is Nip98AuthVerifier.Result.Malformed -> refusal = refusal ?: r - } + // single-use. Every command it can sign is idempotent, so a repeat only repeats a read or + // re-sends an event the relay has, and a client may retry without signing again. + return when (val r = Nip98AuthVerifier(toleranceSeconds = TOKEN_WINDOW_SECONDS).verify(token, "POST", url, request.body)) { + is Nip98AuthVerifier.Result.Verified -> Proof.Signed(r.pubkey) + is Nip98AuthVerifier.Result.Missing -> Proof.Anonymous + is Nip98AuthVerifier.Result.Malformed -> Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${r.reason}")) } - return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${refusal?.reason}")) } - private fun closed(reason: String) = refusal(reason) + /** The `u` a NIP-98 [token] names, or null when it does not decode; the verifier then says why. */ + @OptIn(ExperimentalEncodingApi::class) + private fun claimedUrl(token: String): String? = + try { + val event = OptimizedJsonMapper.fromJson(Base64.decode(token.substring(Nip98AuthVerifier.SCHEME.length)).decodeToString()) + event.tags.firstNotNullOfOrNull(UrlTag::parse) + } catch (_: Exception) { + null + } companion object { - /** A `CLOSED` line with [reason], as NIP-FE sends it: for a host that refuses before the handler runs (413, 429, 503). */ - fun refusal(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + /** A `NOTICE` line: how a request is refused before its command runs (400, 413, 429, 503), by the handler or its host. */ + fun notice(reason: String) = NoticeMessage(reason).toJson() val DEFAULT_DEADLINE = 30_000.milliseconds @@ -321,22 +338,8 @@ class HttpRelayHandler( const val DEFAULT_MAX_QUEUED_FRAMES = 8192 val DEFAULT_TAIL_GRACE = 5_000.milliseconds + + /** NIP-FE: a token is good for 60 seconds either side of its `created_at`. */ + const val TOKEN_WINDOW_SECONDS = 60L } } - -/** The frames that carry a subscription id in the engine; NIP-FE sends them without it. */ -internal val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") - -internal const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\"" - -/** - * [frame] as NIP-FE sends it: the engine's frame with its `"http"` subscription id taken out, - * `["EVENT","http",{…}]` → `["EVENT",{…}]`, `["EOSE","http"]` → `["EOSE"]`. Other frames pass as they are. - */ -internal fun withoutSubId(frame: String): String { - if (!frame.startsWith("[\"")) return frame - val verbEnd = frame.indexOf('"', 2) - if (verbEnd < 0 || frame.substring(2, verbEnd) !in SUBSCRIPTION_FRAMES) return frame - if (!frame.startsWith(SUB_ID_FIELD, verbEnd + 1)) return frame - return frame.substring(0, verbEnd + 1) + frame.substring(verbEnd + 1 + SUB_ID_FIELD.length) -} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt index 3c00b6c9a1..085290682e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt @@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -46,19 +44,9 @@ class HttpRelayAnswerReaderTest { vararg lines: String, ) = HttpRelayAnswerReader(command, status).also { reader -> lines.forEach { reader.read(it) } } - @Test - fun theSubscriptionIdGoesBackWhereItWasTakenOut() { - for (frame in listOf("""["EVENT","http",$event]""", """["EOSE","http"]""", """["CLOSED","http","error: x"]""", """["COUNT","http",{"count":3}]""")) { - assertEquals(frame, withSubId(withoutSubId(frame))) - } - assertEquals("""["OK","id",true,""]""", withSubId("""["OK","id",true,""]""")) - assertEquals("""["NOTICE","hi"]""", withSubId("""["NOTICE","hi"]""")) - assertEquals("""[ "EOSE","http"]""", withSubId("""[ "EOSE"]""")) - } - @Test fun aReqThatEndsOnEoseIsComplete() { - val reader = read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE"]""") + val reader = read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q"]""") assertTrue(reader.complete) assertIs(reader.last) } @@ -66,24 +54,24 @@ class HttpRelayAnswerReaderTest { @Test fun aReqWithItsTailMissingIsIncomplete() { val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200) - val message = reader.read("""["EVENT",$event]""") + val message = reader.read("""["EVENT","q",$event]""") assertIs(message) assertEquals("hi", message.event.content) - assertEquals(HttpRelayCommand.SUB_ID, message.subId) + assertEquals("q", message.subId) assertFalse(reader.complete) assertFalse(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).complete, "an empty body is no answer") } @Test fun aClosedEndsAReqAndACountButNotAnEvent() { - assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["CLOSED","error: the answer ran past 30s"]""").complete) - assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","error: x"]""").complete) - assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","error: x"]""").complete) + assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["CLOSED","q","error: the answer ran past 30s"]""").complete) + assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","q","error: x"]""").complete) + assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","q","error: x"]""").complete) } @Test fun aCountAndAnOkAreWholeAnswers() { - val count = read(HttpRelayCommand.COUNT, 200, """["COUNT",{"count":7}]""") + val count = read(HttpRelayCommand.COUNT, 200, """["COUNT","c",{"count":7}]""") assertTrue(count.complete) assertEquals(7, assertIs(count.last).result.count) val ok = read(HttpRelayCommand.EVENT, 200, """["OK","abc",true,""]""") @@ -93,35 +81,21 @@ class HttpRelayAnswerReaderTest { @Test fun aRefusalIsOneLineWhateverItsFrame() { - val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","auth-required: sign in"]""") + val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","q","auth-required: sign in"]""") assertTrue(refused.complete) assertEquals("auth-required: sign in", assertIs(refused.last).message) - assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","blocked: no"]""", """["EOSE"]""").complete) + assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","q","blocked: no"]""", """["EOSE","q"]""").complete) } @Test fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() { - assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", """["EVENT",$event]""").complete) - assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE""").complete) - assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", "", " ").complete, "blank lines are not frames") + assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", """["EVENT","q",$event]""").complete) + assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q""").complete) + assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", "", " ").complete, "blank lines are not frames") } @Test fun blankLinesAreSkipped() { assertNull(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).read("")) } - - @Test - fun theEndpointsHangOffTheRelayUrl() { - assertEquals("https://relay.example/req", HttpRelayCommand.REQ.url(NormalizedRelayUrl("wss://relay.example/"))) - assertEquals("http://127.0.0.1:7447/nostr/count", HttpRelayCommand.COUNT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr"))) - assertEquals("http://127.0.0.1:7447/nostr/event", HttpRelayCommand.EVENT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr/"))) - } - - @Test - fun aFilterBodyIsTheArrayAfterTheSubscriptionId() { - val body = HttpRelayCommand.body(listOf(Filter(kinds = listOf(1), limit = 2), Filter(kinds = listOf(0)))) - assertEquals("""[{"kinds":[1],"limit":2},{"kinds":[0]}]""", body) - assertEquals("""["REQ","http",{"kinds":[1],"limit":2},{"kinds":[0]}]""", HttpRelayCommand.REQ.frameOf(body)) - } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt index fca059543e..5eb4bd6365 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt @@ -21,10 +21,16 @@ package com.vitorpamplona.quartz.nipFERelayOverHttp import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import kotlinx.coroutines.Dispatchers @@ -40,8 +46,9 @@ import okhttp3.coroutines.executeAsync import okio.IOException /** - * NIP-FE over OkHttp: one relay command per request, its answer read line by line as the relay - * writes it. Nothing stays open after a call returns. + * NIP-FE over OkHttp: one relay command per request, POSTed to the relay's URL as the frame the + * websocket would carry, its answer read line by line as the relay writes it, in the socket's own + * frames. Nothing stays open after a call returns. * * With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for * its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the @@ -57,9 +64,10 @@ class HttpRelayClient( suspend fun req( relay: NormalizedRelayUrl, filters: List, + subId: String = newSubId(), onEvent: (Event) -> Unit, ): HttpRelayAnswer = - send(relay, HttpRelayCommand.REQ, HttpRelayCommand.body(filters)) { + send(relay, ReqCmd(subId, filters)) { if (it is EventMessage) onEvent(it.event) } @@ -67,23 +75,24 @@ class HttpRelayClient( suspend fun count( relay: NormalizedRelayUrl, filters: List, - ): HttpRelayAnswer = send(relay, HttpRelayCommand.COUNT, HttpRelayCommand.body(filters)) + queryId: String = newSubId(), + ): HttpRelayAnswer = send(relay, CountCmd(queryId, filters)) /** Publishes [event]: [HttpRelayAnswer.last] is its OK, or the refusal. */ suspend fun publish( relay: NormalizedRelayUrl, event: Event, - ): HttpRelayAnswer = send(relay, HttpRelayCommand.EVENT, event.toJson()) + ): HttpRelayAnswer = send(relay, EventCmd(event)) - /** Posts [body] to [command]'s endpoint on [relay], handing every frame to [onMessage] as it is read. */ + /** Posts [cmd] (a REQ, COUNT or EVENT) to [relay], handing every frame to [onMessage] as it is read. */ suspend fun send( relay: NormalizedRelayUrl, - command: HttpRelayCommand, - body: String, + cmd: Command, onMessage: (Message) -> Unit = {}, ): HttpRelayAnswer { - val url = command.url(relay) - val bytes = body.encodeToByteArray() + val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" } + val url = relay.toHttp() + val bytes = cmd.toJson().encodeToByteArray() if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) val first = post(command, url, bytes, null, onMessage, retrying = true) if (first.status != HttpRelayStatus.UNAUTHORIZED) return first diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt index 31695d8da3..5612fd3c81 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt @@ -160,10 +160,15 @@ class HttpRelayHandlerTest { ) = HttpRelayHandler(MemoryRelay(backend, signedInOnly), origins = { listOf(origin) }, deadline = deadline) private fun HttpRelayHandler.ask( - command: HttpRelayCommand, - body: String, + frame: String, authorization: String? = null, - ) = Recorded().also { runBlocking { handle(HttpRelayRequest(command, authorization, body.encodeToByteArray()), it) } } + ) = Recorded().also { runBlocking { handle(HttpRelayRequest(authorization, frame.encodeToByteArray()), it) } } + + private fun req(filter: String) = """["REQ","q",$filter]""" + + private fun count(filter: String) = """["COUNT","c",$filter]""" + + private fun publish(event: Event) = """["EVENT",${event.toJson()}]""" private fun note( content: String, @@ -171,19 +176,19 @@ class HttpRelayHandlerTest { ) = alice.sign(at, 1, emptyArray(), content) private fun token( - command: HttpRelayCommand, - body: String, - ) = alice.sign(HTTPAuthorizationEvent.build(origin + command.path, "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken() + frame: String, + url: String = origin, + ) = alice.sign(HTTPAuthorizationEvent.build(url, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken() @Test fun aReqStreamsItsEventsAndEndsOnEose() { val a = note("a") val b = note("b") backend.events += listOf(a, b) - val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""") + val answer = handler().ask(req("""{"kinds":[1]}""")) assertEquals(200, answer.status) assertTrue(answer.streamed) - assertEquals("""["EOSE"]""", answer.lines.last()) + assertEquals("""["EOSE","q"]""", answer.lines.last()) assertEquals( setOf(a.id, b.id), answer.lines @@ -193,23 +198,31 @@ class HttpRelayHandlerTest { ) } + @Test + fun framesGoOutAsTheSocketSendsThemWithTheClientsSubscriptionId() { + val found = note("found") + backend.events += found + val answer = handler().ask("""["REQ","mine",{"kinds":[1]}]""") + assertEquals(listOf("""["EVENT","mine",${found.toJson()}]""", """["EOSE","mine"]"""), answer.lines) + } + @Test fun anEmptyReqIsOneEoseLine() { - val answer = handler().ask(HttpRelayCommand.REQ, """[{"kinds":[30000]}]""") + val answer = handler().ask(req("""{"kinds":[30000]}""")) assertEquals(200, answer.status) - assertEquals(listOf("""["EOSE"]"""), answer.lines) + assertEquals(listOf("""["EOSE","q"]"""), answer.lines) } @Test fun anEventIsAnsweredByItsOkAndAForgeryIsRefused() { val posted = note("posted") - val ok = handler().ask(HttpRelayCommand.EVENT, posted.toJson()) + val ok = handler().ask(publish(posted)) assertEquals(200, ok.status) assertEquals(listOf("""["OK","${posted.id}",true,""]"""), ok.lines) assertTrue(backend.events.any { it.id == posted.id }) val forged = Event(posted.id, posted.pubKey, posted.createdAt, posted.kind, posted.tags, "tampered", posted.sig) - val refused = handler().ask(HttpRelayCommand.EVENT, forged.toJson()) + val refused = handler().ask(publish(forged)) assertEquals(400, refused.status, refused.lines.toString()) assertTrue(refused.lines.single().startsWith("""["OK","${forged.id}",false,"""), refused.lines.toString()) } @@ -217,76 +230,89 @@ class HttpRelayHandlerTest { @Test fun aCountIsOneCountLine() { backend.events += listOf(note("a"), note("b"), note("c")) - val answer = handler().ask(HttpRelayCommand.COUNT, """{"kinds":[1]}""") + val answer = handler().ask(count("""{"kinds":[1]}""")) assertEquals(200, answer.status) - assertTrue(answer.lines.single().startsWith("""["COUNT",{"count":3"""), answer.lines.toString()) + assertTrue(answer.lines.single().startsWith("""["COUNT","c",{"count":3"""), answer.lines.toString()) } @Test - fun aBodyThatIsNotTheCommandsArgumentsIsA400AndOneOverTheLimitA413() { - // Not the command's shape: refused before any session opens. - for ((command, body) in listOf( - HttpRelayCommand.REQ to "[]", - HttpRelayCommand.EVENT to """[{"id":"x"}]""", - HttpRelayCommand.COUNT to "not json", - )) { - val answer = handler().ask(command, body) - assertEquals(400, answer.status, "$command '$body'") - assertTrue(answer.lines.single().startsWith("""["CLOSED","invalid:"""), answer.lines.toString()) + fun aBodyThatIsNotAReqCountOrEventIsA400AndOneOverTheLimitA413() { + for (body in listOf("[]", """{"kinds":[1]}""", "not json", """["CLOSE","q"]""", """["AUTH",{"id":"x"}]""", """["NEG-CLOSE","n"]""")) { + val answer = handler().ask(body) + assertEquals(400, answer.status, body) + assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid:"""), answer.lines.toString()) } - // The right shape with an inside the engine cannot read: its own NOTICE, the command never ran. - val unreadable = handler().ask(HttpRelayCommand.EVENT, """{"id":"not an event"}""") - assertEquals(400, unreadable.status) - assertTrue(unreadable.lines.single().startsWith("""["NOTICE","""), unreadable.lines.toString()) - assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(5_000)}"}""").status) - // Under the byte cap, over it once wrapped in its frame: the engine measures the frame. - assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(4_096 - 20)}"}""").status) + // A REQ the engine refuses as a command: its own NOTICE, the command never ran. + val empty = handler().ask("""["REQ","",{"kinds":[1]}]""") + assertEquals(400, empty.status) + assertTrue(empty.lines.single().startsWith("""["NOTICE","""), empty.lines.toString()) + // Over the relay's message length, in characters, as the socket measures it. + val big = handler().ask(req("""{"search":"${"x".repeat(4_096)}"}""")) + assertEquals(413, big.status) + assertTrue(big.lines.single().startsWith("""["NOTICE","invalid:"""), big.lines.toString()) } @Test fun aNip98SignatureSignsTheSessionInAndAnotherSchemeDoesNot() { backend.events += note("gated") val gated = handler(signedInOnly = true) - val body = """{"kinds":[1]}""" + val frame = req("""{"kinds":[1]}""") - val anonymous = gated.ask(HttpRelayCommand.REQ, body) + val anonymous = gated.ask(frame) assertEquals(401, anonymous.status) - assertTrue(anonymous.lines.single().startsWith("""["CLOSED","auth-required:""")) + assertTrue(anonymous.lines.single().startsWith("""["CLOSED","q","auth-required:""")) - assertEquals(401, gated.ask(HttpRelayCommand.REQ, body, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay") + assertEquals(401, gated.ask(frame, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay") - val signed = gated.ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body)) + val signed = gated.ask(frame, token(frame)) assertEquals(200, signed.status, signed.lines.toString()) - assertEquals("""["EOSE"]""", signed.lines.last()) + assertEquals("""["EOSE","q"]""", signed.lines.last()) } @Test - fun aTokenSignsOnlyItsBodyAndIsNotSingleUse() { + fun aTokenSignsOnlyItsBodyAndIsGoodAgainWithinItsWindow() { val h = handler() - val body = """{"kinds":[1]}""" - val signed = token(HttpRelayCommand.REQ, body) - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status) - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status, "any instance may answer it, so none remembers it") - val other = h.ask(HttpRelayCommand.REQ, """{"kinds":[0]}""", token(HttpRelayCommand.REQ, body)) + val frame = req("""{"kinds":[1]}""") + val signed = token(frame) + assertEquals(200, h.ask(frame, signed).status) + assertEquals(200, h.ask(frame, signed).status, "the same body again only repeats the read") + val other = h.ask(req("""{"kinds":[0]}"""), signed) assertEquals(401, other.status) assertTrue("payload" in other.lines.single(), other.lines.toString()) + assertTrue(other.lines.single().startsWith("""["CLOSED","q","auth-required:"""), other.lines.toString()) + } + + @Test + fun aTokenOutsideItsSixtySecondsIsRefused() { + val frame = req("""{"kinds":[1]}""") + val stale = alice.sign(HTTPAuthorizationEvent.build(origin, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000 - 120) {}).toAuthToken() + assertEquals(401, handler().ask(frame, stale).status) + } + + @Test + fun anEventRefusedForItsTokenIsAnOkFalse() { + val posted = publish(note("unsigned")) + val answer = handler().ask(posted, token(req("{}"))) + assertEquals(401, answer.status) + assertTrue(answer.lines.single().startsWith("""["OK","""), answer.lines.toString()) + assertTrue(""",false,"auth-required:""" in answer.lines.single(), answer.lines.toString()) } @Test fun noFirstFrameWithinTheDeadlineIsA503() { - val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[$STALLED_KIND]}""") + val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[$STALLED_KIND]}""")) assertEquals(503, answer.status) - assertTrue(answer.lines.single().startsWith("""["CLOSED","error: no answer""")) + assertTrue(answer.lines.single().startsWith("""["NOTICE","error: no answer""")) } @Test fun aDeadlineMidAnswerEndsOnAClosedLine() { val found = note("found") backend.events += found - val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[1,$TRICKLE_KIND]}""") + val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[1,$TRICKLE_KIND]}""")) assertEquals(200, answer.status) assertTrue(found.id in answer.lines.first()) - assertTrue(answer.lines.last().startsWith("""["CLOSED","error: the answer ran past"""), answer.lines.toString()) + assertTrue(answer.lines.last().startsWith("""["CLOSED","q","error: the answer ran past"""), answer.lines.toString()) } @Test @@ -308,7 +334,7 @@ class HttpRelayHandlerTest { }.lines() } assertFailsWith { - runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.REQ, null, """{"kinds":[1,$TRICKLE_KIND]}""".encodeToByteArray()), stalled) } + runBlocking { h.handle(HttpRelayRequest(null, req("""{"kinds":[1,$TRICKLE_KIND]}""").encodeToByteArray()), stalled) } } } @@ -322,26 +348,17 @@ class HttpRelayHandlerTest { session.close() } - @Test - fun framesCarryNoSubscriptionId() { - val found = note("found") - backend.events += found - val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""") - assertTrue(answer.lines.first().startsWith("""["EVENT",{"""), answer.lines.toString()) - assertEquals("""["EOSE"]""", answer.lines.last()) - } - @Test fun aDeeplyNestedBodyIsA400NotAStackOverflow() { - for (body in listOf("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000), "[".repeat(20_000) + "]".repeat(20_000))) { - val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body) + for (body in listOf(req("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000)), "[".repeat(20_000) + "]".repeat(20_000))) { + val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(body) assertEquals(400, answer.status, body.take(20)) } } @Test fun aFullAuthPolicyRefusesTransportSignInUntilItOptsIn() { - val body = """{"kinds":[1]}""" + val frame = req("""{"kinds":[1]}""") val relayUrl = RelayUrlNormalizer.normalize("wss://relay.example") val refusing = MemoryRelay(backend, { @@ -349,9 +366,9 @@ class HttpRelayHandlerTest { override suspend fun authorize(event: RelayAuthEvent): Unit = error("backend rejected user") } }) - val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body)) + val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(frame, token(frame)) assertEquals(403, refused.status, refused.lines.toString()) - assertTrue(refused.lines.single().startsWith("""["CLOSED","restricted:"""), refused.lines.toString()) + assertTrue(refused.lines.single().startsWith("""["CLOSED","q","restricted:"""), refused.lines.toString()) val optingIn = MemoryRelay(backend, { @@ -359,14 +376,14 @@ class HttpRelayHandlerTest { override suspend fun authorizeTransport(pubkey: HexKey): String? = null } }) - val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body)) + val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(frame, token(frame)) assertEquals(200, signed.status, signed.lines.toString()) } @Test fun aMessageLimitInThePolicyChainStillRuns() { val limited = MemoryRelay(backend, { LimitsPolicy(RelayLimits(maxMessageLength = 4096)) + VerifyPolicy }, limits = null) - val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(20_000)}"}""") + val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(req("""{"search":"${"x".repeat(20_000)}"}""")) assertEquals(400, answer.status, answer.lines.toString()) assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid: message too large"""), answer.lines.toString()) } @@ -374,13 +391,12 @@ class HttpRelayHandlerTest { @Test fun aMultiByteEventUnderTheCharacterLimitIsAccepted() { // 1,500 CJK characters: about 4,500 UTF-8 bytes, well under 4,096 characters as the engine counts. - val posted = note("\u4E2D".repeat(1_500)) - val answer = handler().ask(HttpRelayCommand.EVENT, posted.toJson()) + val answer = handler().ask(publish(note("中".repeat(1_500)))) assertEquals(200, answer.status, answer.lines.toString()) } @Test - fun aBackendFailureIsA500Line() { + fun aBackendFailureIsA500OkFalse() { val failing = object : SessionBackend by backend { override suspend fun submit( @@ -388,17 +404,18 @@ class HttpRelayHandlerTest { onComplete: (IEventStore.InsertOutcome) -> Unit, ): Unit = error("db is down") } - val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(HttpRelayCommand.EVENT, note("lost").toJson()) + val lost = note("lost") + val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(publish(lost)) assertEquals(500, answer.status, answer.lines.toString()) - assertTrue(answer.lines.single().startsWith("""["CLOSED","error:"""), answer.lines.toString()) + assertTrue(answer.lines.single().startsWith("""["OK","${lost.id}",false,"error:"""), answer.lines.toString()) } @Test fun anInfiniteDeadlineStillStreams() { backend.events += note("forever") - val answer = handler(deadline = Duration.INFINITE).ask(HttpRelayCommand.REQ, """{"kinds":[1]}""") + val answer = handler(deadline = Duration.INFINITE).ask(req("""{"kinds":[1]}""")) assertEquals(200, answer.status) - assertEquals("""["EOSE"]""", answer.lines.last()) + assertEquals("""["EOSE","q"]""", answer.lines.last()) } @Test @@ -414,37 +431,27 @@ class HttpRelayHandlerTest { override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = error("single") } assertFailsWith { - runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.COUNT, null, """{"kinds":[1]}""".encodeToByteArray()), stalled) } + runBlocking { h.handle(HttpRelayRequest(null, count("""{"kinds":[1]}""").encodeToByteArray()), stalled) } } } - @Test - fun aBodyIsSplicedIntoItsFrameAsSent() { - assertEquals("""["REQ","http",{"kinds":[1]}]""", HttpRelayCommand.REQ.frameOf(""" {"kinds":[1]} """)) - assertEquals("""["COUNT","http",{"a":"]"},{"b":"\"["}]""", HttpRelayCommand.COUNT.frameOf("""[{"a":"]"},{"b":"\"["}]""")) - assertEquals("""["EVENT",{"id":"x"}]""", HttpRelayCommand.EVENT.frameOf("""{"id":"x"}""")) - for (bad in listOf("", "[]", "[ ]", "[{}", "1", "null", "\"x\"")) assertEquals(null, HttpRelayCommand.REQ.frameOf(bad), "REQ '$bad'") - for (bad in listOf("""[{"id":"x"}]""", "1")) assertEquals(null, HttpRelayCommand.EVENT.frameOf(bad), "EVENT '$bad'") - } - @Test fun aBodyCannotCarryASecondCommand() { val smuggled = note("smuggled") - val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}],["EVENT",${smuggled.toJson()}""") - assertTrue(answer.lines.last().let { it == """["EOSE"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString()) + val answer = handler().ask(req("""{"kinds":[1]}""") + publish(smuggled)) + assertTrue(answer.lines.last().let { it == """["EOSE","q"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString()) assertTrue(backend.events.none { it.id == smuggled.id }, "only the REQ ran") } @Test fun aTokenSignedAtAnyOfTheRelaysAddressesVerifies() { - val onion = "http://relayxyz.onion" + val onion = "http://relayxyz.onion/" val h = HttpRelayHandler(MemoryRelay(backend, true), origins = { listOf(origin, onion) }) - val body = """{"kinds":[1]}""" - - fun at(base: String) = alice.sign(HTTPAuthorizationEvent.build(base + "/req", "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken() - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(onion)).status) - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(origin)).status) - assertEquals(401, h.ask(HttpRelayCommand.REQ, body, at("https://elsewhere.example")).status) + val frame = req("""{"kinds":[1]}""") + assertEquals(200, h.ask(frame, token(frame, onion)).status) + assertEquals(200, h.ask(frame, token(frame, "http://relayxyz.onion")).status, "with or without the trailing slash") + assertEquals(200, h.ask(frame, token(frame, "$origin/")).status) + assertEquals(401, h.ask(frame, token(frame, "https://elsewhere.example")).status) } private companion object { From af8fb44d466b0627d4dc5f9026280422f0862b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:01:32 +0000 Subject: [PATCH 10/17] fix(nav): keep the bottom bar on screens opened from the drawer Since the April change that hid AppBottomBar on every canPop() entry, any section picked from the navigation drawer (Pictures, Articles, Wallet, Settings, the own profile, ...) lost the bottom bar, because the drawer pushes those screens like any in-app navigation. Fixes #4141. The drawer now opens its destinations through INav.navDrawer, which stamps the new back-stack entry with DRAWER_ROOT_KEY, and the bar asks INav.showsBottomBar() instead of !canPop(): tab roots, Home and drawer destinations show it, in-app pushes (a profile or chat opened from a note, etc.) still don't. The back arrow is unchanged, since drawer screens can still pop. FabBottomBarPadding follows the same rule so FABs don't float 50dp above the bar that now shows. navBottomBar already drops any non-tab- root entry before switching tabs, so tapping a tab from a drawer screen lands on the tab without saving the drawer screen into it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018otSD34gHeE1RT31UgzY1b --- .../ui/navigation/NavigationEffects.kt | 8 ++ .../ui/navigation/bottombars/AppBottomBar.kt | 8 +- .../ui/navigation/drawer/DrawerContent.kt | 12 +- .../amethyst/ui/navigation/navs/Nav.kt | 55 ++++++-- .../concord/ConcordChannelListScreen.kt | 2 +- .../concord/ConcordHomeScreen.kt | 2 +- .../PublicChatChannelScreen.kt | 2 +- .../relayGroup/RelayGroupChannelListScreen.kt | 2 +- .../relayGroup/RelayGroupChatScreen.kt | 2 +- .../ui/navigation/NavBottomBarStackTest.kt | 4 +- .../amethyst/ui/navigation/NavDrawerTest.kt | 118 ++++++++++++++++++ .../ui/layouts/DisappearingScaffold.kt | 2 +- .../bottombars/FabBottomBarPadding.kt | 6 +- .../commons/ui/navigation/navs/INav.kt | 17 +++ 14 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index d7e44af2db..31ad12ee41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -54,6 +54,14 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true +// Per-entry hint stamped by Nav.navDrawer marking that the entry was opened +// from the navigation drawer. It sits on top of the stack like any push (back +// arrow, slide animation), but Nav.showsBottomBar keeps the bottom bar on it: +// a drawer destination is a top-level section, not a detail screen. +const val DRAWER_ROOT_KEY = "drawerRoot" + +fun NavBackStackEntry.isDrawerRoot(): Boolean = savedStateHandle.get(DRAWER_ROOT_KEY) == true + /** * The shell's current layout tier, mirrored for the transition specs below. Transition * lambdas run when a navigation starts — outside composition — so they can't read diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index ec0ca2e514..efaf7c00ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]), * exclusive of the system navigation-bar inset. Used by FAB callers that want to - * reserve the same vertical space when the bar hides itself on canPop entries. */ + * reserve the same vertical space when the bar hides itself on in-app pushes. */ val AppBottomBarHeight = 50.dp @Composable @@ -94,9 +94,9 @@ fun AppBottomBar( // permanently docked drawer (Expanded). if (LocalScreenLayout.current.isLargeScreen) return - // Hide the bar on entries that aren't a tab root (drawer or in-app - // pushes). Mirrors the back-arrow rule in canPop(). - if (nav.canPop()) return + // Hide the bar on in-app pushes. Tab roots, Home and screens opened from + // the drawer keep it, even though the drawer ones still show a back arrow. + if (!nav.showsBottomBar()) return val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index be821834a5..ca068e3aba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -210,7 +210,7 @@ private fun DrawerContentBody( accountViewModel: AccountViewModel, ) { val onClickUser = { - nav.nav(routeFor(accountViewModel.userProfile())) + nav.navDrawer(routeFor(accountViewModel.userProfile())) nav.closeDrawer() } @@ -742,7 +742,7 @@ private fun ScheduledPostsNavigationRow( badgeCount = pendingCount, onClick = { nav.closeDrawer() - nav.nav { def.resolveRoute(accountViewModel) } + nav.navDrawer { def.resolveRoute(accountViewModel) } }, ) } @@ -850,7 +850,7 @@ fun NavigationRow( tint, onClick = { nav.closeDrawer() - nav.nav(route) + nav.navDrawer(route) }, ) } @@ -871,7 +871,7 @@ fun NavigationRow( tint, onClick = { nav.closeDrawer() - nav.nav(computeRoute) + nav.navDrawer(computeRoute) }, ) } @@ -890,7 +890,7 @@ fun NavigationRow( tint = tint, onClick = { nav.closeDrawer() - nav.nav(route) + nav.navDrawer(route) }, ) } @@ -909,7 +909,7 @@ fun NavigationRow( tint = tint, onClick = { nav.closeDrawer() - nav.nav(computeRoute) + nav.navDrawer(computeRoute) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 0818cc8a42..41780eb4e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -36,7 +36,9 @@ import androidx.navigation.NavHostController import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY +import com.vitorpamplona.amethyst.ui.navigation.DRAWER_ROOT_KEY import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot +import com.vitorpamplona.amethyst.ui.navigation.isDrawerRoot import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -89,6 +91,31 @@ class Nav( } } + override fun navDrawer(route: Route) { + navigationScope.launch { + ime.settle() + navigateFromDrawer(route) + } + } + + override fun navDrawer(computeRoute: suspend () -> Route?) { + navigationScope.launch { + ime.settle() + computeRoute()?.let { navigateFromDrawer(it) } + } + } + + /** + * Same push as [nav], then stamps the new entry [DRAWER_ROOT_KEY] so [showsBottomBar] keeps + * the bottom bar on it. The stamp lands in the same frame as the navigate, before the entry + * composes, the way [navBottomBar] stamps its tab roots. + */ + private fun navigateFromDrawer(route: Route) { + if (getRouteWithArguments(route::class, controller) == route) return + controller.navigate(route) + controller.currentBackStackEntry?.savedStateHandle?.set(DRAWER_ROOT_KEY, true) + } + override fun newStack(route: Route) { navigationScope.launch { ime.settle() @@ -107,9 +134,10 @@ class Nav( // A nav-bar tap asks for a tab, never for whatever the user pushed on top of one. Drop // those pushes first, and without saving them, so the restoreState below can never hand - // a deep stack back. On phones this is always a no-op — AppBottomBar hides itself off - // tab roots, so the bar is only ever tapped from one — but the large-screen rail stays - // on screen the whole time and is routinely tapped from three screens deep. + // a deep stack back. On phones the bar shows only on tab roots and on screens opened + // from the drawer (dropped here, back to the tab they were opened over), but the + // large-screen rail stays on screen the whole time and is routinely tapped from three + // screens deep. popPushesAboveTabRoot() // Dropping those pushes is often the whole job — re-tapping the tab the user is inside, @@ -211,11 +239,24 @@ class Nav( // Outside a NavHost destination (shell chrome, drawer) the current owner // is the account-scoped ViewModelStoreOwner, not an entry; fall back to // the globally-current entry so those callers keep their prior behavior. - val entry = - (LocalViewModelStoreOwner.current as? NavBackStackEntry) - ?: controller.currentBackStackEntry - ?: return false + val entry = ownEntry() ?: return false + return canPop(entry) + } + @Composable + override fun showsBottomBar(): Boolean { + val entry = ownEntry() ?: return true + // Drawer destinations can pop (they sit on whatever screen the drawer was opened over), + // but they are top-level sections, so they keep the bar the tab roots have. + return entry.isDrawerRoot() || !canPop(entry) + } + + @Composable + private fun ownEntry(): NavBackStackEntry? = + (LocalViewModelStoreOwner.current as? NavBackStackEntry) + ?: controller.currentBackStackEntry + + private fun canPop(entry: NavBackStackEntry): Boolean { // Hidden on tab roots (reached via the bottom nav) and on Home (the // graph's start destination): nothing sits below either that a back // arrow could return to. Every other entry is a push on top of Home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d95788cda4..8e12603fd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -353,7 +353,7 @@ fun ConcordChannelListScreen( ) }, bottomBar = { - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned Concord community works both as a pushed detail and as a bottom-nav tab. AppBottomBar(Route.ConcordServer(communityId), nav, accountViewModel) { route -> if (route != Route.ConcordServer(communityId)) nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 95a86e69f7..5852cb0061 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -137,7 +137,7 @@ fun ConcordHomeScreen( ) }, bottomBar = { - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so the same screen works both as a pushed destination and a bottom-nav tab. AppBottomBar(Route.Concords, nav, accountViewModel) { route -> if (route != Route.Concords) nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index da19432246..10e544030b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -56,7 +56,7 @@ fun PublicChatChannelScreen( PublicChatTopBar(it, accountViewModel, nav) } }, - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned public chat works both as a pushed detail and as a bottom-nav tab. bottomBar = { AppBottomBar(selfRoute, nav, accountViewModel) { route -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 30efeb7f3b..0bd74d7ba7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -439,7 +439,7 @@ fun RelayGroupChannelListScreen( ) }, bottomBar = { - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned NIP-29 relay works both as a pushed detail and as a bottom-nav tab. AppBottomBar(selfRoute, nav, accountViewModel) { route -> if (route != selfRoute) nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt index d6b6f376a1..e938259b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -58,7 +58,7 @@ fun RelayGroupChatScreen( RelayGroupTopBar(it, inviteCode, accountViewModel, nav) } }, - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned relay group works both as a pushed detail and as a bottom-nav tab. bottomBar = { AppBottomBar(selfRoute, nav, accountViewModel) { route -> diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt index edc44460a9..cac7218dfd 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt @@ -42,8 +42,8 @@ import org.junit.Test /** * A nav-bar tap must land on the tab itself, never on whatever the user had pushed on top of one. * - * The phone bottom bar gets this for free — it hides itself off tab roots, so it can only ever be - * tapped from one. The large-screen navigation rail stays on screen the whole time, which is where + * The phone bottom bar shows only on tab roots and drawer destinations, so it is tapped from at most + * one screen above a tab. The large-screen navigation rail stays on screen the whole time, which is where * the gap showed: tapping Home from a thread three screens deep came back to that thread instead of * the feed. * diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt new file mode 100644 index 0000000000..580b1f63b6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt @@ -0,0 +1,118 @@ +/* + * 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.amethyst.ui.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * A screen opened from the navigation drawer is a top-level section: it keeps the bottom bar + * (issue #4141, where Pictures and every other drawer destination lost it). It still sits on top of + * the screen the drawer was opened over, so it keeps its back arrow too; the bar is told apart + * from an in-app push by the [DRAWER_ROOT_KEY] stamp these tests pin down. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NavDrawerTest { + private fun entry( + isTabRoot: Boolean = false, + isDrawerRoot: Boolean = false, + ): NavBackStackEntry = + mockk(relaxed = true) { + every { savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) } returns isTabRoot + every { savedStateHandle.get(DRAWER_ROOT_KEY) } returns isDrawerRoot + } + + /** A controller whose current entry becomes [pushed] once [route] is navigated to. */ + private fun controllerPushing( + route: Route, + pushed: NavBackStackEntry, + ): NavHostController { + var current = entry(isTabRoot = true) + return mockk(relaxed = true) { + every { currentBackStackEntry } answers { current } + every { navigate(route) } answers { current = pushed } + } + } + + @Test + fun stampsTheEntryItOpens() = + runTest { + val pushed = entry() + val controller = controllerPushing(Route.Pictures(), pushed) + + Nav(controller, this).navDrawer(Route.Pictures()) + advanceUntilIdle() + + verify(exactly = 1) { controller.navigate(Route.Pictures()) } + verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true } + } + + @Test + fun stampsTheEntryOfAResolvedRoute() = + runTest { + val pushed = entry() + val controller = controllerPushing(Route.Articles, pushed) + + Nav(controller, this).navDrawer { Route.Articles } + advanceUntilIdle() + + verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true } + } + + @Test + fun plainPushesAreNotStamped() = + runTest { + val pushed = entry() + val controller = controllerPushing(Route.Pictures(), pushed) + + Nav(controller, this).nav(Route.Pictures()) + advanceUntilIdle() + + verify(exactly = 0) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = any() } + } + + @Test + fun aTabTappedFromADrawerScreenDropsItFirst() = + runTest { + // Home > Pictures (from the drawer): the bar now shows on Pictures, so it can be tapped + // from there. The drawer entry is not a tab root and must not be saved into the tab. + val controller = + mockk(relaxed = true) { + every { currentBackStackEntry } returnsMany + listOf(entry(isDrawerRoot = true), entry(isTabRoot = true)) + every { popBackStack() } returns true + } + + Nav(controller, this).navBottomBar(Route.Message) + advanceUntilIdle() + + verify(exactly = 1) { controller.popBackStack() } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt index 972ba2c8b0..7db01dd845 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt @@ -199,7 +199,7 @@ private fun ScaffoldLayout( }.firstOrNull()?.measure(looseConstraints) } // When the bar lambda is provided but its content emits nothing (e.g. AppBottomBar - // hides itself on canPop entries, or while the keyboard is up), reserve the + // hides itself on in-app pushes, or while the keyboard is up), reserve the // system-nav-bar inset so the FAB and content stay clear of the navigation bar instead // of sliding under it. Subtract the IME inset: the root imePadding has already lifted the // whole scaffold above the keyboard, and the IME inset spans the nav-bar band, so diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt index 723231f179..592ee4cd94 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav /** * Reserves the visual space the `AppBottomBar` occupies on root tab entries so a * FloatingActionButton stays at the same vertical position whether or not the bar is - * rendered. `AppBottomBar` hides itself on canPop entries (drawer pushes, in-app + * rendered. `AppBottomBar` hides itself off [INav.showsBottomBar] entries (in-app * navigations) — without this padding the FAB drops by `AppBottomBarHeight` there. * * The system-navigation-bar inset is already handled by the surrounding Scaffold, so @@ -41,8 +41,8 @@ val FABPaddingFromBottom = 30.dp @Composable fun Modifier.fabBottomBarPadding(nav: INav): Modifier = - if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) { - // canPop entries hide the bar on phones; large screens never render it at all. + if (!nav.showsBottomBar() || LocalScreenLayout.current.isLargeScreen) { + // In-app pushes hide the bar on phones; large screens never render it at all. padding(bottom = FABPaddingFromBottom) } else { this diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt index 08261b7691..9a3a5d3261 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt @@ -52,9 +52,26 @@ interface INav { fun navBottomBar(route: Route) + /** + * Opens [route] as a top-level destination picked from the navigation drawer. It still + * stacks on top of the current screen (so it [canPop]), but unlike an in-app push it keeps + * the bottom bar — see [showsBottomBar]. + */ + fun navDrawer(route: Route) = nav(route) + + /** [navDrawer], for a route that has to be resolved first. */ + fun navDrawer(computeRoute: suspend () -> Route?) = nav(computeRoute) + @Composable fun canPop(): Boolean + /** + * Whether this screen renders the bottom bar: tab roots, the start destination and + * destinations opened from the drawer ([navDrawer]) do; in-app pushes don't. + */ + @Composable + fun showsBottomBar(): Boolean = !canPop() + fun popBack() fun popUpTo( From 4397d82c81bff27ccfe07ca60cc4e2415513caa7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 27 Sep 2026 19:01:58 +0200 Subject: [PATCH 11/17] Upgrade AGP to 9.4.1 --- 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 0aa82c3a96..c449266534 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ activityCompose = "1.13.0" # 9.4.0's PerModuleBundleTask rejects an AAB entry whose name contains a colon, which every # .kotlin_module named after a Gradle project path has. Worked around in amethyst/build.gradle.kts # (stripColonNamedEntries) rather than by pinning to 9.3.1; drop that block if AGP fixes it. -agp = "9.4.0" +agp = "9.4.1" android-compileSdk = "37" android-minSdk = "26" android-targetSdk = "37" From 8f3e74d2294baa27d046a206b959f7572cd8ee5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:07:14 +0000 Subject: [PATCH 12/17] feat: gzip NIP-FE answers with a sync flush; pluggable HTTP transport geode: - Streamed answers are gzipped when the client sends Accept-Encoding: gzip (not q=0), with Vary: Accept-Encoding. GzipLines sync-flushes at every flush the handler makes, so lines still arrive as they are found; compressed bytes also move to the socket past 16 KiB so a long burst keeps backpressure. JDK zip only, no new dependency. One-line answers stay plain. [http].gzip turns it off. - Tests hold EOSE in the store after the first event and require that event to arrive, inflated, before EOSE is released: read raw through okio's GzipSource and through HttpRelayClient. Both time out when the sync flush is turned off. quartz: - HttpRelayClient moves to commonMain over an HttpRelayTransport interface, as NostrClient sits over a WebsocketBuilder. OkHttpRelayTransport (jvmAndroid) picks the OkHttpClient per relay, like BasicOkHttpWebSocket.Builder, so .onion relays can go via Tor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/README.md | 7 +- geode/config.example.toml | 4 + .../geode/config/StaticConfig.kt | 3 + .../vitorpamplona/geode/server/GzipLines.kt | 74 +++++++++++++ .../geode/server/HttpCommandSettings.kt | 2 + .../geode/server/NipFEHttpRoute.kt | 61 +++++++++-- .../com/vitorpamplona/geode/NipFEHttpTest.kt | 103 +++++++++++++++++- .../nipFERelayOverHttp/HttpRelayClient.kt | 93 +++++----------- .../nipFERelayOverHttp/HttpRelayTransport.kt | 47 ++++++++ .../OkHttpRelayTransport.kt | 92 ++++++++++++++++ 10 files changed, 408 insertions(+), 78 deletions(-) create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt (61%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt diff --git a/geode/README.md b/geode/README.md index a32af00068..11732aaad9 100644 --- a/geode/README.md +++ b/geode/README.md @@ -108,7 +108,7 @@ URL: the body is the frame you would send on the socket, and the answer is the socket's frames as NDJSON, streamed — no socket, no subscription left open: ```bash -curl -N -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/ +curl -N --compressed -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/ # ["EVENT","q",{"id":"…","kind":1,…}] # ["EVENT","q",{"id":"…","kind":1,…}] # ["EOSE","q"] @@ -121,7 +121,10 @@ A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or request carries a NIP-98 `Authorization: Nostr …` header whose `u` is the relay's http URL and whose `payload` is the body's sha256. NIP-86 admin calls share the URL, told apart by `Content-Type: application/nostr+json+rpc`. -Quartz's `HttpRelayClient` does all of this for JVM/Android clients. +Streamed answers are gzipped for clients that send `Accept-Encoding: gzip` +(`curl --compressed`), sync-flushed so events still arrive as they are found; +`[http].gzip = false` turns it off. Quartz's `HttpRelayClient` does all of this +for clients, over OkHttp via `OkHttpRelayTransport` or any `HttpRelayTransport`. ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index a2a46fe6c7..891ed025dc 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -185,6 +185,10 @@ max_requests_per_client = 16 # An answer still running after this ends on a CLOSED line. deadline_seconds = 30 max_body_bytes = 524288 +# Gzip streamed answers for clients that send Accept-Encoding: gzip. Each +# batch of lines is sync-flushed, so events still arrive as they are +# found. Turn off if a proxy in front already compresses. +gzip = true retry_after_seconds = 1 # Other addresses this relay is reachable at: a NIP-98 token may name # any of them, as well as [info].relay_url. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index b3d888565c..6212aa7178 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -246,6 +246,8 @@ data class StaticConfig( val deadline_seconds: Long = 30, /** Largest request body read; larger is 413. */ val max_body_bytes: Int = 512 * 1024, + /** Gzip streamed answers when the client sends `Accept-Encoding: gzip`, flushed line by line. */ + val gzip: Boolean = true, /** `Retry-After` on the relay's own 429 and 503. */ val retry_after_seconds: Int = 1, /** @@ -270,6 +272,7 @@ data class StaticConfig( maxPerClient = max_requests_per_client, deadline = deadline_seconds.seconds, maxBodyBytes = max_body_bytes, + compress = gzip, retryAfterSeconds = retry_after_seconds, alternateUrls = alternate_urls.map { it.normalizeRelayUrl() }, trustedProxies = trusted_proxies.toSet(), diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt new file mode 100644 index 0000000000..aa4f4c4e22 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt @@ -0,0 +1,74 @@ +/* + * 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.geode.server + +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.writeFully +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream + +/** + * A gzip body written line by line onto [out], for NIP-FE's streamed answers. Every [flush] is a + * gzip sync flush: the deflater gives up everything it holds, byte-aligned, so the client can + * inflate every line written so far. Without it the compressor sits on the first lines until its + * window fills, and streaming delivers nothing until the answer is nearly done. + */ +internal class GzipLines( + private val out: ByteWriteChannel, +) { + private val compressed = ByteArrayOutputStream(BUFFER) + private val gzip = GZIPOutputStream(compressed, BUFFER, true) + + /** Compresses [line] and its newline. Moves compressed bytes to the socket once enough piled up, so a long burst still meets backpressure. */ + suspend fun line(line: String) { + gzip.write(line.encodeToByteArray()) + gzip.write(NEWLINE) + if (compressed.size() >= BUFFER) drain() + } + + /** A sync flush, then everything onto the socket. */ + suspend fun flush() { + gzip.flush() + drain() + out.flush() + } + + /** Writes the gzip trailer; the body is complete after this. */ + suspend fun finish() { + gzip.finish() + drain() + out.flush() + } + + /** Frees the deflater, finished or not. */ + fun close() = gzip.close() + + private suspend fun drain() { + if (compressed.size() == 0) return + out.writeFully(compressed.toByteArray()) + compressed.reset() + } + + companion object { + private const val BUFFER = 16 * 1024 + private val NEWLINE = byteArrayOf('\n'.code.toByte()) + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt index 5d1017889e..0508511eb7 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -39,6 +39,8 @@ data class HttpCommandSettings( val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE, /** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */ val maxBodyBytes: Int = 512 * 1024, + /** Gzip streamed answers for clients that accept it, sync-flushed so lines still arrive as found. */ + val compress: Boolean = true, /** The `Retry-After` sent with a 429 or 503 the relay decides itself. */ val retryAfterSeconds: Int = 1, /** Other URLs this relay answers at (its .onion); a NIP-98 `u` may name any of them. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt index 77b7ac51b3..8980c3031c 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -132,6 +132,27 @@ internal class NipFEHttpRoute( ?.ifEmpty { null } ?: peer } + /** + * Whether `Accept-Encoding` takes gzip: listed by name or as `*`, without `q=0`. A one-line + * answer is never compressed; only a streamed one is worth it. + */ + private fun acceptsGzip(call: ApplicationCall): Boolean = + call.request + .header(HttpHeaders.AcceptEncoding) + .orEmpty() + .split(',') + .any { entry -> + val parts = entry.split(';').map { it.trim() } + val coding = parts.first().lowercase() + val q = + parts + .drop(1) + .firstOrNull { it.startsWith("q=") } + ?.removePrefix("q=") + ?.toDoubleOrNull() ?: 1.0 + (coding == "gzip" || coding == "*") && q > 0.0 + } + /** A one-line answer: a refusal, or a command answered at once. */ private suspend fun respondLine( call: ApplicationCall, @@ -153,19 +174,41 @@ internal class NipFEHttpRoute( frame: String, ) = respondLine(call, status, frame) - /** Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the writer. */ - override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = + /** + * Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the + * writer. Gzipped when the client takes it and the relay allows it, sync-flushed at every + * flush so the lines still arrive as they are found. + */ + override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) { + val gzip = settings.compress && acceptsGzip(call) + call.response.header(HttpHeaders.Vary, HttpHeaders.AcceptEncoding) + if (gzip) call.response.header(HttpHeaders.ContentEncoding, "gzip") call.respondBytesWriter(NDJSON, HttpStatusCode.OK) { val out = this - object : HttpRelayLines { - override suspend fun line(frame: String) { - out.writeStringUtf8(frame) - out.writeStringUtf8("\n") - } + if (gzip) { + val body = GzipLines(out) + try { + object : HttpRelayLines { + override suspend fun line(frame: String) = body.line(frame) - override suspend fun flush() = out.flush() - }.lines() + override suspend fun flush() = body.flush() + }.lines() + body.finish() + } finally { + body.close() + } + } else { + object : HttpRelayLines { + override suspend fun line(frame: String) { + out.writeStringUtf8(frame) + out.writeStringUtf8("\n") + } + + override suspend fun flush() = out.flush() + }.lines() + } } + } } companion object { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt index 8650bfb503..0403e7fde4 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -31,21 +31,34 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient +import com.vitorpamplona.quartz.nipFERelayOverHttp.OkHttpRelayTransport +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okio.GzipSource +import okio.buffer import java.net.ServerSocket +import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -53,6 +66,7 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay], @@ -77,16 +91,18 @@ class NipFEHttpTest { path: String = "/", settings: HttpCommandSettings? = HttpCommandSettings(), policy: ((NormalizedRelayUrl) -> IRelayPolicy)? = null, + store: (IEventStore) -> IEventStore = { it }, ): NormalizedRelayUrl { val port = ServerSocket(0).use { it.localPort } val url = "ws://127.0.0.1:$port$path".normalizeRelayUrl() - val relay = if (policy == null) RelayEngine(url) else RelayEngine(url, policyBuilder = { policy(url) }) + val events = store(EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy)) + val relay = RelayEngine(url, events, policyBuilder = { policy?.invoke(url) ?: EmptyPolicy }) val server = KtorRelay(relay, host = "127.0.0.1", port = port, path = path, httpCommands = settings).start() running += server to relay return url } - private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(http, signer) + private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(OkHttpRelayTransport { http }, signer) private suspend fun note(text: String): Event = alice.sign(TextNoteEvent.build(text)) @@ -239,7 +255,7 @@ class NipFEHttpTest { assertTrue(assertIs(published.last).success) val got = mutableListOf() - val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add) + val read = HttpRelayClient(OkHttpRelayTransport { http }, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add) assertEquals(200, read.status) assertTrue(read.complete) assertEquals(listOf(n.id), got.map { it.id }) @@ -297,6 +313,82 @@ class NipFEHttpTest { } } + /** Answers a filter naming [HELD_KIND] with what is stored, then holds its EOSE until [release]. */ + private fun holdingEose(release: CompletableDeferred): (IEventStore) -> IEventStore = + { real -> + object : IEventStore by real { + override suspend fun rawQuery( + filters: List, + onEach: (RawEvent) -> Unit, + ) { + real.rawQuery(filters, onEach) + if (filters.any { it.kinds?.contains(HELD_KIND) == true }) release.await() + } + } + } + + @Test + fun aGzippedAnswerStillArrivesLineByLine() = + runBlocking { + val release = CompletableDeferred() + val relay = start(store = holdingEose(release)) + val held = alice.sign(TimeUtils.now(), HELD_KIND, emptyArray(), "held") + client().publish(relay, held) + + // Asking for gzip by hand turns off OkHttp's own inflating, so the body is read as sent. + val patient = http.newBuilder().readTimeout(10, TimeUnit.SECONDS).build() + val request = + Request + .Builder() + .url(relay.toHttp()) + .post("""["REQ","q",{"kinds":[$HELD_KIND]}]""".toRequestBody("text/plain".toMediaType())) + .header("Accept-Encoding", "gzip") + .build() + patient.newCall(request).execute().use { response -> + assertEquals("gzip", response.header("Content-Encoding")) + val lines = GzipSource(response.body.source()).buffer() + // The store has not answered EOSE: this line can only be here if the gzip was flushed. + assertEquals("""["EVENT","q",${held.toJson()}]""", lines.readUtf8Line()) + assertFalse(release.isCompleted) + release.complete(Unit) + assertEquals("""["EOSE","q"]""", lines.readUtf8Line()) + assertEquals(null, lines.readUtf8Line()) + } + } + + @Test + fun theClientReadsAGzippedAnswerAsItStreams() = + runBlocking { + val release = CompletableDeferred() + val relay = start(store = holdingEose(release)) + val held = alice.sign(TimeUtils.now(), HELD_KIND, emptyArray(), "held") + client().publish(relay, held) + + val first = CompletableDeferred() + val answer = async(Dispatchers.IO) { client().req(relay, listOf(Filter(kinds = listOf(HELD_KIND))), onEvent = { first.complete(it) }) } + assertEquals(held.id, withTimeout(10.seconds) { first.await() }.id, "the event arrives before EOSE is written") + release.complete(Unit) + assertTrue(answer.await().complete) + } + + @Test + fun withoutGzipOrWithItOffTheBodyIsPlain() { + for ((settings, accept) in listOf(HttpCommandSettings() to "identity", HttpCommandSettings(compress = false) to "gzip")) { + val relay = start(settings = settings) + val request = + Request + .Builder() + .url(relay.toHttp()) + .post("""["REQ","q",{"kinds":[1]}]""".toRequestBody("text/plain".toMediaType())) + .header("Accept-Encoding", accept) + .build() + http.newCall(request).execute().use { response -> + assertEquals(null, response.header("Content-Encoding"), accept) + assertEquals(listOf("""["EOSE","q"]"""), response.lines()) + } + } + } + @Test fun nip11AdvertisesFE() { val relay = start() @@ -321,4 +413,9 @@ class NipFEHttpTest { assertTrue(answer.complete) assertFalse(answer.last is EoseMessage) } + + private companion object { + /** A regular kind (stored), not a text note, so no other test reads it. */ + const val HELD_KIND = 7_777 + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt similarity index 61% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt index 5eb4bd6365..af7eb13305 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt @@ -33,22 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.coroutines.executeAsync -import okio.IOException /** - * NIP-FE over OkHttp: one relay command per request, POSTed to the relay's URL as the frame the + * NIP-FE client: one relay command per request, POSTed to the relay's URL as the frame the * websocket would carry, its answer read line by line as the relay writes it, in the socket's own - * frames. Nothing stays open after a call returns. + * frames. Nothing stays open after a call returns. [transport] carries the bytes (OkHttp on + * JVM/Android: `OkHttpRelayTransport`), as a websocket builder does for the NostrClient. * * With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for * its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the @@ -56,7 +46,7 @@ import okio.IOException * relay known to want it. */ class HttpRelayClient( - private val http: OkHttpClient, + private val transport: HttpRelayTransport, private val signer: NostrSigner? = null, private val signFirst: Boolean = false, ) { @@ -92,11 +82,11 @@ class HttpRelayClient( ): HttpRelayAnswer { val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" } val url = relay.toHttp() - val bytes = cmd.toJson().encodeToByteArray() - if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) - val first = post(command, url, bytes, null, onMessage, retrying = true) + val body = cmd.toJson().encodeToByteArray() + if (signer == null || signFirst) return post(relay, command, url, body, token(url, body), onMessage, retrying = false) + val first = post(relay, command, url, body, null, onMessage, retrying = true) if (first.status != HttpRelayStatus.UNAUTHORIZED) return first - return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) + return post(relay, command, url, body, token(url, body), onMessage, retrying = false) } private suspend fun token( @@ -105,6 +95,7 @@ class HttpRelayClient( ): String? = signer?.sign(HTTPAuthorizationEvent.build(url, "POST", body))?.toAuthToken() private suspend fun post( + relay: NormalizedRelayUrl, command: HttpRelayCommand, url: String, body: ByteArray, @@ -112,51 +103,25 @@ class HttpRelayClient( onMessage: (Message) -> Unit, /** A signed try follows a 401, so that refusal is not the answer and is not handed on. */ retrying: Boolean, - ): HttpRelayAnswer = - coroutineScope { - val request = - Request - .Builder() - .url(url) - .post(body.toRequestBody(JSON)) - .header("Accept", NDJSON) - .apply { authorization?.let { header("Authorization", it) } } - .build() - val call = http.newCall(request) - // A blocking read does not see coroutine cancellation; cancelling the call unblocks it. - val watcher = - launch { - try { - awaitCancellation() - } finally { - call.cancel() - } - } - try { - call.executeAsync().use { response -> - withContext(Dispatchers.IO) { - val reader = HttpRelayAnswerReader(command, response.code) - val deliver = !(retrying && response.code == HttpRelayStatus.UNAUTHORIZED) - val source = response.body.source() - try { - while (true) { - val line = source.readUtf8Line() ?: break - val message = reader.read(line) - if (message != null && deliver) onMessage(message) - } - } catch (_: IOException) { - // The connection dropped mid-answer: what came is what the reader says it is. - } - reader.answer(response.header("Retry-After")) - } - } - } finally { - watcher.cancel() - } - } - - companion object { - const val NDJSON = "application/x-ndjson" - private val JSON = "application/json".toMediaType() + ): HttpRelayAnswer { + var reader: HttpRelayAnswerReader? = null + var retryAfter: String? = null + var deliver = true + transport.post( + relay = relay, + url = url, + body = body, + authorization = authorization, + onStatus = { status, after -> + reader = HttpRelayAnswerReader(command, status) + retryAfter = after + deliver = !(retrying && status == HttpRelayStatus.UNAUTHORIZED) + }, + onLine = { line -> + val message = checkNotNull(reader) { "a line before the status" }.read(line) + if (message != null && deliver) onMessage(message) + }, + ) + return checkNotNull(reader) { "the transport returned without a status" }.answer(retryAfter) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt new file mode 100644 index 0000000000..918bb8e921 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt @@ -0,0 +1,47 @@ +/* + * 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.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * How [HttpRelayClient] reaches a relay over HTTP: one POST, its response read line by line. The + * NIP-FE side of the exchange (which URL, what body, signing, reading the answer) stays in the + * client; an implementation only moves bytes, the way a + * [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder] does for [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient]. + */ +interface HttpRelayTransport { + /** + * POSTs [body] to [url], [relay]'s HTTP URL, with an `Authorization` header when [authorization] + * is not null. Calls [onStatus] once with the status and any `Retry-After`, then [onLine] for each + * body line as it arrives, and returns when the body ends. A connection that drops mid-body + * returns normally: the answer reader tells a cut-off answer from a whole one. Cancelling the + * caller cancels the request. + */ + suspend fun post( + relay: NormalizedRelayUrl, + url: String, + body: ByteArray, + authorization: String?, + onStatus: (status: Int, retryAfter: String?) -> Unit, + onLine: (String) -> Unit, + ) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt new file mode 100644 index 0000000000..ee65bda983 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt @@ -0,0 +1,92 @@ +/* + * 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.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync +import okio.IOException + +/** + * [HttpRelayTransport] over OkHttp. [httpClient] picks the client per relay, as + * [com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket.Builder] does for + * websockets, so a .onion relay can go through Tor and a clearnet one directly. OkHttp asks for + * gzip and inflates it as the body streams, so a relay's sync-flushed gzip arrives line by line. + */ +class OkHttpRelayTransport( + private val httpClient: (NormalizedRelayUrl) -> OkHttpClient, +) : HttpRelayTransport { + override suspend fun post( + relay: NormalizedRelayUrl, + url: String, + body: ByteArray, + authorization: String?, + onStatus: (status: Int, retryAfter: String?) -> Unit, + onLine: (String) -> Unit, + ) = coroutineScope { + val request = + Request + .Builder() + .url(url) + .post(body.toRequestBody(JSON)) + .header("Accept", NDJSON) + .apply { authorization?.let { header("Authorization", it) } } + .build() + val call = httpClient(relay).newCall(request) + // A blocking read does not see coroutine cancellation; cancelling the call unblocks it. + val watcher = + launch { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + call.executeAsync().use { response -> + onStatus(response.code, response.header("Retry-After")) + withContext(Dispatchers.IO) { + val source = response.body.source() + try { + while (true) onLine(source.readUtf8Line() ?: break) + } catch (_: IOException) { + // The connection dropped mid-answer: what came is what the reader says it is. + } + } + } + } finally { + watcher.cancel() + } + } + + companion object { + const val NDJSON = "application/x-ndjson" + private val JSON = "application/json".toMediaType() + } +} From 7a06b453e4455f0a1031a972d1d34501994c0451 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:11:24 +0000 Subject: [PATCH 13/17] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-hi-rIN/strings.xml | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index ce6505f9b1..aced0f2ff6 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5453,6 +5453,10 @@ प्रमाणपत्र निर्गत %1$s को %2$s द्वारा। मान्य %3$s तक अनुमतियाँ + सेवाप्रसंग अभिलेख तथा जालस्थान जानकारी + प्रवेशांकन तथा आद्यताएँ जो इस जालस्थान ने अभिलेखित किया इस लेखा के लिए + जालस्थान जानकारी रिक्त करें + क्या इस जालस्थान से निर्गमनांकन करें तथा इसकी जानकारी मिटा दें। पुनःबाधित करें %1$s को रखें %1$s पुनःजुडें %1$s @@ -5777,6 +5781,14 @@ सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए। विश्वसनीय पुनःप्रसारक सूची पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए। + मिटाएँ + सम्पादन + आपके सन्देश का सम्पादन + सन्देश पर जाएँ + टाँकें + उत्तर + टाँका हटाएँ + वह परिवर्तन असफल। %1$s इस समुह में नहीं है। वह व्यक्ति अभिलेख में क्या है @@ -5795,4 +5807,112 @@ जोडें उस समायोजक को खोलने में असफल। समायोजक के एनपुब॰ की अनुकृति + समायोजकों के लिए ढूँढें + जोडें + घषणाओं को पढने में असफल + आपके पुनःप्रसारकों में कोई भी घषणा नहीं कर रहा। + घोषित %1$s पूर्व + पूर्व घोषित %1$s + उत्तर दे रहा है। + एक आह्वान असफल। पुनःप्रयासमध्य। + उससे कुछ पूछा नहीं गया अब तक। + पूछें कि कौन हैं + समायोजक कुंचिका + उसके लिए एक नाम। (आपका। विककल्पात्मक) + अधिक क्रियाएँ + कोई समायोजक नहीं अब तक। + मिटाएँ + क्या इस समायोजक को मिटा दें। + %1$s + उत्तर सक्रिय + %1$s स %2$d अधिक + हटाएँ + पुनःनामकरण + कहता है कि यह है। %1$s + उत्तर दिया। पर कुछ भी नामित नहीं। + सब कुछ दिखाएँ %1$d + अल्पतर संख्या दिखाएँ + समूह बनाएँ + + बनाएँ तथा %1$d व्यक्ति को आमन्त्रण दें + बनाएँ तथा %1$d व्यक्तियों को आमन्त्रण दें + + किसी को जोडें + प्रशासक + केवल मैं इस समूह का प्रबन्धक + समायोजक + परिवर्तन + समायोजक ढूँढें + हो गया + एक अन्य समायोजक + समायोजक ख्याप्य कुंचिका। षोडशांक अथवा एनपुब॰ + पुनःप्रसारक जिन पर यह उत्तर देता है। एक प्रति पंक्ति + विवरण (विकल्पात्मक) + सभी जोड सकते हैं तथा हटा सकते हैं + समूह बनाने में असफल। + समूह नाम + कोई समायोजक चयनित नहीं + समायोजक ने आमन्त्रण अस्वीकार किया + योजक भेजें + चर्चा खोलें + कोई कुंचिका नहीं यहाँ। इसलिए कोई स्वागत सन्देश छोडा नहीं जा सका + समूह है + उनकी प्रतीक्षा में + हो गया + कोई नहीं अब तक। दबाएँ लोगों को जोडने के लिए + + अभिगम्य %1$d समायोजक पर + अभिगम्य %1$d समायोजकों पर + + कोई कुंचिका नहीं आपके द्वारा उपयुक्त किसी समायोजक के पास + अब तक जाँच नहीं की + कौन जोड सकता है तथा हटा सकता है + नामकरण + कौन इसमें हैं + कहाँ उसका आवास + नया कोर्डन समूह + आप। सर्वदा प्रशासक + सन्देश लौटाएँ + समायोजक द्वारा स्वीकृत + समझ गया + यह समायोजक क्या देख सकता है + समूह जानकारी + + %1$d समूह + %1$d समूह + + समायोजक + कोई कोर्डन समूह नहीं अब तक + समायोजक कुंचिका + समायोजक योजक + समायोजक एनप्रोफैल॰ की अनुकृति + विवरण सम्पादन + युग + समूह परिचायक + इस समायोजक पर जोडा जा सकता है + सदस्य + क्या समूह से हटाएँ। + हटाएँ + अभिलेखन + तन्त्रज्ञानात्मक विवरण + जुडें + अस्वीकार + आमन्त्रणों को पढने में असफल। + स %1$d अधिक + कोई आमन्त्रण प्रतीक्षा नहीं कर रहा। + पुनःजाचें + एक आमन्त्रण छोडा गया एक अन्य यन्त्र के लिए + कोर्डन आमन्त्रण + %1$s ने उत्तर नहीं दिया + %1$s द्वारा + कुंचिका पोटलियाँ पढने में असफल। + एक का प्रकाशन + अन्तिम उपाय का प्रकाशन + सब लौटा लें + उनको लौटा लें + इस समूह से जुडने का अनुरोध करें + जुडने का अनुरोध असफल। + परखें + उस अभिलेख को खोलने में असफल। + वह अभिलेख पठनशक्य नहीं। From 0f488d10d24ffdf893dca0cc8263e65d81a11bc9 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:12:22 +0000 Subject: [PATCH 14/17] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-hi-rIN/strings.xml | 50 +++++++++++++++++++ .../values-pl-rPL/strings.xml | 30 +++++++++++ 2 files changed, 80 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index aced0f2ff6..a518b1b8dc 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -653,6 +653,19 @@ सब ज्ञात को पढ लिया चिह्नित करें सब नये को पढ लिया चिह्नित करें सब को पढ लिया चिह्नित करें + आपकी गुप्त कुंचिका आपका लेखा है + दबाएँ देखने के लिए + इसे इस क्रम मे लिख लें। बडे अथवा छोटे अक्षर दोनों कार्य करेंगे। + क्यूआर अक्षरराशि + पारणशब्द रक्षित अनुकृति + पारणशब्द + एक ncryptsec1… जो केवल आपके पारणशब्द से कार्य करता है। + न्यूनतम %1$d अक्षर + पारणशब्द दोहराएँ + पारणशब्दों में मेल नहीं + रहस्यीकरण कुंचिका + रहस्यीकरणमध्य… + एक पृथक पारणशब्द का उपयोग करें अपने कुंचिकाओं को सुरक्षित रखें आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय। अभी इसी समय सुरक्षित अनुकृति बनाएँ @@ -5883,6 +5896,12 @@ समायोजक कोई कोर्डन समूह नहीं अब तक + एक का आरम्भ करें + किसी को जोडें + उस नाम से कोई प्राप्त नहीं। + नाम अथवा एनपुब॰ अथवा नाम@जालक्षेत्र + प्रशासक + समायोजक समायोजक कुंचिका समायोजक योजक समायोजक एनप्रोफैल॰ की अनुकृति @@ -5915,4 +5934,35 @@ परखें उस अभिलेख को खोलने में असफल। वह अभिलेख पठनशक्य नहीं। + उस अभिलेख को भेजने में असफल। + + %1$d सदस्य + %1$d सदस्य + + सन्देश मिटाया गया + समायोजक + धावक + भेजा गया + सन्देश विवरण + सम्पादित + टाँका गया %1$s द्वारा + अगला टाँका गया सन्देश + पिछला टाँका गया सन्देश + सभी टाँके गए सन्देशों को दिखाएँ + टाँके गए सन्देश + प्रतिक्रियाएँ + जोडें + अनुरोधों के लिए जाँचें + हटाएँ + अनुरोधों को पढने में असफल। + जुडने की प्रतीक्षा में कोई नहीं। + जुडने के अनुरोध + भेजें + भेजने में असफल। + अनुकृत + योजक अनुकृति + इस समूह को बाँटें + ध्वनि टीका चलाएँ + ध्वनि टीका का अभिलेखन करें + रोकें तथा भेजें diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 8b1f5cd7f1..68e2da7cfc 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -669,6 +669,24 @@ Zaznacz wszystkie popularne jako przeczytane Zaznacz wszystkie nowe jako przeczytane Zaznacz wszystkie jako przeczytane + Twój tajny klucz to Twoje konto + Każdy, kto go posiada, może publikować posty pod twoim imieniem. Jeśli go zgubisz, nikt nie będzie w stanie go odzyskać, nawet Amethyst. + Kliknij, aby przejrzeć + Zapisz to w tej kolejności. Można używać zarówno wielkich, jak i małych liter. + Kod QR + Kopia chroniona hasłem + Hasło + Ncryptsec1… który działa tylko z twoim hasłem. + Co najmniej %1$d znaków + Powtórz hasło + Hasła nie są identyczne + Zaszyfruj klucz + Szyfrowanie… + Można je bezpiecznie przechowywać w menedżerze haseł lub notatkach w chmurze: nie da się ich otworzyć bez hasła, a zapomnianego hasła nie da się odzyskać. + Użyj innego hasła + Schowaj ten zapis w miejscu, które znasz tylko ty, albo zapisz klucz w menedżerze haseł. + Nigdy nie wpisuj swojego klucza na stronach internetowych ani w aplikacjach, do których nie masz zaufania. + Programiści serwisu Amethyst nigdy nie poproszą Cię o podanie klucza. Kopia zapasowa kluczy Twój tajny klucz jest jedynym sposobem na dostęp do tego konta. Jeśli go zgubisz, nigdy nie będzie można go odzyskać. Zapisz go w bezpiecznym miejscu. Utwórz kopię zapasową @@ -5476,6 +5494,12 @@ Jeszcze się nie skontaktowano Odpowiedź Brak odpowiedzi + + %1$d nieudana próba z rzędu + %1$d nieudanych prób z rzędu + %1$d nieudanych prób z rzędu + %1$d nieudane próby z rzędu + Wklej link „cordn1…” udostępniony przez inną osobę, aby sprawdzić, który koordynator prowadzi tę grupę oraz jakie informacje o tobie uzyskałby ten operator, gdybyś do niej dołączył. cordn1… Zbadaj @@ -5564,6 +5588,12 @@ Przynieś grupy tutaj Pobieranie… Powyższe dane zastępują wszelkie grupy „cordn” już istniejące w tym telefonie. Nie można ich połączyć. + + %1$d grupa przeniesiona + %1$d grup przeniesionych + %1$d grup przeniesiono + %1$d grupy przeniesione + Na tym urządzeniu nie ma żadnych grup cordn, które można by przenieść. Najpierw skonfiguruj serwer multimediów — zaszyfrowane dokumenty muszą gdzieś się znaleźć, dopóki drugi telefon je pobierze. Co opuszcza to urządzenie From 845ae7be145274e9a533c701e1d39afe10e2b278 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:18:48 +0000 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20NIP-FE=20audit=20=E2=80=94=20slow?= =?UTF-8?q?=20bodies,=20malformed=20Content-Type,=20body=20buffers,=20one?= =?UTF-8?q?=20parse,=20faster=20gzip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs (reproduced on a live geode before fixing, now regression tests): - A body trickled in forever held its admission slot forever: a client that declared 100 bytes and sent 5 still had its slot after 20 s, so a few addresses could fill the global cap. The body read is now bounded by [http].body_timeout_seconds (10 s): 408, Connection: close. - A malformed Content-Type made the NIP-86 check throw, answering 500. It is now compared as text, and a command needs no type at all. Performance: - Every request allocated a cap-sized body buffer (512 KiB) for what is usually a 50-byte frame. The buffer is now sized to Content-Length, or grows from 4 KiB for a chunked body. - The session parsed each body again after the handler had. RelaySession.receive(text, parsed) runs the raw-text policies on the text and dispatches the parsed command. - Gzip at level 1: 42% of raw against 39% at the default 6 on Nostr-shaped events, for about 2.5x less CPU. Compressed bytes go to the socket from their own buffer instead of a copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/config.example.toml | 2 + .../geode/config/StaticConfig.kt | 4 ++ .../vitorpamplona/geode/server/BoundedBody.kt | 10 ++- .../vitorpamplona/geode/server/GzipLines.kt | 20 +++++- .../geode/server/HttpCommandSettings.kt | 3 + .../geode/server/NipFEHttpRoute.kt | 32 ++++++---- .../com/vitorpamplona/geode/NipFEHttpTest.kt | 61 +++++++++++++++++++ .../nip01Core/relay/server/RelaySession.kt | 23 +++++-- .../nipFERelayOverHttp/HttpRelayHandler.kt | 8 +-- 9 files changed, 139 insertions(+), 24 deletions(-) diff --git a/geode/config.example.toml b/geode/config.example.toml index 891ed025dc..89f6a6c196 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -182,6 +182,8 @@ enabled = true # Retry-After. 0 = no limit. max_concurrent_requests = 256 max_requests_per_client = 16 +# A body still arriving after this is dropped with 408, freeing its slot. +body_timeout_seconds = 10 # An answer still running after this ends on a CLOSED line. deadline_seconds = 30 max_body_bytes = 524288 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 6212aa7178..e12781ad13 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -242,6 +242,8 @@ data class StaticConfig( val max_concurrent_requests: Int = 256, /** Requests one client address may run at once; over it, 429. 0 = no limit. */ val max_requests_per_client: Int = 16, + /** How long a request body may take to arrive before the request is dropped with 408. */ + val body_timeout_seconds: Long = 10, /** How long one answer may run before it ends on a `CLOSED` line. */ val deadline_seconds: Long = 30, /** Largest request body read; larger is 413. */ @@ -270,6 +272,7 @@ data class StaticConfig( HttpCommandSettings( maxConcurrent = max_concurrent_requests, maxPerClient = max_requests_per_client, + bodyTimeout = body_timeout_seconds.seconds, deadline = deadline_seconds.seconds, maxBodyBytes = max_body_bytes, compress = gzip, @@ -357,6 +360,7 @@ data class StaticConfig( fun validate() { require(http.max_concurrent_requests >= 0) { "[http].max_concurrent_requests must be >= 0 (0 = no limit), got ${http.max_concurrent_requests}" } require(http.max_requests_per_client >= 0) { "[http].max_requests_per_client must be >= 0 (0 = no limit), got ${http.max_requests_per_client}" } + require(http.body_timeout_seconds > 0) { "[http].body_timeout_seconds must be > 0, got ${http.body_timeout_seconds}" } require(http.deadline_seconds > 0) { "[http].deadline_seconds must be > 0, got ${http.deadline_seconds}" } require(http.max_body_bytes > 0) { "[http].max_body_bytes must be > 0, got ${http.max_body_bytes}" } require(http.retry_after_seconds >= 0) { "[http].retry_after_seconds must be >= 0, got ${http.retry_after_seconds}" } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt index 7634d48939..571c6312b2 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt @@ -28,6 +28,8 @@ import io.ktor.utils.io.readAvailable /** * Reads the request body up to [cap] bytes. Returns null when it is larger — by its declared * `Content-Length` or by what actually arrives — without reading past the cap; the caller answers 413. + * The buffer is sized to the declared length, or grows from a small start, so a 50-byte command does + * not cost a cap-sized allocation. */ internal suspend fun readBoundedBody( call: ApplicationCall, @@ -36,13 +38,17 @@ internal suspend fun readBoundedBody( val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() if (declared != null && declared > cap) return null val ch = call.receiveChannel() - val buf = ByteArray(cap + 1) + // One byte past the declared length, so a body longer than it claimed is still caught at the cap. + var buf = ByteArray(if (declared != null) declared.toInt() + 1 else minOf(cap + 1, INITIAL_BODY_BUFFER)) var pos = 0 while (pos <= cap) { + if (pos == buf.size) buf = buf.copyOf(minOf(cap + 1, buf.size * 2)) val read = ch.readAvailable(buf, pos, buf.size - pos) if (read <= 0) break pos += read } if (pos > cap) return null - return buf.copyOfRange(0, pos) + return if (pos == buf.size) buf else buf.copyOf(pos) } + +private const val INITIAL_BODY_BUFFER = 4 * 1024 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt index aa4f4c4e22..f1b50e171a 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode.server import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.writeFully import java.io.ByteArrayOutputStream +import java.util.zip.Deflater import java.util.zip.GZIPOutputStream /** @@ -34,8 +35,16 @@ import java.util.zip.GZIPOutputStream internal class GzipLines( private val out: ByteWriteChannel, ) { - private val compressed = ByteArrayOutputStream(BUFFER) - private val gzip = GZIPOutputStream(compressed, BUFFER, true) + private val compressed = Pending() + + // Level 1: on Nostr events it compresses to ~42% of raw against ~39% at the JDK's default 6, for + // about 2.5x less CPU; ids, keys and signatures are hex and barely compress at any level. + private val gzip = + object : GZIPOutputStream(compressed, BUFFER, true) { + init { + def.setLevel(Deflater.BEST_SPEED) + } + } /** Compresses [line] and its newline. Moves compressed bytes to the socket once enough piled up, so a long burst still meets backpressure. */ suspend fun line(line: String) { @@ -63,10 +72,15 @@ internal class GzipLines( private suspend fun drain() { if (compressed.size() == 0) return - out.writeFully(compressed.toByteArray()) + compressed.writeTo(out) compressed.reset() } + /** The compressed bytes not yet on the socket, written from its own array rather than a copy. */ + private class Pending : ByteArrayOutputStream(BUFFER) { + suspend fun writeTo(out: ByteWriteChannel) = out.writeFully(buf, 0, count) + } + companion object { private const val BUFFER = 16 * 1024 private val NEWLINE = byteArrayOf('\n'.code.toByte()) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt index 0508511eb7..9d4e75d2d4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode.server import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: a `POST` to @@ -35,6 +36,8 @@ data class HttpCommandSettings( val maxConcurrent: Int = 256, /** Requests one client address may run at once before its next gets 429; 0 is no limit. */ val maxPerClient: Int = 16, + /** How long the body may take to arrive; past it, 408. It holds an admission slot meanwhile. */ + val bodyTimeout: Duration = 10.seconds, /** How long one answer may run, first byte to last. */ val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE, /** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt index 8980c3031c..57aeefbfa4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -31,13 +31,14 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.response.respondBytesWriter import io.ktor.server.response.respondText import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout /** * NIP-FE over Ktor: the host half of [HttpRelayHandler], on POSTs to the relay's URL that are not @@ -71,11 +72,11 @@ internal class NipFEHttpRoute( * Whether a POST to the relay's URL is a NIP-FE command: anything but NIP-86's * `application/nostr+json+rpc`, since commands need no `Content-Type` at all. */ - fun isCommand(call: ApplicationCall): Boolean = - !call.request - .contentType() - .withoutParameters() - .match(NIP86) + fun isCommand(call: ApplicationCall): Boolean { + // Compared as text: parsing it would throw on a malformed header, and a command needs none. + val type = call.request.header(HttpHeaders.ContentType) ?: return true + return !type.substringBefore(';').trim().equals(Nip86HttpHandler.CONTENT_TYPE, ignoreCase = true) + } /** Answers the command in the body. Admission runs first, so a refused request spends no NIP-98 token. */ suspend fun handle(call: ApplicationCall) { @@ -87,13 +88,22 @@ internal class NipFEHttpRoute( val verdict = admission.admit(clientOf(call)) { + // Bounded in time too: a body trickled in forever would hold this admission slot forever. val body = - readBoundedBody(call, bodyCap) - ?: return@admit respondLine( + try { + withTimeout(settings.bodyTimeout) { readBoundedBody(call, bodyCap) } + } catch (_: TimeoutCancellationException) { + call.response.header(HttpHeaders.Connection, "close") + return@admit respondLine( call, - HttpRelayStatus.PAYLOAD_TOO_LARGE, - HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + REQUEST_TIMEOUT, + HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the body did not arrive within ${settings.bodyTimeout}")), ) + } ?: return@admit respondLine( + call, + HttpRelayStatus.PAYLOAD_TOO_LARGE, + HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + ) handler.handle(HttpRelayRequest(call.request.header(HttpHeaders.Authorization), body), Answer(call)) } when (verdict) { @@ -213,7 +223,7 @@ internal class NipFEHttpRoute( companion object { val NDJSON = ContentType("application", "x-ndjson") - private val NIP86 = ContentType.parse(Nip86HttpHandler.CONTENT_TYPE) + const val REQUEST_TIMEOUT = 408 const val WWW_AUTHENTICATE = "Nostr" const val ACCEL_BUFFERING = "X-Accel-Buffering" const val PREFLIGHT_MAX_AGE_SECONDS = 86_400 diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt index 0403e7fde4..fa1f25b872 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -58,6 +58,7 @@ import okhttp3.Response import okio.GzipSource import okio.buffer import java.net.ServerSocket +import java.net.Socket import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test @@ -66,6 +67,7 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -389,6 +391,65 @@ class NipFEHttpTest { } } + /** Writes [request] on a plain socket to the relay at [relay] and returns the status line of the answer. */ + private fun rawStatus( + relay: NormalizedRelayUrl, + request: String, + ): String = + Socket( + "127.0.0.1", + relay + .toHttp() + .substringAfterLast(':') + .trimEnd('/') + .toInt(), + ).use { socket -> + socket.soTimeout = 10_000 + socket.getOutputStream().write(request.encodeToByteArray()) + socket.getOutputStream().flush() + socket.getInputStream().bufferedReader().readLine() + } + + private fun chunked(body: String) = "${body.length.toString(16)}\r\n$body\r\n0\r\n\r\n" + + @Test + fun aMalformedContentTypeIsStillACommand() { + val relay = start() + val body = """["REQ","q",{"kinds":[1]}]""" + val status = rawStatus(relay, "POST / HTTP/1.1\r\nHost: x\r\nContent-Type: garbage\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body") + assertEquals("HTTP/1.1 200 OK", status) + } + + @Test + fun aBodyThatNeverFinishesIsDroppedAndFreesItsSlot() { + val relay = start(settings = HttpCommandSettings(maxPerClient = 1, bodyTimeout = 500.milliseconds)) + val port = + relay + .toHttp() + .substringAfterLast(':') + .trimEnd('/') + .toInt() + Socket("127.0.0.1", port).use { slow -> + slow.soTimeout = 10_000 + slow.getOutputStream().write("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n[\"REQ\"".encodeToByteArray()) + slow.getOutputStream().flush() + assertEquals("HTTP/1.1 408 Request Timeout", slow.getInputStream().bufferedReader().readLine()) + } + val body = """["REQ","q",{"kinds":[1]}]""" + assertEquals("HTTP/1.1 200 OK", rawStatus(relay, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body")) + } + + @Test + fun aChunkedBodyGrowsItsBufferAndStopsAtTheCap() { + val relay = start(settings = HttpCommandSettings(maxBodyBytes = 20_000)) + // No Content-Length: the buffer starts small and grows past it. + val big = """["REQ","q",{"search":"${"x".repeat(10_000)}"}]""" + val head = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + assertEquals("HTTP/1.1 200 OK", rawStatus(relay, head + chunked(big))) + val over = """["REQ","q",{"search":"${"x".repeat(30_000)}"}]""" + assertEquals("HTTP/1.1 413 Payload Too Large", rawStatus(relay, head + chunked(over))) + } + @Test fun nip11AdvertisesFE() { val relay = start() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index df77566329..adc9b47ec2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -189,10 +189,25 @@ class RelaySession( } /** - * Dispatches an already-parsed command, for a transport that parsed and - * sized it itself (NIP-FE's HTTP bodies). [IRelayPolicy.acceptMessage] - * is not run here — it judges wire text — so such a caller applies the - * message-length limit before calling. + * [receive] for a transport that already parsed [parsed] out of [command] + * (NIP-FE's HTTP bodies): [IRelayPolicy.acceptMessage] still judges the + * wire text, as it would on the socket, but the frame is not parsed twice. + */ + suspend fun receive( + command: String, + parsed: Command, + ) { + policy.acceptMessage(command)?.let { reason -> + send(NoticeMessage(reason)) + return + } + receive(parsed) + } + + /** + * Dispatches an already-parsed command. [IRelayPolicy.acceptMessage] is + * not run here — it judges wire text — so a caller that has the text + * uses the overload that takes both. */ suspend fun receive(cmd: Command) { if (!cmd.isValid()) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt index d4c96f7438..0b845206bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt @@ -121,9 +121,9 @@ class HttpRelayHandler( // Characters, as the engine's own limit counts them. if (max != null && text.length > max) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge)) - // Read here as well as in the engine, to refuse the commands HTTP does not carry and to - // know what ends the answer and how to refuse it. The engine still parses the text itself, - // as socket text, so the policies that judge raw frames run. + // Parsed here, once: to refuse the commands HTTP does not carry, to know what ends the + // answer and how to refuse it, and for the session, which still runs the policies that + // judge the raw text before it dispatches the parsed command. val cmd = try { OptimizedJsonMapper.fromJsonToCommand(text) @@ -188,7 +188,7 @@ class HttpRelayHandler( try { server.serve(sink) { session -> val refused = signedIn?.let { session.authenticateByTransport(it) } - if (refused != null) fail(refused) else session.receive(text) + if (refused != null) fail(refused) else session.receive(text, cmd) ended.await() } } catch (e: CancellationException) { From 348f294e2655570dde13a0e0ffe56e74d58474c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:20:59 +0000 Subject: [PATCH 16/17] fix(nav): bottom bar on every pinnable tab, and FAB padding on drawer screens Audit follow-up to the drawer bottom-bar fix (#4141): - Napplets, Nsites, Marmot Groups, Cordn Groups, the NIP-46 signer and My Blossom data are all pinnable bottom-bar tabs (BottomBarCategories) and drawer destinations, but their screens never rendered AppBottomBar: tapping one of them as a tab made the bar vanish, the same bug the issue reports. They now host the bar like every other tab screen. - That also fixes a regression from the previous commit: showsBottomBar() is true on drawer-opened entries, so FabBottomBarPadded dropped its padding on Marmot Groups, which had no bar to sit above. For the same reason the drawer's Create rows (HLS video, the debug Chess lobby) now open as plain pushes: they are composer/tool screens without a bar. - Marmot Groups showed its back arrow unconditionally, including as a tab root; it now follows canPop() like the other tab screens. - Geocaches and Geocache Hunts are two pinnable tabs of one screen, but the screen always selected Route.Geocaches() and treated any Geocaches route as a re-tap: the Hunts tab never highlighted and tapping one tab from the other scrolled to top instead of switching. It now matches its exact route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018otSD34gHeE1RT31UgzY1b --- .../mediaServers/BlossomBlobManagerScreen.kt | 6 +++++ .../ui/navigation/drawer/DrawerContent.kt | 22 +++++++++++++------ .../chats/cordnGroup/CordnGroupListScreen.kt | 6 +++++ .../marmotGroup/MarmotGroupListScreen.kt | 19 +++++++++++----- .../loggedIn/geocaches/GeocachesScreen.kt | 8 +++++-- .../loggedIn/napplets/NappletsScreen.kt | 7 ++++++ .../ui/screen/loggedIn/nsites/NsitesScreen.kt | 7 ++++++ .../settings/nip46/Nip46SignerScreen.kt | 6 +++++ 8 files changed, 67 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index cb59cc7a72..7be384edf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -221,6 +222,11 @@ fun BlossomBlobManagerScreen( }, ) }, + bottomBar = { + AppBottomBar(Route.ManageBlossomBlobs, nav, accountViewModel) { route -> + if (route != Route.ManageBlossomBlobs) nav.navBottomBar(route) + } + }, ) { padding -> Column( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index ca068e3aba..91a4198d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -641,24 +641,32 @@ fun ListContent( } } -/** The Create section's rows — composer entry points, none of which is a catalog destination. */ +/** + * The Create section's rows — composer entry points, none of which is a catalog destination. They + * open as plain pushes rather than through [INav.navDrawer]: those screens host no bottom bar, and + * a drawer stamp would tell FabBottomBarPadding that one is showing. + */ @Composable private fun CreateRows(nav: INav) { - NavigationRow( + IconRow( title = Res.string.share_hls_video, icon = MaterialSymbols.SettingsInputAntenna, tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.NewHlsVideo, + onClick = { + nav.closeDrawer() + nav.nav(Route.NewHlsVideo) + }, ) if (isDebug) { - NavigationRow( + IconRow( title = Res.string.route_chess, icon = MaterialSymbols.ChessKnight, tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Chess, + onClick = { + nav.closeDrawer() + nav.nav(Route.Chess) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt index 13876ffbe1..d29ffb280a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose @@ -125,6 +126,11 @@ fun CordnGroupListScreen( } } }, + bottomBar = { + AppBottomBar(Route.CordnGroupList, nav, accountViewModel) { route -> + if (route != Route.CordnGroupList) nav.navBottomBar(route) + } + }, ) { padding -> if (runtime == null) { EmptyState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index d94e18af51..ec72529de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 @@ -136,16 +137,24 @@ fun MarmotGroupListScreen( topBar = { TopAppBar( navigationIcon = { - IconButton(onClick = { nav.popBack() }) { - Icon( - symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = stringRes(Res.string.back), - ) + // No arrow as a bottom-nav tab root: there is nothing below it to return to. + if (nav.canPop()) { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(Res.string.back), + ) + } } }, title = { Text(stringRes(Res.string.marmot_groups_title)) }, ) }, + bottomBar = { + AppBottomBar(Route.MarmotGroupList, nav, accountViewModel) { route -> + if (route != Route.MarmotGroupList) nav.navBottomBar(route) + } + }, floatingActionButton = { FabBottomBarPadded(nav) { FloatingActionButton(onClick = { nav.nav(Route.CreateMarmotGroup) }, shape = CircleShape) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt index 6a271c49e2..fb41b9f2e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt @@ -107,8 +107,12 @@ fun GeocachesScreen( } }, bottomBar = { - AppBottomBar(Route.Geocaches(), nav, accountViewModel) { route -> - if (route is Route.Geocaches) { + // Geocaches and Hunts are two separate pinnable tabs of this one screen, so match the + // exact route: a class check highlighted the wrong one and turned a tap on the other + // into a scroll-to-top instead of a tab switch. + val selfRoute = Route.Geocaches(initialTab) + AppBottomBar(selfRoute, nav, accountViewModel) { route -> + if (route == selfRoute) { nearby.sendToTop() } else { nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt index 385ed65e3e..bea2b1ae8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt @@ -37,11 +37,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.napplet_none_found import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.napplet.NappletLauncher +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription @@ -90,6 +92,11 @@ fun NappletsScreen( Scaffold( topBar = { NappletsTopBar(accountViewModel, nav) }, + bottomBar = { + AppBottomBar(Route.Napplets, nav, accountViewModel) { route -> + if (route != Route.Napplets) nav.navBottomBar(route) + } + }, ) { padding -> if (visible.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt index 4bee779bf0..5ef15b0073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt @@ -37,10 +37,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.nsite_none_found import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription @@ -88,6 +90,11 @@ fun NsitesScreen( Scaffold( topBar = { NsitesTopBar(accountViewModel, nav) }, + bottomBar = { + AppBottomBar(Route.Nsites, nav, accountViewModel) { route -> + if (route != Route.Nsites) nav.navBottomBar(route) + } + }, ) { padding -> if (visible.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index df18f48a12..569c6c6ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -125,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner @@ -193,6 +194,11 @@ fun Nip46SignerScreen( Scaffold( topBar = { TopBarWithBackButton(stringRes(Res.string.nip46_signer_title), nav) }, + bottomBar = { + AppBottomBar(Route.Nip46Signer(), nav, accountViewModel) { route -> + if (route != Route.Nip46Signer()) nav.navBottomBar(route) + } + }, ) { padding -> Column( modifier = From ab5119b75ba53f897fc543fa19dcd39b3a9330d6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 15:38:31 -0400 Subject: [PATCH 17/17] fix(marmot): send reactions and deletions inside the group, not as NIP-17 DMs A Marmot message is an unsigned rumor, so the app's generic reaction and deletion paths treated it as a NIP-17 private note: a reaction was gift-wrapped to the author as a DM, and a deletion went the same way (or to nobody, for our own message). Neither reached the MLS group. White Noise never showed an Amethyst reaction or deletion, and group activity left the group's channel as DMs. Only the CLI used the in-group builders, which is why the interop harness passed. Account.reactTo, delete and deletePrivately now check whether the target is a Marmot message (MarmotGroupList.groupIdForNote) and send an inner kind:7 or kind:5 through sendMarmotGroupMessage, like any other group message. Unreact already goes through deletePrivately with the reacted-to message as its target, so it is covered. Custom-emoji reactions carry their emoji tag, as on the public path. Verified on device (Amethyst tablet <-> White Noise Android, MDK 0.10.4): react, unreact and delete from Amethyst all show in White Noise. No gift wraps were sent for them. The White Noise app only shows an incoming reaction after its chat is reopened; wn's materialized timeline has it immediately, so that is White Noise's live refresh. Harness test 31 checks that an amy reaction appears in wn's MATERIALIZED timeline (test 09 only checks the raw event log, which the app does not render). Builder unit tests added. Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/Account.kt | 21 ++++- .../amethyst/model/AccountMarmotActions.kt | 49 +++++++++++ cli/tests/marmot/marmot-interop-headless.sh | 1 + cli/tests/marmot/tests-manage.sh | 36 ++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 30 +++++-- .../marmot/MarmotInnerRumorBuildersTest.kt | 83 +++++++++++++++++++ 6 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e2a51f8237..7f149f0610 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1538,6 +1538,12 @@ class Account( note: Note, reaction: String, ) { + // A Marmot message reacts inside its MLS group; see AccountMarmotActions.reactToMarmotMessage. + marmot.marmotGroupOf(note)?.let { groupId -> + marmot.reactToMarmotMessage(groupId, note, reaction) + return + } + // Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the // inner kind-7 only ever travels as ciphertext, so mining it is pure // waste — those targets skip the queue and sign with the plain signer. @@ -1703,7 +1709,14 @@ class Account( suspend fun delete(notes: List) { if (!isWriteable()) return - val myNotes = notes.filter { it.author == userProfile() && it.event != null } + // Marmot messages are retracted inside their group. A public NIP-09 here would e-tag + // the group's private rumor ids onto public relays. + val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null } + marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) -> + marmot.deleteMarmotMessages(groupId, groupNotes) + } + + val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null } if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. myNotes.chunked(200).forEach { chunkedList -> @@ -1733,6 +1746,12 @@ class Account( if (!isWriteable()) return val targetEvent = target.event ?: return + // In a Marmot group the deletion goes to the group, not to the target's author as a DM. + marmot.marmotGroupOf(target)?.let { groupId -> + marmot.deleteMarmotMessages(groupId, notes) + return + } + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } if (myRumors.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 68c223da21..bdb1f9a9e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 @@ -246,6 +247,54 @@ class AccountMarmotActions( manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) } + /** + * The Marmot group [note] was received or sent in, or null when it is not a + * Marmot message. + * + * Only chat rows are indexed, so pass the MESSAGE a reaction or deletion is + * about, not the reaction itself. + */ + fun marmotGroupOf(note: Note): HexKey? = account.marmotGroupList.groupIdForNote(note.idHex) + + /** + * React to a Marmot message inside its group. + * + * A Marmot message is an unsigned rumor, which the generic reaction path + * handles as a NIP-17 private note: it gift-wrapped the kind:7 to the + * author as a DM. That never reached the group, so no other client showed + * it, and it moved group activity out of the group's channel. The reaction + * is an ordinary inner kind:7 (MIP-03), encrypted to the group like any + * message. + */ + suspend fun reactToMarmotMessage( + nostrGroupId: HexKey, + target: Note, + reaction: String, + ) { + val manager = account.marmotManager ?: return + val targetEvent = target.event ?: return + if (target.hasReacted(account.userProfile(), reaction)) return + val rumor = manager.buildReactionRumor(targetEvent, reaction) + sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId)) + } + + /** + * Retract our own messages or reactions in a Marmot group with an inner + * kind:5, for the same reason [reactToMarmotMessage] exists: the generic + * private path sent a gift-wrapped NIP-09 to the target's author, so the + * other members never saw the deletion. + */ + suspend fun deleteMarmotMessages( + nostrGroupId: HexKey, + notes: List, + ) { + val manager = account.marmotManager ?: return + val mine = notes.filter { it.author == account.userProfile() }.mapNotNull { it.event } + if (mine.isEmpty()) return + val rumor = manager.buildDeletionRumor(mine) + sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId)) + } + /** * Fetch a user's KeyPackage from relays and add them to a Marmot group. * Returns a status message describing the outcome. diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index e1d7e8d2e8..afd2fbb738 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -210,6 +210,7 @@ ALL_TESTS=( test_27_deletion_wn_to_amy test_28_retention_wn_to_amy test_29_disband_amy_to_wn + test_31_reaction_materializes_on_wn ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 9a8fcf5fb1..29d8eff84e 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -250,3 +250,39 @@ test_17_group_image_commit() { record_result "$id" fail "wn could not decrypt A's post-image message — image commit not applied" fi } + +# A reaction White Noise can SEE. Test 09 only proves wn's raw event log holds a +# kind:7; the app renders the materialized timeline, which attaches a reaction +# to its target by its own rules. An amy reaction the raw log kept but the +# timeline dropped read as a pass there and as "no reaction" in the app. +test_31_reaction_materializes_on_wn() { + banner "Test 31 — amy's reaction shows in wn's materialized timeline" + local id="31 reaction materialized" + + local out gid mls_gid b_gid anchor_id + out=$(amy_json marmot group create --name "Interop-31") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + wn_b messages send "$mls_gid" "31 anchor" >/dev/null 2>&1 || true + amy_json marmot await message "$gid" --match "31 anchor" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got the anchor"; return; } + anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages \ + | jq -r 'select((.plaintext // .content // "") == "31 anchor") | (.message_id // .event_id)' | head -n 1) + [[ -n "$anchor_id" && "$anchor_id" != "null" ]] || { record_result "$id" fail "no anchor id in amy's log"; return; } + amy_json marmot message react "$gid" "$anchor_id" "🍕" >/dev/null || { record_result "$id" fail "amy react failed"; return; } + + local deadline=$(( $(date +%s) + 90 )) tl="" + while [[ $(date +%s) -lt $deadline ]]; do + tl=$(wn_b --json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true) + if printf '%s' "$tl" | grep -q '🍕'; then + record_result "$id" pass; return + fi + sleep 3 + done + printf '%s\n' "$tl" >> "$LOG_FILE" + record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)" +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b100491b67..f145024fa1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -570,13 +571,20 @@ class MarmotManager( targetEvent: Event, reaction: String, ): Event { + val hint = + com.vitorpamplona.quartz.nip01Core.hints + .EventHintBundle(targetEvent) + // A custom-emoji reaction (":name:url") carries its image in an emoji + // tag, exactly as the public NIP-25 path builds it. + val emojiUrl = if (reaction.startsWith(":")) EmojiUrlTag.decode(reaction) else null val template = - com.vitorpamplona.quartz.nip25Reactions.ReactionEvent - .build( - reaction, - com.vitorpamplona.quartz.nip01Core.hints - .EventHintBundle(targetEvent), - ) + if (emojiUrl != null) { + com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + .build(emojiUrl, hint) + } else { + com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + .build(reaction, hint) + } return com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler .assembleRumor( signer.pubKey, @@ -739,14 +747,18 @@ class MarmotManager( targetEvents: List, persistOwn: Boolean = true, ): TextMessageBundle { - require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" } - val template = DeletionRequestEvent.build(targetEvents) - val innerEvent = RumorAssembler.assembleRumor(signer.pubKey, template) + val innerEvent = buildDeletionRumor(targetEvents) val outbound = buildGroupMessage(nostrGroupId, innerEvent) if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) } + /** The inner kind:5 deletion rumor alone. See [buildTextRumor]. */ + suspend fun buildDeletionRumor(targetEvents: List): DeletionRequestEvent { + require(targetEvents.isNotEmpty()) { "buildDeletionRumor: targetEvents must not be empty" } + return RumorAssembler.assembleRumor(signer.pubKey, DeletionRequestEvent.build(targetEvents)) + } + /** * A single invitee for [addMemberInvites]: whose KeyPackage is consumed, the bare * KeyPackage bytes as published, and the id of the event that carried them diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt new file mode 100644 index 0000000000..3ce3da21f8 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt @@ -0,0 +1,83 @@ +/* + * 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.amethyst.commons.marmot + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The inner rumors the app sends for reactions and deletions in a Marmot group. + * Both used to leave the group as NIP-17 gift wraps to the target's author; they + * are now plain MIP-03 inner events, so their shape is what peers validate. + */ +class MarmotInnerRumorBuildersTest { + private val signer = NostrSignerInternal(KeyPair()) + private val manager = MarmotManager(signer, SnapshotStateStore()) + + private val target = + Event( + id = "b".repeat(64), + pubKey = "c".repeat(64), + createdAt = 1_790_000_000, + kind = 9, + tags = emptyArray(), + content = "react to me", + sig = "", + ) + + @Test + fun aReactionNamesItsTargetFirstAndIsAnUnsignedRumor() = + runBlocking { + val rumor = manager.buildReactionRumor(target, "🎉") + assertEquals(ReactionEvent.KIND, rumor.kind) + assertEquals("🎉", rumor.content) + assertEquals(signer.pubKey, rumor.pubKey) + assertTrue(rumor.sig.isEmpty(), "MIP-03 inner events are unsigned rumors") + // MDK attaches a reaction to the FIRST e tag. + assertEquals(target.id, rumor.tags.first { it[0] == "e" }[1]) + } + + @Test + fun aCustomEmojiReactionCarriesItsImage() = + runBlocking { + val rumor = manager.buildReactionRumor(target, ":soapbox:https://example.com/soapbox.png") + assertEquals(":soapbox:", rumor.content) + val emoji = rumor.tags.first { it[0] == "emoji" } + assertEquals(listOf("emoji", "soapbox", "https://example.com/soapbox.png"), emoji.take(3)) + } + + @Test + fun aDeletionTargetsEachRetractedEvent() = + runBlocking { + val rumor = manager.buildDeletionRumor(listOf(target)) + assertEquals(DeletionRequestEvent.KIND, rumor.kind) + assertEquals(signer.pubKey, rumor.pubKey) + assertTrue(rumor.sig.isEmpty()) + assertEquals(listOf(target.id), rumor.tags.filter { it[0] == "e" }.map { it[1] }) + } +}