fix: WebRTC call bugs, hardening, camera switch, and network resilience

Bug fixes:
- Fix RemoteVideoMonitor killing group monitor job when primary track switches
- Add mutex protection to CallManager.initiateCall() to prevent state races
- Fix ICE restart offer never being sent to remote peer (was immediately
  replaced by a second offer from onRenegotiationNeeded)
- Fix duplicate duration timer in PiP connected call UI
- Fix error snackbar dismiss button not clearing the error
- Make PeerSessionManager thread-safe with synchronized blocks (accessed
  from WebRTC native threads and coroutine dispatchers concurrently)
- Make CallManager event handlers private (only called from onSignalingEvent)

Improvements:
- Replace fragile ICE candidate regex parsing with kotlinx.serialization JSON
- Respect DND/silent mode: only ring in NORMAL mode, only vibrate in VIBRATE
- Signal camera-off to remote peer by removing video track sender (instead
  of sending frozen/black frame)
- Clear CallSessionBridge on AccountViewModel.onCleared() to prevent stale
  references on account switch
- Custom TURN servers now replace defaults (instead of appending) so
  credentials can be rotated without an app update

New features:
- Front/back camera switch button (visible when video is enabled)
- Network transition handling: ConnectivityManager.NetworkCallback triggers
  ICE restart on all peers when network changes (WiFi/cellular handoff)

https://claude.ai/code/session_01JHn7skAibTrkVqsoWutgYe
This commit is contained in:
Claude
2026-04-10 22:37:43 +00:00
parent 374bcb96cf
commit 865c71e0e2
14 changed files with 302 additions and 101 deletions
@@ -97,9 +97,14 @@ class CallAudioManager(
}
fun startRinging() {
val ringerMode = audioManager.ringerMode
if (ringerMode != AudioManager.RINGER_MODE_SILENT) {
if (ringerMode == AudioManager.RINGER_MODE_NORMAL) {
startRingtone()
}
startVibration()
}
}
fun stopRinging() {
stopRingtone()
@@ -22,6 +22,10 @@ package com.vitorpamplona.amethyst.service.call
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
@@ -87,11 +91,31 @@ class CallController(
private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
val isVideoEnabled: StateFlow<Boolean> = mediaManager.isVideoEnabled
val isFrontCamera: StateFlow<Boolean> = mediaManager.isFrontCamera
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
private var videoPausedByProximity = false
private var foregroundServiceStarted = false
private val videoSenders = mutableMapOf<HexKey, org.webrtc.RtpSender>()
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private var networkCallbackRegistered = false
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d(TAG) { "Network available — triggering ICE restart on all peers" }
restartIceOnAllPeers()
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
Log.d(TAG) { "Network capabilities changed — triggering ICE restart on all peers" }
restartIceOnAllPeers()
}
}
// ---- Initialization ----
@@ -120,6 +144,7 @@ class CallController(
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
updateForegroundServiceNotification()
registerNetworkCallback()
}
is CallState.Connected -> {
@@ -129,6 +154,7 @@ class CallController(
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
updateForegroundServiceNotification()
registerNetworkCallback()
}
is CallState.Ended -> {
@@ -376,6 +402,40 @@ class CallController(
}
}
// ---- Network change handling ----
private fun restartIceOnAllPeers() {
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
peerSessionMgr.allSessionKeys().forEach { key ->
webRtcSession(key)?.triggerIceRestart()
}
}
private fun registerNetworkCallback() {
if (networkCallbackRegistered) return
try {
val request =
NetworkRequest
.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
networkCallbackRegistered = true
} catch (e: Exception) {
Log.e(TAG, "Failed to register network callback", e)
}
}
private fun unregisterNetworkCallback() {
if (!networkCallbackRegistered) return
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (_: Exception) {
}
networkCallbackRegistered = false
}
// ---- UI toggle controls ----
fun toggleAudioMute() {
@@ -389,19 +449,37 @@ class CallController(
if (enabling) {
if (mediaManager.localVideoTrack == null) {
mediaManager.createVideoResources()
peerSessionMgr.allSessionKeys().forEach { key ->
webRtcSession(key)?.let { session ->
mediaManager.localVideoTrack?.let { track -> session.addTrack(track, settingsProvider().callMaxBitrateBps) }
}
}
} else {
mediaManager.enableVideo()
}
// Add video track to all peer connections
peerSessionMgr.allSessionKeys().forEach { key ->
if (videoSenders[key] == null) {
webRtcSession(key)?.let { session ->
mediaManager.localVideoTrack?.let { track ->
val sender = session.addTrack(track, settingsProvider().callMaxBitrateBps)
if (sender != null) {
videoSenders[key] = sender
}
}
}
}
}
} else {
// Remove video track senders so remote peers see track removal
peerSessionMgr.allSessionKeys().forEach { key ->
videoSenders.remove(key)?.let { sender ->
webRtcSession(key)?.removeTrack(sender)
}
}
mediaManager.disableVideo()
}
}
fun switchCamera() {
mediaManager.switchCamera()
}
fun cycleAudioRoute() {
audioManager.cycleAudioRoute()
}
@@ -454,6 +532,9 @@ class CallController(
onDisconnected = { scope.launch { onPeerDisconnected(peerPubKey) } },
onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
onIceRestartOffer = { sdp ->
scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) }
},
)
try {
session.createPeerConnection()
@@ -462,7 +543,12 @@ class CallController(
throw e
}
mediaManager.localAudioTrack?.let { session.addTrack(it) }
mediaManager.localVideoTrack?.let { session.addTrack(it, settingsProvider().callMaxBitrateBps) }
mediaManager.localVideoTrack?.let { track ->
val sender = session.addTrack(track, settingsProvider().callMaxBitrateBps)
if (sender != null) {
videoSenders[peerPubKey] = sender
}
}
return session
}
@@ -496,6 +582,7 @@ class CallController(
// ---- Cleanup ----
fun cleanup() {
unregisterNetworkCallback()
try {
audioManager.release()
} catch (e: Exception) {
@@ -522,6 +609,7 @@ class CallController(
_isAudioMuted.value = false
videoPausedByProximity = false
videoSenders.clear()
}
// ---- Foreground service ----
@@ -67,6 +67,7 @@ class CallMediaManager(
private var cameraCapturer: CameraVideoCapturer? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var usingFrontCamera: Boolean = true
private val _localVideoTrackFlow = MutableStateFlow<VideoTrack?>(null)
val localVideoTrackFlow: StateFlow<VideoTrack?> = _localVideoTrackFlow.asStateFlow()
@@ -74,6 +75,9 @@ class CallMediaManager(
private val _isVideoEnabled = MutableStateFlow(false)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
private val _isFrontCamera = MutableStateFlow(true)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
fun initialize(callType: CallType) {
if (peerConnectionFactory != null) return
@@ -147,8 +151,13 @@ class CallMediaManager(
val egl = sharedEglBase ?: return
val enumerator = Camera2Enumerator(context)
val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return
val preferred =
if (usingFrontCamera) {
enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
} else {
enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
}
val camera = preferred ?: enumerator.deviceNames.firstOrNull() ?: return
val helper = SurfaceTextureHelper.create("CaptureThread", egl.eglBaseContext)
surfaceTextureHelper = helper
@@ -159,6 +168,23 @@ class CallMediaManager(
}
}
fun switchCamera() {
val capturer = cameraCapturer ?: return
capturer.switchCamera(
object : CameraVideoCapturer.CameraSwitchHandler {
override fun onCameraSwitchDone(isFront: Boolean) {
usingFrontCamera = isFront
_isFrontCamera.value = isFront
Log.d(TAG) { "Camera switched: front=$isFront" }
}
override fun onCameraSwitchError(error: String?) {
Log.e(TAG, "Camera switch failed: $error")
}
},
)
}
fun stopCamera() {
try {
cameraCapturer?.stopCapture()
@@ -50,17 +50,25 @@ object IceServerConfig {
.createIceServer(),
)
/**
* Builds the ICE server list. If the user has configured custom TURN
* servers they replace the built-in OpenRelay defaults so that
* credentials can be rotated without an app update. STUN servers
* are always included.
*/
fun buildIceServers(userTurnServers: List<CallTurnServer> = emptyList()): List<PeerConnection.IceServer> {
val servers = (defaultStunServers + defaultTurnServers).toMutableList()
userTurnServers.forEach { turn ->
servers.add(
val turnServers =
if (userTurnServers.isNotEmpty()) {
userTurnServers.map { turn ->
PeerConnection.IceServer
.builder(turn.url)
.setUsername(turn.username)
.setPassword(turn.credential)
.createIceServer(),
)
.createIceServer()
}
return servers
} else {
defaultTurnServers
}
return defaultStunServers + turnServers
}
}
@@ -116,6 +116,7 @@ class RemoteVideoMonitor(
fun dispose() {
stopPrimaryMonitor()
stopGroupMonitor()
for (peerPubKey in perPeerFrameSinks.keys.toList()) {
stopPeerMonitor(peerPubKey)
}
@@ -143,14 +144,17 @@ class RemoteVideoMonitor(
private fun stopPrimaryMonitor() {
remoteVideoMonitorJob?.cancel()
remoteVideoMonitorJob = null
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
try {
_remoteVideoTrack.value?.removeSink(remoteFrameSink)
} catch (_: Exception) {
}
}
private fun stopGroupMonitor() {
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
}
private fun startPeerMonitor(
peerPubKey: HexKey,
track: VideoTrack,
@@ -53,6 +53,7 @@ class WebRtcCallSession(
private val onDisconnected: () -> Unit,
private val onError: (String) -> Unit = {},
private val onRenegotiationNeeded: () -> Unit = {},
private val onIceRestartOffer: (SessionDescription) -> Unit = {},
) {
private var peerConnection: PeerConnection? = null
private var iceRestartAttempted = false
@@ -177,6 +178,13 @@ class WebRtcCallSession(
return sender
}
/**
* Removes the sender for the given track from this PeerConnection.
* This signals to the remote peer that the track has been removed
* (e.g. camera turned off) rather than just sending a black frame.
*/
fun removeTrack(sender: RtpSender): Boolean = peerConnection?.removeTrack(sender) ?: false
fun createOffer(onSdpCreated: (SessionDescription) -> Unit) {
val constraints =
MediaConstraints().apply {
@@ -279,9 +287,9 @@ class WebRtcCallSession(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
Log.d(TAG) { "ICE restart offer created, setting local description" }
Log.d(TAG) { "ICE restart offer created, setting local description and sending to peer" }
peerConnection?.setLocalDescription(loggingSdpObserver("setLocalDescription(ICE_RESTART)"), it)
onRenegotiationNeeded()
onIceRestartOffer(it)
}
}
@@ -299,6 +307,15 @@ class WebRtcCallSession(
)
}
/**
* Triggers an ICE restart proactively (e.g. on network change).
* Resets the attempt counter so the restart is always tried.
*/
fun triggerIceRestart() {
iceRestartAttempted = false
restartIce()
}
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
/**
@@ -195,11 +195,14 @@ fun CallScreen(
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
androidx.compose.material3.TextButton(
onClick = { callController?.clearError() },
) {
Text(
stringRes(R.string.call_dismiss),
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary,
)
}
},
) {
Text(error)
@@ -41,6 +41,8 @@ import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.CameraFront
import androidx.compose.material.icons.filled.CameraRear
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PersonAdd
@@ -108,6 +110,7 @@ fun ConnectedCallUI(
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val isFrontCamera by (callController?.isFrontCamera ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
@@ -160,7 +163,7 @@ fun ConnectedCallUI(
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp),
mirror = true,
mirror = isFrontCamera,
)
}
}
@@ -226,9 +229,11 @@ fun ConnectedCallUI(
CallControls(
isAudioMuted = isAudioMuted,
isVideoEnabled = isVideoEnabled,
isFrontCamera = isFrontCamera,
currentAudioRoute = currentAudioRoute,
onToggleMute = onToggleMute,
onToggleVideo = onToggleVideo,
onSwitchCamera = { callController?.switchCamera() },
onCycleAudioRoute = onCycleAudioRoute,
onAddParticipant = { showAddParticipant = true },
onHangup = onHangup,
@@ -245,9 +250,11 @@ fun ConnectedCallUI(
private fun CallControls(
isAudioMuted: Boolean,
isVideoEnabled: Boolean,
isFrontCamera: Boolean,
currentAudioRoute: AudioRoute,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onSwitchCamera: () -> Unit,
onCycleAudioRoute: () -> Unit,
onAddParticipant: () -> Unit,
onHangup: () -> Unit,
@@ -277,6 +284,21 @@ private fun CallControls(
modifier = Modifier.size(28.dp),
)
}
if (isVideoEnabled) {
IconButton(onClick = onSwitchCamera, modifier = Modifier.size(56.dp)) {
Icon(
imageVector =
if (isFrontCamera) {
Icons.Default.CameraRear
} else {
Icons.Default.CameraFront
},
contentDescription = stringRes(R.string.call_switch_camera),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
}
IconButton(onClick = onCycleAudioRoute, modifier = Modifier.size(56.dp)) {
Icon(
imageVector =
@@ -101,7 +101,6 @@ fun PipConnectedCallUI(
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
@@ -142,7 +141,17 @@ fun PipConnectedCallUI(
)
}
}
} else if (!isRemoteVideoActive) {
// Show timer overlay on top of video
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -161,15 +170,5 @@ fun PipConnectedCallUI(
)
}
}
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
}
}
@@ -1523,6 +1523,8 @@ class AccountViewModel(
override fun onCleared() {
Log.d("AccountViewModel", "onCleared")
callController?.cleanup()
com.vitorpamplona.amethyst.service.call.CallSessionBridge
.clear()
feedStates.destroy()
super.onCleared()
}
+1
View File
@@ -802,6 +802,7 @@
<string name="call_unmute">Unmute</string>
<string name="call_camera_on">Camera on</string>
<string name="call_camera_off">Camera off</string>
<string name="call_switch_camera">Switch camera</string>
<string name="call_speaker">Speaker</string>
<string name="call_earpiece">Earpiece</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -199,7 +199,9 @@ class CallManager(
) {
Log.d("CallManager") { "initiateCall: callId=$callId, callee=${calleePubKey.take(8)}, type=$callType, sdpLength=${sdpOffer.length}" }
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
stateMutex.withLock {
_state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
}
publishEvent(result.wrap)
startTimeout(callId)
Log.d("CallManager") { "initiateCall: offer published, timeout started" }
@@ -207,7 +209,7 @@ class CallManager(
// ---- Incoming call handling ----
fun onIncomingCallEvent(event: CallOfferEvent) {
private fun onIncomingCallEvent(event: CallOfferEvent) {
val callerPubKey = event.pubKey
val callId = event.callId() ?: return
val callType = event.callType() ?: CallType.VOICE
@@ -309,7 +311,7 @@ class CallManager(
result.wraps.forEach { publishEvent(it) }
}
fun onCallAnswered(event: CallAnswerEvent) {
private fun onCallAnswered(event: CallAnswerEvent) {
val current = _state.value
val callId = event.callId()
val answeringPeer = event.pubKey
@@ -394,7 +396,7 @@ class CallManager(
}
}
fun onCallRejected(event: CallRejectEvent) {
private fun onCallRejected(event: CallRejectEvent) {
val current = _state.value
val callId = event.callId()
val rejectingPeer = event.pubKey
@@ -451,11 +453,11 @@ class CallManager(
}
}
fun onIceCandidate(event: CallIceCandidateEvent) {
private fun onIceCandidate(event: CallIceCandidateEvent) {
onIceCandidateReceived?.invoke(event)
}
fun onRenegotiate(event: CallRenegotiateEvent) {
private fun onRenegotiate(event: CallRenegotiateEvent) {
val current = _state.value
val callId = event.callId()
val currentCallId =
@@ -575,7 +577,7 @@ class CallManager(
result.wraps.forEach { publishEvent(it) }
}
fun onPeerHangup(event: CallHangupEvent) {
private fun onPeerHangup(event: CallHangupEvent) {
val current = _state.value
val callId = event.callId() ?: return
val leavingPeer = event.pubKey
@@ -32,6 +32,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
* - **Callee-to-callee mesh**: lower pubkey initiates
*
* It is platform-independent and testable without real WebRTC.
*
* All map-accessing methods are synchronized because ICE candidates
* arrive from WebRTC native threads while session management runs
* on coroutine dispatchers.
*/
class PeerSessionManager(
val localPubKey: HexKey,
@@ -42,6 +46,7 @@ class PeerSessionManager(
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
)
private val lock = Any()
private val sessions = mutableMapOf<HexKey, SessionEntry>()
/** Candidates received before a session exists for the sender. */
@@ -52,24 +57,26 @@ class PeerSessionManager(
fun registerSession(
peerPubKey: HexKey,
session: PeerSession,
): SessionEntry {
): SessionEntry =
synchronized(lock) {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
sessions[peerPubKey] = entry
return entry
entry
}
fun getSession(peerPubKey: HexKey): SessionEntry? = sessions[peerPubKey]
fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] }
fun hasSession(peerPubKey: HexKey): Boolean = sessions.containsKey(peerPubKey)
fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) }
fun removeSession(peerPubKey: HexKey): SessionEntry? {
fun removeSession(peerPubKey: HexKey): SessionEntry? =
synchronized(lock) {
globalPendingIce.remove(peerPubKey)
return sessions.remove(peerPubKey)
sessions.remove(peerPubKey)
}
fun allSessionKeys(): Set<HexKey> = sessions.keys.toSet()
fun allSessionKeys(): Set<HexKey> = synchronized(lock) { sessions.keys.toSet() }
// ---- ICE candidate routing (two-layer buffering) ----
@@ -84,9 +91,10 @@ class PeerSessionManager(
fun routeIceCandidate(
senderPubKey: HexKey,
candidate: IceCandidateData,
): IceRouteAction {
): IceRouteAction =
synchronized(lock) {
val entry = sessions[senderPubKey]
return when {
when {
entry != null && entry.remoteDescriptionSet -> {
entry.session.addIceCandidate(candidate)
IceRouteAction.ADDED_DIRECTLY
@@ -109,17 +117,21 @@ class PeerSessionManager(
* Called after setRemoteDescription succeeds.
*/
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
val entry = sessions[peerPubKey] ?: return 0
val candidates: List<IceCandidateData>
val entry: SessionEntry
synchronized(lock) {
entry = sessions[peerPubKey] ?: return 0
entry.remoteDescriptionSet = true
val candidates = entry.pendingIceCandidates.toList()
candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
}
candidates.forEach { entry.session.addIceCandidate(it) }
return candidates.size
}
fun globalPendingCount(peerPubKey: HexKey): Int = globalPendingIce[peerPubKey]?.size ?: 0
fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 }
fun sessionPendingCount(peerPubKey: HexKey): Int = sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0
fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
// ---- Renegotiation glare handling ----
@@ -135,7 +147,7 @@ class PeerSessionManager(
remoteSdpOffer: String,
onAcceptRemote: (SessionEntry) -> Unit,
): GlareResolution {
val entry = sessions[peerPubKey] ?: return GlareResolution.NO_SESSION
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -172,7 +184,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
sdpAnswer: String,
): AnswerRouteAction {
val entry = sessions[peerPubKey] ?: return AnswerRouteAction.NO_SESSION
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -187,12 +199,16 @@ class PeerSessionManager(
// ---- Cleanup ----
fun disposeAll() {
for (entry in sessions.values) {
entry.session.dispose()
}
val entries: List<SessionEntry>
synchronized(lock) {
entries = sessions.values.toList()
sessions.clear()
globalPendingIce.clear()
}
for (entry in entries) {
entry.session.dispose()
}
}
}
enum class IceRouteAction {
@@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Immutable
class CallIceCandidateEvent(
@@ -37,27 +41,31 @@ class CallIceCandidateEvent(
content: String,
sig: HexKey,
) : WebRTCEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
private val parsedJson by lazy {
try {
Json.parseToJsonElement(content).jsonObject
} catch (_: Exception) {
null
}
}
fun candidateJson() = content
fun candidateSdp(): String = CANDIDATE_REGEX.find(content)?.groupValues?.get(1) ?: ""
fun candidateSdp(): String = parsedJson?.get("candidate")?.jsonPrimitive?.content ?: ""
fun sdpMid(): String = SDP_MID_REGEX.find(content)?.groupValues?.get(1) ?: "0"
fun sdpMid(): String = parsedJson?.get("sdpMid")?.jsonPrimitive?.content ?: "0"
fun sdpMLineIndex(): Int =
SDP_MLINE_INDEX_REGEX
.find(content)
?.groupValues
?.get(1)
?.toIntOrNull() ?: 0
try {
parsedJson?.get("sdpMLineIndex")?.jsonPrimitive?.int ?: 0
} catch (_: Exception) {
0
}
companion object {
const val KIND = 25052
const val ALT_DESCRIPTION = "WebRTC ICE candidate"
private val CANDIDATE_REGEX = """"candidate"\s*:\s*"([^"]*)"""".toRegex()
private val SDP_MID_REGEX = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex()
private val SDP_MLINE_INDEX_REGEX = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex()
fun serializeCandidate(
sdp: String,
sdpMid: String,