diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index 0c7f19612..a1e36ef6b 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -95,6 +95,7 @@ class CallBloc extends Bloc with WidgetsBindingObserver im Timer? _presenceInfoSyncTimer; late final PeerConnectionManager _peerConnectionManager; + late final RenegotiationHandler _renegotiationHandler; final _callkeepSound = WebtritCallkeepSound(); @@ -129,6 +130,7 @@ class CallBloc extends Bloc with WidgetsBindingObserver im }) : super(const CallState()) { _signalingClientFactory = signalingClientFactory; _peerConnectionManager = peerConnectionManager; + _renegotiationHandler = RenegotiationHandler(callErrorReporter: callErrorReporter, sdpMunger: sdpMunger); on(_onCallStarted, transformer: sequential()); on<_AppLifecycleStateChanged>(_onAppLifecycleStateChanged, transformer: sequential()); @@ -1000,7 +1002,29 @@ class CallBloc extends Bloc with WidgetsBindingObserver im if (jsep != null && peerConnection != null) { final remoteDescription = jsep.toDescription(); sdpSanitizer?.apply(remoteDescription); - await peerConnection.setRemoteDescription(remoteDescription); + + // An accepted event with an answer jsep is only valid when the PC is in + // have-local-offer state. During a glare race the local offer may have + // been rolled back and replaced by an answer path in + // __onCallSignalingEventUpdating, leaving the PC in stable. Attempting + // setRemoteDescription(answer) in stable throws a wrong-state error that + // would propagate uncaught. Skip it — RenegotiationHandler will fire a + // fresh offer on the next onRenegotiationNeeded and the video exchange + // will complete via that cycle. + final signalingState = peerConnection.signalingState; + if (remoteDescription.type == 'answer' && signalingState != RTCSignalingState.RTCSignalingStateHaveLocalOffer) { + _logger.warning( + '__onCallSignalingEventAccepted: skipping setRemoteDescription(answer) ' + 'because signalingState=$signalingState (expected have-local-offer)', + ); + return; + } + + try { + await peerConnection.setRemoteDescription(remoteDescription); + } on String catch (e) { + _logger.warning('__onCallSignalingEventAccepted: setRemoteDescription failed ($e)'); + } } } @@ -1094,7 +1118,35 @@ class CallBloc extends Bloc with WidgetsBindingObserver im _logger.warning('__onCallSignalingEventUpdating: peerConnection is null - most likely some state issue'); } else { await peerConnectionPolicyApplier?.apply(peerConnection, hasRemoteVideo: jsep.hasVideo); - await peerConnection.setRemoteDescription(remoteDescription); + + // Optimistic pre-check for glare condition. May be stale because + // flutter_webrtc caches signalingState and updates it only when the + // onSignalingState callback fires — not when setLocalDescription completes. + // The try-catch below is the authoritative fallback. + final signalingState = peerConnection.signalingState; + if (signalingState == RTCSignalingState.RTCSignalingStateHaveLocalOffer) { + _logger.warning( + '__onCallSignalingEventUpdating: glare detected via pre-check (signalingState=$signalingState), rolling back local offer', + ); + await peerConnection.setLocalDescription(RTCSessionDescription('', 'rollback')); + } + + try { + await peerConnection.setRemoteDescription(remoteDescription); + } on String catch (e) { + if (e.contains('have-local-offer')) { + // Glare condition: signalingState pre-check was stale (flutter_webrtc + // caching), setLocalDescription completed on the native side but the + // Dart-side callback had not yet fired. Roll back and retry. + _logger.warning( + '__onCallSignalingEventUpdating: glare detected via catch ($e), rolling back local offer and retrying', + ); + await peerConnection.setLocalDescription(RTCSessionDescription('', 'rollback')); + await peerConnection.setRemoteDescription(remoteDescription); + } else { + rethrow; + } + } final localDescription = await peerConnection.createAnswer({}); sdpMunger?.apply(localDescription); @@ -1102,14 +1154,28 @@ class CallBloc extends Bloc with WidgetsBindingObserver im // localDescription should be set before sending the answer to transition into stable state. await peerConnection.setLocalDescription(localDescription); - await _signalingClient?.execute( - UpdateRequest( - transaction: WebtritSignalingClient.generateTransactionId(), - line: activeCall.line, - callId: activeCall.callId, - jsep: localDescription.toMap(), - ), - ); + // Signaling errors on the answer UpdateRequest (e.g. 448 "SDP type answer is + // incompatible with session status incall") indicate a server-side race rather + // than a fatal call error. The WebRTC PC has already completed its local + // offer/answer exchange, so media continues to flow. libwebrtc keeps the + // [[NegotiationNeeded]] flag set when glare rolled back a pending offer, and + // will re-fire onRenegotiationNeeded once the PC returns to stable — letting + // RenegotiationHandler send a fresh offer after the server settles. + try { + await _signalingClient?.execute( + UpdateRequest( + transaction: WebtritSignalingClient.generateTransactionId(), + line: activeCall.line, + callId: activeCall.callId, + jsep: localDescription.toMap(), + ), + ); + } on WebtritSignalingErrorException catch (e) { + _logger.warning( + '__onCallSignalingEventUpdating: UpdateRequest(answer) rejected by server ($e), ' + 'call continues — RenegotiationHandler will re-offer on next onRenegotiationNeeded', + ); + } } }); } @@ -1119,6 +1185,17 @@ class CallBloc extends Bloc with WidgetsBindingObserver im _peerConnectionManager.completeError(event.callId, e); add(_ResetStateEvent.completeCall(event.callId)); } + + // Always clear the updating flag after processing, regardless of outcome. + // The server normally sends an `updated` event (handled by __onCallSignalingEventUpdated), + // but in the glare+448 scenario it sends a CallErrorEvent instead, which leaves + // `updating: true` stuck and hides the video UI. Clearing it here is safe because + // __onCallSignalingEventUpdated is idempotent (setting false when already false is a no-op). + emit( + state.copyWithMappedActiveCall(event.callId, (activeCall) { + return activeCall.copyWith(updating: false); + }), + ); } Future __onCallSignalingEventUpdated(_CallSignalingEventUpdated event, Emitter emit) async { @@ -2187,21 +2264,35 @@ class CallBloc extends Bloc with WidgetsBindingObserver im '__onPeerConnectionEventIceConnectionStateChanged: peerConnection is null - most likely some state issue', ); } else { - await peerConnection.restartIce(); - final localDescription = await peerConnection.createOffer({}); - sdpMunger?.apply(localDescription); - - // According to RFC 8829 5.6 (https://datatracker.ietf.org/doc/html/rfc8829#section-5.6), - // localDescription should be set before sending the answer to transition into stable state. - await peerConnection.setLocalDescription(localDescription); - - final updateRequest = UpdateRequest( - transaction: WebtritSignalingClient.generateTransactionId(), - line: activeCall.line, - callId: activeCall.callId, - jsep: localDescription.toMap(), - ); - await _signalingClient?.execute(updateRequest); + final pcState = peerConnection.signalingState; + if (pcState == RTCSignalingState.RTCSignalingStateStable) { + await peerConnection.restartIce(); + final localDescription = await peerConnection.createOffer({}); + sdpMunger?.apply(localDescription); + + final currentState = peerConnection.signalingState; + if (currentState == RTCSignalingState.RTCSignalingStateStable) { + // According to the WebRTC spec (https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection-setlocaldescription), + // setLocalDescription must be called before sending the offer to the remote side. + await peerConnection.setLocalDescription(localDescription); + + final updateRequest = UpdateRequest( + transaction: WebtritSignalingClient.generateTransactionId(), + line: activeCall.line, + callId: activeCall.callId, + jsep: localDescription.toMap(), + ); + await _signalingClient?.execute(updateRequest); + } else { + _logger.warning( + '__onPeerConnectionEventIceConnectionStateChanged: signalingState changed mid-flight ($currentState), skipping setLocalDescription', + ); + } + } else { + _logger.warning( + '__onPeerConnectionEventIceConnectionStateChanged: signalingState is $pcState, skipping ICE restart', + ); + } } }); } catch (e, stackTrace) { @@ -2781,51 +2872,29 @@ class CallBloc extends Bloc with WidgetsBindingObserver im onIceCandidate: (candidate) => add(_PeerConnectionEvent.iceCandidateIdentified(callId, candidate)), onAddStream: (stream) => add(_PeerConnectionEvent.streamAdded(callId, stream)), onRemoveStream: (stream) => add(_PeerConnectionEvent.streamRemoved(callId, stream)), - onRenegotiationNeeded: (pc) => _handleRenegotiationNeeded(callId, lineId, pc), + // onAddTrack fires during renegotiation when a new track is added to an + // existing stream. In that case onAddStream does NOT re-fire (only fired + // once per unique stream ID). Forwarding the stream here ensures the BLoC + // state is updated with the latest stream reference when video is added + // mid-call (e.g. after a glare-resolution rollback). + onAddTrack: (stream, track) => add(_PeerConnectionEvent.streamAdded(callId, stream)), + onRenegotiationNeeded: (pc) => + unawaited(_renegotiationHandler.handle(callId, lineId, pc, _sendRenegotiationUpdate)), ), ); } - Future _handleRenegotiationNeeded(String callId, int? lineId, RTCPeerConnection peerConnection) async { - // TODO(Serdun): Handle renegotiation needed - // This implementation does not handle all possible signaling states. - // Specifically, if the current state is `have-remote-offer`, calling - // setLocalDescription with an offer will throw: - // WEBRTC_SET_LOCAL_DESCRIPTION_ERROR: Failed to set local offer sdp: Called in wrong state: have-remote-offer - // - // Known case: when CalleeVideoOfferPolicy.includeInactiveTrack is used, - // the callee may trigger onRenegotiationNeeded before the current remote offer is processed. - // This causes a race where the local peer is still in 'have-remote-offer' state, - // leading to the above error. Currently this does not severely affect behavior, - // since the offer includes only an inactive track, but it should still be handled correctly. - // - // Proper handling should include: - // - Waiting until the signaling state becomes 'stable' before creating and setting a new offer - // - Avoiding renegotiation if a remote offer is currently being processed - // - Ensuring renegotiation is coordinated and state-aware - - final pcState = peerConnection.signalingState; - _logger.fine(() => 'onRenegotiationNeeded signalingState: $pcState'); - if (pcState != null) { - final localDescription = await peerConnection.createOffer({}); - sdpMunger?.apply(localDescription); - - // According to RFC 8829 5.6 (https://datatracker.ietf.org/doc/html/rfc8829#section-5.6), - // localDescription should be set before sending the offer to transition into have-local-offer state. - await peerConnection.setLocalDescription(localDescription); - - try { - final updateRequest = UpdateRequest( - transaction: WebtritSignalingClient.generateTransactionId(), - line: lineId, - callId: callId, - jsep: localDescription.toMap(), - ); - await _signalingClient?.execute(updateRequest); - } catch (e, s) { - callErrorReporter.handle(e, s, '_createPeerConnection:onRenegotiationNeeded error'); - } - } + /// Sends a renegotiation [UpdateRequest] to the signaling server with the given [jsep] offer. + /// + /// Used as a [RenegotiationExecutor] callback by [RenegotiationHandler]. + Future _sendRenegotiationUpdate(String callId, int? lineId, RTCSessionDescription jsep) async { + final updateRequest = UpdateRequest( + transaction: WebtritSignalingClient.generateTransactionId(), + line: lineId, + callId: callId, + jsep: jsep.toMap(), + ); + await _signalingClient?.execute(updateRequest); } void _addToRecents(ActiveCall activeCall) { diff --git a/lib/features/call/bloc/call_state.dart b/lib/features/call/bloc/call_state.dart index eecc68381..e3475b7c9 100644 --- a/lib/features/call/bloc/call_state.dart +++ b/lib/features/call/bloc/call_state.dart @@ -229,7 +229,16 @@ class ActiveCall with _$ActiveCall implements CallEntry { @override bool get wasHungUp => hungUpTime != null; - bool get remoteVideo => remoteStream?.getVideoTracks().isNotEmpty ?? video; + /// Whether the remote peer is expected to send (or is already sending) video. + /// + /// Returns `true` when the remote stream contains at least one video track + /// (confirmed by WebRTC). Falls back to the logical [video] flag when the + /// stream is absent or audio-only — this covers the window between the SDP + /// negotiation completing and the first video frame arriving, which is + /// especially common after a glare-resolution rollback where [onAddStream] + /// does not re-fire for the updated stream and only [onAddTrack] signals the + /// new video track. + bool get remoteVideo => (remoteStream?.getVideoTracks().isNotEmpty ?? false) || video; /// Indicates whether the [localStream] contains at least one video track. /// diff --git a/lib/features/call/utils/renegotiation_handler.dart b/lib/features/call/utils/renegotiation_handler.dart new file mode 100644 index 000000000..2a0d2653c --- /dev/null +++ b/lib/features/call/utils/renegotiation_handler.dart @@ -0,0 +1,111 @@ +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:logging/logging.dart'; + +import 'call_error_reporter.dart'; +import 'sdp_munger.dart'; + +final _logger = Logger('RenegotiationHandler'); + +/// Callback responsible for sending the renegotiation offer to the remote peer +/// via the signaling channel. The caller constructs the transport-specific +/// request; this handler stays decoupled from the signaling layer. +typedef RenegotiationExecutor = Future Function(String callId, int? lineId, RTCSessionDescription jsep); + +/// Handles WebRTC renegotiation triggered by [RTCPeerConnection.onRenegotiationNeeded]. +/// +/// ## Architecture constraints +/// +/// This handler is designed for a **server-mediated** topology (e.g. Janus SFU) +/// where offer/answer exchanges are serialised by the media server. +/// Glare (simultaneous offers from both peers) cannot occur in this topology, +/// so the simplified skip-on-non-stable strategy is sufficient and safe. +/// +/// **P2P note:** in a direct peer-to-peer topology, simultaneous offers from +/// both sides are possible. The current skip logic would silently drop one of +/// the offers. Full [Perfect Negotiation](https://www.w3.org/TR/webrtc/#perfect-negotiation-example) +/// with rollback must be implemented before removing the media server. +/// +/// ## Stable-state guards +/// +/// Two checks enforce the RTCPeerConnection state machine rules +/// (RFC 8829 §4, W3C WebRTC §4.7): +/// +/// 1. **Before `createOffer`** — skips if the current signaling state is not +/// `stable`. In a server-mediated call the skipped renegotiation is safe: +/// if the event was triggered by an incoming remote offer +/// (e.g. [CalleeVideoOfferPolicy.includeInactiveTrack]), the pending track +/// will already be included in the answer; if it was triggered by a genuine +/// local change, libwebrtc will re-fire [onRenegotiationNeeded] once the +/// peer connection returns to `stable`. +/// +/// 2. **After `createOffer` (TOCTOU guard)** — re-checks the state before +/// calling `setLocalDescription`. The `await createOffer()` yields control +/// to the event loop; a concurrent signaling event may transition the +/// connection out of `stable` in the meantime. +class RenegotiationHandler { + RenegotiationHandler({required this.callErrorReporter, this.sdpMunger}); + + final CallErrorReporter callErrorReporter; + final SDPMunger? sdpMunger; + + /// Executes a renegotiation cycle for [callId] on [peerConnection]. + /// + /// Skips silently when the signaling state is not `stable` (see class-level + /// doc). Transient wrong-state errors from [RTCPeerConnection.setLocalDescription] + /// (TOCTOU race with a concurrent [RTCPeerConnection.setRemoteDescription]) are + /// logged at WARNING without escalating to [callErrorReporter] — libwebrtc will + /// re-fire [RTCPeerConnection.onRenegotiationNeeded] once the connection returns + /// to stable. All other WebRTC and signaling errors are forwarded to + /// [callErrorReporter] and do not propagate to the caller. + Future handle( + String callId, + int? lineId, + RTCPeerConnection peerConnection, + RenegotiationExecutor execute, + ) async { + final stateBeforeOffer = peerConnection.signalingState; + _logger.fine(() => 'onRenegotiationNeeded signalingState: $stateBeforeOffer'); + if (stateBeforeOffer != RTCSignalingState.RTCSignalingStateStable) { + _logger.fine(() => 'onRenegotiationNeeded skipped: not in stable state ($stateBeforeOffer)'); + return; + } + + try { + final localDescription = await peerConnection.createOffer({}); + sdpMunger?.apply(localDescription); + + final stateAfterOffer = peerConnection.signalingState; + if (stateAfterOffer != RTCSignalingState.RTCSignalingStateStable) { + _logger.fine( + () => + 'onRenegotiationNeeded: state changed to $stateAfterOffer after createOffer, skipping setLocalDescription', + ); + return; + } + + // According to RFC 8829 5.6 (https://datatracker.ietf.org/doc/html/rfc8829#section-5.6), + // localDescription should be set before sending the offer to transition into have-local-offer state. + await peerConnection.setLocalDescription(localDescription); + + await execute(callId, lineId, localDescription); + } on String catch (e) { + // flutter_webrtc surfaces native errors as plain strings. A "wrong state" failure + // on setLocalDescription means a concurrent setRemoteDescription (e.g. from an + // incoming updating_call) moved the PC out of stable between the TOCTOU guard and + // the setLocalDescription call. This is a transient race — libwebrtc keeps the + // [[NegotiationNeeded]] flag set and will re-fire onRenegotiationNeeded once the + // PC returns to stable. No user notification is needed. + if (e.contains('wrong state') || e.contains('have-remote-offer') || e.contains('have-local-offer')) { + _logger.warning( + () => + 'onRenegotiationNeeded: setLocalDescription failed in wrong state ($e) ' + '— libwebrtc will re-fire onRenegotiationNeeded when stable', + ); + } else { + callErrorReporter.handle(e, null, 'RenegotiationHandler.handle error (callId=$callId, lineId=$lineId)'); + } + } catch (e, s) { + callErrorReporter.handle(e, s, 'RenegotiationHandler.handle error (callId=$callId, lineId=$lineId)'); + } + } +} diff --git a/lib/features/call/utils/utils.dart b/lib/features/call/utils/utils.dart index 60029c74a..aa6ddf178 100644 --- a/lib/features/call/utils/utils.dart +++ b/lib/features/call/utils/utils.dart @@ -7,6 +7,7 @@ export 'ice_filter.dart'; export 'logging_rtp_traffic_monitor_delegate.dart'; export 'peer_connection_factory.dart'; export 'peer_connection_manager.dart'; +export 'renegotiation_handler.dart'; export 'peer_connection_policy_applier.dart'; export 'rtp_traffic_monitor.dart'; export 'sdp_mod_builder.dart'; diff --git a/test/features/call/bloc/call_state_test.dart b/test/features/call/bloc/call_state_test.dart new file mode 100644 index 000000000..ac72c6bca --- /dev/null +++ b/test/features/call/bloc/call_state_test.dart @@ -0,0 +1,1046 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:webtrit_phone/features/call/call.dart'; +import 'package:webtrit_phone/models/models.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const _kHandle = CallkeepHandle.number('+380991234567'); + +ActiveCall _makeCall({ + String callId = 'call-1', + CallDirection direction = CallDirection.incoming, + int? line = 0, + bool video = false, + CallProcessingStatus processingStatus = CallProcessingStatus.connected, + bool held = false, + bool muted = false, + DateTime? acceptedTime, + DateTime? hungUpTime, + Transfer? transfer, + CallkeepHandle handle = _kHandle, + String? displayName, +}) { + return ActiveCall( + callId: callId, + direction: direction, + line: line, + handle: handle, + createdTime: DateTime(2024), + video: video, + processingStatus: processingStatus, + held: held, + muted: muted, + acceptedTime: acceptedTime, + hungUpTime: hungUpTime, + transfer: transfer, + displayName: displayName, + ); +} + +// --------------------------------------------------------------------------- +// ActiveCall — direction +// --------------------------------------------------------------------------- + +void main() { + group('ActiveCall.isIncoming / isOutgoing', () { + test('isIncoming is true for incoming direction', () { + final call = _makeCall(direction: CallDirection.incoming); + expect(call.isIncoming, isTrue); + expect(call.isOutgoing, isFalse); + }); + + test('isOutgoing is true for outgoing direction', () { + final call = _makeCall(direction: CallDirection.outgoing); + expect(call.isOutgoing, isTrue); + expect(call.isIncoming, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCall — acceptance / hangup state + // --------------------------------------------------------------------------- + + group('ActiveCall.wasAccepted / wasHungUp', () { + test('wasAccepted is false before answer', () { + final call = _makeCall(acceptedTime: null); + expect(call.wasAccepted, isFalse); + }); + + test('wasAccepted is true after answer', () { + final call = _makeCall(acceptedTime: DateTime(2024)); + expect(call.wasAccepted, isTrue); + }); + + test('wasHungUp is false while call is active', () { + final call = _makeCall(hungUpTime: null); + expect(call.wasHungUp, isFalse); + }); + + test('wasHungUp is true after hangup', () { + final call = _makeCall(hungUpTime: DateTime(2024)); + expect(call.wasHungUp, isTrue); + }); + + test('a new incoming call has neither accepted nor hung-up timestamps', () { + final call = _makeCall(processingStatus: CallProcessingStatus.incomingFromOffer); + expect(call.wasAccepted, isFalse); + expect(call.wasHungUp, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCall — video / camera state + // --------------------------------------------------------------------------- + + group('ActiveCall.isCameraActive', () { + test('is false for voice call (video = false, no stream)', () { + final call = _makeCall(video: false); + expect(call.isCameraActive, isFalse); + }); + + test('is false for video call without a local video track', () { + // localStream is null — hasLocalVideoTrack returns false + final call = _makeCall(video: true); + expect(call.hasLocalVideoTrack, isFalse); + expect(call.isCameraActive, isFalse); + }); + }); + + group('ActiveCall.remoteVideo', () { + test('falls back to the video flag when remoteStream is null', () { + final voiceCall = _makeCall(video: false); + expect(voiceCall.remoteVideo, isFalse); + + final videoCall = _makeCall(video: true); + expect(videoCall.remoteVideo, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCall — mute / hold flags + // --------------------------------------------------------------------------- + + group('ActiveCall.muted / held', () { + test('fresh call is not muted and not held', () { + final call = _makeCall(); + expect(call.muted, isFalse); + expect(call.held, isFalse); + }); + + test('muted call is reflected in the flag', () { + final call = _makeCall(muted: true); + expect(call.muted, isTrue); + }); + + test('held call is reflected in the flag', () { + final call = _makeCall(held: true); + expect(call.held, isTrue); + }); + + test('muted and held can be true simultaneously', () { + final call = _makeCall(muted: true, held: true); + expect(call.muted, isTrue); + expect(call.held, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCall — processing status lifecycle: incoming flow + // --------------------------------------------------------------------------- + + group('ActiveCall incoming lifecycle statuses', () { + final incomingStates = [ + CallProcessingStatus.incomingFromPush, + CallProcessingStatus.incomingFromOffer, + CallProcessingStatus.incomingSubmittedAnswer, + CallProcessingStatus.incomingPerformingStarted, + CallProcessingStatus.incomingInitializingMedia, + CallProcessingStatus.incomingAnswering, + ]; + + for (final status in incomingStates) { + test('status $status is considered pre-connected (not connected)', () { + final call = _makeCall(processingStatus: status); + expect(call.processingStatus, equals(status)); + expect(call.processingStatus == CallProcessingStatus.connected, isFalse); + }); + } + + test('connected status is set after answer exchange', () { + final call = _makeCall(processingStatus: CallProcessingStatus.connected); + expect(call.processingStatus, CallProcessingStatus.connected); + }); + + test('disconnecting status signals teardown phase', () { + final call = _makeCall(processingStatus: CallProcessingStatus.disconnecting); + expect(call.processingStatus, CallProcessingStatus.disconnecting); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCall — processing status lifecycle: outgoing flow + // --------------------------------------------------------------------------- + + group('ActiveCall outgoing lifecycle statuses', () { + final outgoingStates = [ + CallProcessingStatus.outgoingCreated, + CallProcessingStatus.outgoingCreatedFromRefer, + CallProcessingStatus.outgoingConnectingToSignaling, + CallProcessingStatus.outgoingInitializingMedia, + CallProcessingStatus.outgoingOfferPreparing, + CallProcessingStatus.outgoingOfferSent, + CallProcessingStatus.outgoingRinging, + ]; + + for (final status in outgoingStates) { + test('status $status is considered pre-connected (not connected)', () { + final call = _makeCall(direction: CallDirection.outgoing, processingStatus: status); + expect(call.processingStatus, equals(status)); + expect(call.processingStatus == CallProcessingStatus.connected, isFalse); + }); + } + }); + + // --------------------------------------------------------------------------- + // Transfer state machine + // --------------------------------------------------------------------------- + + group('Transfer state machine', () { + test('BlindTransferInitiated marks the intent to transfer', () { + final call = _makeCall(transfer: const Transfer.blindTransferInitiated()); + expect(call.transfer, isA()); + }); + + test('BlindTransferTransferSubmitted holds the target number', () { + final call = _makeCall(transfer: const Transfer.blindTransferTransferSubmitted(toNumber: '+1234567890')); + final transfer = call.transfer as BlindTransferTransferSubmitted; + expect(transfer.toNumber, '+1234567890'); + }); + + test('AttendedTransferTransferSubmitted holds the replace call id', () { + final call = _makeCall(transfer: const Transfer.attendedTransferTransferSubmitted(replaceCallId: 'replace-1')); + final transfer = call.transfer as AttendedTransferTransferSubmitted; + expect(transfer.replaceCallId, 'replace-1'); + }); + + test('Transfering distinguishes blind vs attended origin', () { + final blind = _makeCall( + transfer: const Transfer.transfering(fromAttendedTransfer: false, fromBlindTransfer: true), + ); + final attended = _makeCall( + transfer: const Transfer.transfering(fromAttendedTransfer: true, fromBlindTransfer: false), + ); + + expect((blind.transfer as Transfering).fromBlindTransfer, isTrue); + expect((attended.transfer as Transfering).fromAttendedTransfer, isTrue); + }); + + test('AttendedTransferConfirmationRequested carries refer metadata', () { + const transfer = Transfer.attendedTransferConfirmationRequested( + referId: 'ref-1', + referTo: '+380991111111', + referredBy: '+380992222222', + ); + final call = _makeCall(transfer: transfer); + final t = call.transfer as AttendedTransferConfirmationRequested; + expect(t.referId, 'ref-1'); + expect(t.referTo, '+380991111111'); + expect(t.referredBy, '+380992222222'); + }); + + test('InviteToAttendedTransfer carries replace and referredBy info', () { + const transfer = Transfer.inviteToAttendedTransfer(replaceCallId: 'replace-1', referredBy: '+380993333333'); + final call = _makeCall(transfer: transfer); + final t = call.transfer as InviteToAttendedTransfer; + expect(t.replaceCallId, 'replace-1'); + expect(t.referredBy, '+380993333333'); + }); + + test('call without transfer has null transfer field', () { + final call = _makeCall(); + expect(call.transfer, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCallIterableExtension — current + // --------------------------------------------------------------------------- + + group('ActiveCallIterableExtension.current', () { + test('returns the only call when one call exists', () { + final call = _makeCall(callId: 'c1'); + expect([call].current, same(call)); + }); + + test('returns the last non-held call when multiple calls exist', () { + final held = _makeCall(callId: 'c1', held: true); + final active = _makeCall(callId: 'c2', held: false); + expect([held, active].current, same(active)); + }); + + test('returns the last call when all calls are held', () { + final hold1 = _makeCall(callId: 'c1', held: true); + final hold2 = _makeCall(callId: 'c2', held: true); + expect([hold1, hold2].current, same(hold2)); + }); + + test('ignores earlier active calls and returns last non-held', () { + final active1 = _makeCall(callId: 'c1', held: false); + final active2 = _makeCall(callId: 'c2', held: false); + // last non-held is active2 + expect([active1, active2].current, same(active2)); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCallIterableExtension — nonCurrent + // --------------------------------------------------------------------------- + + group('ActiveCallIterableExtension.nonCurrent', () { + test('returns empty list for a single call', () { + final call = _makeCall(callId: 'c1'); + expect([call].nonCurrent, isEmpty); + }); + + test('returns all calls except current', () { + final held = _makeCall(callId: 'c1', held: true); + final active = _makeCall(callId: 'c2', held: false); + expect([held, active].nonCurrent, equals([held])); + }); + }); + + // --------------------------------------------------------------------------- + // ActiveCallIterableExtension — blindTransferInitiated + // --------------------------------------------------------------------------- + + group('ActiveCallIterableExtension.blindTransferInitiated', () { + test('returns null when no call has BlindTransferInitiated', () { + final calls = [_makeCall(callId: 'c1'), _makeCall(callId: 'c2')]; + expect(calls.blindTransferInitiated, isNull); + }); + + test('returns the call with BlindTransferInitiated transfer', () { + final regular = _makeCall(callId: 'c1'); + final withTransfer = _makeCall(callId: 'c2', transfer: const Transfer.blindTransferInitiated()); + final calls = [regular, withTransfer]; + expect(calls.blindTransferInitiated, same(withTransfer)); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — display + // --------------------------------------------------------------------------- + + group('CallState.display', () { + test('none — no active calls and minimized is null', () { + const state = CallState(); + expect(state.display, CallDisplay.none); + }); + + test('noneScreen — no active calls and minimized is false (call screen visible but empty)', () { + const state = CallState(minimized: false); + expect(state.display, CallDisplay.noneScreen); + }); + + test('screen — active calls and minimized is not true', () { + final call = _makeCall(); + final state = CallState(activeCalls: [call]); + expect(state.display, CallDisplay.screen); + }); + + test('overlay — active calls and minimized is true', () { + final call = _makeCall(); + final state = CallState(activeCalls: [call], minimized: true); + expect(state.display, CallDisplay.overlay); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — isActive + // --------------------------------------------------------------------------- + + group('CallState.isActive', () { + test('false when no calls', () { + const state = CallState(); + expect(state.isActive, isFalse); + }); + + test('true when at least one call is present', () { + final state = CallState(activeCalls: [_makeCall()]); + expect(state.isActive, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — isVoiceChat + // --------------------------------------------------------------------------- + + group('CallState.isVoiceChat', () { + test('true when current call is audio-only', () { + final state = CallState(activeCalls: [_makeCall(video: false)]); + expect(state.isVoiceChat, isTrue); + }); + + test('false when current call is a video call', () { + final state = CallState(activeCalls: [_makeCall(video: true)]); + expect(state.isVoiceChat, isFalse); + }); + + test('true when active call is held (voice) and the non-held current is voice', () { + final held = _makeCall(callId: 'c1', video: false, held: true); + final active = _makeCall(callId: 'c2', video: false, held: false); + final state = CallState(activeCalls: [held, active]); + expect(state.isVoiceChat, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — shouldListenToProximity + // --------------------------------------------------------------------------- + + group('CallState.shouldListenToProximity', () { + test('true when active voice call and not minimized', () { + final state = CallState(activeCalls: [_makeCall(video: false)]); + expect(state.shouldListenToProximity, isTrue); + }); + + test('false when no active calls', () { + const state = CallState(); + expect(state.shouldListenToProximity, isFalse); + }); + + test('false when call is a video call', () { + final state = CallState(activeCalls: [_makeCall(video: true)]); + expect(state.shouldListenToProximity, isFalse); + }); + + test('false when call screen is minimized (overlay)', () { + final state = CallState(activeCalls: [_makeCall(video: false)], minimized: true); + expect(state.shouldListenToProximity, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — isBlingTransferInitiated + // --------------------------------------------------------------------------- + + group('CallState.isBlingTransferInitiated', () { + test('false when no call has a blind transfer initiated', () { + final state = CallState(activeCalls: [_makeCall()]); + expect(state.isBlingTransferInitiated, isFalse); + }); + + test('true when at least one call has BlindTransferInitiated', () { + final call = _makeCall(transfer: const Transfer.blindTransferInitiated()); + final state = CallState(activeCalls: [call]); + expect(state.isBlingTransferInitiated, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — retrieveIdleLine + // --------------------------------------------------------------------------- + + group('CallState.retrieveIdleLine', () { + test('returns null when linesCount is 0', () { + const state = CallState(linesCount: 0); + expect(state.retrieveIdleLine(), isNull); + }); + + test('returns line 0 when two lines configured and none busy', () { + const state = CallState(linesCount: 2); + expect(state.retrieveIdleLine(), 0); + }); + + test('returns line 1 when line 0 is occupied', () { + final call = _makeCall(line: 0); + final state = CallState(linesCount: 2, activeCalls: [call]); + expect(state.retrieveIdleLine(), 1); + }); + + test('returns null when all lines are occupied', () { + final call0 = _makeCall(callId: 'c0', line: 0); + final call1 = _makeCall(callId: 'c1', line: 1); + final state = CallState(linesCount: 2, activeCalls: [call0, call1]); + expect(state.retrieveIdleLine(), isNull); + }); + + test('ignores null-line calls (guest calls) when computing idle lines', () { + // Guest calls use null line — they should not block numbered lines. + final guest = _makeCall(callId: 'guest', line: null); + final state = CallState(linesCount: 1, activeCalls: [guest]); + expect(state.retrieveIdleLine(), 0); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — retrieveActiveCall + // --------------------------------------------------------------------------- + + group('CallState.retrieveActiveCall', () { + test('returns null for an unknown callId', () { + const state = CallState(); + expect(state.retrieveActiveCall('nonexistent'), isNull); + }); + + test('returns the matching call', () { + final call = _makeCall(callId: 'target'); + final state = CallState( + activeCalls: [ + _makeCall(callId: 'other'), + call, + ], + ); + expect(state.retrieveActiveCall('target'), same(call)); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — copyWithPushActiveCall + // --------------------------------------------------------------------------- + + group('CallState.copyWithPushActiveCall', () { + test('appends the call to an empty list', () { + const state = CallState(); + final call = _makeCall(); + final next = state.copyWithPushActiveCall(call); + expect(next.activeCalls, [call]); + }); + + test('appends to an existing list without mutating the original', () { + final existing = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [existing]); + final incoming = _makeCall(callId: 'c2'); + final next = state.copyWithPushActiveCall(incoming); + + expect(next.activeCalls.length, 2); + expect(state.activeCalls.length, 1); // original unchanged + }); + }); + + // --------------------------------------------------------------------------- + // CallState — copyWithPopActiveCall + // --------------------------------------------------------------------------- + + group('CallState.copyWithPopActiveCall', () { + test('removes the call with the given id', () { + final call = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [call]); + final next = state.copyWithPopActiveCall('c1'); + expect(next.activeCalls, isEmpty); + }); + + test('resets minimized to null when last call is removed', () { + final call = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [call], minimized: false); + final next = state.copyWithPopActiveCall('c1'); + expect(next.minimized, isNull); + }); + + test('preserves minimized when other calls still remain', () { + final c1 = _makeCall(callId: 'c1'); + final c2 = _makeCall(callId: 'c2'); + final state = CallState(activeCalls: [c1, c2], minimized: false); + final next = state.copyWithPopActiveCall('c1'); + expect(next.activeCalls, [c2]); + expect(next.minimized, false); + }); + + test('does nothing when callId does not match any call', () { + final call = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [call]); + final next = state.copyWithPopActiveCall('ghost'); + expect(next.activeCalls, [call]); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — copyWithMappedActiveCall + // --------------------------------------------------------------------------- + + group('CallState.copyWithMappedActiveCall', () { + test('applies the mapper to the matching call only', () { + final c1 = _makeCall(callId: 'c1', muted: false); + final c2 = _makeCall(callId: 'c2', muted: false); + final state = CallState(activeCalls: [c1, c2]); + + final next = state.copyWithMappedActiveCall('c1', (c) => c.copyWith(muted: true)); + + final updated = next.activeCalls.firstWhere((c) => c.callId == 'c1'); + final untouched = next.activeCalls.firstWhere((c) => c.callId == 'c2'); + + expect(updated.muted, isTrue); + expect(untouched.muted, isFalse); + }); + + test('does not change the list when callId is not found', () { + final c1 = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [c1]); + final next = state.copyWithMappedActiveCall('ghost', (c) => c.copyWith(muted: true)); + expect(next.activeCalls.first.muted, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — copyWithMappedActiveCalls + // --------------------------------------------------------------------------- + + group('CallState.copyWithMappedActiveCalls', () { + test('applies the mapper to every call', () { + final c1 = _makeCall(callId: 'c1', muted: false); + final c2 = _makeCall(callId: 'c2', muted: false); + final state = CallState(activeCalls: [c1, c2]); + + final next = state.copyWithMappedActiveCalls((c) => c.copyWith(muted: true)); + + expect(next.activeCalls.every((c) => c.muted), isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // CallState — performOnActiveCall + // --------------------------------------------------------------------------- + + group('CallState.performOnActiveCall', () { + test('returns null when callId is not found', () { + const state = CallState(); + final result = state.performOnActiveCall('missing', (c) => c.callId); + expect(result, isNull); + }); + + test('invokes the callback with the matching call and returns its result', () { + final call = _makeCall(callId: 'c1', displayName: 'Alice'); + final state = CallState(activeCalls: [call]); + final result = state.performOnActiveCall('c1', (c) => c.displayName!); + expect(result, 'Alice'); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: incoming call full lifecycle + // --------------------------------------------------------------------------- + + group('Scenario: incoming call lifecycle', () { + test('push notification creates a call in incomingFromPush status', () { + final call = _makeCall( + direction: CallDirection.incoming, + processingStatus: CallProcessingStatus.incomingFromPush, + ); + final state = CallState(activeCalls: [call]); + + expect(state.isActive, isTrue); + expect(state.activeCalls.first.processingStatus, CallProcessingStatus.incomingFromPush); + expect(state.activeCalls.first.wasAccepted, isFalse); + }); + + test('signaling offer updates call to incomingFromOffer', () { + final call = _makeCall( + direction: CallDirection.incoming, + processingStatus: CallProcessingStatus.incomingFromOffer, + ); + final state = CallState(activeCalls: [call]); + expect(state.activeCalls.first.processingStatus, CallProcessingStatus.incomingFromOffer); + }); + + test('user answers — call moves through answer phases to connected', () { + const phases = [ + CallProcessingStatus.incomingSubmittedAnswer, + CallProcessingStatus.incomingPerformingStarted, + CallProcessingStatus.incomingInitializingMedia, + CallProcessingStatus.incomingAnswering, + CallProcessingStatus.connected, + ]; + for (final phase in phases) { + final call = _makeCall(processingStatus: phase); + final state = CallState(activeCalls: [call]); + expect(state.activeCalls.first.processingStatus, phase); + } + }); + + test('connected incoming call has acceptedTime set', () { + final call = _makeCall(processingStatus: CallProcessingStatus.connected, acceptedTime: DateTime(2024)); + expect(call.wasAccepted, isTrue); + }); + + test('call removed after hangup', () { + final call = _makeCall(callId: 'inc-1'); + final state = CallState(activeCalls: [call]); + final after = state.copyWithPopActiveCall('inc-1'); + expect(after.isActive, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: outgoing call full lifecycle + // --------------------------------------------------------------------------- + + group('Scenario: outgoing call lifecycle', () { + test('outgoing call starts in outgoingCreated', () { + final call = _makeCall( + callId: 'out-1', + direction: CallDirection.outgoing, + processingStatus: CallProcessingStatus.outgoingCreated, + ); + final state = CallState(activeCalls: [call]); + expect(state.isActive, isTrue); + expect(state.activeCalls.first.processingStatus, CallProcessingStatus.outgoingCreated); + }); + + test('ringing phase is reached after offer is sent', () { + final call = _makeCall(direction: CallDirection.outgoing, processingStatus: CallProcessingStatus.outgoingRinging); + expect(call.processingStatus, CallProcessingStatus.outgoingRinging); + }); + + test('call reaches connected when remote accepts', () { + final call = _makeCall( + direction: CallDirection.outgoing, + processingStatus: CallProcessingStatus.connected, + acceptedTime: DateTime(2024), + ); + expect(call.processingStatus, CallProcessingStatus.connected); + expect(call.wasAccepted, isTrue); + }); + + test('outgoing call removed from state after hangup', () { + final call = _makeCall(callId: 'out-1', direction: CallDirection.outgoing); + final state = CallState(activeCalls: [call]); + final after = state.copyWithPopActiveCall('out-1'); + expect(after.isActive, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: declined / unanswered call + // --------------------------------------------------------------------------- + + group('Scenario: declined / unanswered call', () { + test('unanswered call has no acceptedTime but has hungUpTime', () { + final call = _makeCall( + processingStatus: CallProcessingStatus.disconnecting, + acceptedTime: null, + hungUpTime: DateTime(2024), + ); + expect(call.wasAccepted, isFalse); + expect(call.wasHungUp, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: call on hold + // --------------------------------------------------------------------------- + + group('Scenario: call hold / unhold', () { + test('hold sets held flag on the call', () { + final call = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [call]); + final afterHold = state.copyWithMappedActiveCall('c1', (c) => c.copyWith(held: true)); + + expect(afterHold.activeCalls.first.held, isTrue); + }); + + test('unhold clears held flag', () { + final call = _makeCall(callId: 'c1', held: true); + final state = CallState(activeCalls: [call]); + final afterUnhold = state.copyWithMappedActiveCall('c1', (c) => c.copyWith(held: false)); + + expect(afterUnhold.activeCalls.first.held, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: mute / unmute + // --------------------------------------------------------------------------- + + group('Scenario: mute / unmute', () { + test('mute sets the muted flag', () { + final call = _makeCall(callId: 'c1', muted: false); + final state = CallState(activeCalls: [call]); + final muted = state.copyWithMappedActiveCall('c1', (c) => c.copyWith(muted: true)); + expect(muted.activeCalls.first.muted, isTrue); + }); + + test('unmute clears the muted flag', () { + final call = _makeCall(callId: 'c1', muted: true); + final state = CallState(activeCalls: [call]); + final unmuted = state.copyWithMappedActiveCall('c1', (c) => c.copyWith(muted: false)); + expect(unmuted.activeCalls.first.muted, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: two simultaneous calls (one held, one active) + // --------------------------------------------------------------------------- + + group('Scenario: two simultaneous calls', () { + test('current returns the non-held call', () { + final held = _makeCall(callId: 'held', held: true); + final active = _makeCall(callId: 'active', held: false); + final state = CallState(activeCalls: [held, active]); + + expect(state.activeCalls.current.callId, 'active'); + expect(state.activeCalls.nonCurrent.single.callId, 'held'); + }); + + test('both calls are reflected in the state', () { + final c1 = _makeCall(callId: 'c1'); + final c2 = _makeCall(callId: 'c2'); + final state = CallState(activeCalls: [c1, c2]); + expect(state.activeCalls.length, 2); + expect(state.isActive, isTrue); + }); + + test('removing one call leaves the other active', () { + final c1 = _makeCall(callId: 'c1'); + final c2 = _makeCall(callId: 'c2'); + final state = CallState(activeCalls: [c1, c2]); + final next = state.copyWithPopActiveCall('c1'); + + expect(next.activeCalls.length, 1); + expect(next.activeCalls.first.callId, 'c2'); + expect(next.isActive, isTrue); + }); + + test('proximity sensor is off when call is minimized', () { + final call = _makeCall(video: false); + final minimized = CallState(activeCalls: [call], minimized: true); + expect(minimized.shouldListenToProximity, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: blind transfer flow in state + // --------------------------------------------------------------------------- + + group('Scenario: blind transfer flow', () { + test('step 1 — BlindTransferInitiated marks intent', () { + final call = _makeCall(callId: 'c1', transfer: const Transfer.blindTransferInitiated()); + final state = CallState(activeCalls: [call]); + expect(state.isBlingTransferInitiated, isTrue); + expect(state.activeCalls.blindTransferInitiated?.callId, 'c1'); + }); + + test('step 2 — BlindTransferTransferSubmitted records target number', () { + final call = _makeCall( + callId: 'c1', + transfer: const Transfer.blindTransferTransferSubmitted(toNumber: '+1234567890'), + ); + final state = CallState(activeCalls: [call]); + expect(state.isBlingTransferInitiated, isFalse); + final t = state.activeCalls.first.transfer as BlindTransferTransferSubmitted; + expect(t.toNumber, '+1234567890'); + }); + + test('step 3 — Transfering (blind) shows transfer in progress', () { + final call = _makeCall( + callId: 'c1', + transfer: const Transfer.transfering(fromAttendedTransfer: false, fromBlindTransfer: true), + ); + final state = CallState(activeCalls: [call]); + final t = state.activeCalls.first.transfer as Transfering; + expect(t.fromBlindTransfer, isTrue); + expect(t.fromAttendedTransfer, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: attended transfer flow in state + // --------------------------------------------------------------------------- + + group('Scenario: attended transfer flow', () { + test('AttendedTransferTransferSubmitted contains replaceCallId', () { + final call = _makeCall( + callId: 'c1', + transfer: const Transfer.attendedTransferTransferSubmitted(replaceCallId: 'replace-1'), + ); + final t = call.transfer as AttendedTransferTransferSubmitted; + expect(t.replaceCallId, 'replace-1'); + }); + + test('AttendedTransferConfirmationRequested prompts user for decision', () { + final call = _makeCall( + callId: 'c1', + transfer: const Transfer.attendedTransferConfirmationRequested( + referId: 'ref-1', + referTo: '+380991111111', + referredBy: '+380992222222', + ), + ); + expect(call.transfer, isA()); + }); + + test('InviteToAttendedTransfer represents an incoming transfer invitation', () { + final call = _makeCall( + callId: 'c2', + transfer: const Transfer.inviteToAttendedTransfer(replaceCallId: 'c1', referredBy: 'transferor'), + ); + final t = call.transfer as InviteToAttendedTransfer; + expect(t.replaceCallId, 'c1'); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: video call — camera toggle + // --------------------------------------------------------------------------- + + group('Scenario: video call camera toggle', () { + test('video flag false means audio-only call', () { + final call = _makeCall(video: false); + expect(call.isCameraActive, isFalse); + }); + + test('video flag true but no stream — camera not yet active', () { + final call = _makeCall(video: true); + // localStream is null -> hasLocalVideoTrack == false -> isCameraActive == false + expect(call.hasLocalVideoTrack, isFalse); + expect(call.isCameraActive, isFalse); + }); + + test('toggling video flag from false to true via copyWith', () { + final call = _makeCall(video: false); + final upgraded = call.copyWith(video: true); + expect(upgraded.video, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: audio device state + // --------------------------------------------------------------------------- + + group('CallState audio devices', () { + test('audioDevice is null by default', () { + const state = CallState(); + expect(state.audioDevice, isNull); + }); + + test('earpiece device is reflected in state', () { + final earpiece = CallAudioDevice(type: CallAudioDeviceType.earpiece); + final state = CallState(audioDevice: earpiece); + expect(state.audioDevice?.type, CallAudioDeviceType.earpiece); + }); + + test('speaker device is reflected in state', () { + final speaker = CallAudioDevice(type: CallAudioDeviceType.speaker); + final state = CallState(audioDevice: speaker); + expect(state.audioDevice?.type, CallAudioDeviceType.speaker); + }); + + test('availableAudioDevices onlyBuiltIn when only earpiece and speaker present', () { + final List devices = [ + CallAudioDevice(type: CallAudioDeviceType.earpiece), + CallAudioDevice(type: CallAudioDeviceType.speaker), + ]; + expect(devices.onlyBuiltIn, isTrue); + }); + + test('availableAudioDevices onlyBuiltIn is false when bluetooth present', () { + final List devices = [ + CallAudioDevice(type: CallAudioDeviceType.earpiece), + CallAudioDevice(type: CallAudioDeviceType.bluetooth), + ]; + expect(devices.onlyBuiltIn, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario: app lifecycle changes — minimized state + // --------------------------------------------------------------------------- + + group('CallState minimized / display transitions', () { + test('minimized null + no calls = display none', () { + const state = CallState(minimized: null); + expect(state.display, CallDisplay.none); + }); + + test('screen opened (minimized = false) + no calls = noneScreen', () { + const state = CallState(minimized: false); + expect(state.display, CallDisplay.noneScreen); + }); + + test('call arrives while screen is open = display screen', () { + final state = CallState(activeCalls: [_makeCall()], minimized: null); + expect(state.display, CallDisplay.screen); + }); + + test('user minimizes active call = display overlay', () { + final state = CallState(activeCalls: [_makeCall()], minimized: true); + expect(state.display, CallDisplay.overlay); + }); + + test('last call ends while minimized — minimized resets to null', () { + final call = _makeCall(callId: 'c1'); + final state = CallState(activeCalls: [call], minimized: true); + final after = state.copyWithPopActiveCall('c1'); + expect(after.activeCalls, isEmpty); + expect(after.minimized, isNull); + expect(after.display, CallDisplay.none); + }); + }); + + // --------------------------------------------------------------------------- + // CallServiceState — status derivation + // --------------------------------------------------------------------------- + + group('CallServiceState.status', () { + test('connectivityNone when network is unavailable', () { + const s = CallServiceState(networkStatus: NetworkStatus.none); + expect(s.status, CallStatus.connectivityNone); + }); + + test('connectError when last connect attempt failed', () { + const s = CallServiceState(lastSignalingClientConnectError: 'timeout'); + expect(s.status, CallStatus.connectError); + }); + + test('inProgress when still connecting without errors', () { + const s = CallServiceState(signalingClientStatus: SignalingClientStatus.connecting); + expect(s.status, CallStatus.inProgress); + }); + + test('default CallServiceState results in inProgress status', () { + const s = CallServiceState(); + expect(s.status, CallStatus.inProgress); + }); + }); + + // --------------------------------------------------------------------------- + // JsepValue — SDP media detection + // --------------------------------------------------------------------------- + + group('JsepValue SDP parsing', () { + const audioSdp = 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n'; + const videoSdp = 'm=video 9 UDP/TLS/RTP/SAVPF 96\r\n'; + const disabledAudioSdp = 'm=audio 0 RTP/AVP 0\r\n'; + + JsepValue makeJsep(String? sdp) => JsepValue({'type': 'offer', 'sdp': sdp}); + + test('hasAudio returns true when SDP contains active audio section', () { + expect(makeJsep(audioSdp).hasAudio, isTrue); + }); + + test('hasAudio returns false when audio section is disabled (port 0)', () { + expect(makeJsep(disabledAudioSdp).hasAudio, isFalse); + }); + + test('hasVideo returns true when SDP contains video section', () { + expect(makeJsep(videoSdp).hasVideo, isTrue); + }); + + test('hasVideo returns false when no video section', () { + expect(makeJsep(audioSdp).hasVideo, isFalse); + }); + + test('hasAudio and hasVideo both true for combined SDP', () { + final jsep = makeJsep('$audioSdp$videoSdp'); + expect(jsep.hasAudio, isTrue); + expect(jsep.hasVideo, isTrue); + }); + + test('hasAudio and hasVideo false when SDP is null', () { + expect(makeJsep(null).hasAudio, isFalse); + expect(makeJsep(null).hasVideo, isFalse); + }); + }); +} diff --git a/test/features/call/utils/renegotiation_handler_test.dart b/test/features/call/utils/renegotiation_handler_test.dart new file mode 100644 index 000000000..bf0ab8e4f --- /dev/null +++ b/test/features/call/utils/renegotiation_handler_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:webtrit_phone/features/call/utils/call_error_reporter.dart'; +import 'package:webtrit_phone/features/call/utils/renegotiation_handler.dart'; +import 'package:webtrit_phone/features/call/utils/sdp_munger.dart'; + +class MockRTCPeerConnection extends Mock implements RTCPeerConnection {} + +class MockCallErrorReporter extends Mock implements CallErrorReporter {} + +class MockSDPMunger extends Mock implements SDPMunger {} + +void main() { + late MockRTCPeerConnection mockPC; + late MockCallErrorReporter mockErrorReporter; + late MockSDPMunger mockMunger; + late RenegotiationHandler handler; + + const kCallId = 'call-1'; + const kLineId = 0; + final kOffer = RTCSessionDescription('v=0\r\n', 'offer'); + + setUpAll(() { + registerFallbackValue(RTCSessionDescription('', '')); + registerFallbackValue({}); + }); + + setUp(() { + mockPC = MockRTCPeerConnection(); + mockErrorReporter = MockCallErrorReporter(); + mockMunger = MockSDPMunger(); + }); + + group('RenegotiationHandler — state guard (before offer)', () { + test('skips entirely when state is have-remote-offer', () async { + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateHaveRemoteOffer); + + var executeCalled = false; + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => executeCalled = true); + + verifyNever(() => mockPC.createOffer(any())); + expect(executeCalled, isFalse); + }); + + test('skips entirely when state is have-local-offer', () async { + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateHaveLocalOffer); + + var executeCalled = false; + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => executeCalled = true); + + verifyNever(() => mockPC.createOffer(any())); + expect(executeCalled, isFalse); + }); + + test('skips entirely when state is closed', () async { + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateClosed); + + var executeCalled = false; + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => executeCalled = true); + + verifyNever(() => mockPC.createOffer(any())); + expect(executeCalled, isFalse); + }); + + test('skips entirely when state is null', () async { + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + when(() => mockPC.signalingState).thenReturn(null); + + var executeCalled = false; + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => executeCalled = true); + + verifyNever(() => mockPC.createOffer(any())); + expect(executeCalled, isFalse); + }); + }); + + group('RenegotiationHandler — TOCTOU guard (after offer)', () { + test('skips setLocalDescription when state changed to have-remote-offer after createOffer', () async { + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + + var callCount = 0; + when(() => mockPC.signalingState).thenAnswer( + (_) => callCount++ == 0 + ? RTCSignalingState.RTCSignalingStateStable + : RTCSignalingState.RTCSignalingStateHaveRemoteOffer, + ); + when(() => mockPC.createOffer(any())).thenAnswer((_) async => kOffer); + + var executeCalled = false; + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => executeCalled = true); + + verifyNever(() => mockPC.setLocalDescription(any())); + expect(executeCalled, isFalse); + }); + }); + + group('RenegotiationHandler — happy path', () { + setUp(() { + var callCount = 0; + when( + () => mockPC.signalingState, + ).thenAnswer((_) => callCount++ == 0 ? RTCSignalingState.RTCSignalingStateStable : null); + when(() => mockPC.createOffer(any())).thenAnswer((_) async => kOffer); + when(() => mockPC.setLocalDescription(any())).thenAnswer((_) async {}); + }); + + test('calls setLocalDescription and execute when both states are stable', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + + RTCSessionDescription? capturedJsep; + String? capturedCallId; + int? capturedLineId; + + await handler.handle(kCallId, kLineId, mockPC, (callId, lineId, jsep) async { + capturedCallId = callId; + capturedLineId = lineId; + capturedJsep = jsep; + }); + + verify(() => mockPC.setLocalDescription(kOffer)).called(1); + expect(capturedCallId, kCallId); + expect(capturedLineId, kLineId); + expect(capturedJsep, kOffer); + }); + + test('applies sdpMunger before setLocalDescription', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + when(() => mockMunger.apply(any())).thenReturn(null); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter, sdpMunger: mockMunger); + + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async {}); + + verify(() => mockMunger.apply(kOffer)).called(1); + verify(() => mockPC.setLocalDescription(kOffer)).called(1); + }); + + test('does not call sdpMunger when it is null', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async {}); + + verifyNever(() => mockMunger.apply(any())); + }); + }); + + group('RenegotiationHandler — execute error handling', () { + setUp(() { + when(() => mockErrorReporter.handle(any(), any(), any())).thenReturn(null); + }); + + test('reports error via callErrorReporter when execute throws', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + when(() => mockPC.createOffer(any())).thenAnswer((_) async => kOffer); + when(() => mockPC.setLocalDescription(any())).thenAnswer((_) async {}); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + final exception = Exception('signaling error'); + + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => throw exception); + + verify(() => mockErrorReporter.handle(exception, any(), any())).called(1); + }); + + test('reports error via callErrorReporter when createOffer throws', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + final exception = Exception('createOffer error'); + when(() => mockPC.createOffer(any())).thenThrow(exception); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async {}); + + verify(() => mockErrorReporter.handle(exception, any(), any())).called(1); + }); + + test('reports error via callErrorReporter when setLocalDescription throws', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + when(() => mockPC.createOffer(any())).thenAnswer((_) async => kOffer); + final exception = Exception('setLocalDescription error'); + when(() => mockPC.setLocalDescription(any())).thenThrow(exception); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + + await handler.handle(kCallId, kLineId, mockPC, (_, _, _) async {}); + + verify(() => mockErrorReporter.handle(exception, any(), any())).called(1); + }); + + test('does not rethrow when execute throws', () async { + when(() => mockPC.signalingState).thenReturn(RTCSignalingState.RTCSignalingStateStable); + when(() => mockPC.createOffer(any())).thenAnswer((_) async => kOffer); + when(() => mockPC.setLocalDescription(any())).thenAnswer((_) async {}); + handler = RenegotiationHandler(callErrorReporter: mockErrorReporter); + + await expectLater( + handler.handle(kCallId, kLineId, mockPC, (_, _, _) async => throw Exception('error')), + completes, + ); + }); + }); +}