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.AccountsTorStateConnector
|
||||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
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.CrashReportCache
|
||||||
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
|
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
|
||||||
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
|
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
|
||||||
@@ -102,6 +103,10 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
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.onCompletion
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.transform
|
import kotlinx.coroutines.flow.transform
|
||||||
@@ -175,6 +180,21 @@ class AppModules(
|
|||||||
|
|
||||||
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
|
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
|
// Service that will run at all times to receive events from Pokey
|
||||||
val pokeyReceiver = PokeyReceiver()
|
val pokeyReceiver = PokeyReceiver()
|
||||||
|
|
||||||
|
|||||||
+29
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
|
|||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||||
@@ -65,10 +66,15 @@ class TorSharedPreferences(
|
|||||||
value.toSettings(),
|
value.toSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context)
|
||||||
|
|
||||||
|
suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// loads faster when individualized
|
// loads faster when individualized
|
||||||
val TOR_TYPE_KEY = stringPreferencesKey("tor.torType")
|
val TOR_TYPE_KEY = stringPreferencesKey("tor.torType")
|
||||||
val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("tor.externalSocksPort")
|
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 ONION_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.onionRelaysViaTor")
|
||||||
val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor")
|
val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor")
|
||||||
val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor")
|
val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor")
|
||||||
@@ -133,5 +139,28 @@ class TorSharedPreferences(
|
|||||||
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
|
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.loggedIn.LoggedInPage
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.tor.TorConnectionFailureDialog
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,6 +47,8 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) {
|
|||||||
ManageWebOkHttp()
|
ManageWebOkHttp()
|
||||||
ManageRelayServices()
|
ManageRelayServices()
|
||||||
|
|
||||||
|
TorConnectionFailureDialog(Amethyst.instance.torManager)
|
||||||
|
|
||||||
val accountState by accountSessionManager.accountContent.collectAsStateWithLifecycle()
|
val accountState by accountSessionManager.accountContent.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Log.d("ActivityLifecycle") { "AccountScreen $accountState $accountSessionManager" }
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There should be only one instance of the Tor binding per app.
|
* 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.
|
* Tor will connect as soon as status is listened to.
|
||||||
*/
|
*/
|
||||||
class TorManager(
|
class TorManager(
|
||||||
torPrefs: TorSharedPreferences,
|
private val torPrefs: TorSharedPreferences,
|
||||||
app: Context,
|
app: Context,
|
||||||
scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
val service = TorService(app)
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val status =
|
val status =
|
||||||
combine(
|
combine(
|
||||||
torPrefs.value.torType,
|
torPrefs.value.torType,
|
||||||
torPrefs.value.externalSocksPort,
|
torPrefs.value.externalSocksPort,
|
||||||
) { torType, externalSocksPort ->
|
sessionBypass,
|
||||||
Pair(torType, externalSocksPort)
|
) { torType, externalSocksPort, bypass ->
|
||||||
}.transformLatest { (torType, externalSocksPort) ->
|
Triple(torType, externalSocksPort, bypass)
|
||||||
|
}.transformLatest { (torType, externalSocksPort, bypass) ->
|
||||||
|
if (bypass) {
|
||||||
|
service.stop()
|
||||||
|
emit(TorServiceStatus.Off)
|
||||||
|
return@transformLatest
|
||||||
|
}
|
||||||
when (torType) {
|
when (torType) {
|
||||||
TorType.INTERNAL -> {
|
TorType.INTERNAL -> {
|
||||||
service.start()
|
service.start()
|
||||||
@@ -97,7 +136,64 @@ class TorManager(
|
|||||||
(status.value as? TorServiceStatus.Active)?.port,
|
(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 isSocksReady() = status.value is TorServiceStatus.Active
|
||||||
|
|
||||||
fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 17392
|
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="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="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="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="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>
|
<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