Merge pull request #2665 from vitorpamplona/claude/tor-connection-fallback-uJM2E

Add Tor connection timeout with graceful fallback to regular connection
This commit is contained in:
Vitor Pamplona
2026-04-30 14:03:10 -04:00
committed by GitHub
6 changed files with 232 additions and 5 deletions
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
@@ -102,6 +103,10 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
@@ -175,6 +180,21 @@ class AppModules(
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
// Whenever the underlying network identity changes (wifi↔cellular, regained from
// offline, etc.) we clear any active Tor session bypass so the manager re-attempts
// bootstrap on the new network. The remembered-approval window is unaffected: if Tor
// stays stuck we will silently bypass again after the timeout fires.
init {
applicationIOScope.launch {
connManager.status
.map { (it as? ConnectivityStatus.Active)?.networkId }
.filterNotNull()
.distinctUntilChanged()
.drop(1)
.collect { torManager.clearSessionBypass() }
}
}
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
@@ -65,10 +66,15 @@ class TorSharedPreferences(
value.toSettings(),
)
suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context)
suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context)
companion object {
// loads faster when individualized
val TOR_TYPE_KEY = stringPreferencesKey("tor.torType")
val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("tor.externalSocksPort")
val LAST_BYPASS_APPROVAL_MS_KEY = longPreferencesKey("tor.lastBypassApprovalMs")
val ONION_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.onionRelaysViaTor")
val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor")
val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor")
@@ -133,5 +139,28 @@ class TorSharedPreferences(
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
}
}
suspend fun loadLastBypassApprovalMs(context: Context): Long =
try {
context.sharedPreferencesDataStore.data.first()[LAST_BYPASS_APPROVAL_MS_KEY] ?: 0L
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("SharedPreferences") { "Error reading lastBypassApprovalMs: ${e.message}" }
0L
}
suspend fun saveLastBypassApprovalMs(
value: Long,
context: Context,
) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[LAST_BYPASS_APPROVAL_MS_KEY] = value
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("SharedPreferences") { "Error saving lastBypassApprovalMs: ${e.message}" }
}
}
}
}
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorConnectionFailureDialog
import com.vitorpamplona.quartz.utils.Log
@Composable
@@ -46,6 +47,8 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) {
ManageWebOkHttp()
ManageRelayServices()
TorConnectionFailureDialog(Amethyst.instance.torManager)
val accountState by accountSessionManager.accountContent.collectAsStateWithLifecycle()
Log.d("ActivityLifecycle") { "AccountScreen $accountState $accountSessionManager" }
@@ -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.tor
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Sticky alert that appears when Tor has been stuck connecting longer than
* [TorManager.BOOTSTRAP_TIMEOUT_MS]. The user can either:
*
* - Use a regular (non-Tor) connection for the rest of this session, with the choice
* remembered for [TorManager.APPROVAL_REMEMBER_MS] so subsequent failures within the
* window auto-fall-back without re-prompting.
* - Keep waiting the dialog hides until the next connecting span ends and a fresh
* timeout fires.
*/
@Composable
fun TorConnectionFailureDialog(torManager: TorManager) {
val failure by torManager.connectionFailure.collectAsStateWithLifecycle()
val bypass by torManager.sessionBypass.collectAsStateWithLifecycle()
var dismissedForCurrentSpan by remember { mutableStateOf(false) }
// Reset the per-span dismissal whenever the failure flag goes back to false (a new
// Connecting span starts). That way "Keep waiting" only suppresses the current span.
if (!failure && dismissedForCurrentSpan) {
dismissedForCurrentSpan = false
}
if (!failure || bypass || dismissedForCurrentSpan) return
AlertDialog(
onDismissRequest = { /* sticky — user must pick a button */ },
title = { Text(stringRes(R.string.tor_connection_failed_title)) },
text = { Text(stringRes(R.string.tor_connection_failed_body)) },
confirmButton = {
Button(onClick = { torManager.approveBypassForOneHour() }) {
Text(stringRes(R.string.tor_continue_without_for_session))
}
},
dismissButton = {
Button(onClick = { dismissedForCurrentSpan = true }) {
Text(stringRes(R.string.tor_keep_waiting))
}
},
)
}
@@ -27,15 +27,21 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
/**
* There should be only one instance of the Tor binding per app.
@@ -43,20 +49,53 @@ import kotlinx.coroutines.flow.transformLatest
* Tor will connect as soon as status is listened to.
*/
class TorManager(
torPrefs: TorSharedPreferences,
private val torPrefs: TorSharedPreferences,
app: Context,
scope: CoroutineScope,
private val scope: CoroutineScope,
) {
val service = TorService(app)
/**
* In-memory only when true, the manager emits [TorServiceStatus.Off] regardless of
* the persisted [TorType]. Cleared on process death, on network change, and on any
* user-initiated change to [TorType].
*/
val sessionBypass = MutableStateFlow(false)
/**
* Epoch-millis of the user's most recent "Use regular connection" choice, persisted
* across cold starts. While [APPROVAL_REMEMBER_MS] hasn't elapsed, a stuck-connecting
* timeout silently flips [sessionBypass] without re-prompting.
*/
@Volatile private var lastBypassApprovalMs: Long = 0L
init {
scope.launch(Dispatchers.IO) {
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
}
// Any user-initiated change to torType clears the in-memory bypass so the
// explicit user action wins over the implicit override.
torPrefs.value.torType
.drop(1)
.onEach { sessionBypass.value = false }
.launchIn(scope)
}
@OptIn(ExperimentalCoroutinesApi::class)
val status =
combine(
torPrefs.value.torType,
torPrefs.value.externalSocksPort,
) { torType, externalSocksPort ->
Pair(torType, externalSocksPort)
}.transformLatest { (torType, externalSocksPort) ->
sessionBypass,
) { torType, externalSocksPort, bypass ->
Triple(torType, externalSocksPort, bypass)
}.transformLatest { (torType, externalSocksPort, bypass) ->
if (bypass) {
service.stop()
emit(TorServiceStatus.Off)
return@transformLatest
}
when (torType) {
TorType.INTERNAL -> {
service.start()
@@ -97,7 +136,64 @@ class TorManager(
(status.value as? TorServiceStatus.Active)?.port,
)
/**
* Emits true after [BOOTSTRAP_TIMEOUT_MS] of continuous [TorServiceStatus.Connecting]
* (and we are not already bypassing). When the user has approved a bypass within the
* last [APPROVAL_REMEMBER_MS] this auto-flips [sessionBypass] silently and stays at
* false; otherwise it emits true so the UI can show the prompt.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val connectionFailure: StateFlow<Boolean> =
status
.transformLatest { s ->
if (s is TorServiceStatus.Connecting) {
emit(false)
delay(BOOTSTRAP_TIMEOUT_MS)
if (rememberedApprovalActive()) {
sessionBypass.value = true
emit(false)
} else {
emit(true)
}
} else {
emit(false)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
false,
)
fun rememberedApprovalActive(): Boolean {
val ts = lastBypassApprovalMs
return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS
}
/** Called when the user picks "Use regular connection". Starts a fresh 1-hour window. */
fun approveBypassForOneHour() {
val now = System.currentTimeMillis()
lastBypassApprovalMs = now
sessionBypass.value = true
scope.launch(Dispatchers.IO) {
torPrefs.saveLastBypassApprovalMs(now)
}
}
/**
* Re-attempt Tor on this session used on network change. Does not clear the
* remembered-approval window: if Tor stays stuck, we will silently bypass again
* after the timeout fires.
*/
fun clearSessionBypass() {
sessionBypass.value = false
}
fun isSocksReady() = status.value is TorServiceStatus.Active
fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 17392
companion object {
const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L
const val APPROVAL_REMEMBER_MS: Long = 60L * 60L * 1000L
}
}
+4
View File
@@ -1491,6 +1491,10 @@
<string name="wallet_connect_pay_invoice_error_error">Your wallet connect provider returned the following error: %1$s</string>
<string name="could_not_connect_to_tor">Could not connect to Tor</string>
<string name="tor_connection_failed_title">Tor isn\'t connecting</string>
<string name="tor_connection_failed_body">Amethyst couldn\'t finish bootstrapping Tor. Use a regular (non-Tor) connection so the feed can load? We\'ll remember this choice for the next hour and re-try Tor after that.</string>
<string name="tor_continue_without_for_session">Use regular connection</string>
<string name="tor_keep_waiting">Keep waiting</string>
<string name="unable_to_download_relay_document">Download relay document unavailable</string>
<string name="could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup">Could not assemble LNUrl from Lightning Address \"%1$s\". Check the user\'s setup</string>
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">The receiver\'s lightning service at %1$s is not available. It was calculated from the lightning address \"%2$s\". Error: %3$s. Check if the server is up and if the lightning address is correct</string>