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>
This commit is contained in:
Evgeny Poberezkin
2023-04-10 21:01:14 +02:00
committed by GitHub
parent 747cbb8e09
commit 04c90b4f07
6 changed files with 286 additions and 23 deletions

View File

@@ -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<TransportSessionMode> = 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()

View File

@@ -366,7 +366,7 @@ fun PassphraseField(
showStrength: Boolean = false,
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
dependsOn: MutableState<String>? = null,
dependsOn: State<Any?>? = 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 {

View File

@@ -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<TextFieldValue>,
placeholder: String,
modifier: Modifier = Modifier,
showPasswordStrength: Boolean = false,
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
keyboardType: KeyboardType = KeyboardType.Text,
dependsOn: State<Any?>? = 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)
}
}
}
}

View File

@@ -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() {

View File

@@ -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<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
proxyPort: State<Int>,
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<Boolean>,
toggleSocksProxy: (Boolean) -> Unit
proxyPort: State<Int>,
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<OnionHosts>,
@@ -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 = { {} },

View File

@@ -508,6 +508,11 @@
<string name="network_settings">Advanced network settings</string>
<string name="network_settings_title">Network settings</string>
<string name="network_socks_toggle">Use SOCKS proxy (port 9050)</string>
<string name="network_socks_proxy_settings">SOCKS proxy settings</string>
<string name="network_socks_toggle_use_socks_proxy">Use SOCKS proxy</string>
<string name="network_proxy_port">port %d</string>
<string name="host_verb">Host</string>
<string name="port_verb">Port</string>
<string name="network_enable_socks">Use SOCKS proxy?</string>
<string name="network_enable_socks_info">Access the servers via SOCKS proxy on port 9050? Proxy must be started before enabling this option.</string>
<string name="network_disable_socks">Use direct Internet connection?</string>
@@ -529,6 +534,7 @@
<string name="network_session_mode_user_description">A separate TCP connection (and SOCKS credential) will be used <b>for each chat profile you have in the app</b>.</string>
<string name="network_session_mode_entity_description">A separate TCP connection (and SOCKS credential) will be used <b>for each contact and group member</b>.\n<b>Please note</b>: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.</string>
<string name="update_network_session_mode_question">Update transport isolation mode?</string>
<string name="disable_onion_hosts_when_not_supported">Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.</string>
<string name="appearance_settings">Appearance</string>
<string name="app_version_title">App version</string>
<string name="app_version_name">App version: v%s</string>