From 8169c40bb6a52a4823b4185787bf95e7a4bc8186 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 21:41:49 +0000 Subject: [PATCH] fix: handle WebRTC offer glare during renegotiation When two peers simultaneously trigger renegotiation (e.g., both enable video), both create local offers and enter HAVE_LOCAL_OFFER state. When a remote offer arrives in this state, setRemoteDescription fails with "Called in wrong state: have-local-offer". Fix by implementing standard WebRTC glare handling: use pubkey comparison as a tiebreaker (higher pubkey wins). The losing peer rolls back their local offer before accepting the remote one. https://claude.ai/code/session_01XLbnNVx3GDhHrPZKPXfFdD --- .../amethyst/service/call/CallController.kt | 34 ++++++++++++++++--- .../service/call/WebRtcCallSession.kt | 26 ++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) 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 8d8eca8e0..0cbb6021b 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 @@ -539,11 +539,37 @@ class CallController( Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" } scope.launch { - ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) - ps.session.createAnswer { sdp -> - scope.launch { - callManager.sendRenegotiationAnswer(sdp.description, peerPubKey) + val signalingState = ps.session.getSignalingState() + + if (signalingState == PeerConnection.SignalingState.HAVE_LOCAL_OFFER) { + // Glare: both sides sent offers simultaneously. + // Use pubkey comparison as tiebreaker — higher pubkey wins. + val myPubKey = signerProvider().pubKey + if (myPubKey > peerPubKey) { + Log.d(TAG) { "Glare with ${peerPubKey.take(8)}: I win (my offer takes priority), ignoring remote offer" } + return@launch } + Log.d(TAG) { "Glare with ${peerPubKey.take(8)}: I lose, rolling back my offer" } + ps.session.rollback { + scope.launch { + applyRenegotiationOffer(ps, peerPubKey, sdpOffer) + } + } + } else { + applyRenegotiationOffer(ps, peerPubKey, sdpOffer) + } + } + } + + private fun applyRenegotiationOffer( + ps: PeerSessionState, + peerPubKey: HexKey, + sdpOffer: String, + ) { + ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) + ps.session.createAnswer { sdp -> + scope.launch { + callManager.sendRenegotiationAnswer(sdp.description, peerPubKey) } } } 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 44745c0ee..3379e2160 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 @@ -257,6 +257,32 @@ class WebRtcCallSession( fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState() + /** + * Rolls back a local offer so an incoming remote offer can be accepted + * (WebRTC offe glare handling). + */ + fun rollback(onDone: () -> Unit) { + val rollbackSdp = SessionDescription(SessionDescription.Type.ROLLBACK, "") + peerConnection?.setLocalDescription( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) {} + + override fun onCreateFailure(error: String?) {} + + override fun onSetSuccess() { + Log.d(TAG) { "Rollback SUCCESS" } + onDone() + } + + override fun onSetFailure(error: String?) { + Log.e(TAG, "Rollback FAILED: $error") + error?.let { onError("Rollback failed: $it") } + } + }, + rollbackSdp, + ) + } + fun dispose() { peerConnection?.close() peerConnection?.dispose()