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:
+9
-7
@@ -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
|
||||
|
||||
+56
-40
@@ -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 {
|
||||
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<HexKey> = sessions.keys.toSet()
|
||||
fun allSessionKeys(): Set<HexKey> = 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<IceCandidateData>
|
||||
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<SessionEntry>
|
||||
synchronized(lock) {
|
||||
entries = sessions.values.toList()
|
||||
sessions.clear()
|
||||
globalPendingIce.clear()
|
||||
}
|
||||
for (entry in entries) {
|
||||
entry.session.dispose()
|
||||
}
|
||||
sessions.clear()
|
||||
globalPendingIce.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user