From 0f533ea43aa491fea7ac3d4d8def95884e91b9ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 18:42:43 +0000 Subject: [PATCH] fix: guard against stale SDP answers in wrong signaling state Our own renegotiation answers can echo back through the relay and get processed as if from the peer. Check that the peer connection is in HAVE_LOCAL_OFFER state before applying a remote answer SDP, preventing the "Called in wrong state: stable" error. https://claude.ai/code/session_014espsysob7MrE8X8e75jFX --- .../amethyst/service/call/CallController.kt | 15 +++++++++++++-- .../amethyst/service/call/WebRtcCallSession.kt | 2 ++ 2 files changed, 15 insertions(+), 2 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 b33875ac14..8ffcef6e60 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 @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.webrtc.IceCandidate +import org.webrtc.PeerConnection import org.webrtc.SessionDescription import org.webrtc.VideoFrame import org.webrtc.VideoSink @@ -228,8 +229,18 @@ class CallController( } fun onCallAnswerReceived(sdpAnswer: String) { - Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" } - webRtcSession?.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) + val session = webRtcSession ?: return + val signalingState = session.getSignalingState() + Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, signalingState=$signalingState" } + + // An answer is only valid when we have a pending local offer. + // Ignore stale answers (e.g. our own renegotiation answer echoed back by the relay). + if (signalingState != PeerConnection.SignalingState.HAVE_LOCAL_OFFER) { + Log.d(TAG) { "Ignoring answer in $signalingState state (no pending local offer)" } + return + } + + session.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) flushPendingIceCandidates() } 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 274b393995..0713821760 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 @@ -263,6 +263,8 @@ class WebRtcCallSession( peerConnection?.addIceCandidate(candidate) } + fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState() + fun setAudioEnabled(enabled: Boolean) { localAudioTrack?.setEnabled(enabled) }