From cd573a583cf1e796cd5057342de5126c7b3b4bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 17:18:25 +0000 Subject: [PATCH] fix: WebRTC call bugs and add Call Settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - Fix invitePeer() bypassing CallManager state tracking, causing invited peers to not appear in pendingPeerPubKeys - Remove 10-minute proximity wake lock timeout so it lasts the full call duration (released on cleanup) - Send hangup to peers on caller timeout so callees stop ringing immediately instead of waiting for their own 60s timeout - Remove duplicate cleanup() call on Ended→Idle transition New feature: - Add Call Settings screen (TURN servers + video quality) - Users can configure custom TURN servers for restrictive networks - Default STUN/TURN servers are always active and displayed - Video resolution options: 480p, 720p (default), 1080p - Configurable max video bitrate: 750kbps, 1.5Mbps, 3Mbps - Settings wired into IceServerConfig and CallMediaManager https://claude.ai/code/session_01F5RF2yzngiMr1v2gr7f1GP --- .../amethyst/model/AccountSettings.kt | 41 ++ .../amethyst/service/call/CallAudioManager.kt | 5 +- .../amethyst/service/call/CallController.kt | 37 +- .../amethyst/service/call/CallMediaManager.kt | 19 +- .../amethyst/service/call/IceServerConfig.kt | 9 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 1 + .../loggedIn/settings/AllSettingsScreen.kt | 8 + .../loggedIn/settings/CallSettingsScreen.kt | 380 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 13 + .../amethyst/commons/call/CallManager.kt | 28 +- 12 files changed, 521 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 90d0542ad..f6b681b61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -200,6 +200,9 @@ class AccountSettings( val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, + var callTurnServers: List = emptyList(), + var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, + var callMaxBitrateBps: Int = 1_500_000, ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -920,4 +923,42 @@ class AccountSettings( } else { false } + + // --- + // Call settings + // --- + + fun changeCallTurnServers(servers: List) { + callTurnServers = servers + saveAccountSettings() + } + + fun changeCallVideoResolution(resolution: CallVideoResolution) { + callVideoResolution = resolution + saveAccountSettings() + } + + fun changeCallMaxBitrateBps(bitrate: Int) { + callMaxBitrateBps = bitrate + saveAccountSettings() + } +} + +@Serializable +data class CallTurnServer( + val url: String, + val username: String, + val credential: String, +) + +@Serializable +enum class CallVideoResolution( + val width: Int, + val height: Int, + val fps: Int, + val label: String, +) { + SD_480(640, 480, 30, "480p"), + HD_720(1280, 720, 30, "720p (default)"), + FHD_1080(1920, 1080, 30, "1080p"), } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index 75c02967d..0d3b896fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -184,7 +184,10 @@ class CallAudioManager( PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "amethyst:call_proximity", ) - proximityWakeLock?.acquire(10 * 60 * 1000L) + // No timeout — the wake lock is held for the entire call duration + // and released in releaseProximityWakeLock() during cleanup. + @Suppress("WakelockTimeout") + proximityWakeLock?.acquire() registerProximitySensor() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index a2c711098..359ba5331 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -51,7 +51,7 @@ import org.webrtc.VideoTrack import java.util.UUID private const val TAG = "CallController" -private const val VIDEO_MAX_BITRATE_BPS = 1_500_000 +private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000 class CallController( private val context: Context, @@ -60,6 +60,7 @@ class CallController( private val publishWrap: suspend (EphemeralGiftWrapEvent) -> Unit, private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, localPubKey: HexKey, + private val settingsProvider: () -> com.vitorpamplona.amethyst.model.AccountSettings, ) { private var peerSessionMgr = PeerSessionManager(localPubKey) @@ -132,7 +133,9 @@ class CallController( } is CallState.Idle -> { - cleanup() + // No cleanup here — Ended already handled it. + // Ended auto-resets to Idle after 2 seconds; + // running cleanup twice is wasteful and logs errors. } } } @@ -154,6 +157,12 @@ class CallController( } } + private fun applyCallSettings() { + val settings = settingsProvider() + val resolution = settings.callVideoResolution + mediaManager.setCaptureResolution(resolution.width, resolution.height, resolution.fps) + } + // ---- Call initiation (caller side) ---- fun initiateGroupCall( @@ -165,6 +174,7 @@ class CallController( val callId = UUID.randomUUID().toString() _errorMessage.value = null + applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(callType) } } catch (e: Exception) { @@ -202,6 +212,7 @@ class CallController( scope.launch { _errorMessage.value = null + applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) } } catch (e: Exception) { @@ -377,7 +388,7 @@ class CallController( mediaManager.createVideoResources() peerSessionMgr.allSessionKeys().forEach { key -> webRtcSession(key)?.let { session -> - mediaManager.localVideoTrack?.let { track -> session.addTrack(track, VIDEO_MAX_BITRATE_BPS) } + mediaManager.localVideoTrack?.let { track -> session.addTrack(track, settingsProvider().callMaxBitrateBps) } } } } else { @@ -395,7 +406,21 @@ class CallController( fun getEglBase(): EglBase? = mediaManager.sharedEglBase fun invitePeer(peerPubKey: String) { - scope.launch { createAndOfferToPeer(peerPubKey) } + scope.launch { + if (mediaManager.peerConnectionFactory == null) return@launch + val webRtcSession = + try { + withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } + } catch (e: Exception) { + Log.e(TAG, "Failed to create PeerConnection for invite ${peerPubKey.take(8)}", e) + return@launch + } + val adapter = WebRtcPeerSessionAdapter(webRtcSession) + peerSessionMgr.registerSession(peerPubKey, adapter) + webRtcSession.createOffer { sdp -> + scope.launch { callManager.invitePeer(peerPubKey, sdp.description) } + } + } } fun hangup() { @@ -413,7 +438,7 @@ class CallController( val session = WebRtcCallSession( peerConnectionFactory = factory, - iceServers = IceServerConfig.buildIceServers(), + iceServers = IceServerConfig.buildIceServers(settingsProvider().callTurnServers), onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) }, onPeerConnected = { Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" } @@ -437,7 +462,7 @@ class CallController( throw e } mediaManager.localAudioTrack?.let { session.addTrack(it) } - mediaManager.localVideoTrack?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) } + mediaManager.localVideoTrack?.let { session.addTrack(it, settingsProvider().callMaxBitrateBps) } return session } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt index 70477bfba..275bfd273 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt @@ -124,6 +124,23 @@ class CallMediaManager( startCamera() } + var captureWidth: Int = 1280 + private set + var captureHeight: Int = 720 + private set + var captureFps: Int = 30 + private set + + fun setCaptureResolution( + width: Int, + height: Int, + fps: Int, + ) { + captureWidth = width + captureHeight = height + captureFps = fps + } + fun startCamera() { if (cameraCapturer != null) return val source = localVideoSource ?: return @@ -138,7 +155,7 @@ class CallMediaManager( cameraCapturer = enumerator.createCapturer(camera, null)?.also { it.initialize(helper, context, source.capturerObserver) - it.startCapture(1280, 720, 30) + it.startCapture(captureWidth, captureHeight, captureFps) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt index 402a1bf7d..564d50fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.call +import com.vitorpamplona.amethyst.model.CallTurnServer import org.webrtc.PeerConnection object IceServerConfig { @@ -49,7 +50,7 @@ object IceServerConfig { .createIceServer(), ) - fun buildIceServers(userTurnServers: List = emptyList()): List { + fun buildIceServers(userTurnServers: List = emptyList()): List { val servers = (defaultStunServers + defaultTurnServers).toMutableList() userTurnServers.forEach { turn -> servers.add( @@ -63,9 +64,3 @@ object IceServerConfig { return servers } } - -data class TurnServerConfig( - val url: String, - val username: String, - val credential: String, -) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 083aff9ea..9948892b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -133,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVani import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen @@ -262,6 +263,7 @@ fun BuildNavigation( composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9c1d5e497..149a41907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -129,6 +129,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object CallSettings : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3133a092e..c12b0da33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -219,6 +219,7 @@ class AccountViewModel( publishWrap = { wrap -> account.publishCallSignaling(wrap) }, signerProvider = { account.signer }, localPubKey = account.signer.pubKey, + settingsProvider = { account.settings }, ) // Set callbacks before exposing controller to avoid timing races diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index c501ac432..7d0796c8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Settings @@ -142,6 +143,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.SecurityFilters) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.call_settings, + icon = Icons.Outlined.Phone, + tint = tint, + onClick = { nav.nav(Route.CallSettings) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.translations, icon = Icons.Outlined.Translate, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt new file mode 100644 index 000000000..a8b638f3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.CallTurnServer +import com.vitorpamplona.amethyst.model.CallVideoResolution +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun CallSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.call_settings), nav::popBack) + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + CallSettingsContent(accountViewModel) + } + } +} + +@Composable +private fun CallSettingsContent(accountViewModel: AccountViewModel) { + val settings = accountViewModel.account.settings + + SectionHeader(stringRes(R.string.call_settings_video_quality)) + VideoResolutionSection( + currentResolution = settings.callVideoResolution, + onResolutionChanged = { settings.changeCallVideoResolution(it) }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitrateSection( + currentBitrate = settings.callMaxBitrateBps, + onBitrateChanged = { settings.changeCallMaxBitrateBps(it) }, + ) + + HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp)) + + SectionHeader(stringRes(R.string.call_settings_turn_servers)) + Text( + text = stringRes(R.string.call_settings_turn_description), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + ) + DefaultTurnServersSection() + + Spacer(modifier = Modifier.height(8.dp)) + + CustomTurnServersSection( + servers = settings.callTurnServers, + onServersChanged = { settings.changeCallTurnServers(it) }, + ) + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun VideoResolutionSection( + currentResolution: CallVideoResolution, + onResolutionChanged: (CallVideoResolution) -> Unit, +) { + Column(modifier = Modifier.padding(horizontal = 24.dp)) { + CallVideoResolution.entries.forEach { resolution -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + RadioButton( + selected = currentResolution == resolution, + onClick = { onResolutionChanged(resolution) }, + ) + Text( + text = resolution.label, + fontSize = 16.sp, + modifier = Modifier.padding(start = 8.dp), + ) + Text( + text = "${resolution.width}x${resolution.height} @ ${resolution.fps}fps", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +@Composable +private fun BitrateSection( + currentBitrate: Int, + onBitrateChanged: (Int) -> Unit, +) { + val bitrateOptions = + listOf( + 750_000 to "750 kbps", + 1_500_000 to "1.5 Mbps (default)", + 3_000_000 to "3 Mbps", + ) + + Column(modifier = Modifier.padding(horizontal = 24.dp)) { + Text( + text = stringRes(R.string.call_settings_max_bitrate), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp), + ) + bitrateOptions.forEach { (bitrate, label) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + RadioButton( + selected = currentBitrate == bitrate, + onClick = { onBitrateChanged(bitrate) }, + ) + Text( + text = label, + fontSize = 16.sp, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +@Composable +private fun DefaultTurnServersSection() { + val defaults = + listOf( + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun.cloudflare.com:3478", + "turn:openrelay.metered.ca:80", + "turn:openrelay.metered.ca:443", + "turn:openrelay.metered.ca:443?transport=tcp", + ) + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) { + Text( + text = stringRes(R.string.call_settings_default_servers), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp), + ) + defaults.forEach { server -> + Text( + text = server, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } +} + +@Composable +private fun CustomTurnServersSection( + servers: List, + onServersChanged: (List) -> Unit, +) { + val editableServers = remember(servers) { mutableStateListOf(*servers.toTypedArray()) } + var showAddForm by remember { mutableStateOf(false) } + + Column(modifier = Modifier.padding(horizontal = 24.dp)) { + Text( + text = stringRes(R.string.call_settings_custom_turn), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + editableServers.forEachIndexed { index, server -> + TurnServerRow( + server = server, + onDelete = { + editableServers.removeAt(index) + onServersChanged(editableServers.toList()) + }, + ) + if (index < editableServers.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + + if (editableServers.isEmpty() && !showAddForm) { + Text( + text = stringRes(R.string.call_settings_no_custom_turn), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (showAddForm) { + AddTurnServerForm( + onAdd = { server -> + editableServers.add(server) + onServersChanged(editableServers.toList()) + showAddForm = false + }, + onCancel = { showAddForm = false }, + ) + } else { + Button( + onClick = { showAddForm = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.call_settings_add_turn)) + } + } + } +} + +@Composable +private fun TurnServerRow( + server: CallTurnServer, + onDelete: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = server.url, fontSize = 14.sp) + Text( + text = "${server.username} / ****", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Outlined.Delete, + contentDescription = stringRes(R.string.call_settings_remove_turn), + tint = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun AddTurnServerForm( + onAdd: (CallTurnServer) -> Unit, + onCancel: () -> Unit, +) { + var url by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var credential by remember { mutableStateOf("") } + + Column { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringRes(R.string.call_settings_turn_url)) }, + placeholder = { Text("turn:your-server.com:443") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringRes(R.string.call_settings_turn_username)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = credential, + onValueChange = { credential = it }, + label = { Text(stringRes(R.string.call_settings_turn_credential)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = onCancel, + colors = ButtonDefaults.textButtonColors(), + ) { + Text(stringRes(R.string.cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (url.isNotBlank()) { + onAdd(CallTurnServer(url.trim(), username.trim(), credential.trim())) + } + }, + enabled = url.isNotBlank(), + ) { + Text(stringRes(R.string.call_settings_add_turn)) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5f0ddb56e..df1a7cd93 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -813,6 +813,19 @@ Failed to start call Failed to accept call Failed to create call session + Call Settings + Video Quality + Max Video Bitrate + TURN / STUN Servers + Default STUN and TURN servers are always included. Add custom TURN servers for restrictive networks. + Default servers (always active) + Custom TURN Servers + No custom TURN servers configured. + Add TURN server + Remove + Server URL + Username + Credential Notify: diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 9593942b7..0616cf3ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -765,22 +765,32 @@ class CallManager( timeoutJob = scope.launch { delay(CALL_TIMEOUT_MS) - val current = _state.value - val currentCallId = - when (current) { - is CallState.Offering -> current.callId - is CallState.IncomingCall -> current.callId - else -> null - } - if (currentCallId == callId) { - val peerPubKeys = + val peerPubKeys: Set + val wasOffering: Boolean + stateMutex.withLock { + val current = _state.value + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.IncomingCall -> current.callId + else -> null + } + if (currentCallId != callId) return@launch + peerPubKeys = when (current) { is CallState.Offering -> current.peerPubKeys is CallState.IncomingCall -> current.peerPubKeys() else -> return@launch } + wasOffering = current is CallState.Offering transitionToEnded(callId, peerPubKeys, EndReason.TIMEOUT) } + // Notify peers so their phones stop ringing immediately + // instead of waiting for their own 60-second timeout. + if (wasOffering && peerPubKeys.isNotEmpty()) { + val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer) + result.wraps.forEach { publishEvent(it) } + } } }