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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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,
|
||||
|
||||
+19
-2
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+8
-6
@@ -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
|
||||
|
||||
+34
-18
@@ -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 {
|
||||
|
||||
+19
-11
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user