Merge pull request #2205 from vitorpamplona/claude/review-webrtc-calls-0k3G4
Add call settings screen for video quality and TURN server configuration
This commit is contained in:
@@ -200,6 +200,9 @@ class AccountSettings(
|
||||
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
|
||||
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
|
||||
var callTurnServers: List<CallTurnServer> = 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<CallTurnServer>) {
|
||||
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"),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TurnServerConfig> = emptyList()): List<PeerConnection.IceServer> {
|
||||
fun buildIceServers(userTurnServers: List<CallTurnServer> = emptyList()): List<PeerConnection.IceServer> {
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
|
||||
|
||||
@@ -129,6 +129,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object ReactionsSettings : Route()
|
||||
|
||||
@Serializable object CallSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+380
@@ -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<CallTurnServer>,
|
||||
onServersChanged: (List<CallTurnServer>) -> 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,6 +813,19 @@
|
||||
<string name="call_failed_start">Failed to start call</string>
|
||||
<string name="call_failed_accept">Failed to accept call</string>
|
||||
<string name="call_failed_session">Failed to create call session</string>
|
||||
<string name="call_settings">Call Settings</string>
|
||||
<string name="call_settings_video_quality">Video Quality</string>
|
||||
<string name="call_settings_max_bitrate">Max Video Bitrate</string>
|
||||
<string name="call_settings_turn_servers">TURN / STUN Servers</string>
|
||||
<string name="call_settings_turn_description">Default STUN and TURN servers are always included. Add custom TURN servers for restrictive networks.</string>
|
||||
<string name="call_settings_default_servers">Default servers (always active)</string>
|
||||
<string name="call_settings_custom_turn">Custom TURN Servers</string>
|
||||
<string name="call_settings_no_custom_turn">No custom TURN servers configured.</string>
|
||||
<string name="call_settings_add_turn">Add TURN server</string>
|
||||
<string name="call_settings_remove_turn">Remove</string>
|
||||
<string name="call_settings_turn_url">Server URL</string>
|
||||
<string name="call_settings_turn_username">Username</string>
|
||||
<string name="call_settings_turn_credential">Credential</string>
|
||||
|
||||
<string name="reply_notify">Notify: </string>
|
||||
|
||||
|
||||
+19
-9
@@ -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<HexKey>
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user