mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
fix: gate the Buzz Tor→clearnet banner on real relay reachability
The "Can't reach this relay over Tor" card on a community's screen was driven by a bare 6s timer: `connectTimedOut` flipped true after the delay and never flipped back, and the condition never consulted the relay at all. On a Buzz relay the banner is an unconditional item in the sectioned list (the empty-list guard only covers the vanilla NIP-29 branch), so it appeared six seconds after opening any community and stayed up while the relay was connected and delivering messages. The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere, not that this relay is routed through it — onion, localhost and the per-role presets (trusted / DM / new) each decide independently. Gate the offer on live signals instead, extracted into a pure predicate: - routed over Tor — TorRelayEvaluation.useTor, the same predicate the relay pool dials with - Tor is up — TorManager.status is Active, so a bootstrapping Tor isn't misreported as the relay blocking exits - not connected — client.connectedRelaysFlow(); a relay that answers is reachable by definition - nothing loaded — no channels/DMs on screen, so a reconnect blip can't flash the banner over a live list - grace elapsed — unchanged, so a slow first connect isn't nagged Adds unit tests for each gate, including the reported regression (connected and delivering -> banner stays hidden).
This commit is contained in:
+62
-10
@@ -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;
|
||||
|
||||
+75
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user