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:
Vitor Pamplona
2026-04-10 15:17:58 -04:00
committed by GitHub
12 changed files with 521 additions and 24 deletions
@@ -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(
@@ -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
@@ -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,
@@ -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))
}
}
}
}
+13
View File
@@ -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>
@@ -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) }
}
}
}