From 5261886b31250b9289fa4cbfcb836cda7e206592 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 17 Sep 2024 20:09:50 +0000 Subject: [PATCH] android, desktop: proxy configuration includes credentials (#4892) * android, desktop: proxy configuration includes credentials * migration * changes for disabled socks * migration * port * new logic * migration * check validity of fields * validity of host * import changes proxy just in case * send port always * non-nullable * Revert "send port always" This reverts commit 14dd066d80f4c6c6b1061e8e6142bf18f83b97bb. * string --------- Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/model/SimpleXAPI.kt | 91 ++++-- .../views/migration/MigrateFromDevice.kt | 16 +- .../common/views/migration/MigrateToDevice.kt | 177 ++++++----- .../usersettings/AdvancedNetworkSettings.kt | 32 +- .../views/usersettings/NetworkAndServers.kt | 285 ++++++++++++------ .../commonMain/resources/MR/base/strings.xml | 10 + 6 files changed, 377 insertions(+), 234 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4d5caea16a..9cd9376c8d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -129,7 +129,22 @@ class AppPreferences { val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) val networkShowSubscriptionPercentage = mkBoolPreference(SHARED_PREFS_NETWORK_SHOW_SUBSCRIPTION_PERCENTAGE, false) - val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") + private val _networkProxy = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, json.encodeToString(NetworkProxy())) + val networkProxy: SharedPreference = SharedPreference( + get = fun(): NetworkProxy { + val value = _networkProxy.get() ?: return NetworkProxy() + return try { + if (value.startsWith("{")) { + json.decodeFromString(value) + } else { + NetworkProxy(host = value.substringBefore(":").ifBlank { "localhost" }, port = value.substringAfter(":").toIntOrNull() ?: 9050) + } + } catch (e: Throwable) { + NetworkProxy() + } + }, + set = fun(proxy: NetworkProxy) { _networkProxy.set(json.encodeToString(proxy)) } + ) private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) val networkSessionMode: SharedPreference = SharedPreference( get = fun(): TransportSessionMode { @@ -531,7 +546,7 @@ object ChatController { suspend fun startChatWithTemporaryDatabase(ctrl: ChatCtrl, netCfg: NetCfg): User? { Log.d(TAG, "startChatWithTemporaryDatabase") val migrationActiveUser = apiGetActiveUser(null, ctrl) ?: apiCreateActiveUser(null, Profile(displayName = "Temp", fullName = ""), ctrl = ctrl) - if (!apiSetNetworkConfig(netCfg, ctrl)) { + if (!apiSetNetworkConfig(netCfg, ctrl = ctrl)) { Log.e(TAG, "Error setting network config, stopping migration") return null } @@ -976,16 +991,18 @@ object ChatController { throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } - suspend fun apiSetNetworkConfig(cfg: NetCfg, ctrl: ChatCtrl? = null): Boolean { + suspend fun apiSetNetworkConfig(cfg: NetCfg, showAlertOnError: Boolean = true, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) return when (r) { is CR.CmdOk -> true else -> { Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_setting_network_config), - "${r.responseType}: ${r.details}" - ) + if (showAlertOnError) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_setting_network_config), + "${r.responseType}: ${r.details}" + ) + } false } } @@ -2745,13 +2762,9 @@ object ChatController { fun getNetCfg(): NetCfg { val useSocksProxy = appPrefs.networkUseSocksProxy.get() - val proxyHostPort = appPrefs.networkProxyHostPort.get() + val networkProxy = appPrefs.networkProxy.get() val socksProxy = if (useSocksProxy) { - if (proxyHostPort?.startsWith("localhost:") == true) { - proxyHostPort.removePrefix("localhost") - } else { - proxyHostPort ?: ":9050" - } + networkProxy.toProxyString() } else { null } @@ -2793,7 +2806,7 @@ object ChatController { } /** - * [AppPreferences.networkProxyHostPort] is not changed here, use appPrefs to set it + * [AppPreferences.networkProxy] is not changed here, use appPrefs to set it * */ fun setNetCfg(cfg: NetCfg) { appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy) @@ -3529,13 +3542,8 @@ data class NetCfg( val useSocksProxy: Boolean get() = socksProxy != null val enableKeepAlive: Boolean get() = tcpKeepAlive != null - fun withHostPort(hostPort: String?, default: String? = ":9050"): NetCfg { - val socksProxy = if (hostPort?.startsWith("localhost:") == true) { - hostPort.removePrefix("localhost") - } else { - hostPort ?: default - } - return copy(socksProxy = socksProxy) + fun withProxy(proxy: NetworkProxy?, default: String? = ":9050"): NetCfg { + return copy(socksProxy = proxy?.toProxyString() ?: default) } companion object { @@ -3577,6 +3585,39 @@ data class NetCfg( } } +@Serializable +data class NetworkProxy( + val username: String = "", + val password: String = "", + val auth: NetworkProxyAuth = NetworkProxyAuth.ISOLATE, + val host: String = "localhost", + val port: Int = 9050 +) { + fun toProxyString(): String { + var res = "" + if (auth == NetworkProxyAuth.USERNAME && (username.isNotBlank() || password.isNotBlank())) { + res += username.trim() + ":" + password.trim() + "@" + } else if (auth == NetworkProxyAuth.USERNAME) { + res += "@" + } + if (host != "localhost") { + res += if (host.contains(':')) "[${host.trim(' ', '[', ']')}]" else host.trim() + } + if (port != 9050 || res.isEmpty()) { + res += ":$port" + } + return res + } +} + +@Serializable +enum class NetworkProxyAuth { + @SerialName("isolate") + ISOLATE, + @SerialName("username") + USERNAME, +} + enum class OnionHosts { NEVER, PREFER, REQUIRED } @@ -6139,6 +6180,7 @@ enum class NotificationsMode() { @Serializable data class AppSettings( var networkConfig: NetCfg? = null, + var networkProxy: NetworkProxy? = null, var privacyEncryptLocalFiles: Boolean? = null, var privacyAskToApproveRelays: Boolean? = null, var privacyAcceptImages: Boolean? = null, @@ -6170,6 +6212,7 @@ data class AppSettings( val empty = AppSettings() val def = defaults if (networkConfig != def.networkConfig) { empty.networkConfig = networkConfig } + if (networkProxy != def.networkProxy) { empty.networkProxy = networkProxy } if (privacyEncryptLocalFiles != def.privacyEncryptLocalFiles) { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } @@ -6207,8 +6250,12 @@ data class AppSettings( if (net.hostMode == HostMode.Onion) { net = net.copy(hostMode = HostMode.Public, requiredHostMode = true) } + if (net.socksProxy != null) { + net = net.copy(socksProxy = networkProxy?.toProxyString()) + } setNetCfg(net) } + networkProxy?.let { def.networkProxy.set(it) } privacyEncryptLocalFiles?.let { def.privacyEncryptLocalFiles.set(it) } privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) } privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } @@ -6241,6 +6288,7 @@ data class AppSettings( val defaults: AppSettings get() = AppSettings( networkConfig = NetCfg.defaults, + networkProxy = null, privacyEncryptLocalFiles = true, privacyAskToApproveRelays = true, privacyAcceptImages = true, @@ -6274,6 +6322,7 @@ data class AppSettings( val def = appPreferences return defaults.copy( networkConfig = getNetCfg(), + networkProxy = def.networkProxy.get(), privacyEncryptLocalFiles = def.privacyEncryptLocalFiles.get(), privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(), privacyAcceptImages = def.privacyAcceptImages.get(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index a71503e315..4cc7899cc8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -4,7 +4,6 @@ import SectionBottomSpacer import SectionSpacer import SectionTextFooter import SectionView -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -17,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.startChat import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase @@ -38,7 +38,6 @@ import kotlinx.serialization.* import java.io.File import java.net.URLEncoder import kotlin.math.max -import kotlin.math.sqrt @Serializable data class MigrationFileLinkData( @@ -46,16 +45,20 @@ data class MigrationFileLinkData( ) { @Serializable data class NetworkConfig( - val socksProxy: String?, + // Legacy. Remove in 2025 + @SerialName("socksProxy") + val legacySocksProxy: String?, + val networkProxy: NetworkProxy?, val hostMode: HostMode?, val requiredHostMode: Boolean? ) { - fun hasOnionConfigured(): Boolean = socksProxy != null || hostMode == HostMode.Onion + fun hasProxyConfigured(): Boolean = networkProxy != null || legacySocksProxy != null || hostMode == HostMode.Onion fun transformToPlatformSupported(): NetworkConfig { return if (hostMode != null && requiredHostMode != null) { NetworkConfig( - socksProxy = if (hostMode == HostMode.Onion) socksProxy ?: NetCfg.proxyDefaults.socksProxy else socksProxy, + legacySocksProxy = if (hostMode == HostMode.Onion) legacySocksProxy ?: NetCfg.proxyDefaults.socksProxy else legacySocksProxy, + networkProxy = if (hostMode == HostMode.Onion) networkProxy ?: NetworkProxy() else networkProxy, hostMode = if (hostMode == HostMode.Onion) HostMode.OnionViaSocks else hostMode, requiredHostMode = requiredHostMode ) @@ -570,7 +573,8 @@ private fun MutableState.startUploading( val cfg = getNetCfg() val data = MigrationFileLinkData( networkConfig = MigrationFileLinkData.NetworkConfig( - socksProxy = cfg.socksProxy, + legacySocksProxy = null, + networkProxy = if (appPrefs.networkUseSocksProxy.get()) appPrefs.networkProxy.get() else null, hostMode = cfg.hostMode, requiredHostMode = cfg.requiredHostMode ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 8312c213ec..415f5cdd57 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -5,16 +5,15 @@ import SectionItemView import SectionSpacer import SectionTextFooter import SectionView -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import chat.simplex.common.model.* import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_TO_STAGE +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.startChat import chat.simplex.common.model.ChatCtrl @@ -41,10 +40,10 @@ import kotlin.math.max @Serializable sealed class MigrationToDeviceState { - @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToDeviceState() - @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationToDeviceState() - @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationToDeviceState() - @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationToDeviceState() + @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val networkProxy: NetworkProxy?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToDeviceState() + @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToDeviceState() + @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToDeviceState() + @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToDeviceState() companion object { // Here we check whether it's needed to show migration process after app restart or not @@ -66,10 +65,10 @@ sealed class MigrationToDeviceState { null } else { val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) - MigrationToState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) + MigrationToState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg, state.networkProxy) } } - is Passphrase -> MigrationToState.Passphrase("", state.netCfg) + is Passphrase -> MigrationToState.Passphrase("", state.netCfg, state.networkProxy) } if (initial == null) { settings.remove(SHARED_PREFS_MIGRATION_TO_STAGE) @@ -91,16 +90,24 @@ sealed class MigrationToDeviceState { @Serializable sealed class MigrationToState { @Serializable object PasteOrScanLink: MigrationToState() - @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToState() - @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationToState() - @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationToState() - @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationToState() - @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationToState() - @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationToState() - @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationToState() - @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationToState() - @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationToState() - @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationToState() + @Serializable data class Onion( + val link: String, + // Legacy, remove in 2025 + @SerialName("socksProxy") + val legacySocksProxy: String?, + val networkProxy: NetworkProxy?, + val hostMode: HostMode, + val requiredHostMode: Boolean + ): MigrationToState() + @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?, val ctrl: ChatCtrl?): MigrationToState() + @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() + @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg, val networkProxy: NetworkProxy?): MigrationToState() } private var MutableState.state: MigrationToState? @@ -175,16 +182,16 @@ private fun ModalData.SectionByState( when (val s = migrationState.value) { null -> {} is MigrationToState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() - is MigrationToState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) - is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) - is MigrationToState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) + is MigrationToState.Onion -> OnionView(s.link, s.legacySocksProxy, s.networkProxy, s.hostMode, s.requiredHostMode, migrationState) + is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg, s.networkProxy) + is MigrationToState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg, s.networkProxy) is MigrationToState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) - is MigrationToState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) - is MigrationToState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) - is MigrationToState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) - is MigrationToState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) - is MigrationToState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) - is MigrationToState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) + is MigrationToState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg, s.networkProxy) + is MigrationToState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg, s.networkProxy) + is MigrationToState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg, s.networkProxy) + is MigrationToState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg, s.networkProxy) + is MigrationToState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg, s.networkProxy) + is MigrationToState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, s.networkProxy, close) } } @@ -216,21 +223,24 @@ private fun MutableState.PasteLinkView() { } @Composable -private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { +private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, linkNetworkProxy: NetworkProxy?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { val onionHosts = remember { stateGetOrPut("onionHosts") { - getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts + getNetCfg().copy(socksProxy = linkNetworkProxy?.toProxyString() ?: legacyLinkSocksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts } } - val networkUseSocksProxy = remember { stateGetOrPut("networkUseSocksProxy") { socksProxy != null } } + val networkUseSocksProxy = remember { stateGetOrPut("networkUseSocksProxy") { linkNetworkProxy != null || legacyLinkSocksProxy != null } } val sessionMode = remember { stateGetOrPut("sessionMode") { TransportSessionMode.User} } - val networkProxyHostPort = remember { stateGetOrPut("networkHostProxyPort") { - var proxy = (socksProxy ?: chatModel.controller.appPrefs.networkProxyHostPort.get()) - if (proxy?.startsWith(":") == true) proxy = "localhost$proxy" - proxy - } + val networkProxy = remember { stateGetOrPut("networkProxy") { + linkNetworkProxy + ?: if (legacyLinkSocksProxy != null) { + NetworkProxy(host = legacyLinkSocksProxy.substringBefore(":").ifBlank { "localhost" }, port = legacyLinkSocksProxy.substringAfter(":").toIntOrNull() ?: 9050) + } else { + appPrefs.networkProxy.get() + } + } } val netCfg = rememberSaveable(stateSaver = serializableSaver()) { - mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value)) + mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = linkNetworkProxy?.toProxyString() ?: legacyLinkSocksProxy, sessionMode = sessionMode.value)) } SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings).uppercase()) { @@ -241,12 +251,12 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos click = { val updated = netCfg.value .withOnionHosts(onionHosts.value) - .withHostPort(if (networkUseSocksProxy.value) networkProxyHostPort.value else null, null) + .withProxy(if (networkUseSocksProxy.value) networkProxy.value else null, null) .copy( sessionMode = sessionMode.value ) withBGApi { - state.value = MigrationToState.DatabaseInit(link, updated) + state.value = MigrationToState.DatabaseInit(link, updated, if (networkUseSocksProxy.value) networkProxy.value else null) } } ){} @@ -255,8 +265,8 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos SectionSpacer() - val networkProxyHostPortPref = SharedPreference(get = { networkProxyHostPort.value }, set = { - networkProxyHostPort.value = it + val networkProxyPref = SharedPreference(get = { networkProxy.value }, set = { + networkProxy.value = it }) SectionView(stringResource(MR.strings.network_settings_title).uppercase()) { OnionRelatedLayout( @@ -264,13 +274,10 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos networkUseSocksProxy, onionHosts, sessionMode, - networkProxyHostPortPref, + networkProxyPref, toggleSocksProxy = { enable -> networkUseSocksProxy.value = enable }, - useOnion = { - onionHosts.value = it - }, updateSessionMode = { sessionMode.value = it } @@ -279,13 +286,13 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos } @Composable -private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { +private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg, networkProxy: NetworkProxy?) { Box { SectionView(stringResource(MR.strings.migrate_to_device_database_init).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { - prepareDatabase(link, tempDatabaseFile, netCfg) + prepareDatabase(link, tempDatabaseFile, netCfg, networkProxy) } } @@ -297,14 +304,15 @@ private fun MutableState.LinkDownloadingView( archivePath: String, tempDatabaseFile: File, chatReceiver: MutableState, - netCfg: NetCfg + netCfg: NetCfg, + networkProxy: NetworkProxy? ) { Box { SectionView(stringResource(MR.strings.migrate_to_device_downloading_details).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { - startDownloading(0, ctrl, user, tempDatabaseFile, chatReceiver, link, archivePath, netCfg) + startDownloading(0, ctrl, user, tempDatabaseFile, chatReceiver, link, archivePath, netCfg, networkProxy) } } @@ -319,14 +327,14 @@ private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { } @Composable -private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationToChatReceiver?, archivePath: String, netCfg: NetCfg) { +private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationToChatReceiver?, archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { SectionView(stringResource(MR.strings.migrate_to_device_download_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), text = stringResource(MR.strings.migrate_to_device_repeat_download), textColor = MaterialTheme.colors.primary, click = { - state = MigrationToState.DatabaseInit(link, netCfg) + state = MigrationToState.DatabaseInit(link, netCfg, networkProxy) } ) {} SectionTextFooter(stringResource(MR.strings.migrate_to_device_try_again)) @@ -339,25 +347,25 @@ private fun MutableState.DownloadFailedView(link: String, cha } @Composable -private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { +private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { Box { SectionView(stringResource(MR.strings.migrate_to_device_importing_archive).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { - importArchive(archivePath, netCfg) + importArchive(archivePath, netCfg, networkProxy) } } @Composable -private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { +private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { SectionView(stringResource(MR.strings.migrate_to_device_import_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), text = stringResource(MR.strings.migrate_to_device_repeat_import), textColor = MaterialTheme.colors.primary, click = { - state = MigrationToState.ArchiveImport(archivePath, netCfg) + state = MigrationToState.ArchiveImport(archivePath, netCfg, networkProxy) } ) {} SectionTextFooter(stringResource(MR.strings.migrate_to_device_try_again)) @@ -365,7 +373,7 @@ private fun MutableState.ArchiveImportFailedView(archivePath: } @Composable -private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { +private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { val currentKey = rememberSaveable { mutableStateOf(currentKey) } val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) } @@ -395,9 +403,9 @@ private fun MutableState.PassphraseEnteringView(currentKey: S val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp) val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation if (success) { - state = MigrationToState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) + state = MigrationToState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg, networkProxy) } else if (status is DBMigrationResult.ErrorMigration) { - state = MigrationToState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) + state = MigrationToState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg, networkProxy) } else { showErrorOnMigrationIfNeeded(status) } @@ -414,7 +422,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: S } @Composable -private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { +private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg, networkProxy: NetworkProxy?) { data class Tuple4(val a: A, val b: B, val c: C, val d: D) val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) { is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) { @@ -449,7 +457,7 @@ private fun MutableState.MigrationConfirmationView(status: DB text = button, textColor = MaterialTheme.colors.primary, click = { - state = MigrationToState.Migration(passphrase, confirmation, useKeychain, netCfg) + state = MigrationToState.Migration(passphrase, confirmation, useKeychain, netCfg, networkProxy) } ) {} } @@ -458,13 +466,13 @@ private fun MutableState.MigrationConfirmationView(status: DB } @Composable -private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { +private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, networkProxy: NetworkProxy?, close: () -> Unit) { Box { SectionView(stringResource(MR.strings.migrate_to_device_migrating).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { - startChat(passphrase, confirmation, useKeychain, netCfg, close) + startChat(passphrase, confirmation, useKeychain, netCfg, networkProxy, close) } } @@ -476,19 +484,21 @@ private fun ProgressView() { private suspend fun MutableState.checkUserLink(link: String) { if (strHasSimplexFileLink(link.trim())) { val data = MigrationFileLinkData.readFromLink(link) - val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false + val hasProxyConfigured = data?.networkConfig?.hasProxyConfigured() ?: false val networkConfig = data?.networkConfig?.transformToPlatformSupported() // If any of iOS or Android had onion enabled, show onion screen - if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { - state = MigrationToState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) - MigrationToDeviceState.save(MigrationToDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) + if (hasProxyConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { + state = MigrationToState.Onion(link.trim(), networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode) + MigrationToDeviceState.save(MigrationToDeviceState.Onion(link.trim(), networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) } else { val current = getNetCfg() state = MigrationToState.DatabaseInit(link.trim(), current.copy( - socksProxy = networkConfig?.socksProxy, + socksProxy = null, hostMode = networkConfig?.hostMode ?: current.hostMode, requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode - )) + ), + networkProxy = null + ) } } else { AlertManager.shared.showAlertMsg( @@ -502,6 +512,7 @@ private fun MutableState.prepareDatabase( link: String, tempDatabaseFile: File, netCfg: NetCfg, + networkProxy: NetworkProxy? ) { withLongRunningApi { val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg) @@ -513,7 +524,7 @@ private fun MutableState.prepareDatabase( } val (ctrl, user) = ctrlAndUser - state = MigrationToState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) + state = MigrationToState.LinkDownloading(link, ctrl, user, archivePath(), netCfg, networkProxy) } } @@ -526,13 +537,14 @@ private fun MutableState.startDownloading( link: String, archivePath: String, netCfg: NetCfg, + networkProxy: NetworkProxy? ) { withBGApi { chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> when (msg) { is CR.RcvFileProgressXFTP -> { - state = MigrationToState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) - MigrationToDeviceState.save(MigrationToDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) + state = MigrationToState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, networkProxy, ctrl) + MigrationToDeviceState.save(MigrationToDeviceState.DownloadProgress(link, File(archivePath).name, netCfg, networkProxy)) } is CR.RcvStandaloneFileComplete -> { delay(500) @@ -540,8 +552,8 @@ private fun MutableState.startDownloading( if (state == null) { MigrationToDeviceState.save(null) } else { - state = MigrationToState.ArchiveImport(archivePath, netCfg) - MigrationToDeviceState.save(MigrationToDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + state = MigrationToState.ArchiveImport(archivePath, netCfg, networkProxy) + MigrationToDeviceState.save(MigrationToDeviceState.ArchiveImport(File(archivePath).name, netCfg, networkProxy)) } } is CR.RcvFileError -> { @@ -549,7 +561,7 @@ private fun MutableState.startDownloading( generalGetString(MR.strings.migrate_to_device_download_failed), generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) - state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) } is CR.ChatRespError -> { if (msg.chatError is ChatError.ChatErrorChat && msg.chatError.errorType is ChatErrorType.NoRcvFileUser) { @@ -557,7 +569,7 @@ private fun MutableState.startDownloading( generalGetString(MR.strings.migrate_to_device_download_failed), generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) - state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) } else { Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.chatError)}") } @@ -569,7 +581,7 @@ private fun MutableState.startDownloading( val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl) if (res == null) { - state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.migrate_to_device_error_downloading_archive), error @@ -578,7 +590,7 @@ private fun MutableState.startDownloading( } } -private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { +private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { withLongRunningApi { try { if (ChatController.ctrl == null || ChatController.ctrl == -1L) { @@ -592,14 +604,14 @@ private fun MutableState.importArchive(archivePath: String, n if (archiveErrors.isNotEmpty()) { showArchiveImportedWithErrorsAlert(archiveErrors) } - state = MigrationToState.Passphrase("", netCfg) - MigrationToDeviceState.save(MigrationToDeviceState.Passphrase(netCfg)) + state = MigrationToState.Passphrase("", netCfg, networkProxy) + MigrationToDeviceState.save(MigrationToDeviceState.Passphrase(netCfg, networkProxy)) } catch (e: Exception) { - state = MigrationToState.ArchiveImportFailed(archivePath, netCfg) + state = MigrationToState.ArchiveImportFailed(archivePath, netCfg, networkProxy) AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString()) } } catch (e: Exception) { - state = MigrationToState.ArchiveImportFailed(archivePath, netCfg) + state = MigrationToState.ArchiveImportFailed(archivePath, netCfg, networkProxy) AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString()) } } @@ -609,7 +621,7 @@ private suspend fun stopArchiveDownloading(fileId: Long, ctrl: ChatCtrl) { controller.apiCancelFile(null, fileId, ctrl) } -private fun startChat(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { +private fun startChat(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, networkProxy: NetworkProxy?, close: () -> Unit) { if (useKeychain) { ksDatabasePassword.set(passphrase) } else { @@ -621,7 +633,8 @@ private fun startChat(passphrase: String, confirmation: MigrationConfirmation, u try { initChatController(useKey = passphrase, confirmMigrations = confirmation) { CompletableDeferred(false) } val appSettings = controller.apiGetAppSettings(AppSettings.current.prepareForExport()).copy( - networkConfig = netCfg + networkConfig = netCfg, + networkProxy = networkProxy ) finishMigration(appSettings, close) } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index 3c8ab2b70a..6dc0f74df3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -1,10 +1,10 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionItemWithValue +import SectionTextFooter import SectionView import SectionViewSelectableCards import androidx.compose.desktop.ui.tooling.preview.Preview @@ -40,12 +40,10 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U val currentCfg = remember { stateGetOrPut("currentCfg") { controller.getNetCfg() } } val currentCfgVal = currentCfg.value // used only on initialization - val onionHosts = remember { mutableStateOf(currentCfgVal.onionHosts) } val sessionMode = remember { mutableStateOf(currentCfgVal.sessionMode) } val smpProxyMode = remember { mutableStateOf(currentCfgVal.smpProxyMode) } val smpProxyFallback = remember { mutableStateOf(currentCfgVal.smpProxyFallback) } - val networkUseSocksProxy: MutableState = remember { mutableStateOf(currentCfgVal.useSocksProxy) } val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) } val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) } val networkTCPTimeoutPerKb = remember { mutableStateOf(currentCfgVal.tcpTimeoutPerKb) } @@ -90,11 +88,10 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U tcpKeepAlive = tcpKeepAlive, smpPingInterval = networkSMPPingInterval.value, smpPingCount = networkSMPPingCount.value - ).withOnionHosts(onionHosts.value) + ).withOnionHosts(currentCfg.value.onionHosts) } fun updateView(cfg: NetCfg) { - onionHosts.value = cfg.onionHosts sessionMode.value = cfg.sessionMode smpProxyMode.value = cfg.smpProxyMode smpProxyFallback.value = cfg.smpProxyFallback @@ -148,10 +145,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U ) { AdvancedNetworkSettingsLayout( currentRemoteHost = currentRemoteHost, - networkUseSocksProxy = networkUseSocksProxy, developerTools = developerTools, - onionHosts = onionHosts, - useOnion = { onionHosts.value = it; currentCfg.value = currentCfg.value.withOnionHosts(it) }, sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, @@ -183,10 +177,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U @Composable fun AdvancedNetworkSettingsLayout( currentRemoteHost: RemoteHostInfo?, - networkUseSocksProxy: State, developerTools: Boolean, - onionHosts: MutableState, - useOnion: (OnionHosts) -> Unit, sessionMode: MutableState, smpProxyMode: MutableState, smpProxyFallback: MutableState, @@ -223,21 +214,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U SMPProxyFallbackPicker(smpProxyFallback, showModal, updateSMPProxyFallback, enabled = remember { derivedStateOf { smpProxyMode.value != SMPProxyMode.Never } }) SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy) } - SectionCustomFooter { - Text(stringResource(MR.strings.private_routing_explanation)) - } - SectionDividerSpaced(maxTopPadding = true) - } - - if (currentRemoteHost == null && networkUseSocksProxy.value) { - SectionView(stringResource(MR.strings.network_socks_proxy).uppercase()) { - UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) - SectionCustomFooter { - Column { - Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) - } - } - } + SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) SectionDividerSpaced(maxTopPadding = true) } @@ -562,7 +539,6 @@ fun PreviewAdvancedNetworkSettingsLayout() { SimpleXTheme { AdvancedNetworkSettingsLayout( currentRemoteHost = null, - networkUseSocksProxy = remember { mutableStateOf(false) }, developerTools = false, sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) }, @@ -577,8 +553,6 @@ fun PreviewAdvancedNetworkSettingsLayout() { networkTCPKeepIdle = remember { mutableStateOf(10) }, networkTCPKeepIntvl = remember { mutableStateOf(10) }, networkTCPKeepCnt = remember { mutableStateOf(10) }, - onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, - useOnion = {}, updateSessionMode = {}, updateSMPProxyMode = {}, updateSMPProxyFallback = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 5bcb0a545d..5272353c20 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -1,10 +1,10 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionItemWithValue +import SectionTextFooter import SectionView import SectionViewSelectable import TextIconSpaced @@ -37,10 +37,11 @@ fun NetworkAndServersView() { val netCfg = remember { chatModel.controller.getNetCfg() } val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.useSocksProxy) } - val proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } } + val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } NetworkAndServersLayout( currentRemoteHost = currentRemoteHost, networkUseSocksProxy = networkUseSocksProxy, + onionHosts = remember { mutableStateOf(netCfg.onionHosts) }, toggleSocksProxy = { enable -> val def = NetCfg.defaults val proxyDef = NetCfg.proxyDefaults @@ -51,7 +52,7 @@ fun NetworkAndServersView() { confirmText = generalGetString(MR.strings.confirm_verb), onConfirm = { withBGApi { - var conf = controller.getNetCfg().withHostPort(controller.appPrefs.networkProxyHostPort.get()) + var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get()) if (conf.tcpConnectTimeout == def.tcpConnectTimeout) { conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout) } @@ -104,6 +105,7 @@ fun NetworkAndServersView() { @Composable fun NetworkAndServersLayout( currentRemoteHost: RemoteHostInfo?, networkUseSocksProxy: MutableState, + onionHosts: MutableState, toggleSocksProxy: (Boolean) -> Unit, ) { val m = chatModel @@ -120,14 +122,10 @@ fun NetworkAndServersView() { if (currentRemoteHost == null) { UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) - SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxyHostPort, false, it) }}) + SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }}) SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) if (networkUseSocksProxy.value) { - SectionCustomFooter { - Column { - Text(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) - } - } + SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) SectionDividerSpaced(maxTopPadding = true) } else { SectionDividerSpaced() @@ -158,16 +156,14 @@ fun NetworkAndServersView() { networkUseSocksProxy: MutableState, onionHosts: MutableState, sessionMode: MutableState, - networkProxyHostPort: SharedPreference, + networkProxy: SharedPreference, toggleSocksProxy: (Boolean) -> Unit, - useOnion: (OnionHosts) -> Unit, updateSessionMode: (TransportSessionMode) -> Unit, ) { val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.fullscreen.showModal(content = it) } val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.fullscreen.showCustomModal { close -> it(close) }} UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) - SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, networkProxyHostPort, true, it) } }) - UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) + SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, networkProxy, onionHosts, sessionMode.value, true, it) } }) if (developerTools) { SessionModePicker(sessionMode, showModal, updateSessionMode) } @@ -205,46 +201,98 @@ fun UseSocksProxySwitch( @Composable fun SocksProxySettings( networkUseSocksProxy: Boolean, - networkProxyHostPort: SharedPreference = appPrefs.networkProxyHostPort, + networkProxy: SharedPreference, + onionHosts: MutableState, + sessionMode: TransportSessionMode, migration: Boolean, close: () -> Unit ) { - val defaultHostPort = remember { "localhost:9050" } - val hostPortSaved by remember { networkProxyHostPort.state } + val networkProxySaved by remember { networkProxy.state } + val onionHostsSaved = remember { mutableStateOf(onionHosts.value) } + + val usernameUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(networkProxySaved.username)) + } + val passwordUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(networkProxySaved.password)) + } val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.firstOrNull() ?: "localhost")) + mutableStateOf(TextFieldValue(networkProxySaved.host)) } val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.lastOrNull() ?: "9050")) + mutableStateOf(TextFieldValue(networkProxySaved.port.toString())) } - val save = { - val oldValue = networkProxyHostPort.get() - networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) - if (networkUseSocksProxy && !migration) { - withBGApi { - if (!controller.apiSetNetworkConfig(controller.getNetCfg())) { - networkProxyHostPort.set(oldValue) - } + val proxyAuthRandomUnsaved = rememberSaveable { mutableStateOf(networkProxySaved.auth == NetworkProxyAuth.ISOLATE) } + LaunchedEffect(proxyAuthRandomUnsaved.value) { + if (!proxyAuthRandomUnsaved.value && onionHosts.value != OnionHosts.NEVER) { + onionHosts.value = OnionHosts.NEVER + } + } + val proxyAuthModeUnsaved = remember(proxyAuthRandomUnsaved.value, usernameUnsaved.value.text, passwordUnsaved.value.text) { + derivedStateOf { + if (proxyAuthRandomUnsaved.value) { + NetworkProxyAuth.ISOLATE + } else { + NetworkProxyAuth.USERNAME } } } - val saveAndClose = { - val oldValue = networkProxyHostPort.get() - networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) + + val save: (Boolean) -> Unit = { closeOnSuccess -> + val oldValue = networkProxy.get() + usernameUnsaved.value = usernameUnsaved.value.copy(if (proxyAuthModeUnsaved.value == NetworkProxyAuth.USERNAME) usernameUnsaved.value.text.trim() else "") + passwordUnsaved.value = passwordUnsaved.value.copy(if (proxyAuthModeUnsaved.value == NetworkProxyAuth.USERNAME) passwordUnsaved.value.text.trim() else "") + hostUnsaved.value = hostUnsaved.value.copy(hostUnsaved.value.text.trim()) + portUnsaved.value = portUnsaved.value.copy(portUnsaved.value.text.trim()) + + networkProxy.set( + NetworkProxy( + username = usernameUnsaved.value.text, + password = passwordUnsaved.value.text, + host = hostUnsaved.value.text, + port = portUnsaved.value.text.toIntOrNull() ?: 9050, + auth = proxyAuthModeUnsaved.value + ) + ) + val oldCfg = controller.getNetCfg() + val cfg = oldCfg.withOnionHosts(onionHosts.value) + val oldOnionHosts = onionHostsSaved.value + onionHostsSaved.value = onionHosts.value + + if (!migration) { + controller.setNetCfg(cfg) + } if (networkUseSocksProxy && !migration) { withBGApi { - if (controller.apiSetNetworkConfig(controller.getNetCfg())) { - close() + if (controller.apiSetNetworkConfig(cfg, showAlertOnError = false)) { + onionHosts.value = cfg.onionHosts + onionHostsSaved.value = onionHosts.value + if (closeOnSuccess) { + close() + } } else { - networkProxyHostPort.set(oldValue) + controller.setNetCfg(oldCfg) + networkProxy.set(oldValue) + onionHostsSaved.value = oldOnionHosts + showWrongProxyConfigAlert() } } } } - val saveDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text) || - remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value || - remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value - val resetDisabled = hostUnsaved.value.text + ":" + portUnsaved.value.text == defaultHostPort + val saveDisabled = + ( + networkProxySaved.username == usernameUnsaved.value.text.trim() && + networkProxySaved.password == passwordUnsaved.value.text.trim() && + networkProxySaved.host == hostUnsaved.value.text.trim() && + networkProxySaved.port.toString() == portUnsaved.value.text.trim() && + networkProxySaved.auth == proxyAuthModeUnsaved.value && + onionHosts.value == onionHostsSaved.value + ) || + !validCredential(usernameUnsaved.value.text) || + !validCredential(passwordUnsaved.value.text) || + !validHost(hostUnsaved.value.text) || + !validPort(portUnsaved.value.text) + val resetDisabled = hostUnsaved.value.text.trim() == "localhost" && portUnsaved.value.text.trim() == "9050" && proxyAuthRandomUnsaved.value && onionHosts.value == NetCfg.defaults.onionHosts ModalView( close = { if (saveDisabled) { @@ -252,7 +300,7 @@ fun SocksProxySettings( } else { showUnsavedSocksHostPortAlert( confirmText = generalGetString(if (networkUseSocksProxy && !migration) MR.strings.network_options_save_and_reconnect else MR.strings.network_options_save), - save = saveAndClose, + save = { save(true) }, close = close ) } @@ -263,38 +311,78 @@ fun SocksProxySettings( .fillMaxWidth() ) { AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings)) - SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - DefaultConfigurableTextField( - hostUnsaved, - stringResource(MR.strings.host_verb), - modifier = Modifier.fillMaxWidth(), - isValid = ::validHost, - keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), - keyboardType = KeyboardType.Text, - ) - DefaultConfigurableTextField( - portUnsaved, - stringResource(MR.strings.port_verb), - modifier = Modifier.fillMaxWidth(), - isValid = ::validPort, - keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save() }), - keyboardType = KeyboardType.Number, - ) + SectionView(stringResource(MR.strings.network_socks_proxy).uppercase()) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + DefaultConfigurableTextField( + hostUnsaved, + stringResource(MR.strings.host_verb), + modifier = Modifier.fillMaxWidth(), + isValid = ::validHost, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + keyboardType = KeyboardType.Text, + ) + DefaultConfigurableTextField( + portUnsaved, + stringResource(MR.strings.port_verb), + modifier = Modifier.fillMaxWidth(), + isValid = ::validPort, + keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save(false) }), + keyboardType = KeyboardType.Number, + ) + } + + UseOnionHosts(onionHosts, rememberUpdatedState(networkUseSocksProxy && proxyAuthRandomUnsaved.value)) { + onionHosts.value = it + } + SectionTextFooter(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = true) + + SectionView(stringResource(MR.strings.network_proxy_auth).uppercase()) { + PreferenceToggle( + stringResource(MR.strings.network_proxy_random_credentials), + checked = proxyAuthRandomUnsaved.value, + onChange = { proxyAuthRandomUnsaved.value = it } + ) + if (!proxyAuthRandomUnsaved.value) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + DefaultConfigurableTextField( + usernameUnsaved, + stringResource(MR.strings.network_proxy_username), + modifier = Modifier.fillMaxWidth(), + isValid = ::validCredential, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + keyboardType = KeyboardType.Text, + ) + DefaultConfigurableTextField( + passwordUnsaved, + stringResource(MR.strings.network_proxy_password), + modifier = Modifier.fillMaxWidth(), + isValid = ::validCredential, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + keyboardType = KeyboardType.Password, + ) + } + } + SectionTextFooter(proxyAuthFooter(usernameUnsaved.value.text, passwordUnsaved.value.text, proxyAuthModeUnsaved.value, sessionMode)) + } + + SectionDividerSpaced(maxBottomPadding = false, maxTopPadding = true) SectionView { SectionItemView({ - 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)) + hostUnsaved.value = hostUnsaved.value.copy("localhost", TextRange(9)) + portUnsaved.value = portUnsaved.value.copy("9050", TextRange(4)) + usernameUnsaved.value = TextFieldValue() + passwordUnsaved.value = TextFieldValue() + proxyAuthRandomUnsaved.value = true + onionHosts.value = NetCfg.defaults.onionHosts }, disabled = resetDisabled) { Text(stringResource(MR.strings.network_options_reset_to_defaults), color = if (resetDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } SectionItemView( - click = { if (networkUseSocksProxy && !migration) showUpdateNetworkSettingsDialog { save() } else save() }, + click = { if (networkUseSocksProxy && !migration) showUpdateNetworkSettingsDialog { save(false) } else save(false) }, disabled = saveDisabled ) { Text(stringResource(if (networkUseSocksProxy && !migration) MR.strings.network_options_save_and_reconnect else MR.strings.network_options_save), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) @@ -305,6 +393,12 @@ fun SocksProxySettings( } } +private fun proxyAuthFooter(username: String, password: String, auth: NetworkProxyAuth, sessionMode: TransportSessionMode): String = when { + auth == NetworkProxyAuth.ISOLATE -> generalGetString(if (sessionMode == TransportSessionMode.User) MR.strings.network_proxy_auth_mode_isolate_by_auth_user else MR.strings.network_proxy_auth_mode_isolate_by_auth_entity) + username.isBlank() && password.isBlank() -> generalGetString(MR.strings.network_proxy_auth_mode_no_auth) + else -> generalGetString(MR.strings.network_proxy_auth_mode_username_password) +} + private fun showUnsavedSocksHostPortAlert(confirmText: String, save: () -> Unit, close: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.update_network_settings_question), @@ -319,7 +413,6 @@ private fun showUnsavedSocksHostPortAlert(confirmText: String, save: () -> Unit, fun UseOnionHosts( onionHosts: MutableState, enabled: State, - showModal: (@Composable ModalData.() -> Unit) -> Unit, useOnion: (OnionHosts) -> Unit, ) { val values = remember { @@ -331,36 +424,29 @@ fun UseOnionHosts( } } } - val onSelected = { - showModal { - ColumnWithScrollBar( - Modifier.fillMaxWidth(), - ) { - AppBarTitle(stringResource(MR.strings.network_use_onion_hosts)) - SectionViewSelectable(null, onionHosts, values, useOnion) - } - } - } - if (enabled.value) { - SectionItemWithValue( - generalGetString(MR.strings.network_use_onion_hosts), - onionHosts, - values, - icon = painterResource(MR.images.ic_security), - enabled = enabled, - onSelected = onSelected - ) - } else { - // In reality, when socks proxy is disabled, this option acts like NEVER regardless of what was chosen before - SectionItemWithValue( - generalGetString(MR.strings.network_use_onion_hosts), - remember { mutableStateOf(OnionHosts.NEVER) }, - listOf(ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_no_desc)))), - icon = painterResource(MR.images.ic_security), - enabled = enabled, - onSelected = {} - ) + Column { + if (enabled.value) { + ExposedDropDownSettingRow( + generalGetString(MR.strings.network_use_onion_hosts), + values.map { it.value to it.title }, + onionHosts, + icon = painterResource(MR.images.ic_security), + enabled = enabled, + onSelected = useOnion + ) + } else { + // In reality, when socks proxy is disabled, this option acts like NEVER regardless of what was chosen before + ExposedDropDownSettingRow( + generalGetString(MR.strings.network_use_onion_hosts), + listOf(OnionHosts.NEVER to generalGetString(MR.strings.network_use_onion_hosts_no)), + remember { mutableStateOf(OnionHosts.NEVER) }, + icon = painterResource(MR.images.ic_security), + enabled = enabled, + onSelected = {} + ) + } + SectionTextFooter(values.first { it.value == onionHosts.value }.description) } } @@ -398,12 +484,8 @@ fun SessionModePicker( ) } -// 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) -} +private fun validHost(s: String): Boolean = + !s.contains('@') // https://ihateregex.io/expr/port/ fun validPort(s: String): Boolean { @@ -411,6 +493,16 @@ fun validPort(s: String): Boolean { return s.isNotBlank() && s.matches(validPort) } +private fun validCredential(s: String): Boolean = + !s.contains(':') && !s.contains('@') + +fun showWrongProxyConfigAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.network_proxy_incorrect_config_title), + text = generalGetString(MR.strings.network_proxy_incorrect_config_desc), + ) +} + fun showUpdateNetworkSettingsDialog( title: String, startsWith: String = "", @@ -435,6 +527,7 @@ fun PreviewNetworkAndServersLayout() { NetworkAndServersLayout( currentRemoteHost = null, networkUseSocksProxy = remember { mutableStateOf(true) }, + onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, toggleSocksProxy = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b81f733cd1..7ebfb7ce54 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -765,7 +765,17 @@ SOCKS proxy SOCKS proxy settings Use SOCKS proxy + Proxy authentication + Use random credentials + Use different proxy credentials for each profile. + Use different proxy credentials for each connection. + Do not use credentials with proxy. + Your credentials may be sent unencrypted. + Username + Password port %d + Error saving proxy + Make sure proxy configuration is correct. Host Port Use SOCKS proxy?