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 c62cf8ee04..e100807b0a 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 @@ -332,6 +332,42 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } + suspend fun getNetworkConfig(): NetCfg? { + val r = sendCmd(CC.APIGetNetworkConfig()) + if (r is CR.NetworkConfig) return r.networkConfig + Log.e(TAG, "getNetworkConfig bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun setNetworkConfig(cfg: NetCfg): Boolean { + val r = sendCmd(CC.APISetNetworkConfig(cfg)) + return when (r) { + is CR.CmdOk -> true + else -> { + Log.e(TAG, "setNetworkConfig bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg( + generalGetString(R.string.error_setting_network_config), + "${r.responseType}: ${r.details}" + ) + false + } + } + } + + suspend fun apiContactInfo(contactId: Long): ConnectionStats? { + val r = sendCmd(CC.APIContactInfo(contactId)) + if (r is CR.ContactInfo) return r.connectionStats + Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? { + val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId)) + if (r is CR.GroupMemberInfo) return r.connectionStats_ + Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiAddContact(): String? { val r = sendCmd(CC.AddContact()) if (r is CR.Invitation) return r.connReqInvitation @@ -941,8 +977,12 @@ sealed class CC { class ApiRemoveMember(val groupId: Long, val memberId: Long): CC() class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() - class GetUserSMPServers(): CC() + class GetUserSMPServers: CC() class SetUserSMPServers(val smpServers: List): CC() + class APISetNetworkConfig(val networkConfig: NetCfg): CC() + class APIGetNetworkConfig: CC() + class APIContactInfo(val contactId: Long): CC() + class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class AddContact: CC() class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() @@ -987,6 +1027,10 @@ sealed class CC { is ApiListMembers -> "/_members #$groupId" is GetUserSMPServers -> "/smp_servers" is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" + is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" + is APIGetNetworkConfig -> "/network" + is APIContactInfo -> "/_info @$contactId" + is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is AddContact -> "/connect" is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" @@ -1032,6 +1076,10 @@ sealed class CC { is ApiListMembers -> "apiListMembers" is GetUserSMPServers -> "getUserSMPServers" is SetUserSMPServers -> "setUserSMPServers" + is APISetNetworkConfig -> "/apiSetNetworkConfig" + is APIGetNetworkConfig -> "/apiGetNetworkConfig" + is APIContactInfo -> "apiContactInfo" + is APIGroupMemberInfo -> "apiGroupMemberInfo" is AddContact -> "addContact" is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" @@ -1069,6 +1117,9 @@ class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgCon @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) +@Serializable +class NetCfg(val socksProxy: String? = null, val tcpTimeout: Int) + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -1106,6 +1157,9 @@ sealed class CR { @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() + @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() + @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() @Serializable @SerialName("sentInvitation") class SentInvitation: CR() @@ -1187,6 +1241,9 @@ sealed class CR { is ApiChats -> "apiChats" is ApiChat -> "apiChat" is UserSMPServers -> "userSMPServers" + is NetworkConfig -> "networkConfig" + is ContactInfo -> "contactInfo" + is GroupMemberInfo -> "groupMemberInfo" is Invitation -> "invitation" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" @@ -1266,6 +1323,9 @@ sealed class CR { is ApiChats -> json.encodeToString(chats) is ApiChat -> json.encodeToString(chat) is UserSMPServers -> json.encodeToString(smpServers) + is NetworkConfig -> json.encodeToString(networkConfig) + is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}" + is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}" is Invitation -> connReqInvitation is SentConfirmation -> noDetails() is SentInvitation -> noDetails() @@ -1367,6 +1427,9 @@ abstract class TerminalItem { } } +@Serializable +class ConnectionStats(val rcvServers: List?, val sndServers: List?) + @Serializable sealed class ChatError { val string: String get() = when (this) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index d145e88fc5..9f344b2677 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -22,12 +22,13 @@ import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @Composable -fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) { +fun ChatInfoView(chatModel: ChatModel, connStats: ConnectionStats?, close: () -> Unit) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } if (chat != null) { ChatInfoLayout( chat, + connStats, close = close, deleteContact = { deleteChatDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) } @@ -87,6 +88,7 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel) { @Composable fun ChatInfoLayout( chat: Chat, + connStats: ConnectionStats?, close: () -> Unit, deleteContact: () -> Unit, clearChat: () -> Unit @@ -135,6 +137,10 @@ fun ChatInfoLayout( .padding(top = 16.dp) .padding(horizontal = 16.dp) ) + if (connStats != null) { + SimplexServers("receiving via: ", connStats.rcvServers) + SimplexServers("sending via: ", connStats.sndServers) + } } Spacer(Modifier.weight(1F)) @@ -178,6 +184,14 @@ fun ChatInfoLayout( } } +@Composable +fun SimplexServers(text: String, servers: List?) { + if (servers != null) { + val info = text + servers.joinToString(separator = ", ") { it.substringAfter("@") } + Text(info, style = MaterialTheme.typography.body2) + } +} + @Composable fun ServerImage(chat: Chat) { when (chat.serverInfo.networkStatus) { @@ -201,6 +215,7 @@ fun PreviewChatInfoLayout() { chatItems = arrayListOf(), serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) ), + connStats = null, close = {}, deleteContact = {}, clearChat = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 0c151ec688..05f0362bbd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -88,7 +88,16 @@ fun ChatView(chatModel: ChatModel) { chatModel.chatItems, useLinkPreviews = useLinkPreviews, back = { chatModel.chatId.value = null }, - info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, + info = { + withApi { + var connStats: ConnectionStats? = null + val cInfo = chat.chatInfo + if (cInfo is ChatInfo.Direct) { + connStats = chatModel.controller.apiContactInfo(cInfo.apiId) + } + ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, connStats, close) } + } + }, openDirectChat = { contactId -> val c = chatModel.chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkSettings.kt new file mode 100644 index 0000000000..c22efdfbe6 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkSettings.kt @@ -0,0 +1,97 @@ +package chat.simplex.app.views.usersettings + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.NetCfg +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* + +@Composable +fun NetworkSettingsView(chatModel: ChatModel, netCfg: NetCfg) { + val useSocksProxy = remember { mutableStateOf(netCfg.socksProxy != null) } + + NetworkSettingsLayout( + useSocksProxy, + toggleSocksProxy = { enable -> + if (enable) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.network_enable_socks), + text = generalGetString(R.string.network_enable_socks_info), + confirmText = generalGetString(R.string.confirm_verb), + onConfirm = { + withApi { + chatModel.controller.setNetworkConfig(NetCfg(socksProxy = ":9050", tcpTimeout = 10_000_000)) + useSocksProxy.value = true + } + } + ) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.network_disable_socks), + text = generalGetString(R.string.network_disable_socks_info), + confirmText = generalGetString(R.string.confirm_verb), + onConfirm = { + withApi { + chatModel.controller.setNetworkConfig(NetCfg(tcpTimeout = 5_000_000)) + useSocksProxy.value = false + } + } + ) + } + } + ) +} + +@Composable fun NetworkSettingsLayout( + useSocksProxy: MutableState, + toggleSocksProxy: (Boolean) -> Unit +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.network_settings_title), + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + SettingsSectionView(stringResource(R.string.settings_section_title_socks)) { + Row( + Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.network_socks_toggle)) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = useSocksProxy.value, + onCheckedChange = toggleSocksProxy, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewNetworkSettings() { + SimpleXTheme { + NetworkSettingsLayout( + useSocksProxy = remember { mutableStateOf(true) }, + toggleSocksProxy = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 6e42d149ed..d269455cab 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -57,7 +57,20 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { } } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, - showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } } + showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }, + showNetworkSettings = { + withApi { + val cfg = chatModel.controller.getNetworkConfig() + if (cfg != null) { + ModalManager.shared.showCustomModal { close -> + ModalView(close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { + NetworkSettingsView(chatModel, cfg) + } + } + } + } + } // showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } }, ) } @@ -77,6 +90,7 @@ fun SettingsLayout( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showTerminal: () -> Unit, + showNetworkSettings: () -> Unit // showVideoChatPrototype: () -> Unit ) { val uriHandler = LocalUriHandler.current @@ -115,6 +129,8 @@ fun SettingsLayout( PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped) divider() SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped) + divider() + SettingsActionItem(Icons.Outlined.SettingsEthernet, stringResource(R.string.network_settings), showNetworkSettings, disabled = stopped) } spacer() @@ -349,6 +365,7 @@ fun PreviewSettingsLayout() { showSettingsModal = { {} }, showCustomModal = { {} }, showTerminal = {}, + showNetworkSettings = {} // showVideoChatPrototype = {} ) } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 25e6e7ebf5..bad98fc2dc 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -33,9 +33,10 @@ через ссылку-контакт через одноразовую ссылку - + Ошибка при сохранении SMP серверов Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + Ошибка при сохранении настроек сети Существующий контакт @@ -265,6 +266,13 @@ Введите SMP серверы, каждый сервер в отдельной строке: Инфо Сохранить + Настройки сети + Настройки сети + Использовать SOCKS прокси (порт 9050) + Использовать SOCKS прокси? + Соединяться с серверами через SOCKS прокси через порт 9050? Прокси должен быть запущен до включения этой опции. + Использовать прямое соединение с Интернет? + Если вы подтвердите, серверы смогут видеть ваш IP адрес, а провайдер - с какими серверами вы соединяетесь. Создать адрес @@ -441,6 +449,7 @@ УСТРОЙСТВО ЧАТЫ Экспериментальные функции + SOCKS ПРОКСИ Данные чата diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index ef6d0abcac..28abc66a00 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -33,9 +33,10 @@ via contact address link via one-time link - + Error saving SMP servers Make sure SMP server addresses are in correct format, line separated and are not duplicated. + Error updating network configuration Contact already exists @@ -271,6 +272,13 @@ Enter one SMP server per line: How to Save + Network + Network settings + Use SOCKS proxy (port 9050) + Use SOCKS proxy? + Access the servers via SOCKS proxy on port 9050? Proxy must be started before enabling this option. + Use direct Internet connection? + If you confirm, the messaging servers will be able to see your IP address, and your provider - which servers you are connecting to. Create address @@ -443,6 +451,7 @@ DEVICE CHATS Experimental features + SOCKS PROXY Your chat database diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 1891d39719..af6785b66a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -290,6 +290,28 @@ func setUserSMPServers(smpServers: [String]) async throws { try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) } +func getNetworkConfig() async throws -> NetCfg? { + let r = await chatSendCmd(.apiGetNetworkConfig) + if case let .networkConfig(cfg) = r { return cfg } + throw r +} + +func setNetworkConfig(cfg: NetCfg) async throws { + try await sendCommandOkResp(.apiSetNetworkConfig(networkConfig: cfg)) +} + +func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? { + let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) + if case let .contactInfo(_, connStats) = r { return connStats } + throw r +} + +func apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) async throws -> ConnectionStats? { + let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) + if case let .groupMemberInfo(_, _, connStats_) = r { return connStats_ } + throw r +} + func apiAddContact() throws -> String { let r = chatSendCmdSync(.addContact, bgTask: false) if case let .invitation(connReqInvitation) = r { return connReqInvitation } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 8a0b71aa73..40cb1246f4 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -42,6 +42,10 @@ public enum ChatCommand { case apiListMembers(groupId: Int64) case getUserSMPServers case setUserSMPServers(smpServers: [String]) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiGetNetworkConfig + case apiContactInfo(contactId: Int64) + case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case addContact case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) @@ -98,6 +102,10 @@ public enum ChatCommand { case let .apiListMembers(groupId): return "/_members #\(groupId)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" + case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" + case .apiGetNetworkConfig: return "/network" + case let .apiContactInfo(contactId): return "/_info @\(contactId)" + case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case .addContact: return "/connect" case let .connect(connReq): return "/connect \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" @@ -154,6 +162,10 @@ public enum ChatCommand { case .apiListMembers: return "apiListMembers" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" + case .apiSetNetworkConfig: return "apiSetNetworkConfig" + case .apiGetNetworkConfig: return "apiGetNetworkConfig" + case .apiContactInfo: return "apiContactInfo" + case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .addContact: return "addContact" case .connect: return "connect" case .apiDeleteChat: return "apiDeleteChat" @@ -202,6 +214,9 @@ public enum ChatResponse: Decodable, Error { case apiChats(chats: [ChatData]) case apiChat(chat: ChatData) case userSMPServers(smpServers: [String]) + case networkConfig(networkConfig: NetCfg) + case contactInfo(contact: Contact, connectionStats: ConnectionStats) + case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) case invitation(connReqInvitation: String) case sentConfirmation case sentInvitation @@ -288,6 +303,9 @@ public enum ChatResponse: Decodable, Error { case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .userSMPServers: return "userSMPServers" + case .networkConfig: return "networkConfig" + case .contactInfo: return "contactInfo" + case .groupMemberInfo: return "groupMemberInfo" case .invitation: return "invitation" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" @@ -374,6 +392,9 @@ public enum ChatResponse: Decodable, Error { case let .apiChats(chats): return String(describing: chats) case let .apiChat(chat): return String(describing: chat) case let .userSMPServers(smpServers): return String(describing: smpServers) + case let .networkConfig(networkConfig): return String(describing: networkConfig) + case let .contactInfo(contact, connectionStats): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))" + case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\\nconnectionStats_: \(String(describing: connectionStats_))" case let .invitation(connReqInvitation): return connReqInvitation case .sentConfirmation: return noDetails case .sentInvitation: return noDetails @@ -467,6 +488,16 @@ public struct ArchiveConfig: Encodable { } } +public struct NetCfg: Codable { + var socksProxy: String? = nil + var tcpTimeout: Int +} + +public struct ConnectionStats: Codable { + var rcvServers: [String]? + var sndServers: [String]? +} + public protocol SelectableItem: Hashable, Identifiable { var label: LocalizedStringKey { get } static var values: [Self] { get }