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 7ff2e28dac..a5aed495ff 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions -import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.warningColor +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -116,6 +117,32 @@ private const val CHANNEL_LIST_WARMUP_LIMIT = 10 /** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */ private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L +/** + * Whether to offer the Tor→clearnet escape hatch for this relay ([TorClearnetBanner]). + * + * All five conditions are required, and the first four are the ones that keep the offer honest — + * the banner accuses *this relay* of blocking Tor exits, so every other explanation is ruled out first: + * + * - [usesTor]: this relay is *actually* routed over Tor right now (`TorRelayEvaluation.useTor`, the + * same predicate the pool dials with). Tor being enabled globally says nothing about one relay — + * onion, localhost and the per-role presets (trusted / DM / new) each decide independently. + * - [torIsUp]: the SOCKS proxy is Active. While Tor is still bootstrapping *nothing* Tor-routed + * connects, which is Tor's problem (and its own dialog's), not this relay's. + * - [isConnected]: the socket is open. A relay that answers is reachable *by definition*, so no + * clearnet offer, no matter how long the screen has been open. + * - [hasLoadedContent]: something from this relay is on screen. Proof the socket answered at least + * once, so a momentary reconnect doesn't flash "can't reach this relay" over a live channel list. + * - [graceElapsed]: the startup grace period is over, so a slow-but-working relay isn't nagged + * during its first connect. + */ +internal fun shouldOfferTorClearnetFallback( + usesTor: Boolean, + torIsUp: Boolean, + isConnected: Boolean, + hasLoadedContent: Boolean, + graceElapsed: Boolean, +): Boolean = usesTor && torIsUp && graceElapsed && !isConnected && !hasLoadedContent + /** * Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin * + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches @@ -303,19 +330,44 @@ fun RelayGroupChannelListScreen( var showAddPeople by remember { mutableStateOf(false) } // Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over - // Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded - // after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted - // Relay List (connected over clearnet even while Tor stays on for everything else). - val torType by Amethyst.instance.torPrefs.torType - .collectAsStateWithLifecycle(TorType.OFF) - val isOnion = remember(relay) { relay.url.contains(".onion") } - var connectTimedOut by remember(relay) { mutableStateOf(false) } + // Tor. Offer to reach it over clearnet — which adds it to the kind-10089 Trusted Relay List + // (connected over clearnet even while Tor stays on for everything else). + // + // Every input below is a live signal, because a grace timer on its own says nothing about the + // relay: the banner used to fire on `torType != OFF && !onion && !trusted` plus a 6s delay, so on + // a working, answering relay it appeared after six seconds and never went away — the socket state + // was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on* + // doesn't mean this relay goes through it; the per-role presets decide). + val torEvaluation = + Amethyst.instance.torEvaluatorFlow.flow + .collectAsStateWithLifecycle() + // The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay + // the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here). + val usesTor by remember(relay) { derivedStateOf { torEvaluation.value.useTor(relay) } } + + // While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its + // own dialog), so don't let it read as "this relay blocks Tor exits". + val torStatus by Amethyst.instance.torManager.status + .collectAsStateWithLifecycle() + val torIsUp = torStatus is TorServiceStatus.Active + + // Global flow (any relay's connect/disconnect re-emits), so derive this relay's boolean. + val connectedRelays = + accountViewModel.account.client + .connectedRelaysFlow() + .collectAsStateWithLifecycle() + val isConnected by remember(relay) { derivedStateOf { relay in connectedRelays.value } } + + var graceElapsed by remember(relay) { mutableStateOf(false) } LaunchedEffect(relay) { delay(TOR_CLEARNET_HINT_DELAY_MS) - connectTimedOut = true + graceElapsed = true } val scope = rememberCoroutineScope() - val showTorHint = torType != TorType.OFF && !isOnion && relay !in trustedRelays && connectTimedOut + // Anything on screen is proof the socket answered, even if it has since dropped — the offer is + // for a relay we cannot reach, not for one that is merely reconnecting. + val hasLoadedContent = allChannels.isNotEmpty() || buzzChannels.isNotEmpty() || dmRows.isNotEmpty() + val showTorHint = shouldOfferTorClearnetFallback(usesTor, torIsUp, isConnected, hasLoadedContent, graceElapsed) // A pinned relay works both as a pushed detail (from the drawer or another screen) and as a // bottom-nav tab. Read once here (it is @Composable): the back arrow shows only when pushed; diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/TorClearnetFallbackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/TorClearnetFallbackTest.kt new file mode 100644 index 0000000000..cbad44c0ff --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/TorClearnetFallbackTest.kt @@ -0,0 +1,75 @@ +/* + * 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.screen.loggedIn.chats.publicChannels.relayGroup + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The "Can't reach this relay over Tor" banner used to be driven by a bare 6s timer, so it appeared on + * a relay that was connected and delivering. These pin the liveness gates that replaced it. + */ +class TorClearnetFallbackTest { + private fun offer( + usesTor: Boolean = true, + torIsUp: Boolean = true, + isConnected: Boolean = false, + hasLoadedContent: Boolean = false, + graceElapsed: Boolean = true, + ) = shouldOfferTorClearnetFallback(usesTor, torIsUp, isConnected, hasLoadedContent, graceElapsed) + + @Test + fun offersWhenTorRoutedRelayNeverAnswers() { + assertTrue(offer()) + } + + @Test + fun silentWhileTheRelayIsConnected() { + // The regression: the socket is open and the relay is replying, so there is nothing to escape. + assertFalse(offer(isConnected = true)) + assertFalse(offer(isConnected = true, hasLoadedContent = true)) + } + + @Test + fun silentWhenContentFromTheRelayIsOnScreen() { + // A momentary disconnect over a loaded channel list is a reconnect, not an unreachable relay. + assertFalse(offer(hasLoadedContent = true)) + } + + @Test + fun silentWhenTheRelayIsNotRoutedOverTor() { + // Tor can be on globally while this relay is dialed over clearnet (onion/localhost/trusted + // presets) — blaming Tor for that relay's silence would be wrong. + assertFalse(offer(usesTor = false)) + } + + @Test + fun silentWhileTorItselfIsStillBootstrapping() { + // Nothing Tor-routed connects during bootstrap — that's Tor's failure, not the relay's. + assertFalse(offer(torIsUp = false)) + } + + @Test + fun silentDuringTheStartupGrace() { + assertFalse(offer(graceElapsed = false)) + } +}