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
@@ -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,