diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5ea95f359d..d536908bdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index f1ea5ed91c..daebe61dc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -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}" } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 281bd1927f..dc5dad6f9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorConnectionFailureDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorConnectionFailureDialog.kt new file mode 100644 index 0000000000..f801b8dca9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorConnectionFailureDialog.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.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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index a11bf5997b..ade8fa2213 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -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 = + 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 + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a2e65e8dda..999c75b4f8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1491,6 +1491,10 @@ Your wallet connect provider returned the following error: %1$s Could not connect to Tor + Tor isn\'t connecting + 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. + Use regular connection + Keep waiting Download relay document unavailable Could not assemble LNUrl from Lightning Address \"%1$s\". Check the user\'s setup 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