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