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/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..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 @@ -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( @@ -34,8 +35,36 @@ 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, + /** + * 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, ) +/** + * 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 @@ -66,6 +95,21 @@ 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. + * + * [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/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 79b381c4be..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 @@ -74,12 +74,15 @@ 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 import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -229,6 +232,7 @@ fun RenderTopButtons( RenderTopButtons( mediaData = mediaData, hasMultipleQualities = hasMultipleQualities, + castFormatSummary = castFormatSummary(videoGroup), qualityButton = { VideoQualityButton( player = player, @@ -294,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, @@ -313,6 +320,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 +329,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 } @@ -495,6 +507,17 @@ 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, + ), + // 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/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/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index be65b69133..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 @@ -31,6 +31,7 @@ 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 @@ -105,9 +106,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 +117,11 @@ fun CastDevicePickerDialog( } } } + +@Composable +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/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/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..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 @@ -210,7 +210,7 @@ private fun DrawerContentBody( accountViewModel: AccountViewModel, ) { val onClickUser = { - nav.nav(routeFor(accountViewModel.userProfile())) + nav.navDrawer(routeFor(accountViewModel.userProfile())) nav.closeDrawer() } @@ -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) + }, ) } } @@ -742,7 +750,7 @@ private fun ScheduledPostsNavigationRow( badgeCount = pendingCount, onClick = { nav.closeDrawer() - nav.nav { def.resolveRoute(accountViewModel) } + nav.navDrawer { def.resolveRoute(accountViewModel) } }, ) } @@ -850,7 +858,7 @@ fun NavigationRow( tint, onClick = { nav.closeDrawer() - nav.nav(route) + nav.navDrawer(route) }, ) } @@ -871,7 +879,7 @@ fun NavigationRow( tint, onClick = { nav.closeDrawer() - nav.nav(computeRoute) + nav.navDrawer(computeRoute) }, ) } @@ -890,7 +898,7 @@ fun NavigationRow( tint = tint, onClick = { nav.closeDrawer() - nav.nav(route) + nav.navDrawer(route) }, ) } @@ -909,7 +917,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/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/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/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 = 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/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..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 @@ -20,13 +20,19 @@ */ 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.MediaError import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata @@ -38,16 +44,31 @@ 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_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_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 +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 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 @@ -55,6 +76,29 @@ 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. + * + * 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 + /** * Google Cast (Chromecast) caster. * @@ -123,15 +167,29 @@ class ChromecastCaster( override fun onStatusUpdated() { val client = currentMediaClient ?: return val status = client.mediaStatus - Log.d(TAG) { + 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) { - Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" } + override fun onMediaError(mediaError: MediaError) { + Log.w(TAG) { + "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} " + + "type=${mediaError.type} media=${castingFormat ?: "unknown"} customData=${mediaError.customData}" + } + reportMediaFailure(mediaError.detailedErrorCode) } } @@ -155,6 +213,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 +251,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,10 +261,22 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG) { "session.onStartFailed error=$error" } + Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } + cancelLoadWatchdog() pendingSessionStart?.complete(false) pendingSessionStart = null - sessionFlow.value = CastSessionState.Error(currentDevice(), "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) { @@ -209,12 +287,25 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.d(TAG) { "session.onEnded error=$error" } + 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 + // "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)}" } + 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 // ref-count leaks +1 for every failed attempt. - detachMediaClientCallback() pendingSessionStart?.complete(false) pendingSessionStart = null sessionFlow.value = CastSessionState.Idle @@ -242,7 +333,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 } @@ -263,6 +354,102 @@ 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 + + /** + * 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() ?: castingDevice + Log.w(TAG) { + "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" + } + 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 + } + + /** The device a load is currently being watched for, so the message can name it. */ + @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 + + /** + * 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 + + 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() ?: castingDevice + val unsupported = + detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED || + detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_DECODE + val format = castingFormat + val message = + 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, format)) + } + private fun currentDevice(): CastDevice? = when (val s = sessionFlow.value) { is CastSessionState.Connecting -> s.device @@ -275,7 +462,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 +488,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 +509,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 +558,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,11 +570,12 @@ 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") - sessionFlow.value = CastSessionState.Error(device, "Google Play services unavailable") + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_play_services_unavailable)) return } val route = @@ -378,7 +584,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(Res.string.cast_error_device_offline)) return } @@ -390,21 +596,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, @@ -417,12 +636,18 @@ 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 } if (!started) { Log.w(TAG, "cast: session start refused") - sessionFlow.value = CastSessionState.Error(device, "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 } @@ -438,21 +663,57 @@ 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 + castingFormat = request.formatSummary + castingDevice = device + Log.i(TAG) { + "cast: load() submitting contentType=${info?.contentType} " + + "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 + // 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) { + Log.i(TAG) { "cast: load() accepted by receiver" } + } else { + Log.w(TAG) { + "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + + "msg=${status.statusMessage}" + } + // 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 } 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(Res.string.cast_error_load_failed)) } } @@ -462,10 +723,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() @@ -476,8 +746,30 @@ 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). val client = currentMediaClient @@ -487,8 +779,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) } @@ -502,16 +794,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/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/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/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) + } +} 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/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 7f1fa230e5..1218997077 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -211,6 +211,7 @@ ALL_TESTS=( test_28_retention_wn_to_amy test_29_disband_amy_to_wn test_30_wn_commit_after_app_data_update + 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 ec0f884906..2d6ea3f396 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -301,3 +301,39 @@ test_30_wn_commit_after_app_data_update() { record_result "$id" fail "amy applied the rename but cannot decrypt wn's next message (forked)" 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 74d4bd86a1..9a30894f69 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 @@ -608,13 +609,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, @@ -777,14 +785,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] }) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index ce6505f9b1..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 अक्षर + पारणशब्द दोहराएँ + पारणशब्दों में मेल नहीं + रहस्यीकरण कुंचिका + रहस्यीकरणमध्य… + एक पृथक पारणशब्द का उपयोग करें अपने कुंचिकाओं को सुरक्षित रखें आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय। अभी इसी समय सुरक्षित अनुकृति बनाएँ @@ -5453,6 +5466,10 @@ प्रमाणपत्र निर्गत %1$s को %2$s द्वारा। मान्य %3$s तक अनुमतियाँ + सेवाप्रसंग अभिलेख तथा जालस्थान जानकारी + प्रवेशांकन तथा आद्यताएँ जो इस जालस्थान ने अभिलेखित किया इस लेखा के लिए + जालस्थान जानकारी रिक्त करें + क्या इस जालस्थान से निर्गमनांकन करें तथा इसकी जानकारी मिटा दें। पुनःबाधित करें %1$s को रखें %1$s पुनःजुडें %1$s @@ -5777,6 +5794,14 @@ सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए। विश्वसनीय पुनःप्रसारक सूची पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए। + मिटाएँ + सम्पादन + आपके सन्देश का सम्पादन + सन्देश पर जाएँ + टाँकें + उत्तर + टाँका हटाएँ + वह परिवर्तन असफल। %1$s इस समुह में नहीं है। वह व्यक्ति अभिलेख में क्या है @@ -5795,4 +5820,149 @@ जोडें उस समायोजक को खोलने में असफल। समायोजक के एनपुब॰ की अनुकृति + समायोजकों के लिए ढूँढें + जोडें + घषणाओं को पढने में असफल + आपके पुनःप्रसारकों में कोई भी घषणा नहीं कर रहा। + घोषित %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 द्वारा + कुंचिका पोटलियाँ पढने में असफल। + एक का प्रकाशन + अन्तिम उपाय का प्रकाशन + सब लौटा लें + उनको लौटा लें + इस समूह से जुडने का अनुरोध करें + जुडने का अनुरोध असफल। + परखें + उस अभिलेख को खोलने में असफल। + वह अभिलेख पठनशक्य नहीं। + उस अभिलेख को भेजने में असफल। + + %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 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 873fdebe0b..0e304877ab 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2708,6 +2708,20 @@ 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 + 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. + 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 + %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 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( diff --git a/geode/README.md b/geode/README.md index 4c45f710fc..11732aaad9 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,34 @@ 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` 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 --compressed -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 `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`. +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 4651105623..89f6a6c196 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,43 @@ require_auth = false # backfill_seconds = 3600 # filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}' +[http] +# 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 +# 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 +# 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 +# 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. +# 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..4385521331 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -20,13 +20,16 @@ */ 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.HttpRelayHandler import io.ktor.server.application.install import io.ktor.server.application.serverConfig import io.ktor.server.cio.CIO @@ -34,6 +37,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 +81,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 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(), ) { /** * NIP-86 HTTP adapter. Wraps the engine's [RelayEngine.nip86Server] @@ -104,6 +113,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 `relay.url` read as http(s), or 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 @@ -164,12 +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) + } + nipFERoute?.let { route -> + options(path) { route.preflight(call) } } webSocket(path) { if (shuttingDown) { 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..e12781ad13 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,59 @@ data class StaticConfig( val live_index: Boolean = true, ) + /** + * 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, + /** 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 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. */ + 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, + /** + * Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name). + * A NIP-98 token's `u` may name any of them, read as http(s), or `[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 commands over HTTP are off. */ + fun toSettings(): HttpCommandSettings? = + if (!enabled) { + null + } else { + 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, + 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 +358,12 @@ 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.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}" } database.readers?.let { require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" } } @@ -308,7 +375,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..571c6312b2 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt @@ -0,0 +1,54 @@ +/* + * 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. + * 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, + cap: Int, +): ByteArray? { + val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (declared != null && declared > cap) return null + val ch = call.receiveChannel() + // 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 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 new file mode 100644 index 0000000000..f1b50e171a --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt @@ -0,0 +1,88 @@ +/* + * 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.Deflater +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 = 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) { + 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 + 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/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..9d4e75d2d4 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -0,0 +1,58 @@ +/* + * 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 +import kotlin.time.Duration.Companion.seconds + +/** + * 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. */ + 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. */ + 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. */ + 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..57aeefbfa4 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -0,0 +1,231 @@ +/* + * 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.nip86RelayManagement.server.Nip86HttpHandler +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 +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 + * 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 + * 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) + } + + /** + * 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 { + // 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) { + 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)) { + // Bounded in time too: a body trickled in forever would hold this admission slot forever. + val body = + try { + withTimeout(settings.bodyTimeout) { readBoundedBody(call, bodyCap) } + } catch (_: TimeoutCancellationException) { + call.response.header(HttpHeaders.Connection, "close") + return@admit respondLine( + call, + 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) { + HttpAdmission.Verdict.ADMITTED -> {} + + HttpAdmission.Verdict.CLIENT_BUSY -> { + respondLine( + call, + HttpRelayStatus.TOO_MANY_REQUESTS, + HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), + ) + } + + HttpAdmission.Verdict.RELAY_BUSY -> { + respondLine( + call, + HttpRelayStatus.UNAVAILABLE, + HttpRelayHandler.notice(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 + } + + /** + * 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, + 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. 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 + if (gzip) { + val body = GzipLines(out) + try { + object : HttpRelayLines { + override suspend fun line(frame: String) = body.line(frame) + + 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 { + val NDJSON = ContentType("application", "x-ndjson") + 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 new file mode 100644 index 0000000000..fa1f25b872 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -0,0 +1,482 @@ +/* + * 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.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.net.Socket +import java.util.concurrent.TimeUnit +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 +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * 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 sharing the relay URL with NIP-86. + */ +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, + store: (IEventStore) -> IEventStore = { it }, + ): NormalizedRelayUrl { + val port = ServerSocket(0).use { it.localPort } + val url = "ws://127.0.0.1:$port$path".normalizeRelayUrl() + 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(OkHttpRelayTransport { http }, signer) + + private suspend fun note(text: String): Event = alice.sign(TextNoteEvent.build(text)) + + private fun post( + url: String, + body: String, + authorization: String? = null, + contentType: String = "text/plain", + ): Response = + http + .newCall( + Request + .Builder() + .url(url) + .post(body.toRequestBody(contentType.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))), onEvent = 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 theWireIsNdjsonInTheSocketsOwnFrames() = + runBlocking { + val relay = start() + val n = note("hello") + client().publish(relay, n) + 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","q",${n.toJson()}]""", """["EOSE","q"]"""), 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(relay.toHttp(), """["EVENT",$forged]""").use { response -> + assertEquals(400, response.code) + val line = response.lines().single() + assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line) + } + } + + @Test + fun aBodyThatIsNotACommandIs400AndOneOverTheCapIs413() { + val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64)) + 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(relay.toHttp(), """["REQ","q",{"authors":["${"a".repeat(64)}"]}]""").use { response -> + assertEquals(413, response.code) + assertTrue(response.lines().single().startsWith("""["NOTICE","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(relay.toHttp(), """["REQ","q",{}]""").use { response -> + assertEquals(429, response.code) + assertEquals("7", response.header("Retry-After")) + assertEquals("""["CLOSED","q","rate-limited: slow down"]""", response.lines().single()) + } + } + + @Test + fun aPreflightLetsAnyOriginPostWithAuthorization() { + val relay = start() + val preflight = + Request + .Builder() + .url(relay.toHttp()) + .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(relay.toHttp(), """["REQ","q",{}]""").use { response -> + assertEquals(401, response.code) + assertEquals("Nostr", response.header("WWW-Authenticate")) + assertTrue(response.lines().single().startsWith("""["CLOSED","q","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(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 }) + } + + @Test + fun aTokenForAnotherBodyDoesNotSignIn() = + runBlocking { + val relay = start(policy = ::SignInPolicy) + 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, 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 + fun aTokenSignedAtTheOnionAddressVerifies() = + runBlocking { + val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl() + val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion))) + 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 commandsGoToTheRelayUrlPathIncluded() = + runBlocking { + val relay = start(path = "/nostr") + assertTrue(relay.toHttp().trimEnd('/').endsWith("/nostr")) + assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete) + post(relay.toHttp().replace("/nostr", ""), """["REQ","q",{}]""").use { assertEquals(404, it.code) } + } + + @Test + 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(relay.toHttp(), """["REQ","q",{}]""").use { response -> + assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + assertEquals(401, response.code) + } + } + + /** 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()) + } + } + } + + /** 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() + 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) + } + + 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/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/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" 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/HttpRelayAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt new file mode 100644 index 0000000000..98b04bfd88 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt @@ -0,0 +1,84 @@ +/* + * 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], 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(line) + } 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) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt new file mode 100644 index 0000000000..af7eb13305 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.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.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 + +/** + * 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. [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 + * 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 transport: HttpRelayTransport, + 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, + subId: String = newSubId(), + onEvent: (Event) -> Unit, + ): HttpRelayAnswer = + send(relay, ReqCmd(subId, 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, + 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, EventCmd(event)) + + /** Posts [cmd] (a REQ, COUNT or EVENT) to [relay], handing every frame to [onMessage] as it is read. */ + suspend fun send( + relay: NormalizedRelayUrl, + cmd: Command, + onMessage: (Message) -> Unit = {}, + ): HttpRelayAnswer { + val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" } + val url = relay.toHttp() + 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(relay, command, url, body, token(url, body), onMessage, retrying = false) + } + + private suspend fun token( + url: String, + body: ByteArray, + ): String? = signer?.sign(HTTPAuthorizationEvent.build(url, "POST", body))?.toAuthToken() + + private suspend fun post( + relay: NormalizedRelayUrl, + 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 { + 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/HttpRelayCommand.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt index cb5a865ea6..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,50 +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 /** - * 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]" - } - } - } - - /** 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) { @@ -79,12 +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 } + /** 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 a4054bbd08..0b845206bf 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)) + + // 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) + } 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, cmd) 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,56 +291,55 @@ 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) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + /** 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 `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 /** The websocket's slow-consumer bound in the reference relays. */ 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. */ -private val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") - -private 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/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/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..085290682e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt @@ -0,0 +1,101 @@ +/* + * 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 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 aReqThatEndsOnEoseIsComplete() { + val reader = read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q"]""") + assertTrue(reader.complete) + assertIs(reader.last) + } + + @Test + fun aReqWithItsTailMissingIsIncomplete() { + val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200) + val message = reader.read("""["EVENT","q",$event]""") + assertIs(message) + assertEquals("hi", message.event.content) + 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","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","c",{"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","q","auth-required: sign in"]""") + assertTrue(refused.complete) + assertEquals("auth-required: sign in", assertIs(refused.last).message) + assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","q","blocked: no"]""", """["EOSE","q"]""").complete) + } + + @Test + fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() { + 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("")) + } +} 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() + } +} 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 {