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:
@@ -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()
|
||||
|
||||
|
||||
+29
@@ -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" }
|
||||
|
||||
+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.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user