@@ -95,6 +95,7 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
9595 Timer ? _presenceInfoSyncTimer;
9696
9797 late final PeerConnectionManager _peerConnectionManager;
98+ late final RenegotiationHandler _renegotiationHandler;
9899
99100 final _callkeepSound = WebtritCallkeepSound ();
100101
@@ -129,6 +130,7 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
129130 }) : super (const CallState ()) {
130131 _signalingClientFactory = signalingClientFactory;
131132 _peerConnectionManager = peerConnectionManager;
133+ _renegotiationHandler = RenegotiationHandler (callErrorReporter: callErrorReporter, sdpMunger: sdpMunger);
132134
133135 on < CallStarted > (_onCallStarted, transformer: sequential ());
134136 on < _AppLifecycleStateChanged > (_onAppLifecycleStateChanged, transformer: sequential ());
@@ -1000,7 +1002,41 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
10001002 if (jsep != null && peerConnection != null ) {
10011003 final remoteDescription = jsep.toDescription ();
10021004 sdpSanitizer? .apply (remoteDescription);
1003- await peerConnection.setRemoteDescription (remoteDescription);
1005+
1006+ // An accepted event with an answer jsep is only valid when the PC is in
1007+ // have-local-offer state. During a glare race the local offer may have
1008+ // been rolled back in __onCallSignalingEventUpdating, leaving the PC in
1009+ // stable. Applying a stale answer in stable throws a wrong-state error,
1010+ // so skip it and rely on libwebrtc re-firing onRenegotiationNeeded once
1011+ // the PC returns to stable.
1012+ final signalingState = peerConnection.signalingState;
1013+ if (remoteDescription.type == 'answer' && signalingState != RTCSignalingState .RTCSignalingStateHaveLocalOffer ) {
1014+ _logger.warning (
1015+ '__onCallSignalingEventAccepted: skipping setRemoteDescription(answer) '
1016+ 'because signalingState=$signalingState (expected have-local-offer).' ,
1017+ );
1018+ return ;
1019+ }
1020+
1021+ _logger.info (
1022+ '__onCallSignalingEventAccepted answer SDP (callId=${event .callId }, initialAccept=$initialAccept ):\n '
1023+ '${remoteDescription .sdp }' ,
1024+ );
1025+
1026+ try {
1027+ await peerConnection.setRemoteDescription (remoteDescription);
1028+ final transceivers = await peerConnection.getTransceivers ();
1029+ for (final t in transceivers) {
1030+ final dir = await t.getDirection ();
1031+ final curDir = await t.getCurrentDirection ();
1032+ _logger.info (
1033+ '__onCallSignalingEventAccepted transceiver: mid=${t .mid } '
1034+ 'direction=$dir currentDirection=$curDir ' ,
1035+ );
1036+ }
1037+ } on String catch (e) {
1038+ _logger.warning ('__onCallSignalingEventAccepted: setRemoteDescription failed ($e )' );
1039+ }
10041040 }
10051041 }
10061042
@@ -1094,7 +1130,35 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
10941130 _logger.warning ('__onCallSignalingEventUpdating: peerConnection is null - most likely some state issue' );
10951131 } else {
10961132 await peerConnectionPolicyApplier? .apply (peerConnection, hasRemoteVideo: jsep.hasVideo);
1097- await peerConnection.setRemoteDescription (remoteDescription);
1133+
1134+ // Optimistic pre-check for glare condition. May be stale because
1135+ // flutter_webrtc caches signalingState and updates it only when the
1136+ // onSignalingState callback fires — not when setLocalDescription completes.
1137+ // The try-catch below is the authoritative fallback.
1138+ final signalingState = peerConnection.signalingState;
1139+ if (signalingState == RTCSignalingState .RTCSignalingStateHaveLocalOffer ) {
1140+ _logger.warning (
1141+ '__onCallSignalingEventUpdating: glare detected via pre-check (signalingState=$signalingState ), rolling back local offer' ,
1142+ );
1143+ await peerConnection.setLocalDescription (RTCSessionDescription ('' , 'rollback' ));
1144+ }
1145+
1146+ try {
1147+ await peerConnection.setRemoteDescription (remoteDescription);
1148+ } on String catch (e) {
1149+ if (e.contains ('have-local-offer' )) {
1150+ // Glare condition: signalingState pre-check was stale (flutter_webrtc
1151+ // caching), setLocalDescription completed on the native side but the
1152+ // Dart-side callback had not yet fired. Roll back and retry.
1153+ _logger.warning (
1154+ '__onCallSignalingEventUpdating: glare detected via catch ($e ), rolling back local offer and retrying' ,
1155+ );
1156+ await peerConnection.setLocalDescription (RTCSessionDescription ('' , 'rollback' ));
1157+ await peerConnection.setRemoteDescription (remoteDescription);
1158+ } else {
1159+ rethrow ;
1160+ }
1161+ }
10981162 final localDescription = await peerConnection.createAnswer ({});
10991163 sdpMunger? .apply (localDescription);
11001164
@@ -2187,21 +2251,35 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
21872251 '__onPeerConnectionEventIceConnectionStateChanged: peerConnection is null - most likely some state issue' ,
21882252 );
21892253 } else {
2190- await peerConnection.restartIce ();
2191- final localDescription = await peerConnection.createOffer ({});
2192- sdpMunger? .apply (localDescription);
2193-
2194- // According to RFC 8829 5.6 (https://datatracker.ietf.org/doc/html/rfc8829#section-5.6),
2195- // localDescription should be set before sending the answer to transition into stable state.
2196- await peerConnection.setLocalDescription (localDescription);
2197-
2198- final updateRequest = UpdateRequest (
2199- transaction: WebtritSignalingClient .generateTransactionId (),
2200- line: activeCall.line,
2201- callId: activeCall.callId,
2202- jsep: localDescription.toMap (),
2203- );
2204- await _signalingClient? .execute (updateRequest);
2254+ final pcState = peerConnection.signalingState;
2255+ if (pcState == RTCSignalingState .RTCSignalingStateStable ) {
2256+ await peerConnection.restartIce ();
2257+ final localDescription = await peerConnection.createOffer ({});
2258+ sdpMunger? .apply (localDescription);
2259+
2260+ final currentState = peerConnection.signalingState;
2261+ if (currentState == RTCSignalingState .RTCSignalingStateStable ) {
2262+ // According to the WebRTC spec (https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection-setlocaldescription),
2263+ // setLocalDescription must be called before sending the offer to the remote side.
2264+ await peerConnection.setLocalDescription (localDescription);
2265+
2266+ final updateRequest = UpdateRequest (
2267+ transaction: WebtritSignalingClient .generateTransactionId (),
2268+ line: activeCall.line,
2269+ callId: activeCall.callId,
2270+ jsep: localDescription.toMap (),
2271+ );
2272+ await _signalingClient? .execute (updateRequest);
2273+ } else {
2274+ _logger.warning (
2275+ '__onPeerConnectionEventIceConnectionStateChanged: signalingState changed mid-flight ($currentState ), skipping setLocalDescription' ,
2276+ );
2277+ }
2278+ } else {
2279+ _logger.warning (
2280+ '__onPeerConnectionEventIceConnectionStateChanged: signalingState is $pcState , skipping ICE restart' ,
2281+ );
2282+ }
22052283 }
22062284 });
22072285 } catch (e, stackTrace) {
@@ -2250,6 +2328,28 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
22502328 // Skip stub stream created by Janus on unidirectional video
22512329 if (event.stream.id == 'janus' ) return ;
22522330
2331+ final currentStream = state.retrieveActiveCall (event.callId)? .remoteStream;
2332+ final sameRef = identical (currentStream, event.stream);
2333+ _logger.info (
2334+ '__onPeerConnectionEventStreamAdded: callId=${event .callId } '
2335+ 'streamId=${event .stream .id } '
2336+ 'videoTracks=${event .stream .getVideoTracks ().length } '
2337+ 'sameRef=$sameRef ' ,
2338+ );
2339+
2340+ // When onAddTrack fires with the same stream reference (existing stream gains
2341+ // a new video track during renegotiation), the Freezed equality check on
2342+ // ActiveCall would consider the state unchanged and skip the emit, leaving the
2343+ // RTCVideoRenderer subscribed to the old track. Clear remoteStream first to
2344+ // force a reference change that triggers renderer refresh.
2345+ if (sameRef) {
2346+ emit (
2347+ state.copyWithMappedActiveCall (event.callId, (activeCall) {
2348+ return activeCall.copyWith (remoteStream: null );
2349+ }),
2350+ );
2351+ }
2352+
22532353 emit (
22542354 state.copyWithMappedActiveCall (event.callId, (activeCall) {
22552355 return activeCall.copyWith (remoteStream: event.stream);
@@ -2781,51 +2881,29 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
27812881 onIceCandidate: (candidate) => add (_PeerConnectionEvent .iceCandidateIdentified (callId, candidate)),
27822882 onAddStream: (stream) => add (_PeerConnectionEvent .streamAdded (callId, stream)),
27832883 onRemoveStream: (stream) => add (_PeerConnectionEvent .streamRemoved (callId, stream)),
2784- onRenegotiationNeeded: (pc) => _handleRenegotiationNeeded (callId, lineId, pc),
2884+ // onAddTrack fires during renegotiation when a new track is added to an
2885+ // existing stream. In that case onAddStream does NOT re-fire (only fired
2886+ // once per unique stream ID). Forwarding the stream here ensures the BLoC
2887+ // state is updated with the latest stream reference when video is added
2888+ // mid-call (e.g. after a glare-resolution rollback).
2889+ onAddTrack: (stream, track) => add (_PeerConnectionEvent .streamAdded (callId, stream)),
2890+ onRenegotiationNeeded: (pc) =>
2891+ unawaited (_renegotiationHandler.handle (callId, lineId, pc, _sendRenegotiationUpdate)),
27852892 ),
27862893 );
27872894 }
27882895
2789- Future <void > _handleRenegotiationNeeded (String callId, int ? lineId, RTCPeerConnection peerConnection) async {
2790- // TODO(Serdun): Handle renegotiation needed
2791- // This implementation does not handle all possible signaling states.
2792- // Specifically, if the current state is `have-remote-offer`, calling
2793- // setLocalDescription with an offer will throw:
2794- // WEBRTC_SET_LOCAL_DESCRIPTION_ERROR: Failed to set local offer sdp: Called in wrong state: have-remote-offer
2795- //
2796- // Known case: when CalleeVideoOfferPolicy.includeInactiveTrack is used,
2797- // the callee may trigger onRenegotiationNeeded before the current remote offer is processed.
2798- // This causes a race where the local peer is still in 'have-remote-offer' state,
2799- // leading to the above error. Currently this does not severely affect behavior,
2800- // since the offer includes only an inactive track, but it should still be handled correctly.
2801- //
2802- // Proper handling should include:
2803- // - Waiting until the signaling state becomes 'stable' before creating and setting a new offer
2804- // - Avoiding renegotiation if a remote offer is currently being processed
2805- // - Ensuring renegotiation is coordinated and state-aware
2806-
2807- final pcState = peerConnection.signalingState;
2808- _logger.fine (() => 'onRenegotiationNeeded signalingState: $pcState ' );
2809- if (pcState != null ) {
2810- final localDescription = await peerConnection.createOffer ({});
2811- sdpMunger? .apply (localDescription);
2812-
2813- // According to RFC 8829 5.6 (https://datatracker.ietf.org/doc/html/rfc8829#section-5.6),
2814- // localDescription should be set before sending the offer to transition into have-local-offer state.
2815- await peerConnection.setLocalDescription (localDescription);
2816-
2817- try {
2818- final updateRequest = UpdateRequest (
2819- transaction: WebtritSignalingClient .generateTransactionId (),
2820- line: lineId,
2821- callId: callId,
2822- jsep: localDescription.toMap (),
2823- );
2824- await _signalingClient? .execute (updateRequest);
2825- } catch (e, s) {
2826- callErrorReporter.handle (e, s, '_createPeerConnection:onRenegotiationNeeded error' );
2827- }
2828- }
2896+ /// Sends a renegotiation [UpdateRequest] to the signaling server with the given [jsep] offer.
2897+ ///
2898+ /// Used as a [RenegotiationExecutor] callback by [RenegotiationHandler] .
2899+ Future <void > _sendRenegotiationUpdate (String callId, int ? lineId, RTCSessionDescription jsep) async {
2900+ final updateRequest = UpdateRequest (
2901+ transaction: WebtritSignalingClient .generateTransactionId (),
2902+ line: lineId,
2903+ callId: callId,
2904+ jsep: jsep.toMap (),
2905+ );
2906+ await _signalingClient? .execute (updateRequest);
28292907 }
28302908
28312909 void _addToRecents (ActiveCall activeCall) {
0 commit comments