From a9ba16b07a550559bb258f6b13fd7b45aaaba6b0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 22 Sep 2022 11:16:25 +0300 Subject: [PATCH] android: Allow configuring WebRTC ICE servers (#1090) * Allow configuring WebRTC ICE servers * refactor Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 13 +- .../simplex/app/views/call/CallManager.kt | 9 +- .../chat/simplex/app/views/call/WebRTC.kt | 50 ++++- .../app/views/usersettings/CallSettings.kt | 12 +- .../views/usersettings/NetworkAndServers.kt | 6 +- .../app/views/usersettings/RTCSettings.kt | 211 ++++++++++++++++++ .../app/views/usersettings/SMPServers.kt | 4 +- .../app/views/usersettings/SettingsView.kt | 2 +- .../app/src/main/res/values-ru/strings.xml | 12 +- .../app/src/main/res/values/strings.xml | 12 +- 10 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/usersettings/RTCSettings.kt 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 8e5ad23e3a..18657e539c 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 @@ -87,6 +87,7 @@ class AppPreferences(val context: Context) { ) val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false) + val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null) val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) @@ -174,6 +175,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen" private const val SHARED_PREFS_PERFORM_LA = "PerformLA" private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown" + private const val SHARED_PREFS_WEBRTC_ICE_SERVERS = "WebrtcICEServers" private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" @@ -960,7 +962,16 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey) val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get() - chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, relay = useRelay) + val iceServers = getIceServers() + Log.d(TAG, ".callOffer iceServers $iceServers") + chatModel.callCommand.value = WCallCommand.Offer( + offer = r.offer.rtcSession, + iceCandidates = r.offer.rtcIceCandidates, + media = r.callType.media, + aesKey = r.sharedKey, + iceServers = iceServers, + relay = useRelay + ) } } is CR.CallAnswer -> { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt index 441b97d6bd..60f67b9337 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -51,7 +51,14 @@ class CallManager(val chatModel: ChatModel) { ) showCallView.value = true val useRelay = controller.appPrefs.webrtcPolicyRelay.get() - callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay) + val iceServers = getIceServers() + Log.d(TAG, "answerIncomingCall iceServers: $iceServers") + callCommand.value = WCallCommand.Start( + media = invitation.callType.media, + aesKey = invitation.sharedKey, + iceServers = iceServers, + relay = useRelay + ) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index bb079e8008..94ab0ad4c6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -3,11 +3,13 @@ package chat.simplex.app.views.call import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import chat.simplex.app.R +import chat.simplex.app.SimplexApp import chat.simplex.app.model.Contact import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.net.URI data class Call( val contact: Contact, @@ -115,7 +117,7 @@ sealed class WCallResponse { // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate @Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?) // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer -@Serializable class RTCIceServer(val urls: List, val username: String? = null, val credential: String? = null) +@Serializable data class RTCIceServer(val urls: List, val username: String? = null, val credential: String? = null) // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type @Serializable @@ -153,4 +155,48 @@ class ConnectionState( val iceConnectionState: String, val iceGatheringState: String, val signalingState: String -) \ No newline at end of file +) + +// the servers are expected in this format: +// stun:stun.simplex.im:443 +// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443 +fun parseRTCIceServer(str: String): RTCIceServer? { + var s = replaceScheme(str, "stun:") + s = replaceScheme(s, "turn:") + val u = runCatching { URI(s) }.getOrNull() + if (u != null) { + val scheme = u.scheme + val host = u.host + val port = u.port + if (u.path == "" && (scheme == "stun" || scheme == "turn")) { + val userInfo = u.userInfo?.split(":") + return RTCIceServer( + urls = listOf("$scheme:$host:$port"), + username = userInfo?.getOrNull(0), + credential = userInfo?.getOrNull(1) + ) + } + } + return null +} + +private fun replaceScheme(s: String, scheme: String): String = if (s.startsWith(scheme)) s.replace(scheme, "$scheme//") else s + +fun parseRTCIceServers(servers: List): List? { + val iceServers: ArrayList = ArrayList() + for (s in servers) { + val server = parseRTCIceServer(s) + if (server != null) { + iceServers.add(server) + } else { + return null + } + } + return if (iceServers.isEmpty()) null else iceServers +} + +fun getIceServers(): List? { + val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null + val servers: List = value.split("\n") + return parseRTCIceServers(servers) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 7535138ee7..915229db62 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -6,8 +6,6 @@ import SectionView import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Info import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,10 +17,13 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight @Composable -fun CallSettingsView(m: ChatModel) { +fun CallSettingsView(m: ChatModel, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), +) { CallSettingsLayout( webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay, - callOnLockScreen = m.controller.appPrefs.callOnLockScreen + callOnLockScreen = m.controller.appPrefs.callOnLockScreen, + editIceServers = showModal { RTCServersView(m) } ) } @@ -30,6 +31,7 @@ fun CallSettingsView(m: ChatModel) { fun CallSettingsLayout( webrtcPolicyRelay: Preference, callOnLockScreen: Preference, + editIceServers: () -> Unit, ) { Column( Modifier.fillMaxWidth(), @@ -58,6 +60,8 @@ fun CallSettingsLayout( SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT) } } + SectionDivider() + SectionItemView(editIceServers) { Text(stringResource(R.string.webrtc_ice_servers)) } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt index f15e1cc29b..8c93a13136 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt @@ -115,7 +115,7 @@ fun NetworkAndServersView( Modifier.padding(start = 16.dp, bottom = 24.dp), style = MaterialTheme.typography.h1 ) - SectionView { + SectionView(generalGetString(R.string.settings_section_title_calls)) { SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }) SectionDivider() SectionItemView { @@ -128,6 +128,10 @@ fun NetworkAndServersView( SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } } + Spacer(Modifier.height(8.dp)) + SectionView(generalGetString(R.string.settings_section_title_calls)) { + SettingsActionItem(Icons.Outlined.ElectricalServices, stringResource(R.string.webrtc_ice_servers), showModal { RTCServersView(it) }) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/RTCSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/RTCSettings.kt new file mode 100644 index 0000000000..f3042ba377 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/RTCSettings.kt @@ -0,0 +1,211 @@ +package chat.simplex.app.views.usersettings + +import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.views.call.parseRTCIceServers +import chat.simplex.app.views.helpers.* + +@Composable +fun RTCServersView( + chatModel: ChatModel +) { + var userRTCServers by remember { + mutableStateOf(chatModel.controller.appPrefs.webrtcIceServers.get()?.split("\n") ?: listOf()) + } + var isUserRTCServers by remember { mutableStateOf(userRTCServers.isNotEmpty()) } + var editRTCServers by remember { mutableStateOf(!isUserRTCServers) } + val userRTCServersStr = remember { mutableStateOf(if (isUserRTCServers) userRTCServers.joinToString(separator = "\n") else "") } + fun saveUserRTCServers() { + val srvs = userRTCServersStr.value.split("\n") + if (srvs.isNotEmpty() && srvs.toSet().size == srvs.size && parseRTCIceServers(srvs) != null) { + userRTCServers = srvs + chatModel.controller.appPrefs.webrtcIceServers.set(srvs.joinToString(separator = "\n")) + editRTCServers = false + } else { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.error_saving_ICE_servers), + generalGetString(R.string.ensure_ICE_server_address_are_correct_format_and_unique) + ) + } + } + + fun resetRTCServers() { + isUserRTCServers = false + userRTCServers = listOf() + chatModel.controller.appPrefs.webrtcIceServers.set(null) + } + + RTCServersLayout( + isUserRTCServers = isUserRTCServers, + editRTCServers = editRTCServers, + userRTCServersStr = userRTCServersStr, + isUserRTCServersOnOff = { switch -> + if (switch) { + isUserRTCServers = true + } else if (userRTCServers.isNotEmpty()) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.use_simplex_chat_servers__question), + text = generalGetString(R.string.saved_ICE_servers_will_be_removed), + confirmText = generalGetString(R.string.confirm_verb), + onConfirm = { + resetRTCServers() + isUserRTCServers = false + userRTCServersStr.value = "" + } + ) + } else { + isUserRTCServers = false + userRTCServersStr.value = "" + } + }, + cancelEdit = { + isUserRTCServers = userRTCServers.isNotEmpty() + editRTCServers = !isUserRTCServers + userRTCServersStr.value = if (isUserRTCServers) userRTCServers.joinToString(separator = "\n") else "" + }, + saveRTCServers = ::saveUserRTCServers, + editOn = { editRTCServers = true }, + ) +} + +@Composable +fun RTCServersLayout( + isUserRTCServers: Boolean, + editRTCServers: Boolean, + userRTCServersStr: MutableState, + isUserRTCServersOnOff: (Boolean) -> Unit, + cancelEdit: () -> Unit, + saveRTCServers: () -> Unit, + editOn: () -> Unit, +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.your_ICE_servers), + Modifier.padding(bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.configure_ICE_servers), Modifier.padding(end = 24.dp)) + Switch( + checked = isUserRTCServers, + onCheckedChange = isUserRTCServersOnOff, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + ) + } + + if (!isUserRTCServers) { + Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp) + } else { + Text(stringResource(R.string.enter_one_ICE_server_per_line)) + if (editRTCServers) { + TextEditor(Modifier.height(160.dp), text = userRTCServersStr) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(horizontalAlignment = Alignment.Start) { + Row { + Text( + stringResource(R.string.cancel_verb), + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable(onClick = cancelEdit) + ) + Spacer(Modifier.padding(horizontal = 8.dp)) + Text( + stringResource(R.string.save_servers_button), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable(onClick = { + saveRTCServers() + }) + ) + } + } + Column(horizontalAlignment = Alignment.End) { + howToButton() + } + } + } else { + Surface( + modifier = Modifier + .height(160.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + ) { + SelectionContainer( + Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + userRTCServersStr.value, + Modifier + .padding(vertical = 5.dp, horizontal = 7.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp), + ) + } + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(horizontalAlignment = Alignment.Start) { + Text( + stringResource(R.string.edit_verb), + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable(onClick = editOn) + ) + } + Column(horizontalAlignment = Alignment.End) { + howToButton() + } + } + } + } + } +} + +@Composable +private fun howToButton() { + val uriHandler = LocalUriHandler.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md#configure-mobile-apps") } + ) { + Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary) + Icon( + Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(horizontal = 5.dp) + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index eabb7da66f..0cba7c34cd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -58,7 +58,7 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers.isNotEmpty()) { AlertManager.shared.showAlertMsg( title = generalGetString(R.string.use_simplex_chat_servers__question), - text = generalGetString(R.string.saved_SMP_servers_will_br_removed), + text = generalGetString(R.string.saved_SMP_servers_will_be_removed), confirmText = generalGetString(R.string.confirm_verb), onConfirm = { saveSMPServers(listOf()) @@ -198,7 +198,7 @@ fun SMPServersLayout( } @Composable -fun howToButton() { +private fun howToButton() { val uriHandler = LocalUriHandler.current Row( verticalAlignment = Alignment.CenterVertically, 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 fd1e150a91..fb6bcb49ed 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 @@ -122,7 +122,7 @@ fun SettingsLayout( SectionView(stringResource(R.string.settings_section_title_settings)) { SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it, showCustomModal) }) SectionDivider() - SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped) + SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) SectionDivider() SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped) SectionDivider() 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 f61d65aa29..2880152488 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -327,12 +327,18 @@ SMP серверы SimpleX Chat для терминала Использовать серверы предосталенные SimpleX Chat? - Сохраненные SMP серверы будут удалены. + Сохраненные SMP серверы будут удалены. Ваши SMP серверы Настройка SMP серверов Используются серверы предоставленные SimpleX Chat. Введите SMP серверы, каждый сервер в отдельной строке: Инфо + Сохраненные WebRTC ICE серверы будут удалены. + Ваши ICE серверы + Настройка ICE серверов + ICE серверы (один на строке) + Ошибка при сохранении ICE серверов + Пожалуйста, проверьте, что адреса WebRTC ICE серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. Сохранить Сеть и серверы Настройки сети @@ -476,6 +482,8 @@ Принимать Показывать Выключить + Ваши ICE серверы + WebRTC ICE серверы Откройте SimpleX Chat\nчтобы принять звонок @@ -533,6 +541,8 @@ SOCKS ПРОКСИ ИКОНКА ТЕМЫ + СООБЩЕНИЯ + ЗВОНКИ Режим Инкогнито diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 999d7cc274..66ecc72fa6 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -331,12 +331,18 @@ SMP servers Install SimpleX Chat for terminal Use SimpleX Chat servers? - Saved SMP servers will be removed. + Saved SMP servers will be removed. Your SMP servers Configure SMP servers Using SimpleX Chat servers. Enter one SMP server per line: How to + Saved WebRTC ICE servers will be removed. + Your ICE servers + Configure ICE servers + ICE servers (one per line) + Error saving ICE servers + Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. Save Network & servers Advanced network settings @@ -477,6 +483,8 @@ Accept Show Disable + Your ICE servers + WebRTC ICE servers Open SimpleX Chat to accept call @@ -534,6 +542,8 @@ SOCKS PROXY APP ICON THEMES + MESSAGES + CALLS Incognito mode