fix: wire SDP renegotiation so video toggle works for remote peer

When toggling video mid-call, addVideoTrack triggers
onRenegotiationNeeded but the callback was empty — the remote peer
never received the updated SDP, so video never appeared on their side.

This wires up the full renegotiation flow using the existing
CallRenegotiateEvent (kind 25055):
- WebRtcCallSession: forward onRenegotiationNeeded to a callback
- CallController: create and send renegotiation offers, handle
  incoming renegotiation offers by creating answers
- CallManager: route CallRenegotiateEvent, allow CallAnswerEvent
  during Connected state for renegotiation answers

https://claude.ai/code/session_01WdmqktFBjXKB6PYRZ5b4FD
This commit is contained in:
Claude
2026-04-02 17:05:10 +00:00
parent ec99373ae3
commit fe1a009fb4
3 changed files with 86 additions and 6 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -93,6 +94,8 @@ class CallController(
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
init { init {
callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) }
scope.launch { scope.launch {
callManager.state.collect { state -> callManager.state.collect { state ->
when (state) { when (state) {
@@ -282,6 +285,34 @@ class CallController(
_errorMessage.value = null _errorMessage.value = null
} }
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
val session = webRtcSession ?: return
val sdpOffer = event.sdpOffer()
Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" }
scope.launch {
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
session.createAnswer { sdp ->
scope.launch {
callManager.sendRenegotiationAnswer(sdp.description)
}
}
}
}
private fun performRenegotiation() {
val session = webRtcSession ?: return
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
Log.d(TAG) { "Starting renegotiation" }
session.createOffer { sdp ->
scope.launch {
callManager.sendRenegotiation(sdp.description)
}
}
}
fun cleanup() { fun cleanup() {
audioManager.release() audioManager.release()
stopForegroundService() stopForegroundService()
@@ -337,6 +368,7 @@ class CallController(
}, },
onDisconnected = { scope.launch { callManager.hangup() } }, onDisconnected = { scope.launch { callManager.hangup() } },
onError = { error -> _errorMessage.value = error }, onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation() },
) )
try { try {
session.initialize() session.initialize()
@@ -52,6 +52,7 @@ class WebRtcCallSession(
private val onRemoteVideoTrack: (VideoTrack) -> Unit, private val onRemoteVideoTrack: (VideoTrack) -> Unit,
private val onDisconnected: () -> Unit, private val onDisconnected: () -> Unit,
private val onError: (String) -> Unit = {}, private val onError: (String) -> Unit = {},
private val onRenegotiationNeeded: () -> Unit = {},
) { ) {
private var peerConnectionFactory: PeerConnectionFactory? = null private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null private var peerConnection: PeerConnection? = null
@@ -131,7 +132,10 @@ class WebRtcCallSession(
override fun onDataChannel(channel: DataChannel?) {} override fun onDataChannel(channel: DataChannel?) {}
override fun onRenegotiationNeeded() {} override fun onRenegotiationNeeded() {
Log.d(TAG) { "Renegotiation needed" }
this@WebRtcCallSession.onRenegotiationNeeded()
}
override fun onAddTrack( override fun onAddTrack(
receiver: RtpReceiver?, receiver: RtpReceiver?,
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -54,6 +55,7 @@ class CallManager(
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
var onRenegotiationOfferReceived: ((CallRenegotiateEvent) -> Unit)? = null
private var timeoutJob: Job? = null private var timeoutJob: Job? = null
private var resetJob: Job? = null private var resetJob: Job? = null
@@ -119,12 +121,26 @@ class CallManager(
fun onCallAnswered(event: CallAnswerEvent) { fun onCallAnswered(event: CallAnswerEvent) {
val current = _state.value val current = _state.value
if (current !is CallState.Offering) return val callId = event.callId()
if (event.callId() != current.callId) return
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) when (current) {
cancelTimeout() is CallState.Offering -> {
onAnswerReceived?.invoke(event) if (callId != current.callId) return
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
cancelTimeout()
onAnswerReceived?.invoke(event)
}
is CallState.Connected -> {
// Renegotiation answer (e.g., peer accepted our video upgrade offer)
if (callId != current.callId) return
onAnswerReceived?.invoke(event)
}
else -> {
return
}
}
} }
fun onCallRejected(event: CallRejectEvent) { fun onCallRejected(event: CallRejectEvent) {
@@ -139,6 +155,33 @@ class CallManager(
onIceCandidateReceived?.invoke(event) onIceCandidateReceived?.invoke(event)
} }
fun onRenegotiate(event: CallRenegotiateEvent) {
val current = _state.value
val callId = event.callId()
val currentCallId =
when (current) {
is CallState.Connecting -> current.callId
is CallState.Connected -> current.callId
else -> return
}
if (callId != currentCallId) return
onRenegotiationOfferReceived?.invoke(event)
}
suspend fun sendRenegotiation(sdpOffer: String) {
val callId = currentCallId() ?: return
val peerPubKey = currentPeerPubKey() ?: return
val result = factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer)
publishEvent(result.wrap)
}
suspend fun sendRenegotiationAnswer(sdpAnswer: String) {
val callId = currentCallId() ?: return
val peerPubKey = currentPeerPubKey() ?: return
val result = factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer)
publishEvent(result.wrap)
}
fun onPeerConnected() { fun onPeerConnected() {
val current = _state.value val current = _state.value
if (current !is CallState.Connecting) return if (current !is CallState.Connecting) return
@@ -213,6 +256,7 @@ class CallManager(
is CallRejectEvent -> onCallRejected(event) is CallRejectEvent -> onCallRejected(event)
is CallHangupEvent -> onPeerHangup(event) is CallHangupEvent -> onPeerHangup(event)
is CallIceCandidateEvent -> onIceCandidate(event) is CallIceCandidateEvent -> onIceCandidate(event)
is CallRenegotiateEvent -> onRenegotiate(event)
} }
} }