LCOV - code coverage report
Current view: top level - lib/src/voip - call_session.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 368 833 44.2 %
Date: 2024-11-12 07:37:08 Functions: 0 0 -

          Line data    Source code
       1             : /*
       2             :  *   Famedly Matrix SDK
       3             :  *   Copyright (C) 2021 Famedly GmbH
       4             :  *
       5             :  *   This program is free software: you can redistribute it and/or modify
       6             :  *   it under the terms of the GNU Affero General Public License as
       7             :  *   published by the Free Software Foundation, either version 3 of the
       8             :  *   License, or (at your option) any later version.
       9             :  *
      10             :  *   This program is distributed in the hope that it will be useful,
      11             :  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
      12             :  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
      13             :  *   GNU Affero General Public License for more details.
      14             :  *
      15             :  *   You should have received a copy of the GNU Affero General Public License
      16             :  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
      17             :  */
      18             : 
      19             : import 'dart:async';
      20             : import 'dart:core';
      21             : import 'dart:math';
      22             : 
      23             : import 'package:collection/collection.dart';
      24             : import 'package:webrtc_interface/webrtc_interface.dart';
      25             : 
      26             : import 'package:matrix/matrix.dart';
      27             : import 'package:matrix/src/utils/cached_stream_controller.dart';
      28             : import 'package:matrix/src/voip/models/call_options.dart';
      29             : import 'package:matrix/src/voip/models/voip_id.dart';
      30             : import 'package:matrix/src/voip/utils/stream_helper.dart';
      31             : import 'package:matrix/src/voip/utils/user_media_constraints.dart';
      32             : 
      33             : /// Parses incoming matrix events to the apropriate webrtc layer underneath using
      34             : /// a `WebRTCDelegate`. This class is also responsible for sending any outgoing
      35             : /// matrix events if required (f.ex m.call.answer).
      36             : ///
      37             : /// Handles p2p calls as well individual mesh group call peer connections.
      38             : class CallSession {
      39           2 :   CallSession(this.opts);
      40             :   CallOptions opts;
      41           6 :   CallType get type => opts.type;
      42           6 :   Room get room => opts.room;
      43           6 :   VoIP get voip => opts.voip;
      44           6 :   String? get groupCallId => opts.groupCallId;
      45           6 :   String get callId => opts.callId;
      46           6 :   String get localPartyId => opts.localPartyId;
      47             : 
      48           6 :   CallDirection get direction => opts.dir;
      49             : 
      50           4 :   CallState get state => _state;
      51             :   CallState _state = CallState.kFledgling;
      52             : 
      53           0 :   bool get isOutgoing => direction == CallDirection.kOutgoing;
      54             : 
      55           0 :   bool get isRinging => state == CallState.kRinging;
      56             : 
      57             :   RTCPeerConnection? pc;
      58             : 
      59             :   final _remoteCandidates = <RTCIceCandidate>[];
      60             :   final _localCandidates = <RTCIceCandidate>[];
      61             : 
      62           0 :   AssertedIdentity? get remoteAssertedIdentity => _remoteAssertedIdentity;
      63             :   AssertedIdentity? _remoteAssertedIdentity;
      64             : 
      65           6 :   bool get callHasEnded => state == CallState.kEnded;
      66             : 
      67             :   bool _iceGatheringFinished = false;
      68             : 
      69             :   bool _inviteOrAnswerSent = false;
      70             : 
      71           0 :   bool get localHold => _localHold;
      72             :   bool _localHold = false;
      73             : 
      74           0 :   bool get remoteOnHold => _remoteOnHold;
      75             :   bool _remoteOnHold = false;
      76             : 
      77             :   bool _answeredByUs = false;
      78             : 
      79             :   bool _speakerOn = false;
      80             : 
      81             :   bool _makingOffer = false;
      82             : 
      83             :   bool _ignoreOffer = false;
      84             : 
      85           0 :   bool get answeredByUs => _answeredByUs;
      86             : 
      87           8 :   Client get client => opts.room.client;
      88             : 
      89             :   /// The local participant in the call, with id userId + deviceId
      90           6 :   CallParticipant? get localParticipant => voip.localParticipant;
      91             : 
      92             :   /// The ID of the user being called. If omitted, any user in the room can answer.
      93             :   String? remoteUserId;
      94             : 
      95           0 :   User? get remoteUser => remoteUserId != null
      96           0 :       ? room.unsafeGetUserFromMemoryOrFallback(remoteUserId!)
      97             :       : null;
      98             : 
      99             :   /// The ID of the device being called. If omitted, any device for the remoteUserId in the room can answer.
     100             :   String? remoteDeviceId;
     101             :   String? remoteSessionId; // same
     102             :   String? remotePartyId; // random string
     103             : 
     104             :   CallErrorCode? hangupReason;
     105             :   CallSession? _successor;
     106             :   int _toDeviceSeq = 0;
     107             :   int _candidateSendTries = 0;
     108           4 :   bool get isGroupCall => groupCallId != null;
     109             :   bool _missedCall = true;
     110             : 
     111             :   final CachedStreamController<CallSession> onCallStreamsChanged =
     112             :       CachedStreamController();
     113             : 
     114             :   final CachedStreamController<CallSession> onCallReplaced =
     115             :       CachedStreamController();
     116             : 
     117             :   final CachedStreamController<CallSession> onCallHangupNotifierForGroupCalls =
     118             :       CachedStreamController();
     119             : 
     120             :   final CachedStreamController<CallState> onCallStateChanged =
     121             :       CachedStreamController();
     122             : 
     123             :   final CachedStreamController<CallStateChange> onCallEventChanged =
     124             :       CachedStreamController();
     125             : 
     126             :   final CachedStreamController<WrappedMediaStream> onStreamAdd =
     127             :       CachedStreamController();
     128             : 
     129             :   final CachedStreamController<WrappedMediaStream> onStreamRemoved =
     130             :       CachedStreamController();
     131             : 
     132             :   SDPStreamMetadata? _remoteSDPStreamMetadata;
     133             :   final List<RTCRtpSender> _usermediaSenders = [];
     134             :   final List<RTCRtpSender> _screensharingSenders = [];
     135             :   final List<WrappedMediaStream> _streams = <WrappedMediaStream>[];
     136             : 
     137           2 :   List<WrappedMediaStream> get getLocalStreams =>
     138          10 :       _streams.where((element) => element.isLocal()).toList();
     139           0 :   List<WrappedMediaStream> get getRemoteStreams =>
     140           0 :       _streams.where((element) => !element.isLocal()).toList();
     141             : 
     142           0 :   bool get isLocalVideoMuted => localUserMediaStream?.isVideoMuted() ?? false;
     143             : 
     144           0 :   bool get isMicrophoneMuted => localUserMediaStream?.isAudioMuted() ?? false;
     145             : 
     146           0 :   bool get screensharingEnabled => localScreenSharingStream != null;
     147             : 
     148           2 :   WrappedMediaStream? get localUserMediaStream {
     149           4 :     final stream = getLocalStreams.where(
     150           6 :       (element) => element.purpose == SDPStreamMetadataPurpose.Usermedia,
     151             :     );
     152           2 :     if (stream.isNotEmpty) {
     153           2 :       return stream.first;
     154             :     }
     155             :     return null;
     156             :   }
     157             : 
     158           2 :   WrappedMediaStream? get localScreenSharingStream {
     159           4 :     final stream = getLocalStreams.where(
     160           6 :       (element) => element.purpose == SDPStreamMetadataPurpose.Screenshare,
     161             :     );
     162           2 :     if (stream.isNotEmpty) {
     163           0 :       return stream.first;
     164             :     }
     165             :     return null;
     166             :   }
     167             : 
     168           0 :   WrappedMediaStream? get remoteUserMediaStream {
     169           0 :     final stream = getRemoteStreams.where(
     170           0 :       (element) => element.purpose == SDPStreamMetadataPurpose.Usermedia,
     171             :     );
     172           0 :     if (stream.isNotEmpty) {
     173           0 :       return stream.first;
     174             :     }
     175             :     return null;
     176             :   }
     177             : 
     178           0 :   WrappedMediaStream? get remoteScreenSharingStream {
     179           0 :     final stream = getRemoteStreams.where(
     180           0 :       (element) => element.purpose == SDPStreamMetadataPurpose.Screenshare,
     181             :     );
     182           0 :     if (stream.isNotEmpty) {
     183           0 :       return stream.first;
     184             :     }
     185             :     return null;
     186             :   }
     187             : 
     188             :   /// returns whether a 1:1 call sender has video tracks
     189           0 :   Future<bool> hasVideoToSend() async {
     190           0 :     final transceivers = await pc!.getTransceivers();
     191           0 :     final localUserMediaVideoTrack = localUserMediaStream?.stream
     192           0 :         ?.getTracks()
     193           0 :         .singleWhereOrNull((track) => track.kind == 'video');
     194             : 
     195             :     // check if we have a video track locally and have transceivers setup correctly.
     196             :     return localUserMediaVideoTrack != null &&
     197           0 :         transceivers.singleWhereOrNull(
     198           0 :               (transceiver) =>
     199           0 :                   transceiver.sender.track?.id == localUserMediaVideoTrack.id,
     200             :             ) !=
     201             :             null;
     202             :   }
     203             : 
     204             :   Timer? _inviteTimer;
     205             :   Timer? _ringingTimer;
     206             : 
     207             :   // outgoing call
     208           2 :   Future<void> initOutboundCall(CallType type) async {
     209           2 :     await _preparePeerConnection();
     210           2 :     setCallState(CallState.kCreateOffer);
     211           2 :     final stream = await _getUserMedia(type);
     212             :     if (stream != null) {
     213           2 :       await addLocalStream(stream, SDPStreamMetadataPurpose.Usermedia);
     214             :     }
     215             :   }
     216             : 
     217             :   // incoming call
     218           2 :   Future<void> initWithInvite(
     219             :     CallType type,
     220             :     RTCSessionDescription offer,
     221             :     SDPStreamMetadata? metadata,
     222             :     int lifetime,
     223             :     bool isGroupCall,
     224             :   ) async {
     225             :     if (!isGroupCall) {
     226             :       // glare fixes
     227          10 :       final prevCallId = voip.incomingCallRoomId[room.id];
     228             :       if (prevCallId != null) {
     229             :         // This is probably an outbound call, but we already have a incoming invite, so let's terminate it.
     230             :         final prevCall =
     231          12 :             voip.calls[VoipId(roomId: room.id, callId: prevCallId)];
     232             :         if (prevCall != null) {
     233           2 :           if (prevCall._inviteOrAnswerSent) {
     234           4 :             Logs().d('[glare] invite or answer sent, lex compare now');
     235           8 :             if (callId.compareTo(prevCall.callId) > 0) {
     236           4 :               Logs().d(
     237           6 :                 '[glare] new call $callId needs to be canceled because the older one ${prevCall.callId} has a smaller lex',
     238             :               );
     239           2 :               await hangup(reason: CallErrorCode.unknownError);
     240           4 :               voip.currentCID =
     241           8 :                   VoipId(roomId: room.id, callId: prevCall.callId);
     242             :             } else {
     243           0 :               Logs().d(
     244           0 :                 '[glare] nice, lex of newer call $callId is smaller auto accept this here',
     245             :               );
     246             : 
     247             :               /// These fixes do not work all the time because sometimes the code
     248             :               /// is at an unrecoverable stage (invite already sent when we were
     249             :               /// checking if we want to send a invite), so commented out answering
     250             :               /// automatically to prevent unknown cases
     251             :               // await answer();
     252             :               // return;
     253             :             }
     254             :           } else {
     255           4 :             Logs().d(
     256           4 :               '[glare] ${prevCall.callId} was still preparing prev call, nvm now cancel it',
     257             :             );
     258           2 :             await prevCall.hangup(reason: CallErrorCode.unknownError);
     259             :           }
     260             :         }
     261             :       }
     262             :     }
     263             : 
     264           2 :     await _preparePeerConnection();
     265             :     if (metadata != null) {
     266           0 :       _updateRemoteSDPStreamMetadata(metadata);
     267             :     }
     268           4 :     await pc!.setRemoteDescription(offer);
     269             : 
     270             :     /// only add local stream if it is not a group call.
     271             :     if (!isGroupCall) {
     272           2 :       final stream = await _getUserMedia(type);
     273             :       if (stream != null) {
     274           2 :         await addLocalStream(stream, SDPStreamMetadataPurpose.Usermedia);
     275             :       } else {
     276             :         // we don't have a localstream, call probably crashed
     277             :         // for sanity
     278           0 :         if (state == CallState.kEnded) {
     279             :           return;
     280             :         }
     281             :       }
     282             :     }
     283             : 
     284           2 :     setCallState(CallState.kRinging);
     285             : 
     286           4 :     _ringingTimer = Timer(CallTimeouts.callInviteLifetime, () {
     287           0 :       if (state == CallState.kRinging) {
     288           0 :         Logs().v('[VOIP] Call invite has expired. Hanging up.');
     289             : 
     290           0 :         fireCallEvent(CallStateChange.kHangup);
     291           0 :         hangup(reason: CallErrorCode.inviteTimeout);
     292             :       }
     293           0 :       _ringingTimer?.cancel();
     294           0 :       _ringingTimer = null;
     295             :     });
     296             :   }
     297             : 
     298           0 :   Future<void> answerWithStreams(List<WrappedMediaStream> callFeeds) async {
     299           0 :     if (_inviteOrAnswerSent) return;
     300           0 :     Logs().d('answering call $callId');
     301           0 :     await gotCallFeedsForAnswer(callFeeds);
     302             :   }
     303             : 
     304           0 :   Future<void> replacedBy(CallSession newCall) async {
     305           0 :     if (state == CallState.kWaitLocalMedia) {
     306           0 :       Logs().v('Telling new call to wait for local media');
     307           0 :     } else if (state == CallState.kCreateOffer ||
     308           0 :         state == CallState.kInviteSent) {
     309           0 :       Logs().v('Handing local stream to new call');
     310           0 :       await newCall.gotCallFeedsForAnswer(getLocalStreams);
     311             :     }
     312           0 :     _successor = newCall;
     313           0 :     onCallReplaced.add(newCall);
     314             :     // ignore: unawaited_futures
     315           0 :     hangup(reason: CallErrorCode.replaced);
     316             :   }
     317             : 
     318           0 :   Future<void> sendAnswer(RTCSessionDescription answer) async {
     319           0 :     final callCapabilities = CallCapabilities()
     320           0 :       ..dtmf = false
     321           0 :       ..transferee = false;
     322             : 
     323           0 :     final metadata = SDPStreamMetadata({
     324           0 :       localUserMediaStream!.stream!.id: SDPStreamPurpose(
     325             :         purpose: SDPStreamMetadataPurpose.Usermedia,
     326           0 :         audio_muted: localUserMediaStream!.stream!.getAudioTracks().isEmpty,
     327           0 :         video_muted: localUserMediaStream!.stream!.getVideoTracks().isEmpty,
     328             :       ),
     329             :     });
     330             : 
     331           0 :     final res = await sendAnswerCall(
     332           0 :       room,
     333           0 :       callId,
     334           0 :       answer.sdp!,
     335           0 :       localPartyId,
     336           0 :       type: answer.type!,
     337             :       capabilities: callCapabilities,
     338             :       metadata: metadata,
     339             :     );
     340           0 :     Logs().v('[VOIP] answer res => $res');
     341             :   }
     342             : 
     343           0 :   Future<void> gotCallFeedsForAnswer(List<WrappedMediaStream> callFeeds) async {
     344           0 :     if (state == CallState.kEnded) return;
     345             : 
     346           0 :     for (final element in callFeeds) {
     347           0 :       await addLocalStream(await element.stream!.clone(), element.purpose);
     348             :     }
     349             : 
     350           0 :     await answer();
     351             :   }
     352             : 
     353           0 :   Future<void> placeCallWithStreams(
     354             :     List<WrappedMediaStream> callFeeds, {
     355             :     bool requestScreenSharing = false,
     356             :   }) async {
     357             :     // create the peer connection now so it can be gathering candidates while we get user
     358             :     // media (assuming a candidate pool size is configured)
     359           0 :     await _preparePeerConnection();
     360           0 :     await gotCallFeedsForInvite(
     361             :       callFeeds,
     362             :       requestScreenSharing: requestScreenSharing,
     363             :     );
     364             :   }
     365             : 
     366           0 :   Future<void> gotCallFeedsForInvite(
     367             :     List<WrappedMediaStream> callFeeds, {
     368             :     bool requestScreenSharing = false,
     369             :   }) async {
     370           0 :     if (_successor != null) {
     371           0 :       await _successor!.gotCallFeedsForAnswer(callFeeds);
     372             :       return;
     373             :     }
     374           0 :     if (state == CallState.kEnded) {
     375           0 :       await cleanUp();
     376             :       return;
     377             :     }
     378             : 
     379           0 :     for (final element in callFeeds) {
     380           0 :       await addLocalStream(await element.stream!.clone(), element.purpose);
     381             :     }
     382             : 
     383             :     if (requestScreenSharing) {
     384           0 :       await pc!.addTransceiver(
     385             :         kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
     386           0 :         init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
     387             :       );
     388             :     }
     389             : 
     390           0 :     setCallState(CallState.kCreateOffer);
     391             : 
     392           0 :     Logs().d('gotUserMediaForInvite');
     393             :     // Now we wait for the negotiationneeded event
     394             :   }
     395             : 
     396           0 :   Future<void> onAnswerReceived(
     397             :     RTCSessionDescription answer,
     398             :     SDPStreamMetadata? metadata,
     399             :   ) async {
     400             :     if (metadata != null) {
     401           0 :       _updateRemoteSDPStreamMetadata(metadata);
     402             :     }
     403             : 
     404           0 :     if (direction == CallDirection.kOutgoing) {
     405           0 :       setCallState(CallState.kConnecting);
     406           0 :       await pc!.setRemoteDescription(answer);
     407           0 :       for (final candidate in _remoteCandidates) {
     408           0 :         await pc!.addCandidate(candidate);
     409             :       }
     410             :     }
     411           0 :     if (remotePartyId != null) {
     412             :       /// Send select_answer event.
     413           0 :       await sendSelectCallAnswer(
     414           0 :         opts.room,
     415           0 :         callId,
     416           0 :         localPartyId,
     417           0 :         remotePartyId!,
     418             :       );
     419             :     }
     420             :   }
     421             : 
     422           0 :   Future<void> onNegotiateReceived(
     423             :     SDPStreamMetadata? metadata,
     424             :     RTCSessionDescription description,
     425             :   ) async {
     426           0 :     final polite = direction == CallDirection.kIncoming;
     427             : 
     428             :     // Here we follow the perfect negotiation logic from
     429             :     // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation
     430           0 :     final offerCollision = ((description.type == 'offer') &&
     431           0 :         (_makingOffer ||
     432           0 :             pc!.signalingState != RTCSignalingState.RTCSignalingStateStable));
     433             : 
     434           0 :     _ignoreOffer = !polite && offerCollision;
     435           0 :     if (_ignoreOffer) {
     436           0 :       Logs().i('Ignoring colliding negotiate event because we\'re impolite');
     437             :       return;
     438             :     }
     439             : 
     440           0 :     final prevLocalOnHold = await isLocalOnHold();
     441             : 
     442             :     if (metadata != null) {
     443           0 :       _updateRemoteSDPStreamMetadata(metadata);
     444             :     }
     445             : 
     446             :     try {
     447           0 :       await pc!.setRemoteDescription(description);
     448             :       RTCSessionDescription? answer;
     449           0 :       if (description.type == 'offer') {
     450             :         try {
     451           0 :           answer = await pc!.createAnswer({});
     452             :         } catch (e) {
     453           0 :           await terminate(CallParty.kLocal, CallErrorCode.createAnswer, true);
     454             :           rethrow;
     455             :         }
     456             : 
     457           0 :         await sendCallNegotiate(
     458           0 :           room,
     459           0 :           callId,
     460           0 :           CallTimeouts.defaultCallEventLifetime.inMilliseconds,
     461           0 :           localPartyId,
     462           0 :           answer.sdp!,
     463           0 :           type: answer.type!,
     464             :         );
     465           0 :         await pc!.setLocalDescription(answer);
     466             :       }
     467             :     } catch (e, s) {
     468           0 :       Logs().e('[VOIP] onNegotiateReceived => ', e, s);
     469           0 :       await _getLocalOfferFailed(e);
     470             :       return;
     471             :     }
     472             : 
     473           0 :     final newLocalOnHold = await isLocalOnHold();
     474           0 :     if (prevLocalOnHold != newLocalOnHold) {
     475           0 :       _localHold = newLocalOnHold;
     476           0 :       fireCallEvent(CallStateChange.kLocalHoldUnhold);
     477             :     }
     478             :   }
     479             : 
     480           0 :   Future<void> updateMediaDeviceForCall() async {
     481           0 :     await updateMediaDevice(
     482           0 :       voip.delegate,
     483             :       MediaKind.audio,
     484           0 :       _usermediaSenders,
     485             :     );
     486           0 :     await updateMediaDevice(
     487           0 :       voip.delegate,
     488             :       MediaKind.video,
     489           0 :       _usermediaSenders,
     490             :     );
     491             :   }
     492             : 
     493           0 :   void _updateRemoteSDPStreamMetadata(SDPStreamMetadata metadata) {
     494           0 :     _remoteSDPStreamMetadata = metadata;
     495           0 :     _remoteSDPStreamMetadata?.sdpStreamMetadatas
     496           0 :         .forEach((streamId, sdpStreamMetadata) {
     497           0 :       Logs().i(
     498           0 :         'Stream purpose update: \nid = "$streamId", \npurpose = "${sdpStreamMetadata.purpose}",  \naudio_muted = ${sdpStreamMetadata.audio_muted}, \nvideo_muted = ${sdpStreamMetadata.video_muted}',
     499             :       );
     500             :     });
     501           0 :     for (final wpstream in getRemoteStreams) {
     502           0 :       final streamId = wpstream.stream!.id;
     503           0 :       final purpose = metadata.sdpStreamMetadatas[streamId];
     504             :       if (purpose != null) {
     505             :         wpstream
     506           0 :             .setAudioMuted(metadata.sdpStreamMetadatas[streamId]!.audio_muted);
     507             :         wpstream
     508           0 :             .setVideoMuted(metadata.sdpStreamMetadatas[streamId]!.video_muted);
     509           0 :         wpstream.purpose = metadata.sdpStreamMetadatas[streamId]!.purpose;
     510             :       } else {
     511           0 :         Logs().i('Not found purpose for remote stream $streamId, remove it?');
     512           0 :         wpstream.stopped = true;
     513           0 :         fireCallEvent(CallStateChange.kFeedsChanged);
     514             :       }
     515             :     }
     516             :   }
     517             : 
     518           0 :   Future<void> onSDPStreamMetadataReceived(SDPStreamMetadata metadata) async {
     519           0 :     _updateRemoteSDPStreamMetadata(metadata);
     520           0 :     fireCallEvent(CallStateChange.kFeedsChanged);
     521             :   }
     522             : 
     523           2 :   Future<void> onCandidatesReceived(List<dynamic> candidates) async {
     524           4 :     for (final json in candidates) {
     525           2 :       final candidate = RTCIceCandidate(
     526           2 :         json['candidate'],
     527           2 :         json['sdpMid'] ?? '',
     528           4 :         json['sdpMLineIndex']?.round() ?? 0,
     529             :       );
     530             : 
     531           2 :       if (!candidate.isValid) {
     532           0 :         Logs().w(
     533           0 :           '[VOIP] onCandidatesReceived => skip invalid candidate ${candidate.toMap()}',
     534             :         );
     535             :         continue;
     536             :       }
     537             : 
     538           4 :       if (direction == CallDirection.kOutgoing &&
     539           0 :           pc != null &&
     540           0 :           await pc!.getRemoteDescription() == null) {
     541           0 :         _remoteCandidates.add(candidate);
     542             :         continue;
     543             :       }
     544             : 
     545           4 :       if (pc != null && _inviteOrAnswerSent) {
     546             :         try {
     547           0 :           await pc!.addCandidate(candidate);
     548             :         } catch (e, s) {
     549           0 :           Logs().e('[VOIP] onCandidatesReceived => ', e, s);
     550             :         }
     551             :       } else {
     552           4 :         _remoteCandidates.add(candidate);
     553             :       }
     554             :     }
     555             :   }
     556             : 
     557           0 :   void onAssertedIdentityReceived(AssertedIdentity identity) {
     558           0 :     _remoteAssertedIdentity = identity;
     559           0 :     fireCallEvent(CallStateChange.kAssertedIdentityChanged);
     560             :   }
     561             : 
     562           0 :   Future<bool> setScreensharingEnabled(bool enabled) async {
     563             :     // Skip if there is nothing to do
     564           0 :     if (enabled && localScreenSharingStream != null) {
     565           0 :       Logs().w(
     566             :         'There is already a screensharing stream - there is nothing to do!',
     567             :       );
     568             :       return true;
     569           0 :     } else if (!enabled && localScreenSharingStream == null) {
     570           0 :       Logs().w(
     571             :         'There already isn\'t a screensharing stream - there is nothing to do!',
     572             :       );
     573             :       return false;
     574             :     }
     575             : 
     576           0 :     Logs().d('Set screensharing enabled? $enabled');
     577             : 
     578             :     if (enabled) {
     579             :       try {
     580           0 :         final stream = await _getDisplayMedia();
     581             :         if (stream == null) {
     582             :           return false;
     583             :         }
     584           0 :         for (final track in stream.getTracks()) {
     585             :           // screen sharing should only have 1 video track anyway, so this only
     586             :           // fires once
     587           0 :           track.onEnded = () async {
     588           0 :             await setScreensharingEnabled(false);
     589             :           };
     590             :         }
     591             : 
     592           0 :         await addLocalStream(stream, SDPStreamMetadataPurpose.Screenshare);
     593             :         return true;
     594             :       } catch (err) {
     595           0 :         fireCallEvent(CallStateChange.kError);
     596             :         return false;
     597             :       }
     598             :     } else {
     599             :       try {
     600           0 :         for (final sender in _screensharingSenders) {
     601           0 :           await pc!.removeTrack(sender);
     602             :         }
     603           0 :         for (final track in localScreenSharingStream!.stream!.getTracks()) {
     604           0 :           await track.stop();
     605             :         }
     606           0 :         localScreenSharingStream!.stopped = true;
     607           0 :         await _removeStream(localScreenSharingStream!.stream!);
     608           0 :         fireCallEvent(CallStateChange.kFeedsChanged);
     609             :         return false;
     610             :       } catch (e, s) {
     611           0 :         Logs().e('[VOIP] stopping screen sharing track failed', e, s);
     612             :         return false;
     613             :       }
     614             :     }
     615             :   }
     616             : 
     617           2 :   Future<void> addLocalStream(
     618             :     MediaStream stream,
     619             :     String purpose, {
     620             :     bool addToPeerConnection = true,
     621             :   }) async {
     622             :     final existingStream =
     623           4 :         getLocalStreams.where((element) => element.purpose == purpose);
     624           2 :     if (existingStream.isNotEmpty) {
     625           0 :       existingStream.first.setNewStream(stream);
     626             :     } else {
     627           2 :       final newStream = WrappedMediaStream(
     628           2 :         participant: localParticipant!,
     629           4 :         room: opts.room,
     630             :         stream: stream,
     631             :         purpose: purpose,
     632           2 :         client: client,
     633           4 :         audioMuted: stream.getAudioTracks().isEmpty,
     634           4 :         videoMuted: stream.getVideoTracks().isEmpty,
     635           2 :         isGroupCall: groupCallId != null,
     636           2 :         pc: pc,
     637           2 :         voip: voip,
     638             :       );
     639           4 :       _streams.add(newStream);
     640           4 :       onStreamAdd.add(newStream);
     641             :     }
     642             : 
     643             :     if (addToPeerConnection) {
     644           2 :       if (purpose == SDPStreamMetadataPurpose.Screenshare) {
     645           0 :         _screensharingSenders.clear();
     646           0 :         for (final track in stream.getTracks()) {
     647           0 :           _screensharingSenders.add(await pc!.addTrack(track, stream));
     648             :         }
     649           2 :       } else if (purpose == SDPStreamMetadataPurpose.Usermedia) {
     650           4 :         _usermediaSenders.clear();
     651           2 :         for (final track in stream.getTracks()) {
     652           0 :           _usermediaSenders.add(await pc!.addTrack(track, stream));
     653             :         }
     654             :       }
     655             :     }
     656             : 
     657           2 :     if (purpose == SDPStreamMetadataPurpose.Usermedia) {
     658           6 :       _speakerOn = type == CallType.kVideo;
     659          10 :       if (!voip.delegate.isWeb && stream.getAudioTracks().isNotEmpty) {
     660           0 :         final audioTrack = stream.getAudioTracks()[0];
     661           0 :         audioTrack.enableSpeakerphone(_speakerOn);
     662             :       }
     663             :     }
     664             : 
     665           2 :     fireCallEvent(CallStateChange.kFeedsChanged);
     666             :   }
     667             : 
     668           0 :   Future<void> _addRemoteStream(MediaStream stream) async {
     669             :     //final userId = remoteUser.id;
     670           0 :     final metadata = _remoteSDPStreamMetadata?.sdpStreamMetadatas[stream.id];
     671             :     if (metadata == null) {
     672           0 :       Logs().i(
     673           0 :         'Ignoring stream with id ${stream.id} because we didn\'t get any metadata about it',
     674             :       );
     675             :       return;
     676             :     }
     677             : 
     678           0 :     final purpose = metadata.purpose;
     679           0 :     final audioMuted = metadata.audio_muted;
     680           0 :     final videoMuted = metadata.video_muted;
     681             : 
     682             :     // Try to find a feed with the same purpose as the new stream,
     683             :     // if we find it replace the old stream with the new one
     684             :     final existingStream =
     685           0 :         getRemoteStreams.where((element) => element.purpose == purpose);
     686           0 :     if (existingStream.isNotEmpty) {
     687           0 :       existingStream.first.setNewStream(stream);
     688             :     } else {
     689           0 :       final newStream = WrappedMediaStream(
     690           0 :         participant: CallParticipant(
     691           0 :           voip,
     692           0 :           userId: remoteUserId!,
     693           0 :           deviceId: remoteDeviceId,
     694             :         ),
     695           0 :         room: opts.room,
     696             :         stream: stream,
     697             :         purpose: purpose,
     698           0 :         client: client,
     699             :         audioMuted: audioMuted,
     700             :         videoMuted: videoMuted,
     701           0 :         isGroupCall: groupCallId != null,
     702           0 :         pc: pc,
     703           0 :         voip: voip,
     704             :       );
     705           0 :       _streams.add(newStream);
     706           0 :       onStreamAdd.add(newStream);
     707             :     }
     708           0 :     fireCallEvent(CallStateChange.kFeedsChanged);
     709           0 :     Logs().i('Pushed remote stream (id="${stream.id}", purpose=$purpose)');
     710             :   }
     711             : 
     712           0 :   Future<void> deleteAllStreams() async {
     713           0 :     for (final stream in _streams) {
     714           0 :       if (stream.isLocal() || groupCallId == null) {
     715           0 :         await stream.dispose();
     716             :       }
     717             :     }
     718           0 :     _streams.clear();
     719           0 :     fireCallEvent(CallStateChange.kFeedsChanged);
     720             :   }
     721             : 
     722           0 :   Future<void> deleteFeedByStream(MediaStream stream) async {
     723             :     final index =
     724           0 :         _streams.indexWhere((element) => element.stream!.id == stream.id);
     725           0 :     if (index == -1) {
     726           0 :       Logs().w('Didn\'t find the feed with stream id ${stream.id} to delete');
     727             :       return;
     728             :     }
     729           0 :     final wstream = _streams.elementAt(index);
     730           0 :     onStreamRemoved.add(wstream);
     731           0 :     await deleteStream(wstream);
     732             :   }
     733             : 
     734           0 :   Future<void> deleteStream(WrappedMediaStream stream) async {
     735           0 :     await stream.dispose();
     736           0 :     _streams.removeAt(_streams.indexOf(stream));
     737           0 :     fireCallEvent(CallStateChange.kFeedsChanged);
     738             :   }
     739             : 
     740           0 :   Future<void> removeLocalStream(WrappedMediaStream callFeed) async {
     741           0 :     final senderArray = callFeed.purpose == SDPStreamMetadataPurpose.Usermedia
     742           0 :         ? _usermediaSenders
     743           0 :         : _screensharingSenders;
     744             : 
     745           0 :     for (final element in senderArray) {
     746           0 :       await pc!.removeTrack(element);
     747             :     }
     748             : 
     749           0 :     if (callFeed.purpose == SDPStreamMetadataPurpose.Screenshare) {
     750           0 :       await stopMediaStream(callFeed.stream);
     751             :     }
     752             : 
     753             :     // Empty the array
     754           0 :     senderArray.removeRange(0, senderArray.length);
     755           0 :     onStreamRemoved.add(callFeed);
     756           0 :     await deleteStream(callFeed);
     757             :   }
     758             : 
     759           2 :   void setCallState(CallState newState) {
     760           2 :     _state = newState;
     761           4 :     onCallStateChanged.add(newState);
     762           2 :     fireCallEvent(CallStateChange.kState);
     763             :   }
     764             : 
     765           0 :   Future<void> setLocalVideoMuted(bool muted) async {
     766             :     if (!muted) {
     767           0 :       final videoToSend = await hasVideoToSend();
     768             :       if (!videoToSend) {
     769           0 :         if (_remoteSDPStreamMetadata == null) return;
     770           0 :         await insertVideoTrackToAudioOnlyStream();
     771             :       }
     772             :     }
     773           0 :     localUserMediaStream?.setVideoMuted(muted);
     774           0 :     await updateMuteStatus();
     775             :   }
     776             : 
     777             :   // used for upgrading 1:1 calls
     778           0 :   Future<void> insertVideoTrackToAudioOnlyStream() async {
     779           0 :     if (localUserMediaStream != null && localUserMediaStream!.stream != null) {
     780           0 :       final stream = await _getUserMedia(CallType.kVideo);
     781             :       if (stream != null) {
     782           0 :         Logs().d('[VOIP] running replaceTracks() on stream: ${stream.id}');
     783           0 :         _setTracksEnabled(stream.getVideoTracks(), true);
     784             :         // replace local tracks
     785           0 :         for (final track in localUserMediaStream!.stream!.getTracks()) {
     786             :           try {
     787           0 :             await localUserMediaStream!.stream!.removeTrack(track);
     788           0 :             await track.stop();
     789             :           } catch (e) {
     790           0 :             Logs().w('failed to stop track');
     791             :           }
     792             :         }
     793           0 :         final streamTracks = stream.getTracks();
     794           0 :         for (final newTrack in streamTracks) {
     795           0 :           await localUserMediaStream!.stream!.addTrack(newTrack);
     796             :         }
     797             : 
     798             :         // remove any screen sharing or remote transceivers, these don't need
     799             :         // to be replaced anyway.
     800           0 :         final transceivers = await pc!.getTransceivers();
     801           0 :         transceivers.removeWhere(
     802           0 :           (transceiver) =>
     803           0 :               transceiver.sender.track == null ||
     804           0 :               (localScreenSharingStream != null &&
     805           0 :                   localScreenSharingStream!.stream != null &&
     806           0 :                   localScreenSharingStream!.stream!
     807           0 :                       .getTracks()
     808           0 :                       .map((e) => e.id)
     809           0 :                       .contains(transceiver.sender.track?.id)),
     810             :         );
     811             : 
     812             :         // in an ideal case the following should happen
     813             :         // - audio track gets replaced
     814             :         // - new video track gets added
     815           0 :         for (final newTrack in streamTracks) {
     816           0 :           final transceiver = transceivers.singleWhereOrNull(
     817           0 :             (transceiver) => transceiver.sender.track!.kind == newTrack.kind,
     818             :           );
     819             :           if (transceiver != null) {
     820           0 :             Logs().d(
     821           0 :               '[VOIP] replacing ${transceiver.sender.track} in transceiver',
     822             :             );
     823           0 :             final oldSender = transceiver.sender;
     824           0 :             await oldSender.replaceTrack(newTrack);
     825           0 :             await transceiver.setDirection(
     826           0 :               await transceiver.getDirection() ==
     827             :                       TransceiverDirection.Inactive // upgrade, send now
     828             :                   ? TransceiverDirection.SendOnly
     829             :                   : TransceiverDirection.SendRecv,
     830             :             );
     831             :           } else {
     832             :             // adding transceiver
     833           0 :             Logs().d('[VOIP] adding track $newTrack to pc');
     834           0 :             await pc!.addTrack(newTrack, localUserMediaStream!.stream!);
     835             :           }
     836             :         }
     837             :         // for renderer to be able to show new video track
     838           0 :         localUserMediaStream?.onStreamChanged
     839           0 :             .add(localUserMediaStream!.stream!);
     840             :       }
     841             :     }
     842             :   }
     843             : 
     844           0 :   Future<void> setMicrophoneMuted(bool muted) async {
     845           0 :     localUserMediaStream?.setAudioMuted(muted);
     846           0 :     await updateMuteStatus();
     847             :   }
     848             : 
     849           0 :   Future<void> setRemoteOnHold(bool onHold) async {
     850           0 :     if (remoteOnHold == onHold) return;
     851           0 :     _remoteOnHold = onHold;
     852           0 :     final transceivers = await pc!.getTransceivers();
     853           0 :     for (final transceiver in transceivers) {
     854           0 :       await transceiver.setDirection(
     855             :         onHold ? TransceiverDirection.SendOnly : TransceiverDirection.SendRecv,
     856             :       );
     857             :     }
     858           0 :     await updateMuteStatus();
     859           0 :     fireCallEvent(CallStateChange.kRemoteHoldUnhold);
     860             :   }
     861             : 
     862           0 :   Future<bool> isLocalOnHold() async {
     863           0 :     if (state != CallState.kConnected) return false;
     864             :     var callOnHold = true;
     865             :     // We consider a call to be on hold only if *all* the tracks are on hold
     866             :     // (is this the right thing to do?)
     867           0 :     final transceivers = await pc!.getTransceivers();
     868           0 :     for (final transceiver in transceivers) {
     869           0 :       final currentDirection = await transceiver.getCurrentDirection();
     870           0 :       final trackOnHold = (currentDirection == TransceiverDirection.Inactive ||
     871           0 :           currentDirection == TransceiverDirection.RecvOnly);
     872             :       if (!trackOnHold) {
     873             :         callOnHold = false;
     874             :       }
     875             :     }
     876             :     return callOnHold;
     877             :   }
     878             : 
     879           2 :   Future<void> answer({String? txid}) async {
     880           2 :     if (_inviteOrAnswerSent) {
     881             :       return;
     882             :     }
     883             :     // stop play ringtone
     884           6 :     await voip.delegate.stopRingtone();
     885             : 
     886           4 :     if (direction == CallDirection.kIncoming) {
     887           2 :       setCallState(CallState.kCreateAnswer);
     888             : 
     889           6 :       final answer = await pc!.createAnswer({});
     890           4 :       for (final candidate in _remoteCandidates) {
     891           4 :         await pc!.addCandidate(candidate);
     892             :       }
     893             : 
     894           2 :       final callCapabilities = CallCapabilities()
     895           2 :         ..dtmf = false
     896           2 :         ..transferee = false;
     897             : 
     898           4 :       final metadata = SDPStreamMetadata({
     899           2 :         if (localUserMediaStream != null)
     900          10 :           localUserMediaStream!.stream!.id: SDPStreamPurpose(
     901             :             purpose: SDPStreamMetadataPurpose.Usermedia,
     902           4 :             audio_muted: localUserMediaStream!.audioMuted,
     903           4 :             video_muted: localUserMediaStream!.videoMuted,
     904             :           ),
     905           2 :         if (localScreenSharingStream != null)
     906           0 :           localScreenSharingStream!.stream!.id: SDPStreamPurpose(
     907             :             purpose: SDPStreamMetadataPurpose.Screenshare,
     908           0 :             audio_muted: localScreenSharingStream!.audioMuted,
     909           0 :             video_muted: localScreenSharingStream!.videoMuted,
     910             :           ),
     911             :       });
     912             : 
     913           4 :       await pc!.setLocalDescription(answer);
     914           2 :       setCallState(CallState.kConnecting);
     915             : 
     916             :       // Allow a short time for initial candidates to be gathered
     917           4 :       await Future.delayed(Duration(milliseconds: 200));
     918             : 
     919           2 :       final res = await sendAnswerCall(
     920           2 :         room,
     921           2 :         callId,
     922           2 :         answer.sdp!,
     923           2 :         localPartyId,
     924           2 :         type: answer.type!,
     925             :         capabilities: callCapabilities,
     926             :         metadata: metadata,
     927             :         txid: txid,
     928             :       );
     929           6 :       Logs().v('[VOIP] answer res => $res');
     930             : 
     931           2 :       _inviteOrAnswerSent = true;
     932           2 :       _answeredByUs = true;
     933             :     }
     934             :   }
     935             : 
     936             :   /// Reject a call
     937             :   /// This used to be done by calling hangup, but is a separate method and protocol
     938             :   /// event as of MSC2746.
     939           2 :   Future<void> reject({CallErrorCode? reason, bool shouldEmit = true}) async {
     940           2 :     setCallState(CallState.kEnding);
     941           8 :     if (state != CallState.kRinging && state != CallState.kFledgling) {
     942           4 :       Logs().e(
     943           6 :         '[VOIP] Call must be in \'ringing|fledgling\' state to reject! (current state was: ${state.toString()}) Calling hangup instead',
     944             :       );
     945           2 :       await hangup(reason: CallErrorCode.userHangup, shouldEmit: shouldEmit);
     946             :       return;
     947             :     }
     948           0 :     Logs().d('[VOIP] Rejecting call: $callId');
     949           0 :     await terminate(CallParty.kLocal, CallErrorCode.userHangup, shouldEmit);
     950             :     if (shouldEmit) {
     951           0 :       await sendCallReject(room, callId, localPartyId);
     952             :     }
     953             :   }
     954             : 
     955           2 :   Future<void> hangup({
     956             :     required CallErrorCode reason,
     957             :     bool shouldEmit = true,
     958             :   }) async {
     959           2 :     setCallState(CallState.kEnding);
     960           2 :     await terminate(CallParty.kLocal, reason, shouldEmit);
     961             :     try {
     962             :       final res =
     963           8 :           await sendHangupCall(room, callId, localPartyId, 'userHangup');
     964           6 :       Logs().v('[VOIP] hangup res => $res');
     965             :     } catch (e) {
     966           0 :       Logs().v('[VOIP] hangup error => ${e.toString()}');
     967             :     }
     968             :   }
     969             : 
     970           0 :   Future<void> sendDTMF(String tones) async {
     971           0 :     final senders = await pc!.getSenders();
     972           0 :     for (final sender in senders) {
     973           0 :       if (sender.track != null && sender.track!.kind == 'audio') {
     974           0 :         await sender.dtmfSender.insertDTMF(tones);
     975             :         return;
     976             :       } else {
     977           0 :         Logs().w('[VOIP] Unable to find a track to send DTMF on');
     978             :       }
     979             :     }
     980             :   }
     981             : 
     982           2 :   Future<void> terminate(
     983             :     CallParty party,
     984             :     CallErrorCode reason,
     985             :     bool shouldEmit,
     986             :   ) async {
     987           4 :     if (state == CallState.kConnected) {
     988           0 :       await hangup(
     989             :         reason: CallErrorCode.userHangup,
     990             :         shouldEmit: true,
     991             :       );
     992             :       return;
     993             :     }
     994             : 
     995           4 :     Logs().d('[VOIP] terminating call');
     996           4 :     _inviteTimer?.cancel();
     997           2 :     _inviteTimer = null;
     998             : 
     999           4 :     _ringingTimer?.cancel();
    1000           2 :     _ringingTimer = null;
    1001             : 
    1002             :     try {
    1003           6 :       await voip.delegate.stopRingtone();
    1004             :     } catch (e) {
    1005             :       // maybe rigntone never started (group calls) or has been stopped already
    1006           0 :       Logs().d('stopping ringtone failed ', e);
    1007             :     }
    1008             : 
    1009           2 :     hangupReason = reason;
    1010             : 
    1011             :     // don't see any reason to wrap this with shouldEmit atm,
    1012             :     // looks like a local state change only
    1013           2 :     setCallState(CallState.kEnded);
    1014             : 
    1015           2 :     if (!isGroupCall) {
    1016             :       // when a call crash and this call is already terminated the currentCId is null.
    1017             :       // So don't return bc the hangup or reject will not proceed anymore.
    1018           4 :       if (voip.currentCID != null &&
    1019          14 :           voip.currentCID != VoipId(roomId: room.id, callId: callId)) return;
    1020           4 :       voip.currentCID = null;
    1021          12 :       voip.incomingCallRoomId.removeWhere((key, value) => value == callId);
    1022             :     }
    1023             : 
    1024          14 :     voip.calls.removeWhere((key, value) => key.callId == callId);
    1025             : 
    1026           2 :     await cleanUp();
    1027             :     if (shouldEmit) {
    1028           4 :       onCallHangupNotifierForGroupCalls.add(this);
    1029           6 :       await voip.delegate.handleCallEnded(this);
    1030           2 :       fireCallEvent(CallStateChange.kHangup);
    1031           2 :       if ((party == CallParty.kRemote &&
    1032           2 :           _missedCall &&
    1033           2 :           reason != CallErrorCode.answeredElsewhere)) {
    1034           0 :         await voip.delegate.handleMissedCall(this);
    1035             :       }
    1036             :     }
    1037             :   }
    1038             : 
    1039           0 :   Future<void> onRejectReceived(CallErrorCode? reason) async {
    1040           0 :     Logs().v('[VOIP] Reject received for call ID $callId');
    1041             :     // No need to check party_id for reject because if we'd received either
    1042             :     // an answer or reject, we wouldn't be in state InviteSent
    1043           0 :     final shouldTerminate = (state == CallState.kFledgling &&
    1044           0 :             direction == CallDirection.kIncoming) ||
    1045           0 :         CallState.kInviteSent == state ||
    1046           0 :         CallState.kRinging == state;
    1047             : 
    1048             :     if (shouldTerminate) {
    1049           0 :       await terminate(
    1050             :         CallParty.kRemote,
    1051             :         reason ?? CallErrorCode.userHangup,
    1052             :         true,
    1053             :       );
    1054             :     } else {
    1055           0 :       Logs().e('[VOIP] Call is in state: ${state.toString()}: ignoring reject');
    1056             :     }
    1057             :   }
    1058             : 
    1059           2 :   Future<void> _gotLocalOffer(RTCSessionDescription offer) async {
    1060           2 :     if (callHasEnded) {
    1061           0 :       Logs().d(
    1062           0 :         'Ignoring newly created offer on call ID ${opts.callId} because the call has ended',
    1063             :       );
    1064             :       return;
    1065             :     }
    1066             : 
    1067             :     try {
    1068           4 :       await pc!.setLocalDescription(offer);
    1069             :     } catch (err) {
    1070           0 :       Logs().d('Error setting local description! ${err.toString()}');
    1071           0 :       await terminate(
    1072             :         CallParty.kLocal,
    1073             :         CallErrorCode.setLocalDescription,
    1074             :         true,
    1075             :       );
    1076             :       return;
    1077             :     }
    1078             : 
    1079           6 :     if (pc!.iceGatheringState ==
    1080             :         RTCIceGatheringState.RTCIceGatheringStateGathering) {
    1081             :       // Allow a short time for initial candidates to be gathered
    1082           0 :       await Future.delayed(CallTimeouts.iceGatheringDelay);
    1083             :     }
    1084             : 
    1085           2 :     if (callHasEnded) return;
    1086             : 
    1087           2 :     final callCapabilities = CallCapabilities()
    1088           2 :       ..dtmf = false
    1089           2 :       ..transferee = false;
    1090           2 :     final metadata = _getLocalSDPStreamMetadata();
    1091           4 :     if (state == CallState.kCreateOffer) {
    1092           2 :       await sendInviteToCall(
    1093           2 :         room,
    1094           2 :         callId,
    1095           2 :         CallTimeouts.callInviteLifetime.inMilliseconds,
    1096           2 :         localPartyId,
    1097           2 :         offer.sdp!,
    1098             :         capabilities: callCapabilities,
    1099             :         metadata: metadata,
    1100             :       );
    1101             :       // just incase we ended the call but already sent the invite
    1102             :       // raraley happens during glares
    1103           4 :       if (state == CallState.kEnded) {
    1104           0 :         await hangup(reason: CallErrorCode.replaced);
    1105             :         return;
    1106             :       }
    1107           2 :       _inviteOrAnswerSent = true;
    1108             : 
    1109           2 :       if (!isGroupCall) {
    1110           4 :         Logs().d('[glare] set callid because new invite sent');
    1111          12 :         voip.incomingCallRoomId[room.id] = callId;
    1112             :       }
    1113             : 
    1114           2 :       setCallState(CallState.kInviteSent);
    1115             : 
    1116           4 :       _inviteTimer = Timer(CallTimeouts.callInviteLifetime, () {
    1117           0 :         if (state == CallState.kInviteSent) {
    1118           0 :           hangup(reason: CallErrorCode.inviteTimeout);
    1119             :         }
    1120           0 :         _inviteTimer?.cancel();
    1121           0 :         _inviteTimer = null;
    1122             :       });
    1123             :     } else {
    1124           0 :       await sendCallNegotiate(
    1125           0 :         room,
    1126           0 :         callId,
    1127           0 :         CallTimeouts.defaultCallEventLifetime.inMilliseconds,
    1128           0 :         localPartyId,
    1129           0 :         offer.sdp!,
    1130           0 :         type: offer.type!,
    1131             :         capabilities: callCapabilities,
    1132             :         metadata: metadata,
    1133             :       );
    1134             :     }
    1135             :   }
    1136             : 
    1137           2 :   Future<void> onNegotiationNeeded() async {
    1138           4 :     Logs().d('Negotiation is needed!');
    1139           2 :     _makingOffer = true;
    1140             :     try {
    1141             :       // The first addTrack(audio track) on iOS will trigger
    1142             :       // onNegotiationNeeded, which causes creatOffer to only include
    1143             :       // audio m-line, add delay and wait for video track to be added,
    1144             :       // then createOffer can get audio/video m-line correctly.
    1145           2 :       await Future.delayed(CallTimeouts.delayBeforeOffer);
    1146           6 :       final offer = await pc!.createOffer({});
    1147           2 :       await _gotLocalOffer(offer);
    1148             :     } catch (e) {
    1149           0 :       await _getLocalOfferFailed(e);
    1150             :       return;
    1151             :     } finally {
    1152           2 :       _makingOffer = false;
    1153             :     }
    1154             :   }
    1155             : 
    1156           2 :   Future<void> _preparePeerConnection() async {
    1157             :     int iceRestartedCount = 0;
    1158             : 
    1159             :     try {
    1160           4 :       pc = await _createPeerConnection();
    1161           6 :       pc!.onRenegotiationNeeded = onNegotiationNeeded;
    1162             : 
    1163           4 :       pc!.onIceCandidate = (RTCIceCandidate candidate) async {
    1164           0 :         if (callHasEnded) return;
    1165           0 :         _localCandidates.add(candidate);
    1166             : 
    1167           0 :         if (state == CallState.kRinging || !_inviteOrAnswerSent) return;
    1168             : 
    1169             :         // MSC2746 recommends these values (can be quite long when calling because the
    1170             :         // callee will need a while to answer the call)
    1171           0 :         final delay = direction == CallDirection.kIncoming ? 500 : 2000;
    1172           0 :         if (_candidateSendTries == 0) {
    1173           0 :           Timer(Duration(milliseconds: delay), () {
    1174           0 :             _sendCandidateQueue();
    1175             :           });
    1176             :         }
    1177             :       };
    1178             : 
    1179           6 :       pc!.onIceGatheringState = (RTCIceGatheringState state) async {
    1180           8 :         Logs().v('[VOIP] IceGatheringState => ${state.toString()}');
    1181           2 :         if (state == RTCIceGatheringState.RTCIceGatheringStateGathering) {
    1182           0 :           Timer(Duration(seconds: 3), () async {
    1183           0 :             if (!_iceGatheringFinished) {
    1184           0 :               _iceGatheringFinished = true;
    1185           0 :               await _sendCandidateQueue();
    1186             :             }
    1187             :           });
    1188             :         }
    1189           2 :         if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) {
    1190           2 :           if (!_iceGatheringFinished) {
    1191           2 :             _iceGatheringFinished = true;
    1192           2 :             await _sendCandidateQueue();
    1193             :           }
    1194             :         }
    1195             :       };
    1196           6 :       pc!.onIceConnectionState = (RTCIceConnectionState state) async {
    1197           8 :         Logs().v('[VOIP] RTCIceConnectionState => ${state.toString()}');
    1198           2 :         if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
    1199           4 :           _localCandidates.clear();
    1200           4 :           _remoteCandidates.clear();
    1201             :           iceRestartedCount = 0;
    1202           2 :           setCallState(CallState.kConnected);
    1203             :           // fix any state/race issues we had with sdp packets and cloned streams
    1204           2 :           await updateMuteStatus();
    1205           2 :           _missedCall = false;
    1206             :         } else if ({
    1207           2 :           RTCIceConnectionState.RTCIceConnectionStateFailed,
    1208           2 :           RTCIceConnectionState.RTCIceConnectionStateDisconnected,
    1209           2 :         }.contains(state)) {
    1210           0 :           if (iceRestartedCount < 3) {
    1211           0 :             await restartIce();
    1212           0 :             iceRestartedCount++;
    1213             :           } else {
    1214           0 :             await hangup(reason: CallErrorCode.iceFailed);
    1215             :           }
    1216             :         }
    1217             :       };
    1218             :     } catch (e) {
    1219           0 :       Logs().v('[VOIP] prepareMediaStream error => ${e.toString()}');
    1220             :     }
    1221             :   }
    1222             : 
    1223           0 :   Future<void> onAnsweredElsewhere() async {
    1224           0 :     Logs().d('Call ID $callId answered elsewhere');
    1225           0 :     await terminate(CallParty.kRemote, CallErrorCode.answeredElsewhere, true);
    1226             :   }
    1227             : 
    1228           2 :   Future<void> cleanUp() async {
    1229             :     try {
    1230           4 :       for (final stream in _streams) {
    1231           2 :         await stream.dispose();
    1232             :       }
    1233           4 :       _streams.clear();
    1234             :     } catch (e, s) {
    1235           0 :       Logs().e('[VOIP] cleaning up streams failed', e, s);
    1236             :     }
    1237             : 
    1238             :     try {
    1239           2 :       if (pc != null) {
    1240           4 :         await pc!.close();
    1241           4 :         await pc!.dispose();
    1242             :       }
    1243             :     } catch (e, s) {
    1244           0 :       Logs().e('[VOIP] removing pc failed', e, s);
    1245             :     }
    1246             :   }
    1247             : 
    1248           2 :   Future<void> updateMuteStatus() async {
    1249           2 :     final micShouldBeMuted = (localUserMediaStream != null &&
    1250           0 :             localUserMediaStream!.isAudioMuted()) ||
    1251           2 :         _remoteOnHold;
    1252           2 :     final vidShouldBeMuted = (localUserMediaStream != null &&
    1253           0 :             localUserMediaStream!.isVideoMuted()) ||
    1254           2 :         _remoteOnHold;
    1255             : 
    1256           2 :     _setTracksEnabled(
    1257           4 :       localUserMediaStream?.stream?.getAudioTracks() ?? [],
    1258             :       !micShouldBeMuted,
    1259             :     );
    1260           2 :     _setTracksEnabled(
    1261           4 :       localUserMediaStream?.stream?.getVideoTracks() ?? [],
    1262             :       !vidShouldBeMuted,
    1263             :     );
    1264             : 
    1265           2 :     await sendSDPStreamMetadataChanged(
    1266           2 :       room,
    1267           2 :       callId,
    1268           2 :       localPartyId,
    1269           2 :       _getLocalSDPStreamMetadata(),
    1270             :     );
    1271             :   }
    1272             : 
    1273           2 :   void _setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) {
    1274           2 :     for (final track in tracks) {
    1275           0 :       track.enabled = enabled;
    1276             :     }
    1277             :   }
    1278             : 
    1279           2 :   SDPStreamMetadata _getLocalSDPStreamMetadata() {
    1280           2 :     final sdpStreamMetadatas = <String, SDPStreamPurpose>{};
    1281           4 :     for (final wpstream in getLocalStreams) {
    1282           2 :       if (wpstream.stream != null) {
    1283           8 :         sdpStreamMetadatas[wpstream.stream!.id] = SDPStreamPurpose(
    1284           2 :           purpose: wpstream.purpose,
    1285           2 :           audio_muted: wpstream.audioMuted,
    1286           2 :           video_muted: wpstream.videoMuted,
    1287             :         );
    1288             :       }
    1289             :     }
    1290           2 :     final metadata = SDPStreamMetadata(sdpStreamMetadatas);
    1291          10 :     Logs().v('Got local SDPStreamMetadata ${metadata.toJson().toString()}');
    1292             :     return metadata;
    1293             :   }
    1294             : 
    1295           0 :   Future<void> restartIce() async {
    1296           0 :     Logs().v('[VOIP] iceRestart.');
    1297             :     // Needs restart ice on session.pc and renegotiation.
    1298           0 :     _iceGatheringFinished = false;
    1299           0 :     _localCandidates.clear();
    1300           0 :     await pc!.restartIce();
    1301             :   }
    1302             : 
    1303           2 :   Future<MediaStream?> _getUserMedia(CallType type) async {
    1304           2 :     final mediaConstraints = {
    1305             :       'audio': UserMediaConstraints.micMediaConstraints,
    1306           2 :       'video': type == CallType.kVideo
    1307             :           ? UserMediaConstraints.camMediaConstraints
    1308             :           : false,
    1309             :     };
    1310             :     try {
    1311           8 :       return await voip.delegate.mediaDevices.getUserMedia(mediaConstraints);
    1312             :     } catch (e) {
    1313           0 :       await _getUserMediaFailed(e);
    1314             :       rethrow;
    1315             :     }
    1316             :   }
    1317             : 
    1318           0 :   Future<MediaStream?> _getDisplayMedia() async {
    1319             :     try {
    1320           0 :       return await voip.delegate.mediaDevices
    1321           0 :           .getDisplayMedia(UserMediaConstraints.screenMediaConstraints);
    1322             :     } catch (e) {
    1323           0 :       await _getUserMediaFailed(e);
    1324             :     }
    1325             :     return null;
    1326             :   }
    1327             : 
    1328           2 :   Future<RTCPeerConnection> _createPeerConnection() async {
    1329           2 :     final configuration = <String, dynamic>{
    1330           4 :       'iceServers': opts.iceServers,
    1331             :       'sdpSemantics': 'unified-plan',
    1332             :     };
    1333           6 :     final pc = await voip.delegate.createPeerConnection(configuration);
    1334           2 :     pc.onTrack = (RTCTrackEvent event) async {
    1335           0 :       for (final stream in event.streams) {
    1336           0 :         await _addRemoteStream(stream);
    1337           0 :         for (final track in stream.getTracks()) {
    1338           0 :           track.onEnded = () async {
    1339           0 :             if (stream.getTracks().isEmpty) {
    1340           0 :               Logs().d('[VOIP] detected a empty stream, removing it');
    1341           0 :               await _removeStream(stream);
    1342             :             }
    1343             :           };
    1344             :         }
    1345             :       }
    1346             :     };
    1347             :     return pc;
    1348             :   }
    1349             : 
    1350           0 :   Future<void> createDataChannel(
    1351             :     String label,
    1352             :     RTCDataChannelInit dataChannelDict,
    1353             :   ) async {
    1354           0 :     await pc?.createDataChannel(label, dataChannelDict);
    1355             :   }
    1356             : 
    1357           0 :   Future<void> tryRemoveStopedStreams() async {
    1358           0 :     final removedStreams = <String, WrappedMediaStream>{};
    1359           0 :     for (final stream in _streams) {
    1360           0 :       if (stream.stopped) {
    1361           0 :         removedStreams[stream.stream!.id] = stream;
    1362             :       }
    1363             :     }
    1364           0 :     _streams
    1365           0 :         .removeWhere((stream) => removedStreams.containsKey(stream.stream!.id));
    1366           0 :     for (final element in removedStreams.entries) {
    1367           0 :       await _removeStream(element.value.stream!);
    1368             :     }
    1369             :   }
    1370             : 
    1371           0 :   Future<void> _removeStream(MediaStream stream) async {
    1372           0 :     Logs().v('Removing feed with stream id ${stream.id}');
    1373             : 
    1374           0 :     final it = _streams.where((element) => element.stream!.id == stream.id);
    1375           0 :     if (it.isEmpty) {
    1376           0 :       Logs().v('Didn\'t find the feed with stream id ${stream.id} to delete');
    1377             :       return;
    1378             :     }
    1379           0 :     final wpstream = it.first;
    1380           0 :     _streams.removeWhere((element) => element.stream!.id == stream.id);
    1381           0 :     onStreamRemoved.add(wpstream);
    1382           0 :     fireCallEvent(CallStateChange.kFeedsChanged);
    1383           0 :     await wpstream.dispose();
    1384             :   }
    1385             : 
    1386           2 :   Future<void> _sendCandidateQueue() async {
    1387           2 :     if (callHasEnded) return;
    1388             :     /*
    1389             :     Currently, trickle-ice is not supported, so it will take a
    1390             :     long time to wait to collect all the canidates, set the
    1391             :     timeout for collection canidates to speed up the connection.
    1392             :     */
    1393           2 :     final candidatesQueue = _localCandidates;
    1394             :     try {
    1395           2 :       if (candidatesQueue.isNotEmpty) {
    1396           0 :         final candidates = <Map<String, dynamic>>[];
    1397           0 :         for (final element in candidatesQueue) {
    1398           0 :           candidates.add(element.toMap());
    1399             :         }
    1400           0 :         _localCandidates.clear();
    1401           0 :         final res = await sendCallCandidates(
    1402           0 :           opts.room,
    1403           0 :           callId,
    1404           0 :           localPartyId,
    1405             :           candidates,
    1406             :         );
    1407           0 :         Logs().v('[VOIP] sendCallCandidates res => $res');
    1408             :       }
    1409             :     } catch (e) {
    1410           0 :       Logs().v('[VOIP] sendCallCandidates e => ${e.toString()}');
    1411           0 :       _candidateSendTries++;
    1412           0 :       _localCandidates.clear();
    1413           0 :       _localCandidates.addAll(candidatesQueue);
    1414             : 
    1415           0 :       if (_candidateSendTries > 5) {
    1416           0 :         Logs().d(
    1417           0 :           'Failed to send candidates on attempt $_candidateSendTries Giving up on this call.',
    1418             :         );
    1419           0 :         await hangup(reason: CallErrorCode.iceTimeout);
    1420             :         return;
    1421             :       }
    1422             : 
    1423           0 :       final delay = 500 * pow(2, _candidateSendTries);
    1424           0 :       Timer(Duration(milliseconds: delay as int), () {
    1425           0 :         _sendCandidateQueue();
    1426             :       });
    1427             :     }
    1428             :   }
    1429             : 
    1430           2 :   void fireCallEvent(CallStateChange event) {
    1431           4 :     onCallEventChanged.add(event);
    1432           8 :     Logs().i('CallStateChange: ${event.toString()}');
    1433             :     switch (event) {
    1434           2 :       case CallStateChange.kFeedsChanged:
    1435           4 :         onCallStreamsChanged.add(this);
    1436             :         break;
    1437           2 :       case CallStateChange.kState:
    1438          10 :         Logs().i('CallState: ${state.toString()}');
    1439             :         break;
    1440           2 :       case CallStateChange.kError:
    1441             :         break;
    1442           2 :       case CallStateChange.kHangup:
    1443             :         break;
    1444           0 :       case CallStateChange.kReplaced:
    1445             :         break;
    1446           0 :       case CallStateChange.kLocalHoldUnhold:
    1447             :         break;
    1448           0 :       case CallStateChange.kRemoteHoldUnhold:
    1449             :         break;
    1450           0 :       case CallStateChange.kAssertedIdentityChanged:
    1451             :         break;
    1452             :     }
    1453             :   }
    1454             : 
    1455           0 :   Future<void> _getLocalOfferFailed(dynamic err) async {
    1456           0 :     Logs().e('Failed to get local offer ${err.toString()}');
    1457           0 :     fireCallEvent(CallStateChange.kError);
    1458             : 
    1459           0 :     await terminate(CallParty.kLocal, CallErrorCode.localOfferFailed, true);
    1460             :   }
    1461             : 
    1462           0 :   Future<void> _getUserMediaFailed(dynamic err) async {
    1463           0 :     Logs().w('Failed to get user media - ending call ${err.toString()}');
    1464           0 :     fireCallEvent(CallStateChange.kError);
    1465           0 :     await terminate(CallParty.kLocal, CallErrorCode.userMediaFailed, true);
    1466             :   }
    1467             : 
    1468           2 :   Future<void> onSelectAnswerReceived(String? selectedPartyId) async {
    1469           4 :     if (direction != CallDirection.kIncoming) {
    1470           0 :       Logs().w('Got select_answer for an outbound call: ignoring');
    1471             :       return;
    1472             :     }
    1473             :     if (selectedPartyId == null) {
    1474           0 :       Logs().w(
    1475             :         'Got nonsensical select_answer with null/undefined selected_party_id: ignoring',
    1476             :       );
    1477             :       return;
    1478             :     }
    1479             : 
    1480           4 :     if (selectedPartyId != localPartyId) {
    1481           4 :       Logs().w(
    1482           4 :         'Got select_answer for party ID $selectedPartyId: we are party ID $localPartyId.',
    1483             :       );
    1484             :       // The other party has picked somebody else's answer
    1485           2 :       await terminate(CallParty.kRemote, CallErrorCode.answeredElsewhere, true);
    1486             :     }
    1487             :   }
    1488             : 
    1489             :   /// This is sent by the caller when they wish to establish a call.
    1490             :   /// [callId] is a unique identifier for the call.
    1491             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1492             :   /// [lifetime] is the time in milliseconds that the invite is valid for. Once the invite age exceeds this value,
    1493             :   /// clients should discard it. They should also no longer show the call as awaiting an answer in the UI.
    1494             :   /// [type] The type of session description. Must be 'offer'.
    1495             :   /// [sdp] The SDP text of the session description.
    1496             :   /// [invitee] The user ID of the person who is being invited. Invites without an invitee field are defined to be
    1497             :   /// intended for any member of the room other than the sender of the event.
    1498             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1499           2 :   Future<String?> sendInviteToCall(
    1500             :     Room room,
    1501             :     String callId,
    1502             :     int lifetime,
    1503             :     String party_id,
    1504             :     String sdp, {
    1505             :     String type = 'offer',
    1506             :     String version = voipProtoVersion,
    1507             :     String? txid,
    1508             :     CallCapabilities? capabilities,
    1509             :     SDPStreamMetadata? metadata,
    1510             :   }) async {
    1511           2 :     final content = {
    1512           2 :       'call_id': callId,
    1513           2 :       'party_id': party_id,
    1514           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1515           2 :       'version': version,
    1516           2 :       'lifetime': lifetime,
    1517           4 :       'offer': {'sdp': sdp, 'type': type},
    1518           2 :       if (remoteUserId != null)
    1519           2 :         'invitee':
    1520           2 :             remoteUserId!, // TODO: rename this to invitee_user_id? breaks spec though
    1521           2 :       if (remoteDeviceId != null) 'invitee_device_id': remoteDeviceId!,
    1522           2 :       if (remoteDeviceId != null)
    1523           0 :         'device_id': client
    1524           0 :             .deviceID!, // Having a remoteDeviceId means you are doing to-device events, so you want to send your deviceId too
    1525           4 :       if (capabilities != null) 'capabilities': capabilities.toJson(),
    1526           4 :       if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
    1527             :     };
    1528           2 :     return await _sendContent(
    1529             :       room,
    1530           2 :       isGroupCall ? EventTypes.GroupCallMemberInvite : EventTypes.CallInvite,
    1531             :       content,
    1532             :       txid: txid,
    1533             :     );
    1534             :   }
    1535             : 
    1536             :   /// The calling party sends the party_id of the first selected answer.
    1537             :   ///
    1538             :   /// Usually after receiving the first answer sdp in the client.onCallAnswer event,
    1539             :   /// save the `party_id`, and then send `CallSelectAnswer` to others peers that the call has been picked up.
    1540             :   ///
    1541             :   /// [callId] is a unique identifier for the call.
    1542             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1543             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1544             :   /// [selected_party_id] The party ID for the selected answer.
    1545           2 :   Future<String?> sendSelectCallAnswer(
    1546             :     Room room,
    1547             :     String callId,
    1548             :     String party_id,
    1549             :     String selected_party_id, {
    1550             :     String version = voipProtoVersion,
    1551             :     String? txid,
    1552             :   }) async {
    1553           2 :     final content = {
    1554           2 :       'call_id': callId,
    1555           2 :       'party_id': party_id,
    1556           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1557           2 :       'version': version,
    1558           2 :       'selected_party_id': selected_party_id,
    1559             :     };
    1560             : 
    1561           2 :     return await _sendContent(
    1562             :       room,
    1563           2 :       isGroupCall
    1564             :           ? EventTypes.GroupCallMemberSelectAnswer
    1565             :           : EventTypes.CallSelectAnswer,
    1566             :       content,
    1567             :       txid: txid,
    1568             :     );
    1569             :   }
    1570             : 
    1571             :   /// Reject a call
    1572             :   /// [callId] is a unique identifier for the call.
    1573             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1574             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1575           2 :   Future<String?> sendCallReject(
    1576             :     Room room,
    1577             :     String callId,
    1578             :     String party_id, {
    1579             :     String version = voipProtoVersion,
    1580             :     String? txid,
    1581             :   }) async {
    1582           2 :     final content = {
    1583           2 :       'call_id': callId,
    1584           2 :       'party_id': party_id,
    1585           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1586           2 :       'version': version,
    1587             :     };
    1588             : 
    1589           2 :     return await _sendContent(
    1590             :       room,
    1591           2 :       isGroupCall ? EventTypes.GroupCallMemberReject : EventTypes.CallReject,
    1592             :       content,
    1593             :       txid: txid,
    1594             :     );
    1595             :   }
    1596             : 
    1597             :   /// When local audio/video tracks are added/deleted or hold/unhold,
    1598             :   /// need to createOffer and renegotiation.
    1599             :   /// [callId] is a unique identifier for the call.
    1600             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1601             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1602           2 :   Future<String?> sendCallNegotiate(
    1603             :     Room room,
    1604             :     String callId,
    1605             :     int lifetime,
    1606             :     String party_id,
    1607             :     String sdp, {
    1608             :     String type = 'offer',
    1609             :     String version = voipProtoVersion,
    1610             :     String? txid,
    1611             :     CallCapabilities? capabilities,
    1612             :     SDPStreamMetadata? metadata,
    1613             :   }) async {
    1614           2 :     final content = {
    1615           2 :       'call_id': callId,
    1616           2 :       'party_id': party_id,
    1617           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1618           2 :       'version': version,
    1619           2 :       'lifetime': lifetime,
    1620           4 :       'description': {'sdp': sdp, 'type': type},
    1621           0 :       if (capabilities != null) 'capabilities': capabilities.toJson(),
    1622           0 :       if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
    1623             :     };
    1624           2 :     return await _sendContent(
    1625             :       room,
    1626           2 :       isGroupCall
    1627             :           ? EventTypes.GroupCallMemberNegotiate
    1628             :           : EventTypes.CallNegotiate,
    1629             :       content,
    1630             :       txid: txid,
    1631             :     );
    1632             :   }
    1633             : 
    1634             :   /// This is sent by callers after sending an invite and by the callee after answering.
    1635             :   /// Its purpose is to give the other party additional ICE candidates to try using to communicate.
    1636             :   ///
    1637             :   /// [callId] The ID of the call this event relates to.
    1638             :   ///
    1639             :   /// [version] The version of the VoIP specification this messages adheres to. This specification is version 1.
    1640             :   ///
    1641             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1642             :   ///
    1643             :   /// [candidates] Array of objects describing the candidates. Example:
    1644             :   ///
    1645             :   /// ```
    1646             :   /// [
    1647             :   ///       {
    1648             :   ///           "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0",
    1649             :   ///           "sdpMLineIndex": 0,
    1650             :   ///           "sdpMid": "audio"
    1651             :   ///       }
    1652             :   ///   ],
    1653             :   /// ```
    1654           2 :   Future<String?> sendCallCandidates(
    1655             :     Room room,
    1656             :     String callId,
    1657             :     String party_id,
    1658             :     List<Map<String, dynamic>> candidates, {
    1659             :     String version = voipProtoVersion,
    1660             :     String? txid,
    1661             :   }) async {
    1662           2 :     final content = {
    1663           2 :       'call_id': callId,
    1664           2 :       'party_id': party_id,
    1665           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1666           2 :       'version': version,
    1667           2 :       'candidates': candidates,
    1668             :     };
    1669           2 :     return await _sendContent(
    1670             :       room,
    1671           2 :       isGroupCall
    1672             :           ? EventTypes.GroupCallMemberCandidates
    1673             :           : EventTypes.CallCandidates,
    1674             :       content,
    1675             :       txid: txid,
    1676             :     );
    1677             :   }
    1678             : 
    1679             :   /// This event is sent by the callee when they wish to answer the call.
    1680             :   /// [callId] is a unique identifier for the call.
    1681             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1682             :   /// [type] The type of session description. Must be 'answer'.
    1683             :   /// [sdp] The SDP text of the session description.
    1684             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1685           2 :   Future<String?> sendAnswerCall(
    1686             :     Room room,
    1687             :     String callId,
    1688             :     String sdp,
    1689             :     String party_id, {
    1690             :     String type = 'answer',
    1691             :     String version = voipProtoVersion,
    1692             :     String? txid,
    1693             :     CallCapabilities? capabilities,
    1694             :     SDPStreamMetadata? metadata,
    1695             :   }) async {
    1696           2 :     final content = {
    1697           2 :       'call_id': callId,
    1698           2 :       'party_id': party_id,
    1699           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1700           2 :       'version': version,
    1701           4 :       'answer': {'sdp': sdp, 'type': type},
    1702           4 :       if (capabilities != null) 'capabilities': capabilities.toJson(),
    1703           4 :       if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
    1704             :     };
    1705           2 :     return await _sendContent(
    1706             :       room,
    1707           2 :       isGroupCall ? EventTypes.GroupCallMemberAnswer : EventTypes.CallAnswer,
    1708             :       content,
    1709             :       txid: txid,
    1710             :     );
    1711             :   }
    1712             : 
    1713             :   /// This event is sent by the callee when they wish to answer the call.
    1714             :   /// [callId] The ID of the call this event relates to.
    1715             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1716             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1717           2 :   Future<String?> sendHangupCall(
    1718             :     Room room,
    1719             :     String callId,
    1720             :     String party_id,
    1721             :     String? hangupCause, {
    1722             :     String version = voipProtoVersion,
    1723             :     String? txid,
    1724             :   }) async {
    1725           2 :     final content = {
    1726           2 :       'call_id': callId,
    1727           2 :       'party_id': party_id,
    1728           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1729           2 :       'version': version,
    1730           2 :       if (hangupCause != null) 'reason': hangupCause,
    1731             :     };
    1732           2 :     return await _sendContent(
    1733             :       room,
    1734           2 :       isGroupCall ? EventTypes.GroupCallMemberHangup : EventTypes.CallHangup,
    1735             :       content,
    1736             :       txid: txid,
    1737             :     );
    1738             :   }
    1739             : 
    1740             :   /// Send SdpStreamMetadata Changed event.
    1741             :   ///
    1742             :   /// This MSC also adds a new call event m.call.sdp_stream_metadata_changed,
    1743             :   /// which has the common VoIP fields as specified in
    1744             :   /// MSC2746 (version, call_id, party_id) and a sdp_stream_metadata object which
    1745             :   /// is the same thing as sdp_stream_metadata in m.call.negotiate, m.call.invite
    1746             :   /// and m.call.answer. The client sends this event the when sdp_stream_metadata
    1747             :   /// has changed but no negotiation is required
    1748             :   ///  (e.g. the user mutes their camera/microphone).
    1749             :   ///
    1750             :   /// [callId] The ID of the call this event relates to.
    1751             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1752             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1753             :   /// [metadata] The sdp_stream_metadata object.
    1754           2 :   Future<String?> sendSDPStreamMetadataChanged(
    1755             :     Room room,
    1756             :     String callId,
    1757             :     String party_id,
    1758             :     SDPStreamMetadata metadata, {
    1759             :     String version = voipProtoVersion,
    1760             :     String? txid,
    1761             :   }) async {
    1762           2 :     final content = {
    1763           2 :       'call_id': callId,
    1764           2 :       'party_id': party_id,
    1765           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1766           2 :       'version': version,
    1767           4 :       sdpStreamMetadataKey: metadata.toJson(),
    1768             :     };
    1769           2 :     return await _sendContent(
    1770             :       room,
    1771           2 :       isGroupCall
    1772             :           ? EventTypes.GroupCallMemberSDPStreamMetadataChanged
    1773             :           : EventTypes.CallSDPStreamMetadataChanged,
    1774             :       content,
    1775             :       txid: txid,
    1776             :     );
    1777             :   }
    1778             : 
    1779             :   /// CallReplacesEvent for Transfered calls
    1780             :   ///
    1781             :   /// [callId] The ID of the call this event relates to.
    1782             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1783             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1784             :   /// [callReplaces] transfer info
    1785           2 :   Future<String?> sendCallReplaces(
    1786             :     Room room,
    1787             :     String callId,
    1788             :     String party_id,
    1789             :     CallReplaces callReplaces, {
    1790             :     String version = voipProtoVersion,
    1791             :     String? txid,
    1792             :   }) async {
    1793           2 :     final content = {
    1794           2 :       'call_id': callId,
    1795           2 :       'party_id': party_id,
    1796           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1797           2 :       'version': version,
    1798           2 :       ...callReplaces.toJson(),
    1799             :     };
    1800           2 :     return await _sendContent(
    1801             :       room,
    1802           2 :       isGroupCall
    1803             :           ? EventTypes.GroupCallMemberReplaces
    1804             :           : EventTypes.CallReplaces,
    1805             :       content,
    1806             :       txid: txid,
    1807             :     );
    1808             :   }
    1809             : 
    1810             :   /// send AssertedIdentity event
    1811             :   ///
    1812             :   /// [callId] The ID of the call this event relates to.
    1813             :   /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
    1814             :   /// [party_id] The party ID for call, Can be set to client.deviceId.
    1815             :   /// [assertedIdentity] the asserted identity
    1816           2 :   Future<String?> sendAssertedIdentity(
    1817             :     Room room,
    1818             :     String callId,
    1819             :     String party_id,
    1820             :     AssertedIdentity assertedIdentity, {
    1821             :     String version = voipProtoVersion,
    1822             :     String? txid,
    1823             :   }) async {
    1824           2 :     final content = {
    1825           2 :       'call_id': callId,
    1826           2 :       'party_id': party_id,
    1827           2 :       if (groupCallId != null) 'conf_id': groupCallId!,
    1828           2 :       'version': version,
    1829           4 :       'asserted_identity': assertedIdentity.toJson(),
    1830             :     };
    1831           2 :     return await _sendContent(
    1832             :       room,
    1833           2 :       isGroupCall
    1834             :           ? EventTypes.GroupCallMemberAssertedIdentity
    1835             :           : EventTypes.CallAssertedIdentity,
    1836             :       content,
    1837             :       txid: txid,
    1838             :     );
    1839             :   }
    1840             : 
    1841           2 :   Future<String?> _sendContent(
    1842             :     Room room,
    1843             :     String type,
    1844             :     Map<String, Object> content, {
    1845             :     String? txid,
    1846             :   }) async {
    1847           6 :     Logs().d('[VOIP] sending content type $type, with conf: $content');
    1848           0 :     txid ??= VoIP.customTxid ?? client.generateUniqueTransactionId();
    1849           2 :     final mustEncrypt = room.encrypted && client.encryptionEnabled;
    1850             : 
    1851             :     // opponentDeviceId is only set for a few events during group calls,
    1852             :     // therefore only group calls use to-device messages for call events
    1853           2 :     if (isGroupCall && remoteDeviceId != null) {
    1854           0 :       final toDeviceSeq = _toDeviceSeq++;
    1855           0 :       final Map<String, Object> data = {
    1856             :         ...content,
    1857           0 :         'seq': toDeviceSeq,
    1858           0 :         if (remoteSessionId != null) 'dest_session_id': remoteSessionId!,
    1859           0 :         'sender_session_id': voip.currentSessionId,
    1860           0 :         'room_id': room.id,
    1861             :       };
    1862             : 
    1863             :       if (mustEncrypt) {
    1864           0 :         await client.userDeviceKeysLoading;
    1865           0 :         if (client.userDeviceKeys[remoteUserId]?.deviceKeys[remoteDeviceId] !=
    1866             :             null) {
    1867           0 :           await client.sendToDeviceEncrypted(
    1868           0 :             [
    1869           0 :               client.userDeviceKeys[remoteUserId]!.deviceKeys[remoteDeviceId]!,
    1870             :             ],
    1871             :             type,
    1872             :             data,
    1873             :           );
    1874             :         } else {
    1875           0 :           Logs().w(
    1876           0 :             '[VOIP] _sendCallContent missing device keys for $remoteUserId',
    1877             :           );
    1878             :         }
    1879             :       } else {
    1880           0 :         await client.sendToDevice(
    1881             :           type,
    1882             :           txid,
    1883           0 :           {
    1884           0 :             remoteUserId!: {remoteDeviceId!: data},
    1885             :           },
    1886             :         );
    1887             :       }
    1888             :       return '';
    1889             :     } else {
    1890             :       final sendMessageContent = mustEncrypt
    1891           0 :           ? await client.encryption!
    1892           0 :               .encryptGroupMessagePayload(room.id, content, type: type)
    1893             :           : content;
    1894           4 :       return await client.sendMessage(
    1895           2 :         room.id,
    1896           2 :         sendMessageContent.containsKey('ciphertext')
    1897             :             ? EventTypes.Encrypted
    1898             :             : type,
    1899             :         txid,
    1900             :         sendMessageContent,
    1901             :       );
    1902             :     }
    1903             :   }
    1904             : }

Generated by: LCOV version 1.14