Skip to content

Commit 261eb97

Browse files
committed
fix: add signalingState guards and Perfect Negotiation rollback to call flow
ICE restart handler: - Skip setLocalDescription when signalingState != stable to prevent native crash - Log warning instead of silently skipping Renegotiation / accepted handler: - Guard setRemoteDescription(answer) against wrong state after glare resolution - Log transceivers after setRemoteDescription for SDP debugging - Catch String errors from setRemoteDescription to prevent unhandled exception escalation Updating handler (Perfect Negotiation rollback): - Pre-check signalingState for glare: if have-local-offer, roll back local offer before setRemoteDescription - Catch String errors containing have-local-offer as fallback for stale flutter_webrtc signalingState cache - Roll back and retry setRemoteDescription on confirmed glare Renderer and state: - Always refresh srcObject in RTCStreamView.didUpdateWidget to handle renegotiation-replaced tracks - Fix remoteVideo getter to use logical OR (stream tracks || video flag) instead of short-circuit Add call_state_test.dart covering ActiveCall equality and remoteVideo edge cases
1 parent 444fb51 commit 261eb97

4 files changed

Lines changed: 1197 additions & 62 deletions

File tree

lib/features/call/bloc/call_bloc.dart

Lines changed: 136 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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) {

lib/features/call/bloc/call_state.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,16 @@ class ActiveCall with _$ActiveCall implements CallEntry {
229229
@override
230230
bool get wasHungUp => hungUpTime != null;
231231

232-
bool get remoteVideo => remoteStream?.getVideoTracks().isNotEmpty ?? video;
232+
/// Whether the remote peer is expected to send (or is already sending) video.
233+
///
234+
/// Returns `true` when the remote stream contains at least one video track
235+
/// (confirmed by WebRTC). Falls back to the logical [video] flag when the
236+
/// stream is absent or audio-only — this covers the window between the SDP
237+
/// negotiation completing and the first video frame arriving, which is
238+
/// especially common after a glare-resolution rollback where [onAddStream]
239+
/// does not re-fire for the updated stream and only [onAddTrack] signals the
240+
/// new video track.
241+
bool get remoteVideo => (remoteStream?.getVideoTracks().isNotEmpty ?? false) || video;
233242

234243
/// Indicates whether the [localStream] contains at least one video track.
235244
///

lib/features/call/widgets/rtc_stream_view.dart

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ class _RTCStreamViewState extends State<RTCStreamView> {
4242
@override
4343
didUpdateWidget(RTCStreamView oldWidget) {
4444
super.didUpdateWidget(oldWidget);
45-
if (oldWidget.stream != widget.stream) {
46-
renderer.srcObject = widget.stream;
47-
}
45+
// Always refresh srcObject to handle the case where the stream reference
46+
// is the same object but its video tracks were replaced by renegotiation.
47+
// The native videoRendererSetSrcObject re-scans the stream's current tracks
48+
// and re-subscribes the renderer, ensuring the new track receives frames.
49+
renderer.srcObject = widget.stream;
4850
}
4951

5052
@override

0 commit comments

Comments
 (0)