From 04c90b4f07657886b54808a7c1fa31ab85ef7501 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 10 Apr 2023 21:01:14 +0200 Subject: [PATCH] android: SOCKS proxy settings (#2158) * android: draft UI for SOCKS proxy settings * footer comment * UI for proxy host-port selection * keyboard type in port text field * footer * better text validation logic * use italic in footer --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 13 +- .../views/database/DatabaseEncryptionView.kt | 4 +- .../views/helpers/DefaultBasicTextField.kt | 120 ++++++++++++++- .../usersettings/AdvancedNetworkSettings.kt | 22 +-- .../views/usersettings/NetworkAndServers.kt | 144 +++++++++++++++++- .../app/src/main/res/values/strings.xml | 6 + 6 files changed, 286 insertions(+), 23 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index c14eff4481..b5fd441123 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -108,6 +108,7 @@ class AppPreferences(val context: Context) { val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) + val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) val networkSessionMode: SharedPreference = SharedPreference( get = fun(): TransportSessionMode { @@ -224,6 +225,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" + private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort" private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" @@ -1765,7 +1767,16 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a fun getNetCfg(): NetCfg { val useSocksProxy = appPrefs.networkUseSocksProxy.get() - val socksProxy = if (useSocksProxy) ":9050" else null + val proxyHostPort = appPrefs.networkProxyHostPort.get() + val socksProxy = if (useSocksProxy) { + if (proxyHostPort?.startsWith("localhost:") == true) { + proxyHostPort.removePrefix("localhost") + } else { + proxyHostPort ?: ":9050" + } + } else { + null + } val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) val requiredHostMode = appPrefs.networkRequiredHostMode.get() val sessionMode = appPrefs.networkSessionMode.get() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt index 634028ce4c..7a616301b7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt @@ -366,7 +366,7 @@ fun PassphraseField( showStrength: Boolean = false, isValid: (String) -> Boolean, keyboardActions: KeyboardActions = KeyboardActions(), - dependsOn: MutableState? = null, + dependsOn: State? = null, ) { var valid by remember { mutableStateOf(validKey(key.value)) } var showKey by remember { mutableStateOf(false) } @@ -479,7 +479,7 @@ private fun passphraseEntropy(s: String): Double { return s.length * log2(poolSize.toDouble()) } -private enum class PassphraseStrength(val color: Color) { +enum class PassphraseStrength(val color: Color) { VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen); companion object { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt index 0698fd292a..51c9870897 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt @@ -3,10 +3,15 @@ package chat.simplex.app.views.helpers import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.ZeroCornerSize import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Error import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -14,13 +19,18 @@ import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.views.database.PassphraseStrength +import chat.simplex.app.views.database.validKey import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -110,3 +120,109 @@ fun DefaultBasicTextField( } ) } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DefaultConfigurableTextField( + state: MutableState, + placeholder: String, + modifier: Modifier = Modifier, + showPasswordStrength: Boolean = false, + isValid: (String) -> Boolean, + keyboardActions: KeyboardActions = KeyboardActions(), + keyboardType: KeyboardType = KeyboardType.Text, + dependsOn: State? = null, +) { + var valid by remember { mutableStateOf(validKey(state.value.text)) } + var showKey by remember { mutableStateOf(false) } + val icon = if (valid) { + if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility + } else Icons.Outlined.Error + val iconColor = if (valid) { + if (showPasswordStrength && state.value.text.isNotEmpty()) PassphraseStrength.check(state.value.text).color else HighOrLowlight + } else Color.Red + val keyboard = LocalSoftwareKeyboardController.current + val keyboardOptions = KeyboardOptions( + imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done, + autoCorrect = keyboardType != KeyboardType.Password, + keyboardType = keyboardType + ) + val enabled = true + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + val color = MaterialTheme.colors.onBackground + val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = state.value, + modifier = modifier + .fillMaxWidth() + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, false, interactionSource, colors) + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = { + state.value = it + }, + cursorBrush = SolidColor(colors.cursorColor(false).value), + visualTransformation = if (showKey || keyboardType != KeyboardType.Password) + VisualTransformation.None + else + VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) }, + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions(onDone = { + keyboard?.hide() + keyboardActions.onDone?.invoke(this) + }), + singleLine = true, + textStyle = TextStyle.Default.copy( + color = color, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = state.value.text, + innerTextField = innerTextField, + placeholder = { Text(placeholder, color = HighOrLowlight) }, + singleLine = true, + enabled = enabled, + isError = !valid, + trailingIcon = { + if (keyboardType == KeyboardType.Password || !valid) { + IconButton({ showKey = !showKey }) { + Icon(icon, null, tint = iconColor) + } + } + }, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp), + visualTransformation = VisualTransformation.None, + colors = colors + ) + } + ) + LaunchedEffect(Unit) { + launch { + snapshotFlow { state.value } + .distinctUntilChanged() + .collect { + valid = isValid(it.text) + } + } + launch { + snapshotFlow { dependsOn?.value } + .distinctUntilChanged() + .collect { + valid = isValid(state.value.text) + } + } + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt index 45b8d3c650..f5c633c245 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt @@ -102,15 +102,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { saveCfg(newCfg) } - fun updateSettingsDialog(action: () -> Unit) { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.update_network_settings_question), - text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers), - confirmText = generalGetString(R.string.update_network_settings_confirmation), - onConfirm = action - ) - } - AdvancedNetworkSettingsLayout( networkTCPConnectTimeout, networkTCPTimeout, @@ -121,10 +112,10 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { networkTCPKeepIntvl, networkTCPKeepCnt, resetDisabled = if (currentCfg.value.useSocksProxy) currentCfg.value == NetCfg.proxyDefaults else currentCfg.value == NetCfg.defaults, - reset = { updateSettingsDialog(::reset) }, + reset = { showUpdateNetworkSettingsDialog(::reset) }, footerDisabled = buildCfg() == currentCfg.value, revert = { updateView(currentCfg.value) }, - save = { updateSettingsDialog { saveCfg(buildCfg()) } } + save = { showUpdateNetworkSettingsDialog { saveCfg(buildCfg()) } } ) } @@ -415,6 +406,15 @@ fun FooterButton(icon: ImageVector, title: String, action: () -> Unit, disabled: } } +fun showUpdateNetworkSettingsDialog(action: () -> Unit) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.update_network_settings_question), + text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers), + confirmText = generalGetString(R.string.update_network_settings_confirmation), + onConfirm = action + ) +} + @Preview(showBackground = true) @Composable fun PreviewAdvancedNetworkSettingsLayout() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt index 1ed408f150..b59ed8f2de 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt @@ -1,18 +1,26 @@ package chat.simplex.app.views.usersettings +import SectionCustomFooter import SectionDivider import SectionItemView import SectionItemWithValue +import SectionSpacer +import SectionTextFooter import SectionView import SectionViewSelectable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -44,6 +52,7 @@ fun NetworkAndServersView( networkUseSocksProxy = networkUseSocksProxy, onionHosts = onionHosts, sessionMode = sessionMode, + proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } }, showModal = showModal, showSettingsModal = showSettingsModal, showCustomModal = showCustomModal, @@ -87,7 +96,7 @@ fun NetworkAndServersView( OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert) OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert) } - updateNetworkSettingsDialog( + showUpdateNetworkSettingsDialog( title = generalGetString(R.string.update_onion_hosts_settings_question), startsWith, onDismiss = { @@ -114,7 +123,7 @@ fun NetworkAndServersView( TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description) TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description) } - updateNetworkSettingsDialog( + showUpdateNetworkSettingsDialog( title = generalGetString(R.string.update_network_session_mode_question), startsWith, onDismiss = { sessionMode.value = prevValue } @@ -140,6 +149,7 @@ fun NetworkAndServersView( networkUseSocksProxy: MutableState, onionHosts: MutableState, sessionMode: MutableState, + proxyPort: State, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), @@ -163,7 +173,7 @@ fun NetworkAndServersView( } SectionItemView { - UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) } SectionDivider() UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) @@ -174,7 +184,10 @@ fun NetworkAndServersView( } SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } - Spacer(Modifier.height(8.dp)) + if (networkUseSocksProxy.value) { + SectionCustomFooter { Text(annotatedStringResource(R.string.disable_onion_hosts_when_not_supported)) } + } + Spacer(Modifier.height(16.dp)) SectionView(generalGetString(R.string.settings_section_title_calls)) { SettingsActionItem(Icons.Outlined.ElectricalServices, stringResource(R.string.webrtc_ice_servers), showModal { RTCServersView(it) }) } @@ -184,7 +197,9 @@ fun NetworkAndServersView( @Composable fun UseSocksProxySwitch( networkUseSocksProxy: MutableState, - toggleSocksProxy: (Boolean) -> Unit + proxyPort: State, + toggleSocksProxy: (Boolean) -> Unit, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { Row( Modifier.fillMaxWidth(), @@ -201,7 +216,19 @@ fun UseSocksProxySwitch( stringResource(R.string.network_socks_toggle), tint = HighOrLowlight ) - Text(stringResource(R.string.network_socks_toggle)) + if (networkUseSocksProxy.value) { + Row { + Text(generalGetString(R.string.network_socks_toggle_use_socks_proxy) + " (") + Text( + generalGetString(R.string.network_proxy_port).format(proxyPort.value), + Modifier.clickable { showSettingsModal { SockProxySettings(it) }() }, + color = MaterialTheme.colors.primary + ) + Text(")") + } + } else { + Text(stringResource(R.string.network_socks_toggle)) + } } Switch( checked = networkUseSocksProxy.value, @@ -214,6 +241,83 @@ fun UseSocksProxySwitch( } } +@Composable +fun SockProxySettings(m: ChatModel) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = DEFAULT_BOTTOM_PADDING), + ) { + val defaultHostPort = remember { "localhost:9050" } + AppBarTitle(generalGetString(R.string.network_socks_proxy_settings)) + val hostPort by remember { m.controller.appPrefs.networkProxyHostPort.state } + val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(hostPort?.split(":")?.firstOrNull() ?: "localhost")) + } + val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(hostPort?.split(":")?.lastOrNull() ?: "9050")) + } + val save = { + withBGApi { + m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) + m.controller.apiSetNetworkConfig(m.controller.getNetCfg()) + } + } + SectionView { + SectionItemView { + ResetToDefaultsButton({ + showUpdateNetworkSettingsDialog { + m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort) + val newHost = defaultHostPort.split(":").first() + val newPort = defaultHostPort.split(":").last() + hostUnsaved.value = hostUnsaved.value.copy(newHost, TextRange(newHost.length)) + portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length)) + save() + } + }, disabled = hostPort == defaultHostPort) + } + SectionDivider() + SectionItemView { + DefaultConfigurableTextField( + hostUnsaved, + stringResource(R.string.host_verb), + modifier = Modifier, + isValid = ::validHost, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + keyboardType = KeyboardType.Text, + ) + } + SectionDivider() + SectionItemView { + DefaultConfigurableTextField( + portUnsaved, + stringResource(R.string.port_verb), + modifier = Modifier, + isValid = ::validPort, + keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save() }), + keyboardType = KeyboardType.Number, + ) + } + } + SectionCustomFooter { + NetworkSectionFooter( + revert = { + val prevHost = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.firstOrNull() ?: "localhost" + val prevPort = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.lastOrNull() ?: "9050" + hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length)) + portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length)) + }, + save = { showUpdateNetworkSettingsDialog { save() } }, + revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text), + saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) || + remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value || + remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value + ) + } + } +} + @Composable private fun UseOnionHosts( onionHosts: MutableState, @@ -282,7 +386,32 @@ private fun SessionModePicker( ) } -private fun updateNetworkSettingsDialog( +@Composable +private fun NetworkSectionFooter(revert: () -> Unit, save: () -> Unit, revertDisabled: Boolean, saveDisabled: Boolean) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + FooterButton(Icons.Outlined.Replay, stringResource(R.string.network_options_revert), revert, revertDisabled) + FooterButton(Icons.Outlined.Check, stringResource(R.string.network_options_save), save, saveDisabled) + } +} + +// https://stackoverflow.com/a/106223 +private fun validHost(s: String): Boolean { + val validIp = Regex("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])[.]){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$") + val validHostname = Regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])[.])*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$"); + return s.matches(validIp) || s.matches(validHostname) +} + +// https://ihateregex.io/expr/port/ +private fun validPort(s: String): Boolean { + val validPort = Regex("^(6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4})$") + return s.isNotBlank() && s.matches(validPort) +} + +private fun showUpdateNetworkSettingsDialog( title: String, startsWith: String = "", message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers), @@ -307,6 +436,7 @@ fun PreviewNetworkAndServersLayout() { developerTools = true, xftpSendEnabled = remember { mutableStateOf(true) }, networkUseSocksProxy = remember { mutableStateOf(true) }, + proxyPort = remember { mutableStateOf(9050) }, showModal = { {} }, showSettingsModal = { {} }, showCustomModal = { {} }, diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index b59775407c..7a4526d43c 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -508,6 +508,11 @@ Advanced network settings Network settings Use SOCKS proxy (port 9050) + SOCKS proxy settings + Use SOCKS proxy + port %d + Host + Port Use SOCKS proxy? Access the servers via SOCKS proxy on port 9050? Proxy must be started before enabling this option. Use direct Internet connection? @@ -529,6 +534,7 @@ A separate TCP connection (and SOCKS credential) will be used for each chat profile you have in the app. A separate TCP connection (and SOCKS credential) will be used for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. Update transport isolation mode? + Set Use .onion hosts to No if SOCKS proxy does not support them. Appearance App version App version: v%s