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 0d3b896fc..afde99043 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 @@ -97,8 +97,13 @@ class CallAudioManager( } fun startRinging() { - startRingtone() - startVibration() + val ringerMode = audioManager.ringerMode + if (ringerMode != AudioManager.RINGER_MODE_SILENT) { + if (ringerMode == AudioManager.RINGER_MODE_NORMAL) { + startRingtone() + } + startVibration() + } } fun stopRinging() { 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 355af1880..ae2e05d4c 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 @@ -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 = _isAudioMuted.asStateFlow() val isVideoEnabled: StateFlow = mediaManager.isVideoEnabled + val isFrontCamera: StateFlow = mediaManager.isFrontCamera val audioRoute: StateFlow = audioManager.audioRoute val isBluetoothAvailable: StateFlow = audioManager.isBluetoothAvailable private var videoPausedByProximity = false private var foregroundServiceStarted = false + private val videoSenders = mutableMapOf() + + 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 ---- 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 275bfd273..35a279271 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 @@ -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(null) val localVideoTrackFlow: StateFlow = _localVideoTrackFlow.asStateFlow() @@ -74,6 +75,9 @@ class CallMediaManager( private val _isVideoEnabled = MutableStateFlow(false) val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() + private val _isFrontCamera = MutableStateFlow(true) + val isFrontCamera: StateFlow = _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() 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 564d50fdc..e62f1a371 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 @@ -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 = emptyList()): List { - val servers = (defaultStunServers + defaultTurnServers).toMutableList() - userTurnServers.forEach { turn -> - servers.add( - PeerConnection.IceServer - .builder(turn.url) - .setUsername(turn.username) - .setPassword(turn.credential) - .createIceServer(), - ) - } - return servers + val turnServers = + if (userTurnServers.isNotEmpty()) { + userTurnServers.map { turn -> + PeerConnection.IceServer + .builder(turn.url) + .setUsername(turn.username) + .setPassword(turn.credential) + .createIceServer() + } + } else { + defaultTurnServers + } + return defaultStunServers + turnServers } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt index 2daa4e450..0a5993b03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index 9385b6aa2..cd164e2c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -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() /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 9ed32f92a..01172e3e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -195,11 +195,14 @@ fun CallScreen( Snackbar( modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), action = { - Text( - stringRes(R.string.call_dismiss), - modifier = Modifier.padding(8.dp), - color = MaterialTheme.colorScheme.inversePrimary, - ) + androidx.compose.material3.TextButton( + onClick = { callController?.clearError() }, + ) { + Text( + stringRes(R.string.call_dismiss), + color = MaterialTheme.colorScheme.inversePrimary, + ) + } }, ) { Text(error) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt index 75eb73f94..e78235ec4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt index 5c66b5e97..45aa48f73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt @@ -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), - ) } } 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 c12b0da33..8d43ec541 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 @@ -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() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index df1a7cd93..48c2a623a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -802,6 +802,7 @@ Unmute Camera on Camera off + Switch camera Speaker Earpiece Bluetooth 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 0616cf3ea..92db33a3b 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 @@ -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) - _state.value = CallState.Offering(callId, setOf(calleePubKey), callType) + 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt index bce345f4a..3758f90c6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt @@ -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 = mutableListOf(), ) + private val lock = Any() private val sessions = mutableMapOf() /** Candidates received before a session exists for the sender. */ @@ -52,24 +57,26 @@ class PeerSessionManager( fun registerSession( peerPubKey: HexKey, session: PeerSession, - ): SessionEntry { - val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList() - val entry = SessionEntry(session) - entry.pendingIceCandidates.addAll(globalPending) - sessions[peerPubKey] = entry - return entry - } + ): SessionEntry = + synchronized(lock) { + val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList() + val entry = SessionEntry(session) + entry.pendingIceCandidates.addAll(globalPending) + sessions[peerPubKey] = 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? { - globalPendingIce.remove(peerPubKey) - return sessions.remove(peerPubKey) - } + fun removeSession(peerPubKey: HexKey): SessionEntry? = + synchronized(lock) { + globalPendingIce.remove(peerPubKey) + sessions.remove(peerPubKey) + } - fun allSessionKeys(): Set = sessions.keys.toSet() + fun allSessionKeys(): Set = synchronized(lock) { sessions.keys.toSet() } // ---- ICE candidate routing (two-layer buffering) ---- @@ -84,42 +91,47 @@ class PeerSessionManager( fun routeIceCandidate( senderPubKey: HexKey, candidate: IceCandidateData, - ): IceRouteAction { - val entry = sessions[senderPubKey] - return when { - entry != null && entry.remoteDescriptionSet -> { - entry.session.addIceCandidate(candidate) - IceRouteAction.ADDED_DIRECTLY - } + ): IceRouteAction = + synchronized(lock) { + val entry = sessions[senderPubKey] + when { + entry != null && entry.remoteDescriptionSet -> { + entry.session.addIceCandidate(candidate) + IceRouteAction.ADDED_DIRECTLY + } - entry != null -> { - entry.pendingIceCandidates.add(candidate) - IceRouteAction.BUFFERED_PER_SESSION - } + entry != null -> { + entry.pendingIceCandidates.add(candidate) + IceRouteAction.BUFFERED_PER_SESSION + } - else -> { - globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate) - IceRouteAction.BUFFERED_GLOBALLY + else -> { + globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate) + IceRouteAction.BUFFERED_GLOBALLY + } } } - } /** * Flushes all per-session buffered candidates into the PeerConnection. * Called after setRemoteDescription succeeds. */ fun flushPendingIceCandidates(peerPubKey: HexKey): Int { - val entry = sessions[peerPubKey] ?: return 0 - entry.remoteDescriptionSet = true - val candidates = entry.pendingIceCandidates.toList() - entry.pendingIceCandidates.clear() + val candidates: List + val entry: SessionEntry + synchronized(lock) { + entry = sessions[peerPubKey] ?: return 0 + entry.remoteDescriptionSet = true + 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,11 +199,15 @@ class PeerSessionManager( // ---- Cleanup ---- fun disposeAll() { - for (entry in sessions.values) { + val entries: List + synchronized(lock) { + entries = sessions.values.toList() + sessions.clear() + globalPendingIce.clear() + } + for (entry in entries) { entry.session.dispose() } - sessions.clear() - globalPendingIce.clear() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index 697198a66..76ff088c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -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,