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 2 : if (pc != null &&
524 : {
525 2 : RTCIceConnectionState.RTCIceConnectionStateDisconnected,
526 2 : RTCIceConnectionState.RTCIceConnectionStateFailed
527 6 : }.contains(pc!.iceConnectionState)) {
528 0 : await restartIce();
529 : }
530 : }
531 :
532 0 : void onAssertedIdentityReceived(AssertedIdentity identity) {
533 0 : _remoteAssertedIdentity = identity;
534 0 : fireCallEvent(CallStateChange.kAssertedIdentityChanged);
535 : }
536 :
537 0 : Future<bool> setScreensharingEnabled(bool enabled) async {
538 : // Skip if there is nothing to do
539 0 : if (enabled && localScreenSharingStream != null) {
540 0 : Logs().w(
541 : 'There is already a screensharing stream - there is nothing to do!');
542 : return true;
543 0 : } else if (!enabled && localScreenSharingStream == null) {
544 0 : Logs().w(
545 : 'There already isn\'t a screensharing stream - there is nothing to do!');
546 : return false;
547 : }
548 :
549 0 : Logs().d('Set screensharing enabled? $enabled');
550 :
551 : if (enabled) {
552 : try {
553 0 : final stream = await _getDisplayMedia();
554 : if (stream == null) {
555 : return false;
556 : }
557 0 : for (final track in stream.getTracks()) {
558 : // screen sharing should only have 1 video track anyway, so this only
559 : // fires once
560 0 : track.onEnded = () async {
561 0 : await setScreensharingEnabled(false);
562 : };
563 : }
564 :
565 0 : await addLocalStream(stream, SDPStreamMetadataPurpose.Screenshare);
566 : return true;
567 : } catch (err) {
568 0 : fireCallEvent(CallStateChange.kError);
569 :
570 : return false;
571 : }
572 : } else {
573 : try {
574 0 : for (final sender in _screensharingSenders) {
575 0 : await pc!.removeTrack(sender);
576 : }
577 0 : for (final track in localScreenSharingStream!.stream!.getTracks()) {
578 0 : await track.stop();
579 : }
580 0 : localScreenSharingStream!.stopped = true;
581 0 : await _removeStream(localScreenSharingStream!.stream!);
582 0 : fireCallEvent(CallStateChange.kFeedsChanged);
583 : return false;
584 : } catch (e, s) {
585 0 : Logs().e('[VOIP] stopping screen sharing track failed', e, s);
586 : return false;
587 : }
588 : }
589 : }
590 :
591 2 : Future<void> addLocalStream(
592 : MediaStream stream,
593 : String purpose, {
594 : bool addToPeerConnection = true,
595 : }) async {
596 : final existingStream =
597 4 : getLocalStreams.where((element) => element.purpose == purpose);
598 2 : if (existingStream.isNotEmpty) {
599 0 : existingStream.first.setNewStream(stream);
600 : } else {
601 2 : final newStream = WrappedMediaStream(
602 2 : participant: localParticipant!,
603 4 : room: opts.room,
604 : stream: stream,
605 : purpose: purpose,
606 2 : client: client,
607 4 : audioMuted: stream.getAudioTracks().isEmpty,
608 4 : videoMuted: stream.getVideoTracks().isEmpty,
609 2 : isGroupCall: groupCallId != null,
610 2 : pc: pc,
611 2 : voip: voip,
612 : );
613 4 : _streams.add(newStream);
614 4 : onStreamAdd.add(newStream);
615 : }
616 :
617 : if (addToPeerConnection) {
618 2 : if (purpose == SDPStreamMetadataPurpose.Screenshare) {
619 0 : _screensharingSenders.clear();
620 0 : for (final track in stream.getTracks()) {
621 0 : _screensharingSenders.add(await pc!.addTrack(track, stream));
622 : }
623 2 : } else if (purpose == SDPStreamMetadataPurpose.Usermedia) {
624 4 : _usermediaSenders.clear();
625 2 : for (final track in stream.getTracks()) {
626 0 : _usermediaSenders.add(await pc!.addTrack(track, stream));
627 : }
628 : }
629 : }
630 :
631 2 : if (purpose == SDPStreamMetadataPurpose.Usermedia) {
632 6 : _speakerOn = type == CallType.kVideo;
633 10 : if (!voip.delegate.isWeb && stream.getAudioTracks().isNotEmpty) {
634 0 : final audioTrack = stream.getAudioTracks()[0];
635 0 : audioTrack.enableSpeakerphone(_speakerOn);
636 : }
637 : }
638 :
639 2 : fireCallEvent(CallStateChange.kFeedsChanged);
640 : }
641 :
642 0 : Future<void> _addRemoteStream(MediaStream stream) async {
643 : //final userId = remoteUser.id;
644 0 : final metadata = _remoteSDPStreamMetadata?.sdpStreamMetadatas[stream.id];
645 : if (metadata == null) {
646 0 : Logs().i(
647 0 : 'Ignoring stream with id ${stream.id} because we didn\'t get any metadata about it');
648 : return;
649 : }
650 :
651 0 : final purpose = metadata.purpose;
652 0 : final audioMuted = metadata.audio_muted;
653 0 : final videoMuted = metadata.video_muted;
654 :
655 : // Try to find a feed with the same purpose as the new stream,
656 : // if we find it replace the old stream with the new one
657 : final existingStream =
658 0 : getRemoteStreams.where((element) => element.purpose == purpose);
659 0 : if (existingStream.isNotEmpty) {
660 0 : existingStream.first.setNewStream(stream);
661 : } else {
662 0 : final newStream = WrappedMediaStream(
663 0 : participant: CallParticipant(
664 0 : voip,
665 0 : userId: remoteUserId!,
666 0 : deviceId: remoteDeviceId,
667 : ),
668 0 : room: opts.room,
669 : stream: stream,
670 : purpose: purpose,
671 0 : client: client,
672 : audioMuted: audioMuted,
673 : videoMuted: videoMuted,
674 0 : isGroupCall: groupCallId != null,
675 0 : pc: pc,
676 0 : voip: voip,
677 : );
678 0 : _streams.add(newStream);
679 0 : onStreamAdd.add(newStream);
680 : }
681 0 : fireCallEvent(CallStateChange.kFeedsChanged);
682 0 : Logs().i('Pushed remote stream (id="${stream.id}", purpose=$purpose)');
683 : }
684 :
685 0 : Future<void> deleteAllStreams() async {
686 0 : for (final stream in _streams) {
687 0 : if (stream.isLocal() || groupCallId == null) {
688 0 : await stream.dispose();
689 : }
690 : }
691 0 : _streams.clear();
692 0 : fireCallEvent(CallStateChange.kFeedsChanged);
693 : }
694 :
695 0 : Future<void> deleteFeedByStream(MediaStream stream) async {
696 : final index =
697 0 : _streams.indexWhere((element) => element.stream!.id == stream.id);
698 0 : if (index == -1) {
699 0 : Logs().w('Didn\'t find the feed with stream id ${stream.id} to delete');
700 : return;
701 : }
702 0 : final wstream = _streams.elementAt(index);
703 0 : onStreamRemoved.add(wstream);
704 0 : await deleteStream(wstream);
705 : }
706 :
707 0 : Future<void> deleteStream(WrappedMediaStream stream) async {
708 0 : await stream.dispose();
709 0 : _streams.removeAt(_streams.indexOf(stream));
710 0 : fireCallEvent(CallStateChange.kFeedsChanged);
711 : }
712 :
713 0 : Future<void> removeLocalStream(WrappedMediaStream callFeed) async {
714 0 : final senderArray = callFeed.purpose == SDPStreamMetadataPurpose.Usermedia
715 0 : ? _usermediaSenders
716 0 : : _screensharingSenders;
717 :
718 0 : for (final element in senderArray) {
719 0 : await pc!.removeTrack(element);
720 : }
721 :
722 0 : if (callFeed.purpose == SDPStreamMetadataPurpose.Screenshare) {
723 0 : await stopMediaStream(callFeed.stream);
724 : }
725 :
726 : // Empty the array
727 0 : senderArray.removeRange(0, senderArray.length);
728 0 : onStreamRemoved.add(callFeed);
729 0 : await deleteStream(callFeed);
730 : }
731 :
732 2 : void setCallState(CallState newState) {
733 2 : _state = newState;
734 4 : onCallStateChanged.add(newState);
735 2 : fireCallEvent(CallStateChange.kState);
736 : }
737 :
738 0 : Future<void> setLocalVideoMuted(bool muted) async {
739 : if (!muted) {
740 0 : final videoToSend = await hasVideoToSend();
741 : if (!videoToSend) {
742 0 : if (_remoteSDPStreamMetadata == null) return;
743 0 : await insertVideoTrackToAudioOnlyStream();
744 : }
745 : }
746 0 : localUserMediaStream?.setVideoMuted(muted);
747 0 : await updateMuteStatus();
748 : }
749 :
750 : // used for upgrading 1:1 calls
751 0 : Future<void> insertVideoTrackToAudioOnlyStream() async {
752 0 : if (localUserMediaStream != null && localUserMediaStream!.stream != null) {
753 0 : final stream = await _getUserMedia(CallType.kVideo);
754 : if (stream != null) {
755 0 : Logs().d('[VOIP] running replaceTracks() on stream: ${stream.id}');
756 0 : _setTracksEnabled(stream.getVideoTracks(), true);
757 : // replace local tracks
758 0 : for (final track in localUserMediaStream!.stream!.getTracks()) {
759 : try {
760 0 : await localUserMediaStream!.stream!.removeTrack(track);
761 0 : await track.stop();
762 : } catch (e) {
763 0 : Logs().w('failed to stop track');
764 : }
765 : }
766 0 : final streamTracks = stream.getTracks();
767 0 : for (final newTrack in streamTracks) {
768 0 : await localUserMediaStream!.stream!.addTrack(newTrack);
769 : }
770 :
771 : // remove any screen sharing or remote transceivers, these don't need
772 : // to be replaced anyway.
773 0 : final transceivers = await pc!.getTransceivers();
774 0 : transceivers.removeWhere((transceiver) =>
775 0 : transceiver.sender.track == null ||
776 0 : (localScreenSharingStream != null &&
777 0 : localScreenSharingStream!.stream != null &&
778 0 : localScreenSharingStream!.stream!
779 0 : .getTracks()
780 0 : .map((e) => e.id)
781 0 : .contains(transceiver.sender.track?.id)));
782 :
783 : // in an ideal case the following should happen
784 : // - audio track gets replaced
785 : // - new video track gets added
786 0 : for (final newTrack in streamTracks) {
787 0 : final transceiver = transceivers.singleWhereOrNull(
788 0 : (transceiver) => transceiver.sender.track!.kind == newTrack.kind);
789 : if (transceiver != null) {
790 0 : Logs().d(
791 0 : '[VOIP] replacing ${transceiver.sender.track} in transceiver');
792 0 : final oldSender = transceiver.sender;
793 0 : await oldSender.replaceTrack(newTrack);
794 0 : await transceiver.setDirection(
795 0 : await transceiver.getDirection() ==
796 : TransceiverDirection.Inactive // upgrade, send now
797 : ? TransceiverDirection.SendOnly
798 : : TransceiverDirection.SendRecv,
799 : );
800 : } else {
801 : // adding transceiver
802 0 : Logs().d('[VOIP] adding track $newTrack to pc');
803 0 : await pc!.addTrack(newTrack, localUserMediaStream!.stream!);
804 : }
805 : }
806 : // for renderer to be able to show new video track
807 0 : localUserMediaStream?.onStreamChanged
808 0 : .add(localUserMediaStream!.stream!);
809 : }
810 : }
811 : }
812 :
813 0 : Future<void> setMicrophoneMuted(bool muted) async {
814 0 : localUserMediaStream?.setAudioMuted(muted);
815 0 : await updateMuteStatus();
816 : }
817 :
818 0 : Future<void> setRemoteOnHold(bool onHold) async {
819 0 : if (remoteOnHold == onHold) return;
820 0 : _remoteOnHold = onHold;
821 0 : final transceivers = await pc!.getTransceivers();
822 0 : for (final transceiver in transceivers) {
823 0 : await transceiver.setDirection(onHold
824 : ? TransceiverDirection.SendOnly
825 : : TransceiverDirection.SendRecv);
826 : }
827 0 : await updateMuteStatus();
828 0 : fireCallEvent(CallStateChange.kRemoteHoldUnhold);
829 : }
830 :
831 0 : Future<bool> isLocalOnHold() async {
832 0 : if (state != CallState.kConnected) return false;
833 : var callOnHold = true;
834 : // We consider a call to be on hold only if *all* the tracks are on hold
835 : // (is this the right thing to do?)
836 0 : final transceivers = await pc!.getTransceivers();
837 0 : for (final transceiver in transceivers) {
838 0 : final currentDirection = await transceiver.getCurrentDirection();
839 0 : final trackOnHold = (currentDirection == TransceiverDirection.Inactive ||
840 0 : currentDirection == TransceiverDirection.RecvOnly);
841 : if (!trackOnHold) {
842 : callOnHold = false;
843 : }
844 : }
845 : return callOnHold;
846 : }
847 :
848 2 : Future<void> answer({String? txid}) async {
849 2 : if (_inviteOrAnswerSent) {
850 : return;
851 : }
852 : // stop play ringtone
853 6 : await voip.delegate.stopRingtone();
854 :
855 4 : if (direction == CallDirection.kIncoming) {
856 2 : setCallState(CallState.kCreateAnswer);
857 :
858 6 : final answer = await pc!.createAnswer({});
859 4 : for (final candidate in _remoteCandidates) {
860 4 : await pc!.addCandidate(candidate);
861 : }
862 :
863 2 : final callCapabilities = CallCapabilities()
864 2 : ..dtmf = false
865 2 : ..transferee = false;
866 :
867 4 : final metadata = SDPStreamMetadata({
868 2 : if (localUserMediaStream != null)
869 10 : localUserMediaStream!.stream!.id: SDPStreamPurpose(
870 : purpose: SDPStreamMetadataPurpose.Usermedia,
871 4 : audio_muted: localUserMediaStream!.audioMuted,
872 4 : video_muted: localUserMediaStream!.videoMuted),
873 2 : if (localScreenSharingStream != null)
874 0 : localScreenSharingStream!.stream!.id: SDPStreamPurpose(
875 : purpose: SDPStreamMetadataPurpose.Screenshare,
876 0 : audio_muted: localScreenSharingStream!.audioMuted,
877 0 : video_muted: localScreenSharingStream!.videoMuted),
878 : });
879 :
880 4 : await pc!.setLocalDescription(answer);
881 2 : setCallState(CallState.kConnecting);
882 :
883 : // Allow a short time for initial candidates to be gathered
884 4 : await Future.delayed(Duration(milliseconds: 200));
885 :
886 2 : final res = await sendAnswerCall(
887 2 : room,
888 2 : callId,
889 2 : answer.sdp!,
890 2 : localPartyId,
891 2 : type: answer.type!,
892 : capabilities: callCapabilities,
893 : metadata: metadata,
894 : txid: txid,
895 : );
896 6 : Logs().v('[VOIP] answer res => $res');
897 :
898 2 : _inviteOrAnswerSent = true;
899 2 : _answeredByUs = true;
900 : }
901 : }
902 :
903 : /// Reject a call
904 : /// This used to be done by calling hangup, but is a separate method and protocol
905 : /// event as of MSC2746.
906 2 : Future<void> reject({CallErrorCode? reason, bool shouldEmit = true}) async {
907 2 : setCallState(CallState.kEnding);
908 8 : if (state != CallState.kRinging && state != CallState.kFledgling) {
909 4 : Logs().e(
910 6 : '[VOIP] Call must be in \'ringing|fledgling\' state to reject! (current state was: ${state.toString()}) Calling hangup instead');
911 2 : await hangup(reason: CallErrorCode.userHangup, shouldEmit: shouldEmit);
912 : return;
913 : }
914 0 : Logs().d('[VOIP] Rejecting call: $callId');
915 0 : await terminate(CallParty.kLocal, CallErrorCode.userHangup, shouldEmit);
916 : if (shouldEmit) {
917 0 : await sendCallReject(room, callId, localPartyId);
918 : }
919 : }
920 :
921 2 : Future<void> hangup(
922 : {required CallErrorCode reason, bool shouldEmit = true}) async {
923 2 : setCallState(CallState.kEnding);
924 2 : await terminate(CallParty.kLocal, reason, shouldEmit);
925 : try {
926 : final res =
927 8 : await sendHangupCall(room, callId, localPartyId, 'userHangup');
928 6 : Logs().v('[VOIP] hangup res => $res');
929 : } catch (e) {
930 0 : Logs().v('[VOIP] hangup error => ${e.toString()}');
931 : }
932 : }
933 :
934 0 : Future<void> sendDTMF(String tones) async {
935 0 : final senders = await pc!.getSenders();
936 0 : for (final sender in senders) {
937 0 : if (sender.track != null && sender.track!.kind == 'audio') {
938 0 : await sender.dtmfSender.insertDTMF(tones);
939 : return;
940 : }
941 : }
942 0 : Logs().e('[VOIP] Unable to find a track to send DTMF on');
943 : }
944 :
945 2 : Future<void> terminate(
946 : CallParty party,
947 : CallErrorCode reason,
948 : bool shouldEmit,
949 : ) async {
950 4 : if (state == CallState.kConnected) {
951 0 : await hangup(
952 : reason: CallErrorCode.userHangup,
953 : shouldEmit: true,
954 : );
955 : return;
956 : }
957 :
958 4 : Logs().d('[VOIP] terminating call');
959 4 : _inviteTimer?.cancel();
960 2 : _inviteTimer = null;
961 :
962 4 : _ringingTimer?.cancel();
963 2 : _ringingTimer = null;
964 :
965 : try {
966 6 : await voip.delegate.stopRingtone();
967 : } catch (e) {
968 : // maybe rigntone never started (group calls) or has been stopped already
969 0 : Logs().d('stopping ringtone failed ', e);
970 : }
971 :
972 2 : hangupReason = reason;
973 :
974 : // don't see any reason to wrap this with shouldEmit atm,
975 : // looks like a local state change only
976 2 : setCallState(CallState.kEnded);
977 :
978 2 : if (!isGroupCall) {
979 : // when a call crash and this call is already terminated the currentCId is null.
980 : // So don't return bc the hangup or reject will not proceed anymore.
981 4 : if (voip.currentCID != null &&
982 14 : voip.currentCID != VoipId(roomId: room.id, callId: callId)) return;
983 4 : voip.currentCID = null;
984 12 : voip.incomingCallRoomId.removeWhere((key, value) => value == callId);
985 : }
986 :
987 14 : voip.calls.removeWhere((key, value) => key.callId == callId);
988 :
989 2 : await cleanUp();
990 : if (shouldEmit) {
991 4 : onCallHangupNotifierForGroupCalls.add(this);
992 6 : await voip.delegate.handleCallEnded(this);
993 2 : fireCallEvent(CallStateChange.kHangup);
994 4 : if ((party == CallParty.kRemote && _missedCall)) {
995 6 : await voip.delegate.handleMissedCall(this);
996 : }
997 : }
998 : }
999 :
1000 0 : Future<void> onRejectReceived(CallErrorCode? reason) async {
1001 0 : Logs().v('[VOIP] Reject received for call ID $callId');
1002 : // No need to check party_id for reject because if we'd received either
1003 : // an answer or reject, we wouldn't be in state InviteSent
1004 0 : final shouldTerminate = (state == CallState.kFledgling &&
1005 0 : direction == CallDirection.kIncoming) ||
1006 0 : CallState.kInviteSent == state ||
1007 0 : CallState.kRinging == state;
1008 :
1009 : if (shouldTerminate) {
1010 0 : await terminate(
1011 : CallParty.kRemote, reason ?? CallErrorCode.userHangup, true);
1012 : } else {
1013 0 : Logs().e('[VOIP] Call is in state: ${state.toString()}: ignoring reject');
1014 : }
1015 : }
1016 :
1017 2 : Future<void> _gotLocalOffer(RTCSessionDescription offer) async {
1018 2 : if (callHasEnded) {
1019 0 : Logs().d(
1020 0 : 'Ignoring newly created offer on call ID ${opts.callId} because the call has ended');
1021 : return;
1022 : }
1023 :
1024 : try {
1025 4 : await pc!.setLocalDescription(offer);
1026 : } catch (err) {
1027 0 : Logs().d('Error setting local description! ${err.toString()}');
1028 0 : await terminate(
1029 : CallParty.kLocal, CallErrorCode.setLocalDescription, true);
1030 : return;
1031 : }
1032 :
1033 6 : if (pc!.iceGatheringState ==
1034 : RTCIceGatheringState.RTCIceGatheringStateGathering) {
1035 : // Allow a short time for initial candidates to be gathered
1036 0 : await Future.delayed(CallTimeouts.iceGatheringDelay);
1037 : }
1038 :
1039 2 : if (callHasEnded) return;
1040 :
1041 2 : final callCapabilities = CallCapabilities()
1042 2 : ..dtmf = false
1043 2 : ..transferee = false;
1044 2 : final metadata = _getLocalSDPStreamMetadata();
1045 4 : if (state == CallState.kCreateOffer) {
1046 2 : await sendInviteToCall(
1047 2 : room,
1048 2 : callId,
1049 2 : CallTimeouts.callInviteLifetime.inMilliseconds,
1050 2 : localPartyId,
1051 2 : offer.sdp!,
1052 : capabilities: callCapabilities,
1053 : metadata: metadata);
1054 : // just incase we ended the call but already sent the invite
1055 : // raraley happens during glares
1056 4 : if (state == CallState.kEnded) {
1057 0 : await hangup(reason: CallErrorCode.replaced);
1058 : return;
1059 : }
1060 2 : _inviteOrAnswerSent = true;
1061 :
1062 2 : if (!isGroupCall) {
1063 4 : Logs().d('[glare] set callid because new invite sent');
1064 12 : voip.incomingCallRoomId[room.id] = callId;
1065 : }
1066 :
1067 2 : setCallState(CallState.kInviteSent);
1068 :
1069 4 : _inviteTimer = Timer(CallTimeouts.callInviteLifetime, () {
1070 0 : if (state == CallState.kInviteSent) {
1071 0 : hangup(reason: CallErrorCode.inviteTimeout);
1072 : }
1073 0 : _inviteTimer?.cancel();
1074 0 : _inviteTimer = null;
1075 : });
1076 : } else {
1077 0 : await sendCallNegotiate(
1078 0 : room,
1079 0 : callId,
1080 0 : CallTimeouts.defaultCallEventLifetime.inMilliseconds,
1081 0 : localPartyId,
1082 0 : offer.sdp!,
1083 0 : type: offer.type!,
1084 : capabilities: callCapabilities,
1085 : metadata: metadata);
1086 : }
1087 : }
1088 :
1089 2 : Future<void> onNegotiationNeeded() async {
1090 4 : Logs().i('Negotiation is needed!');
1091 2 : _makingOffer = true;
1092 : try {
1093 : // The first addTrack(audio track) on iOS will trigger
1094 : // onNegotiationNeeded, which causes creatOffer to only include
1095 : // audio m-line, add delay and wait for video track to be added,
1096 : // then createOffer can get audio/video m-line correctly.
1097 2 : await Future.delayed(CallTimeouts.delayBeforeOffer);
1098 6 : final offer = await pc!.createOffer({});
1099 2 : await _gotLocalOffer(offer);
1100 : } catch (e) {
1101 0 : await _getLocalOfferFailed(e);
1102 : return;
1103 : } finally {
1104 2 : _makingOffer = false;
1105 : }
1106 : }
1107 :
1108 2 : Future<void> _preparePeerConnection() async {
1109 : try {
1110 4 : pc = await _createPeerConnection();
1111 6 : pc!.onRenegotiationNeeded = onNegotiationNeeded;
1112 :
1113 4 : pc!.onIceCandidate = (RTCIceCandidate candidate) async {
1114 0 : if (callHasEnded) return;
1115 : //Logs().v('[VOIP] onIceCandidate => ${candidate.toMap().toString()}');
1116 0 : _localCandidates.add(candidate);
1117 :
1118 0 : if (state == CallState.kRinging || !_inviteOrAnswerSent) return;
1119 :
1120 : // MSC2746 recommends these values (can be quite long when calling because the
1121 : // callee will need a while to answer the call)
1122 0 : final delay = direction == CallDirection.kIncoming ? 500 : 2000;
1123 0 : if (_candidateSendTries == 0) {
1124 0 : Timer(Duration(milliseconds: delay), () {
1125 0 : _sendCandidateQueue();
1126 : });
1127 : }
1128 : };
1129 :
1130 6 : pc!.onIceGatheringState = (RTCIceGatheringState state) async {
1131 8 : Logs().v('[VOIP] IceGatheringState => ${state.toString()}');
1132 2 : if (state == RTCIceGatheringState.RTCIceGatheringStateGathering) {
1133 0 : Timer(Duration(seconds: 3), () async {
1134 0 : if (!_iceGatheringFinished) {
1135 0 : _iceGatheringFinished = true;
1136 0 : await _sendCandidateQueue();
1137 : }
1138 : });
1139 : }
1140 2 : if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) {
1141 2 : if (!_iceGatheringFinished) {
1142 2 : _iceGatheringFinished = true;
1143 2 : await _sendCandidateQueue();
1144 : }
1145 : }
1146 : };
1147 6 : pc!.onIceConnectionState = (RTCIceConnectionState state) async {
1148 8 : Logs().v('[VOIP] RTCIceConnectionState => ${state.toString()}');
1149 2 : if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
1150 4 : _localCandidates.clear();
1151 4 : _remoteCandidates.clear();
1152 2 : setCallState(CallState.kConnected);
1153 : // fix any state/race issues we had with sdp packets and cloned streams
1154 2 : await updateMuteStatus();
1155 2 : _missedCall = false;
1156 2 : } else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
1157 0 : await hangup(reason: CallErrorCode.iceFailed);
1158 : }
1159 : };
1160 : } catch (e) {
1161 0 : Logs().v('[VOIP] prepareMediaStream error => ${e.toString()}');
1162 : }
1163 : }
1164 :
1165 0 : Future<void> onAnsweredElsewhere() async {
1166 0 : Logs().d('Call ID $callId answered elsewhere');
1167 0 : await terminate(CallParty.kRemote, CallErrorCode.answeredElsewhere, true);
1168 : }
1169 :
1170 2 : Future<void> cleanUp() async {
1171 : try {
1172 4 : for (final stream in _streams) {
1173 2 : await stream.dispose();
1174 : }
1175 4 : _streams.clear();
1176 : } catch (e, s) {
1177 0 : Logs().e('[VOIP] cleaning up streams failed', e, s);
1178 : }
1179 :
1180 : try {
1181 2 : if (pc != null) {
1182 4 : await pc!.close();
1183 4 : await pc!.dispose();
1184 : }
1185 : } catch (e, s) {
1186 0 : Logs().e('[VOIP] removing pc failed', e, s);
1187 : }
1188 : }
1189 :
1190 2 : Future<void> updateMuteStatus() async {
1191 2 : final micShouldBeMuted = (localUserMediaStream != null &&
1192 0 : localUserMediaStream!.isAudioMuted()) ||
1193 2 : _remoteOnHold;
1194 2 : final vidShouldBeMuted = (localUserMediaStream != null &&
1195 0 : localUserMediaStream!.isVideoMuted()) ||
1196 2 : _remoteOnHold;
1197 :
1198 6 : _setTracksEnabled(localUserMediaStream?.stream?.getAudioTracks() ?? [],
1199 : !micShouldBeMuted);
1200 6 : _setTracksEnabled(localUserMediaStream?.stream?.getVideoTracks() ?? [],
1201 : !vidShouldBeMuted);
1202 :
1203 2 : await sendSDPStreamMetadataChanged(
1204 2 : room,
1205 2 : callId,
1206 2 : localPartyId,
1207 2 : _getLocalSDPStreamMetadata(),
1208 : );
1209 : }
1210 :
1211 2 : void _setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) {
1212 2 : for (final track in tracks) {
1213 0 : track.enabled = enabled;
1214 : }
1215 : }
1216 :
1217 2 : SDPStreamMetadata _getLocalSDPStreamMetadata() {
1218 2 : final sdpStreamMetadatas = <String, SDPStreamPurpose>{};
1219 4 : for (final wpstream in getLocalStreams) {
1220 2 : if (wpstream.stream != null) {
1221 8 : sdpStreamMetadatas[wpstream.stream!.id] = SDPStreamPurpose(
1222 2 : purpose: wpstream.purpose,
1223 2 : audio_muted: wpstream.audioMuted,
1224 2 : video_muted: wpstream.videoMuted,
1225 : );
1226 : }
1227 : }
1228 2 : final metadata = SDPStreamMetadata(sdpStreamMetadatas);
1229 10 : Logs().v('Got local SDPStreamMetadata ${metadata.toJson().toString()}');
1230 : return metadata;
1231 : }
1232 :
1233 0 : Future<void> restartIce() async {
1234 0 : Logs().v('[VOIP] iceRestart.');
1235 : // Needs restart ice on session.pc and renegotiation.
1236 0 : _iceGatheringFinished = false;
1237 : final desc =
1238 0 : await pc!.createOffer(_getOfferAnswerConstraints(iceRestart: true));
1239 0 : await pc!.setLocalDescription(desc);
1240 0 : _localCandidates.clear();
1241 : }
1242 :
1243 2 : Future<MediaStream?> _getUserMedia(CallType type) async {
1244 2 : final mediaConstraints = {
1245 : 'audio': UserMediaConstraints.micMediaConstraints,
1246 2 : 'video': type == CallType.kVideo
1247 : ? UserMediaConstraints.camMediaConstraints
1248 : : false,
1249 : };
1250 : try {
1251 8 : return await voip.delegate.mediaDevices.getUserMedia(mediaConstraints);
1252 : } catch (e) {
1253 0 : await _getUserMediaFailed(e);
1254 : rethrow;
1255 : }
1256 : }
1257 :
1258 0 : Future<MediaStream?> _getDisplayMedia() async {
1259 : try {
1260 0 : return await voip.delegate.mediaDevices
1261 0 : .getDisplayMedia(UserMediaConstraints.screenMediaConstraints);
1262 : } catch (e) {
1263 0 : await _getUserMediaFailed(e);
1264 : }
1265 : return null;
1266 : }
1267 :
1268 2 : Future<RTCPeerConnection> _createPeerConnection() async {
1269 2 : final configuration = <String, dynamic>{
1270 4 : 'iceServers': opts.iceServers,
1271 : 'sdpSemantics': 'unified-plan'
1272 : };
1273 6 : final pc = await voip.delegate.createPeerConnection(configuration);
1274 2 : pc.onTrack = (RTCTrackEvent event) async {
1275 0 : if (event.streams.isNotEmpty) {
1276 0 : final stream = event.streams[0];
1277 0 : await _addRemoteStream(stream);
1278 0 : for (final track in stream.getTracks()) {
1279 0 : track.onEnded = () async {
1280 0 : if (stream.getTracks().isEmpty) {
1281 0 : Logs().d('[VOIP] detected a empty stream, removing it');
1282 0 : await _removeStream(stream);
1283 : }
1284 : };
1285 : }
1286 : }
1287 : };
1288 : return pc;
1289 : }
1290 :
1291 0 : Future<void> createDataChannel(
1292 : String label, RTCDataChannelInit dataChannelDict) async {
1293 0 : await pc?.createDataChannel(label, dataChannelDict);
1294 : }
1295 :
1296 0 : Future<void> tryRemoveStopedStreams() async {
1297 0 : final removedStreams = <String, WrappedMediaStream>{};
1298 0 : for (final stream in _streams) {
1299 0 : if (stream.stopped) {
1300 0 : removedStreams[stream.stream!.id] = stream;
1301 : }
1302 : }
1303 0 : _streams
1304 0 : .removeWhere((stream) => removedStreams.containsKey(stream.stream!.id));
1305 0 : for (final element in removedStreams.entries) {
1306 0 : await _removeStream(element.value.stream!);
1307 : }
1308 : }
1309 :
1310 0 : Future<void> _removeStream(MediaStream stream) async {
1311 0 : Logs().v('Removing feed with stream id ${stream.id}');
1312 :
1313 0 : final it = _streams.where((element) => element.stream!.id == stream.id);
1314 0 : if (it.isEmpty) {
1315 0 : Logs().v('Didn\'t find the feed with stream id ${stream.id} to delete');
1316 : return;
1317 : }
1318 0 : final wpstream = it.first;
1319 0 : _streams.removeWhere((element) => element.stream!.id == stream.id);
1320 0 : onStreamRemoved.add(wpstream);
1321 0 : fireCallEvent(CallStateChange.kFeedsChanged);
1322 0 : await wpstream.dispose();
1323 : }
1324 :
1325 0 : Map<String, dynamic> _getOfferAnswerConstraints({bool iceRestart = false}) {
1326 0 : return {
1327 0 : 'mandatory': {if (iceRestart) 'IceRestart': true},
1328 0 : 'optional': [],
1329 : };
1330 : }
1331 :
1332 2 : Future<void> _sendCandidateQueue() async {
1333 2 : if (callHasEnded) return;
1334 : /*
1335 : Currently, trickle-ice is not supported, so it will take a
1336 : long time to wait to collect all the canidates, set the
1337 : timeout for collection canidates to speed up the connection.
1338 : */
1339 2 : final candidatesQueue = _localCandidates;
1340 : try {
1341 2 : if (candidatesQueue.isNotEmpty) {
1342 0 : final candidates = <Map<String, dynamic>>[];
1343 0 : for (final element in candidatesQueue) {
1344 0 : candidates.add(element.toMap());
1345 : }
1346 0 : _localCandidates.clear();
1347 0 : final res = await sendCallCandidates(
1348 0 : opts.room, callId, localPartyId, candidates);
1349 0 : Logs().v('[VOIP] sendCallCandidates res => $res');
1350 : }
1351 : } catch (e) {
1352 0 : Logs().v('[VOIP] sendCallCandidates e => ${e.toString()}');
1353 0 : _candidateSendTries++;
1354 0 : _localCandidates.clear();
1355 0 : _localCandidates.addAll(candidatesQueue);
1356 :
1357 0 : if (_candidateSendTries > 5) {
1358 0 : Logs().d(
1359 0 : 'Failed to send candidates on attempt $_candidateSendTries Giving up on this call.');
1360 0 : await hangup(reason: CallErrorCode.iceTimeout);
1361 : return;
1362 : }
1363 :
1364 0 : final delay = 500 * pow(2, _candidateSendTries);
1365 0 : Timer(Duration(milliseconds: delay as int), () {
1366 0 : _sendCandidateQueue();
1367 : });
1368 : }
1369 : }
1370 :
1371 2 : void fireCallEvent(CallStateChange event) {
1372 4 : onCallEventChanged.add(event);
1373 8 : Logs().i('CallStateChange: ${event.toString()}');
1374 : switch (event) {
1375 2 : case CallStateChange.kFeedsChanged:
1376 4 : onCallStreamsChanged.add(this);
1377 : break;
1378 2 : case CallStateChange.kState:
1379 10 : Logs().i('CallState: ${state.toString()}');
1380 : break;
1381 2 : case CallStateChange.kError:
1382 : break;
1383 2 : case CallStateChange.kHangup:
1384 : break;
1385 0 : case CallStateChange.kReplaced:
1386 : break;
1387 0 : case CallStateChange.kLocalHoldUnhold:
1388 : break;
1389 0 : case CallStateChange.kRemoteHoldUnhold:
1390 : break;
1391 0 : case CallStateChange.kAssertedIdentityChanged:
1392 : break;
1393 : }
1394 : }
1395 :
1396 0 : Future<void> _getLocalOfferFailed(dynamic err) async {
1397 0 : Logs().e('Failed to get local offer ${err.toString()}');
1398 0 : fireCallEvent(CallStateChange.kError);
1399 :
1400 0 : await terminate(CallParty.kLocal, CallErrorCode.localOfferFailed, true);
1401 : }
1402 :
1403 0 : Future<void> _getUserMediaFailed(dynamic err) async {
1404 0 : Logs().w('Failed to get user media - ending call ${err.toString()}');
1405 0 : fireCallEvent(CallStateChange.kError);
1406 0 : await terminate(CallParty.kLocal, CallErrorCode.userMediaFailed, true);
1407 : }
1408 :
1409 2 : Future<void> onSelectAnswerReceived(String? selectedPartyId) async {
1410 4 : if (direction != CallDirection.kIncoming) {
1411 0 : Logs().w('Got select_answer for an outbound call: ignoring');
1412 : return;
1413 : }
1414 : if (selectedPartyId == null) {
1415 0 : Logs().w(
1416 : 'Got nonsensical select_answer with null/undefined selected_party_id: ignoring');
1417 : return;
1418 : }
1419 :
1420 4 : if (selectedPartyId != localPartyId) {
1421 4 : Logs().w(
1422 4 : 'Got select_answer for party ID $selectedPartyId: we are party ID $localPartyId.');
1423 : // The other party has picked somebody else's answer
1424 2 : await terminate(CallParty.kRemote, CallErrorCode.answeredElsewhere, true);
1425 : }
1426 : }
1427 :
1428 : /// This is sent by the caller when they wish to establish a call.
1429 : /// [callId] is a unique identifier for the call.
1430 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1431 : /// [lifetime] is the time in milliseconds that the invite is valid for. Once the invite age exceeds this value,
1432 : /// clients should discard it. They should also no longer show the call as awaiting an answer in the UI.
1433 : /// [type] The type of session description. Must be 'offer'.
1434 : /// [sdp] The SDP text of the session description.
1435 : /// [invitee] The user ID of the person who is being invited. Invites without an invitee field are defined to be
1436 : /// intended for any member of the room other than the sender of the event.
1437 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1438 2 : Future<String?> sendInviteToCall(
1439 : Room room, String callId, int lifetime, String party_id, String sdp,
1440 : {String type = 'offer',
1441 : String version = voipProtoVersion,
1442 : String? txid,
1443 : CallCapabilities? capabilities,
1444 : SDPStreamMetadata? metadata}) async {
1445 2 : final content = {
1446 2 : 'call_id': callId,
1447 2 : 'party_id': party_id,
1448 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1449 2 : 'version': version,
1450 2 : 'lifetime': lifetime,
1451 4 : 'offer': {'sdp': sdp, 'type': type},
1452 2 : if (remoteUserId != null)
1453 2 : 'invitee':
1454 2 : remoteUserId!, // TODO: rename this to invitee_user_id? breaks spec though
1455 2 : if (remoteDeviceId != null) 'invitee_device_id': remoteDeviceId!,
1456 2 : if (remoteDeviceId != null)
1457 0 : 'device_id': client
1458 0 : .deviceID!, // Having a remoteDeviceId means you are doing to-device events, so you want to send your deviceId too
1459 4 : if (capabilities != null) 'capabilities': capabilities.toJson(),
1460 4 : if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
1461 : };
1462 2 : return await _sendContent(
1463 : room,
1464 2 : isGroupCall ? EventTypes.GroupCallMemberInvite : EventTypes.CallInvite,
1465 : content,
1466 : txid: txid,
1467 : );
1468 : }
1469 :
1470 : /// The calling party sends the party_id of the first selected answer.
1471 : ///
1472 : /// Usually after receiving the first answer sdp in the client.onCallAnswer event,
1473 : /// save the `party_id`, and then send `CallSelectAnswer` to others peers that the call has been picked up.
1474 : ///
1475 : /// [callId] is a unique identifier for the call.
1476 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1477 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1478 : /// [selected_party_id] The party ID for the selected answer.
1479 2 : Future<String?> sendSelectCallAnswer(
1480 : Room room, String callId, String party_id, String selected_party_id,
1481 : {String version = voipProtoVersion, String? txid}) async {
1482 2 : final content = {
1483 2 : 'call_id': callId,
1484 2 : 'party_id': party_id,
1485 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1486 2 : 'version': version,
1487 2 : 'selected_party_id': selected_party_id,
1488 : };
1489 :
1490 2 : return await _sendContent(
1491 : room,
1492 2 : isGroupCall
1493 : ? EventTypes.GroupCallMemberSelectAnswer
1494 : : EventTypes.CallSelectAnswer,
1495 : content,
1496 : txid: txid,
1497 : );
1498 : }
1499 :
1500 : /// Reject a call
1501 : /// [callId] is a unique identifier for the call.
1502 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1503 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1504 2 : Future<String?> sendCallReject(Room room, String callId, String party_id,
1505 : {String version = voipProtoVersion, String? txid}) async {
1506 2 : final content = {
1507 2 : 'call_id': callId,
1508 2 : 'party_id': party_id,
1509 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1510 2 : 'version': version,
1511 : };
1512 :
1513 2 : return await _sendContent(
1514 : room,
1515 2 : isGroupCall ? EventTypes.GroupCallMemberReject : EventTypes.CallReject,
1516 : content,
1517 : txid: txid,
1518 : );
1519 : }
1520 :
1521 : /// When local audio/video tracks are added/deleted or hold/unhold,
1522 : /// need to createOffer and renegotiation.
1523 : /// [callId] is a unique identifier for the call.
1524 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1525 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1526 2 : Future<String?> sendCallNegotiate(
1527 : Room room, String callId, int lifetime, String party_id, String sdp,
1528 : {String type = 'offer',
1529 : String version = voipProtoVersion,
1530 : String? txid,
1531 : CallCapabilities? capabilities,
1532 : SDPStreamMetadata? metadata}) async {
1533 2 : final content = {
1534 2 : 'call_id': callId,
1535 2 : 'party_id': party_id,
1536 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1537 2 : 'version': version,
1538 2 : 'lifetime': lifetime,
1539 4 : 'description': {'sdp': sdp, 'type': type},
1540 0 : if (capabilities != null) 'capabilities': capabilities.toJson(),
1541 0 : if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
1542 : };
1543 2 : return await _sendContent(
1544 : room,
1545 2 : isGroupCall
1546 : ? EventTypes.GroupCallMemberNegotiate
1547 : : EventTypes.CallNegotiate,
1548 : content,
1549 : txid: txid,
1550 : );
1551 : }
1552 :
1553 : /// This is sent by callers after sending an invite and by the callee after answering.
1554 : /// Its purpose is to give the other party additional ICE candidates to try using to communicate.
1555 : ///
1556 : /// [callId] The ID of the call this event relates to.
1557 : ///
1558 : /// [version] The version of the VoIP specification this messages adheres to. This specification is version 1.
1559 : ///
1560 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1561 : ///
1562 : /// [candidates] Array of objects describing the candidates. Example:
1563 : ///
1564 : /// ```
1565 : /// [
1566 : /// {
1567 : /// "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0",
1568 : /// "sdpMLineIndex": 0,
1569 : /// "sdpMid": "audio"
1570 : /// }
1571 : /// ],
1572 : /// ```
1573 2 : Future<String?> sendCallCandidates(
1574 : Room room,
1575 : String callId,
1576 : String party_id,
1577 : List<Map<String, dynamic>> candidates, {
1578 : String version = voipProtoVersion,
1579 : String? txid,
1580 : }) async {
1581 2 : final content = {
1582 2 : 'call_id': callId,
1583 2 : 'party_id': party_id,
1584 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1585 2 : 'version': version,
1586 2 : 'candidates': candidates,
1587 : };
1588 2 : return await _sendContent(
1589 : room,
1590 2 : isGroupCall
1591 : ? EventTypes.GroupCallMemberCandidates
1592 : : EventTypes.CallCandidates,
1593 : content,
1594 : txid: txid,
1595 : );
1596 : }
1597 :
1598 : /// This event is sent by the callee when they wish to answer the call.
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 : /// [type] The type of session description. Must be 'answer'.
1602 : /// [sdp] The SDP text of the session description.
1603 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1604 2 : Future<String?> sendAnswerCall(
1605 : Room room, String callId, String sdp, String party_id,
1606 : {String type = 'answer',
1607 : String version = voipProtoVersion,
1608 : String? txid,
1609 : CallCapabilities? capabilities,
1610 : SDPStreamMetadata? metadata}) async {
1611 2 : final content = {
1612 2 : 'call_id': callId,
1613 2 : 'party_id': party_id,
1614 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1615 2 : 'version': version,
1616 4 : 'answer': {'sdp': sdp, 'type': type},
1617 4 : if (capabilities != null) 'capabilities': capabilities.toJson(),
1618 4 : if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
1619 : };
1620 2 : return await _sendContent(
1621 : room,
1622 2 : isGroupCall ? EventTypes.GroupCallMemberAnswer : EventTypes.CallAnswer,
1623 : content,
1624 : txid: txid,
1625 : );
1626 : }
1627 :
1628 : /// This event is sent by the callee when they wish to answer the call.
1629 : /// [callId] The ID of the call this event relates to.
1630 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1631 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1632 2 : Future<String?> sendHangupCall(
1633 : Room room, String callId, String party_id, String? hangupCause,
1634 : {String version = voipProtoVersion, String? txid}) async {
1635 2 : final content = {
1636 2 : 'call_id': callId,
1637 2 : 'party_id': party_id,
1638 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1639 2 : 'version': version,
1640 2 : if (hangupCause != null) 'reason': hangupCause,
1641 : };
1642 2 : return await _sendContent(
1643 : room,
1644 2 : isGroupCall ? EventTypes.GroupCallMemberHangup : EventTypes.CallHangup,
1645 : content,
1646 : txid: txid,
1647 : );
1648 : }
1649 :
1650 : /// Send SdpStreamMetadata Changed event.
1651 : ///
1652 : /// This MSC also adds a new call event m.call.sdp_stream_metadata_changed,
1653 : /// which has the common VoIP fields as specified in
1654 : /// MSC2746 (version, call_id, party_id) and a sdp_stream_metadata object which
1655 : /// is the same thing as sdp_stream_metadata in m.call.negotiate, m.call.invite
1656 : /// and m.call.answer. The client sends this event the when sdp_stream_metadata
1657 : /// has changed but no negotiation is required
1658 : /// (e.g. the user mutes their camera/microphone).
1659 : ///
1660 : /// [callId] The ID of the call this event relates to.
1661 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1662 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1663 : /// [metadata] The sdp_stream_metadata object.
1664 2 : Future<String?> sendSDPStreamMetadataChanged(
1665 : Room room, String callId, String party_id, SDPStreamMetadata metadata,
1666 : {String version = voipProtoVersion, String? txid}) async {
1667 2 : final content = {
1668 2 : 'call_id': callId,
1669 2 : 'party_id': party_id,
1670 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1671 2 : 'version': version,
1672 4 : sdpStreamMetadataKey: metadata.toJson(),
1673 : };
1674 2 : return await _sendContent(
1675 : room,
1676 2 : isGroupCall
1677 : ? EventTypes.GroupCallMemberSDPStreamMetadataChanged
1678 : : EventTypes.CallSDPStreamMetadataChanged,
1679 : content,
1680 : txid: txid,
1681 : );
1682 : }
1683 :
1684 : /// CallReplacesEvent for Transfered calls
1685 : ///
1686 : /// [callId] The ID of the call this event relates to.
1687 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1688 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1689 : /// [callReplaces] transfer info
1690 2 : Future<String?> sendCallReplaces(
1691 : Room room, String callId, String party_id, CallReplaces callReplaces,
1692 : {String version = voipProtoVersion, String? txid}) async {
1693 2 : final content = {
1694 2 : 'call_id': callId,
1695 2 : 'party_id': party_id,
1696 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1697 2 : 'version': version,
1698 2 : ...callReplaces.toJson(),
1699 : };
1700 2 : return await _sendContent(
1701 : room,
1702 2 : isGroupCall
1703 : ? EventTypes.GroupCallMemberReplaces
1704 : : EventTypes.CallReplaces,
1705 : content,
1706 : txid: txid,
1707 : );
1708 : }
1709 :
1710 : /// send AssertedIdentity event
1711 : ///
1712 : /// [callId] The ID of the call this event relates to.
1713 : /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
1714 : /// [party_id] The party ID for call, Can be set to client.deviceId.
1715 : /// [assertedIdentity] the asserted identity
1716 2 : Future<String?> sendAssertedIdentity(Room room, String callId,
1717 : String party_id, AssertedIdentity assertedIdentity,
1718 : {String version = voipProtoVersion, String? txid}) async {
1719 2 : final content = {
1720 2 : 'call_id': callId,
1721 2 : 'party_id': party_id,
1722 2 : if (groupCallId != null) 'conf_id': groupCallId!,
1723 2 : 'version': version,
1724 4 : 'asserted_identity': assertedIdentity.toJson(),
1725 : };
1726 2 : return await _sendContent(
1727 : room,
1728 2 : isGroupCall
1729 : ? EventTypes.GroupCallMemberAssertedIdentity
1730 : : EventTypes.CallAssertedIdentity,
1731 : content,
1732 : txid: txid,
1733 : );
1734 : }
1735 :
1736 2 : Future<String?> _sendContent(
1737 : Room room,
1738 : String type,
1739 : Map<String, Object> content, {
1740 : String? txid,
1741 : }) async {
1742 6 : Logs().d('[VOIP] sending content type $type, with conf: $content');
1743 0 : txid ??= VoIP.customTxid ?? client.generateUniqueTransactionId();
1744 2 : final mustEncrypt = room.encrypted && client.encryptionEnabled;
1745 :
1746 : // opponentDeviceId is only set for a few events during group calls,
1747 : // therefore only group calls use to-device messages for call events
1748 2 : if (isGroupCall && remoteDeviceId != null) {
1749 0 : final toDeviceSeq = _toDeviceSeq++;
1750 0 : final Map<String, Object> data = {
1751 : ...content,
1752 0 : 'seq': toDeviceSeq,
1753 0 : if (remoteSessionId != null) 'dest_session_id': remoteSessionId!,
1754 0 : 'sender_session_id': voip.currentSessionId,
1755 0 : 'room_id': room.id,
1756 : };
1757 :
1758 : if (mustEncrypt) {
1759 0 : await client.userDeviceKeysLoading;
1760 0 : if (client.userDeviceKeys[remoteUserId]?.deviceKeys[remoteDeviceId] !=
1761 : null) {
1762 0 : await client.sendToDeviceEncrypted([
1763 0 : client.userDeviceKeys[remoteUserId]!.deviceKeys[remoteDeviceId]!
1764 : ], type, data);
1765 : } else {
1766 0 : Logs().w(
1767 0 : '[VOIP] _sendCallContent missing device keys for $remoteUserId');
1768 : }
1769 : } else {
1770 0 : await client.sendToDevice(
1771 : type,
1772 : txid,
1773 0 : {
1774 0 : remoteUserId!: {remoteDeviceId!: data}
1775 : },
1776 : );
1777 : }
1778 : return '';
1779 : } else {
1780 : final sendMessageContent = mustEncrypt
1781 0 : ? await client.encryption!
1782 0 : .encryptGroupMessagePayload(room.id, content, type: type)
1783 : : content;
1784 4 : return await client.sendMessage(
1785 2 : room.id,
1786 2 : sendMessageContent.containsKey('ciphertext')
1787 : ? EventTypes.Encrypted
1788 : : type,
1789 : txid,
1790 : sendMessageContent,
1791 : );
1792 : }
1793 : }
1794 : }
|