Line data Source code
1 : import 'dart:async';
2 : import 'dart:convert';
3 : import 'dart:core';
4 :
5 : import 'package:collection/collection.dart';
6 : import 'package:sdp_transform/sdp_transform.dart' as sdp_transform;
7 : import 'package:webrtc_interface/webrtc_interface.dart';
8 :
9 : import 'package:matrix/matrix.dart';
10 : import 'package:matrix/src/utils/cached_stream_controller.dart';
11 : import 'package:matrix/src/utils/crypto/crypto.dart';
12 : import 'package:matrix/src/voip/models/call_membership.dart';
13 : import 'package:matrix/src/voip/models/call_options.dart';
14 : import 'package:matrix/src/voip/models/voip_id.dart';
15 : import 'package:matrix/src/voip/utils/stream_helper.dart';
16 :
17 : /// The parent highlevel voip class, this trnslates matrix events to webrtc methods via
18 : /// `CallSession` or `GroupCallSession` methods
19 : class VoIP {
20 : // used only for internal tests, all txids for call events will be overwritten to this
21 : static String? customTxid;
22 :
23 : /// set to true if you want to use the ratcheting mechanism with your keyprovider
24 : /// remember to set the window size correctly on your keyprovider
25 : ///
26 : /// at client level because reinitializing a `GroupCallSession` and its `KeyProvider`
27 : /// everytime this changed would be a pain
28 : final bool enableSFUE2EEKeyRatcheting;
29 :
30 : /// cached turn creds
31 : TurnServerCredentials? _turnServerCredentials;
32 :
33 4 : Map<VoipId, CallSession> get calls => _calls;
34 : final Map<VoipId, CallSession> _calls = {};
35 :
36 4 : Map<VoipId, GroupCallSession> get groupCalls => _groupCalls;
37 : final Map<VoipId, GroupCallSession> _groupCalls = {};
38 :
39 : /// The stream is used to prepare for incoming peer calls in a mesh call
40 : /// For example, registering listeners
41 : final CachedStreamController<CallSession> onIncomingCallSetup =
42 : CachedStreamController();
43 :
44 : /// The stream is used to signal the start of an incoming peer call in a mesh call
45 : final CachedStreamController<CallSession> onIncomingCallStart =
46 : CachedStreamController();
47 :
48 : VoipId? currentCID;
49 : VoipId? currentGroupCID;
50 :
51 4 : String get localPartyId => currentSessionId;
52 :
53 : final Client client;
54 : final WebRTCDelegate delegate;
55 : final StreamController<GroupCallSession> onIncomingGroupCall =
56 : StreamController();
57 :
58 6 : CallParticipant? get localParticipant => client.isLogged()
59 2 : ? CallParticipant(
60 : this,
61 4 : userId: client.userID!,
62 4 : deviceId: client.deviceID,
63 : )
64 : : null;
65 :
66 : /// map of roomIds to the invites they are currently processing or in a call with
67 : /// used for handling glare in p2p calls
68 4 : Map<String, String> get incomingCallRoomId => _incomingCallRoomId;
69 : final Map<String, String> _incomingCallRoomId = {};
70 :
71 : /// the current instance of voip, changing this will drop any ongoing mesh calls
72 : /// with that sessionId
73 : late String currentSessionId;
74 2 : VoIP(
75 : this.client,
76 : this.delegate, {
77 : this.enableSFUE2EEKeyRatcheting = false,
78 2 : }) : super() {
79 6 : currentSessionId = base64Encode(secureRandomBytes(16));
80 8 : Logs().v('set currentSessionId to $currentSessionId');
81 : // to populate groupCalls with already present calls
82 6 : for (final room in client.rooms) {
83 2 : final memsList = room.getCallMembershipsFromRoom();
84 2 : for (final mems in memsList.values) {
85 0 : for (final mem in mems) {
86 0 : unawaited(createGroupCallFromRoomStateEvent(mem));
87 : }
88 : }
89 : }
90 :
91 : /// handles events todevice and matrix events for invite, candidates, hangup, etc.
92 10 : client.onCallEvents.stream.listen((events) async {
93 2 : await _handleCallEvents(events);
94 : });
95 :
96 : // handles the com.famedly.call events.
97 8 : client.onRoomState.stream.listen(
98 2 : (update) async {
99 : final event = update.state;
100 2 : if (event is! Event) return;
101 6 : if (event.room.membership != Membership.join) return;
102 4 : if (event.type != EventTypes.GroupCallMember) return;
103 :
104 8 : Logs().v('[VOIP] onRoomState: type ${event.toJson()}');
105 4 : final mems = event.room.getCallMembershipsFromEvent(event);
106 4 : for (final mem in mems) {
107 4 : unawaited(createGroupCallFromRoomStateEvent(mem));
108 : }
109 6 : for (final map in groupCalls.entries) {
110 10 : if (map.key.roomId == event.room.id) {
111 : // because we don't know which call got updated, just update all
112 : // group calls we have entered for that room
113 4 : await map.value.onMemberStateChanged();
114 : }
115 : }
116 : },
117 : );
118 :
119 8 : delegate.mediaDevices.ondevicechange = _onDeviceChange;
120 : }
121 :
122 2 : Future<void> _handleCallEvents(List<BasicEventWithSender> callEvents) async {
123 : // Call invites should be omitted for a call that is already answered,
124 : // has ended, is rejectd or replaced.
125 2 : final callEventsCopy = List<BasicEventWithSender>.from(callEvents);
126 4 : for (final callEvent in callEventsCopy) {
127 4 : final callId = callEvent.content.tryGet<String>('call_id');
128 :
129 4 : if (CallConstants.callEndedEventTypes.contains(callEvent.type)) {
130 0 : callEvents.removeWhere((event) {
131 0 : if (CallConstants.omitWhenCallEndedTypes.contains(event.type) &&
132 0 : event.content.tryGet<String>('call_id') == callId) {
133 0 : Logs().v(
134 0 : 'Ommit "${event.type}" event for an already terminated call',
135 : );
136 : return true;
137 : }
138 :
139 : return false;
140 : });
141 : }
142 :
143 : // checks for ended events and removes invites for that call id.
144 2 : if (callEvent is Event) {
145 : // removes expired invites
146 4 : final age = callEvent.unsigned?.tryGet<int>('age') ??
147 6 : (DateTime.now().millisecondsSinceEpoch -
148 4 : callEvent.originServerTs.millisecondsSinceEpoch);
149 :
150 4 : callEvents.removeWhere((element) {
151 4 : if (callEvent.type == EventTypes.CallInvite &&
152 2 : age >
153 4 : (callEvent.content.tryGet<int>('lifetime') ??
154 0 : CallTimeouts.callInviteLifetime.inMilliseconds)) {
155 4 : Logs().w(
156 4 : '[VOIP] Ommiting invite event ${callEvent.eventId} as age was older than lifetime',
157 : );
158 : return true;
159 : }
160 : return false;
161 : });
162 : }
163 : }
164 :
165 : // and finally call the respective methods on the clean callEvents list
166 4 : for (final callEvent in callEvents) {
167 2 : await _handleCallEvent(callEvent);
168 : }
169 : }
170 :
171 2 : Future<void> _handleCallEvent(BasicEventWithSender event) async {
172 : // member event updates handled in onRoomState for ease
173 4 : if (event.type == EventTypes.GroupCallMember) return;
174 :
175 : GroupCallSession? groupCallSession;
176 : Room? room;
177 2 : final remoteUserId = event.senderId;
178 : String? remoteDeviceId;
179 :
180 2 : if (event is Event) {
181 2 : room = event.room;
182 :
183 : /// this can also be sent in p2p calls when they want to call a specific device
184 4 : remoteDeviceId = event.content.tryGet<String>('invitee_device_id');
185 0 : } else if (event is ToDeviceEvent) {
186 0 : final roomId = event.content.tryGet<String>('room_id');
187 0 : final confId = event.content.tryGet<String>('conf_id');
188 :
189 : /// to-device events specifically, m.call.invite and encryption key sending and requesting
190 0 : remoteDeviceId = event.content.tryGet<String>('device_id');
191 :
192 : if (roomId != null && confId != null) {
193 0 : room = client.getRoomById(roomId);
194 0 : groupCallSession = groupCalls[VoipId(roomId: roomId, callId: confId)];
195 : } else {
196 0 : Logs().w(
197 0 : '[VOIP] Ignoring to_device event of type ${event.type} but did not find group call for id: $confId',
198 : );
199 : return;
200 : }
201 :
202 0 : if (!event.type.startsWith(EventTypes.GroupCallMemberEncryptionKeys)) {
203 : // livekit calls have their own session deduplication logic so ignore sessionId deduplication for them
204 0 : final destSessionId = event.content.tryGet<String>('dest_session_id');
205 0 : if (destSessionId != currentSessionId) {
206 0 : Logs().w(
207 0 : '[VOIP] Ignoring to_device event of type ${event.type} did not match currentSessionId: $currentSessionId, dest_session_id was set to $destSessionId',
208 : );
209 : return;
210 : }
211 : } else if (groupCallSession == null || remoteDeviceId == null) {
212 0 : Logs().w(
213 0 : '[VOIP] _handleCallEvent ${event.type} recieved but either groupCall ${groupCallSession?.groupCallId} or deviceId $remoteDeviceId was null, ignoring',
214 : );
215 : return;
216 : }
217 : } else {
218 0 : Logs().w(
219 0 : '[VOIP] _handleCallEvent can only handle Event or ToDeviceEvent, it got ${event.runtimeType}',
220 : );
221 : return;
222 : }
223 :
224 2 : final content = event.content;
225 :
226 : if (room == null) {
227 0 : Logs().w(
228 : '[VOIP] _handleCallEvent call event does not contain a room_id, ignoring',
229 : );
230 : return;
231 4 : } else if (client.userID != null &&
232 4 : client.deviceID != null &&
233 6 : remoteUserId == client.userID &&
234 0 : remoteDeviceId == client.deviceID) {
235 0 : Logs().v(
236 0 : 'Ignoring call event ${event.type} for room ${room.id} from our own device',
237 : );
238 : return;
239 2 : } else if (!event.type
240 2 : .startsWith(EventTypes.GroupCallMemberEncryptionKeys)) {
241 : // skip webrtc event checks on encryption_keys
242 2 : final callId = content['call_id'] as String?;
243 2 : final partyId = content['party_id'] as String?;
244 0 : if (callId == null && event.type.startsWith('m.call')) {
245 0 : Logs().w('Ignoring call event ${event.type} because call_id was null');
246 : return;
247 : }
248 : if (callId != null) {
249 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
250 : if (call == null &&
251 4 : !{EventTypes.CallInvite, EventTypes.GroupCallMemberInvite}
252 4 : .contains(event.type)) {
253 0 : Logs().w(
254 0 : 'Ignoring call event ${event.type} for room ${room.id} because we do not have the call',
255 : );
256 : return;
257 : } else if (call != null) {
258 : // multiple checks to make sure the events sent are from the the
259 : // expected party
260 8 : if (call.room.id != room.id) {
261 0 : Logs().w(
262 0 : 'Ignoring call event ${event.type} for room ${room.id} claiming to be for call in room ${call.room.id}',
263 : );
264 : return;
265 : }
266 6 : if (call.remoteUserId != null && call.remoteUserId != remoteUserId) {
267 0 : Logs().d(
268 0 : 'Ignoring call event ${event.type} for room ${room.id} from sender $remoteUserId, expected sender: ${call.remoteUserId}',
269 : );
270 : return;
271 : }
272 6 : if (call.remotePartyId != null && call.remotePartyId != partyId) {
273 0 : Logs().w(
274 0 : 'Ignoring call event ${event.type} for room ${room.id} from sender with a different party_id $partyId, expected party_id: ${call.remotePartyId}',
275 : );
276 : return;
277 : }
278 2 : if ((call.remotePartyId != null &&
279 6 : call.remotePartyId == localPartyId)) {
280 0 : Logs().v(
281 0 : 'Ignoring call event ${event.type} for room ${room.id} from our own partyId',
282 : );
283 : return;
284 : }
285 : }
286 : }
287 : }
288 4 : Logs().v(
289 8 : '[VOIP] Handling event of type: ${event.type}, content ${event.content} from sender ${event.senderId} rp: $remoteUserId:$remoteDeviceId',
290 : );
291 :
292 2 : switch (event.type) {
293 2 : case EventTypes.CallInvite:
294 2 : case EventTypes.GroupCallMemberInvite:
295 2 : await onCallInvite(room, remoteUserId, remoteDeviceId, content);
296 : break;
297 2 : case EventTypes.CallAnswer:
298 2 : case EventTypes.GroupCallMemberAnswer:
299 0 : await onCallAnswer(room, remoteUserId, remoteDeviceId, content);
300 : break;
301 2 : case EventTypes.CallCandidates:
302 2 : case EventTypes.GroupCallMemberCandidates:
303 2 : await onCallCandidates(room, content);
304 : break;
305 2 : case EventTypes.CallHangup:
306 2 : case EventTypes.GroupCallMemberHangup:
307 0 : await onCallHangup(room, content);
308 : break;
309 2 : case EventTypes.CallReject:
310 2 : case EventTypes.GroupCallMemberReject:
311 0 : await onCallReject(room, content);
312 : break;
313 2 : case EventTypes.CallNegotiate:
314 2 : case EventTypes.GroupCallMemberNegotiate:
315 0 : await onCallNegotiate(room, content);
316 : break;
317 : // case EventTypes.CallReplaces:
318 : // await onCallReplaces(room, content);
319 : // break;
320 2 : case EventTypes.CallSelectAnswer:
321 0 : case EventTypes.GroupCallMemberSelectAnswer:
322 2 : await onCallSelectAnswer(room, content);
323 : break;
324 0 : case EventTypes.CallSDPStreamMetadataChanged:
325 0 : case EventTypes.CallSDPStreamMetadataChangedPrefix:
326 0 : case EventTypes.GroupCallMemberSDPStreamMetadataChanged:
327 0 : await onSDPStreamMetadataChangedReceived(room, content);
328 : break;
329 0 : case EventTypes.CallAssertedIdentity:
330 0 : case EventTypes.CallAssertedIdentityPrefix:
331 0 : case EventTypes.GroupCallMemberAssertedIdentity:
332 0 : await onAssertedIdentityReceived(room, content);
333 : break;
334 0 : case EventTypes.GroupCallMemberEncryptionKeys:
335 0 : await groupCallSession!.backend.onCallEncryption(
336 : groupCallSession,
337 : remoteUserId,
338 : remoteDeviceId!,
339 : content,
340 : );
341 : break;
342 0 : case EventTypes.GroupCallMemberEncryptionKeysRequest:
343 0 : await groupCallSession!.backend.onCallEncryptionKeyRequest(
344 : groupCallSession,
345 : remoteUserId,
346 : remoteDeviceId!,
347 : content,
348 : );
349 : break;
350 : }
351 : }
352 :
353 0 : Future<void> _onDeviceChange(dynamic _) async {
354 0 : Logs().v('[VOIP] _onDeviceChange');
355 0 : for (final call in calls.values) {
356 0 : if (call.state == CallState.kConnected && !call.isGroupCall) {
357 0 : await call.updateMediaDeviceForCall();
358 : }
359 : }
360 0 : for (final groupCall in groupCalls.values) {
361 0 : if (groupCall.state == GroupCallState.entered) {
362 0 : await groupCall.backend.updateMediaDeviceForCalls();
363 : }
364 : }
365 : }
366 :
367 2 : Future<void> onCallInvite(
368 : Room room,
369 : String remoteUserId,
370 : String? remoteDeviceId,
371 : Map<String, dynamic> content,
372 : ) async {
373 4 : Logs().v(
374 12 : '[VOIP] onCallInvite $remoteUserId:$remoteDeviceId => ${client.userID}:${client.deviceID}, \ncontent => ${content.toString()}',
375 : );
376 :
377 2 : final String callId = content['call_id'];
378 2 : final int lifetime = content['lifetime'];
379 2 : final String? confId = content['conf_id'];
380 :
381 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
382 :
383 4 : Logs().d(
384 10 : '[glare] got new call ${content.tryGet('call_id')} and currently room id is mapped to ${incomingCallRoomId.tryGet(room.id)}',
385 : );
386 :
387 0 : if (call != null && call.state == CallState.kEnded) {
388 : // Session already exist.
389 0 : Logs().v('[VOIP] onCallInvite: Session [$callId] already exist.');
390 : return;
391 : }
392 :
393 2 : final inviteeUserId = content['invitee'];
394 0 : if (inviteeUserId != null && inviteeUserId != localParticipant?.userId) {
395 0 : Logs().w('[VOIP] Ignoring call, meant for user $inviteeUserId');
396 : return; // This invite was meant for another user in the room
397 : }
398 2 : final inviteeDeviceId = content['invitee_device_id'];
399 : if (inviteeDeviceId != null &&
400 0 : inviteeDeviceId != localParticipant?.deviceId) {
401 0 : Logs().w('[VOIP] Ignoring call, meant for device $inviteeDeviceId');
402 : return; // This invite was meant for another device in the room
403 : }
404 :
405 2 : if (content['capabilities'] != null) {
406 0 : final capabilities = CallCapabilities.fromJson(content['capabilities']);
407 0 : Logs().v(
408 0 : '[VOIP] CallCapabilities: dtmf => ${capabilities.dtmf}, transferee => ${capabilities.transferee}',
409 : );
410 : }
411 :
412 : var callType = CallType.kVoice;
413 : SDPStreamMetadata? sdpStreamMetadata;
414 2 : if (content[sdpStreamMetadataKey] != null) {
415 : sdpStreamMetadata =
416 0 : SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]);
417 0 : sdpStreamMetadata.sdpStreamMetadatas
418 0 : .forEach((streamId, SDPStreamPurpose purpose) {
419 0 : Logs().v(
420 0 : '[VOIP] [$streamId] => purpose: ${purpose.purpose}, audioMuted: ${purpose.audio_muted}, videoMuted: ${purpose.video_muted}',
421 : );
422 :
423 0 : if (!purpose.video_muted) {
424 : callType = CallType.kVideo;
425 : }
426 : });
427 : } else {
428 6 : callType = getCallType(content['offer']['sdp']);
429 : }
430 :
431 2 : final opts = CallOptions(
432 : voip: this,
433 : callId: callId,
434 : groupCallId: confId,
435 : dir: CallDirection.kIncoming,
436 : type: callType,
437 : room: room,
438 2 : localPartyId: localPartyId,
439 2 : iceServers: await getIceServers(),
440 : );
441 :
442 2 : final newCall = createNewCall(opts);
443 :
444 : /// both invitee userId and deviceId are set here because there can be
445 : /// multiple devices from same user in a call, so we specifiy who the
446 : /// invite is for
447 2 : newCall.remoteUserId = remoteUserId;
448 2 : newCall.remoteDeviceId = remoteDeviceId;
449 4 : newCall.remotePartyId = content['party_id'];
450 4 : newCall.remoteSessionId = content['sender_session_id'];
451 :
452 : // newCall.remoteSessionId = remoteParticipant.sessionId;
453 :
454 4 : if (!delegate.canHandleNewCall &&
455 : (confId == null ||
456 0 : currentGroupCID != VoipId(roomId: room.id, callId: confId))) {
457 0 : Logs().v(
458 : '[VOIP] onCallInvite: Unable to handle new calls, maybe user is busy.',
459 : );
460 : // no need to emit here because handleNewCall was never triggered yet
461 0 : await newCall.reject(reason: CallErrorCode.userBusy, shouldEmit: false);
462 0 : await delegate.handleMissedCall(newCall);
463 : return;
464 : }
465 :
466 2 : final offer = RTCSessionDescription(
467 4 : content['offer']['sdp'],
468 4 : content['offer']['type'],
469 : );
470 :
471 : /// play ringtone. We decided to play the ringtone before adding the call to
472 : /// the incoming call stream because getUserMedia from initWithInvite fails
473 : /// on firefox unless the tab is in focus. We should atleast be able to notify
474 : /// the user about an incoming call
475 : ///
476 : /// Autoplay on firefox still needs interaction, without which all notifications
477 : /// could be blocked.
478 : if (confId == null) {
479 4 : await delegate.playRingtone();
480 : }
481 :
482 : // When getUserMedia throws an exception, we handle it by terminating the call,
483 : // and all this happens inside initWithInvite. If we set currentCID after
484 : // initWithInvite, we might set it to callId even after it was reset to null
485 : // by terminate.
486 6 : currentCID = VoipId(roomId: room.id, callId: callId);
487 :
488 : if (confId == null) {
489 4 : await delegate.registerListeners(newCall);
490 : } else {
491 0 : onIncomingCallSetup.add(newCall);
492 : }
493 :
494 2 : await newCall.initWithInvite(
495 : callType,
496 : offer,
497 : sdpStreamMetadata,
498 : lifetime,
499 : confId != null,
500 : );
501 :
502 : // Popup CallingPage for incoming call.
503 2 : if (confId == null && !newCall.callHasEnded) {
504 4 : await delegate.handleNewCall(newCall);
505 : }
506 :
507 : if (confId != null) {
508 0 : onIncomingCallStart.add(newCall);
509 : }
510 : }
511 :
512 0 : Future<void> onCallAnswer(
513 : Room room,
514 : String remoteUserId,
515 : String? remoteDeviceId,
516 : Map<String, dynamic> content,
517 : ) async {
518 0 : Logs().v('[VOIP] onCallAnswer => ${content.toString()}');
519 0 : final String callId = content['call_id'];
520 :
521 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
522 : if (call != null) {
523 0 : if (!call.answeredByUs) {
524 0 : await delegate.stopRingtone();
525 : }
526 0 : if (call.state == CallState.kRinging) {
527 0 : await call.onAnsweredElsewhere();
528 : }
529 :
530 0 : if (call.room.id != room.id) {
531 0 : Logs().w(
532 0 : 'Ignoring call answer for room ${room.id} claiming to be for call in room ${call.room.id}',
533 : );
534 : return;
535 : }
536 :
537 0 : if (call.remoteUserId == null) {
538 0 : Logs().i(
539 : '[VOIP] you probably called the room without setting a userId in invite, setting the calls remote user id to what I get from m.call.answer now',
540 : );
541 0 : call.remoteUserId = remoteUserId;
542 : }
543 :
544 0 : if (call.remoteDeviceId == null) {
545 0 : Logs().i(
546 : '[VOIP] you probably called the room without setting a userId in invite, setting the calls remote user id to what I get from m.call.answer now',
547 : );
548 0 : call.remoteDeviceId = remoteDeviceId;
549 : }
550 0 : if (call.remotePartyId != null) {
551 0 : Logs().d(
552 0 : 'Ignoring call answer from party ${content['party_id']}, we are already with ${call.remotePartyId}',
553 : );
554 : return;
555 : } else {
556 0 : call.remotePartyId = content['party_id'];
557 : }
558 :
559 0 : final answer = RTCSessionDescription(
560 0 : content['answer']['sdp'],
561 0 : content['answer']['type'],
562 : );
563 :
564 : SDPStreamMetadata? metadata;
565 0 : if (content[sdpStreamMetadataKey] != null) {
566 0 : metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]);
567 : }
568 0 : await call.onAnswerReceived(answer, metadata);
569 : } else {
570 0 : Logs().v('[VOIP] onCallAnswer: Session [$callId] not found!');
571 : }
572 : }
573 :
574 2 : Future<void> onCallCandidates(Room room, Map<String, dynamic> content) async {
575 8 : Logs().v('[VOIP] onCallCandidates => ${content.toString()}');
576 2 : final String callId = content['call_id'];
577 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
578 : if (call != null) {
579 4 : await call.onCandidatesReceived(content['candidates']);
580 : } else {
581 0 : Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!');
582 : }
583 : }
584 :
585 0 : Future<void> onCallHangup(Room room, Map<String, dynamic> content) async {
586 : // stop play ringtone, if this is an incoming call
587 0 : await delegate.stopRingtone();
588 0 : Logs().v('[VOIP] onCallHangup => ${content.toString()}');
589 0 : final String callId = content['call_id'];
590 :
591 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
592 : if (call != null) {
593 : // hangup in any case, either if the other party hung up or we did on another device
594 0 : await call.terminate(
595 : CallParty.kRemote,
596 0 : CallErrorCode.values.firstWhereOrNull(
597 0 : (element) => element.reason == content['reason'],
598 : ) ??
599 : CallErrorCode.userHangup,
600 : true,
601 : );
602 : } else {
603 0 : Logs().v('[VOIP] onCallHangup: Session [$callId] not found!');
604 : }
605 0 : if (callId == currentCID?.callId) {
606 0 : currentCID = null;
607 : }
608 : }
609 :
610 0 : Future<void> onCallReject(Room room, Map<String, dynamic> content) async {
611 0 : final String callId = content['call_id'];
612 0 : Logs().d('Reject received for call ID $callId');
613 :
614 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
615 : if (call != null) {
616 0 : await call.onRejectReceived(
617 0 : CallErrorCode.values.firstWhereOrNull(
618 0 : (element) => element.reason == content['reason'],
619 : ) ??
620 : CallErrorCode.userHangup,
621 : );
622 : } else {
623 0 : Logs().v('[VOIP] onCallReject: Session [$callId] not found!');
624 : }
625 : }
626 :
627 2 : Future<void> onCallSelectAnswer(
628 : Room room,
629 : Map<String, dynamic> content,
630 : ) async {
631 2 : final String callId = content['call_id'];
632 6 : Logs().d('SelectAnswer received for call ID $callId');
633 2 : final String selectedPartyId = content['selected_party_id'];
634 :
635 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
636 : if (call != null) {
637 8 : if (call.room.id != room.id) {
638 0 : Logs().w(
639 0 : 'Ignoring call select answer for room ${room.id} claiming to be for call in room ${call.room.id}',
640 : );
641 : return;
642 : }
643 2 : await call.onSelectAnswerReceived(selectedPartyId);
644 : }
645 : }
646 :
647 0 : Future<void> onSDPStreamMetadataChangedReceived(
648 : Room room,
649 : Map<String, dynamic> content,
650 : ) async {
651 0 : final String callId = content['call_id'];
652 0 : Logs().d('SDP Stream metadata received for call ID $callId');
653 :
654 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
655 : if (call != null) {
656 0 : if (content[sdpStreamMetadataKey] == null) {
657 0 : Logs().d('SDP Stream metadata is null');
658 : return;
659 : }
660 0 : await call.onSDPStreamMetadataReceived(
661 0 : SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]),
662 : );
663 : }
664 : }
665 :
666 0 : Future<void> onAssertedIdentityReceived(
667 : Room room,
668 : Map<String, dynamic> content,
669 : ) async {
670 0 : final String callId = content['call_id'];
671 0 : Logs().d('Asserted identity received for call ID $callId');
672 :
673 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
674 : if (call != null) {
675 0 : if (content['asserted_identity'] == null) {
676 0 : Logs().d('asserted_identity is null ');
677 : return;
678 : }
679 0 : call.onAssertedIdentityReceived(
680 0 : AssertedIdentity.fromJson(content['asserted_identity']),
681 : );
682 : }
683 : }
684 :
685 0 : Future<void> onCallNegotiate(Room room, Map<String, dynamic> content) async {
686 0 : final String callId = content['call_id'];
687 0 : Logs().d('Negotiate received for call ID $callId');
688 :
689 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
690 : if (call != null) {
691 : // ideally you also check the lifetime here and discard negotiation events
692 : // if age of the event was older than the lifetime but as to device events
693 : // do not have a unsigned age nor a origin_server_ts there's no easy way to
694 : // override this one function atm
695 :
696 0 : final description = content['description'];
697 : try {
698 : SDPStreamMetadata? metadata;
699 0 : if (content[sdpStreamMetadataKey] != null) {
700 0 : metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]);
701 : }
702 0 : await call.onNegotiateReceived(
703 : metadata,
704 0 : RTCSessionDescription(description['sdp'], description['type']),
705 : );
706 : } catch (e, s) {
707 0 : Logs().e('[VOIP] Failed to complete negotiation', e, s);
708 : }
709 : }
710 : }
711 :
712 2 : CallType getCallType(String sdp) {
713 : try {
714 2 : final session = sdp_transform.parse(sdp);
715 8 : if (session['media'].indexWhere((e) => e['type'] == 'video') != -1) {
716 : return CallType.kVideo;
717 : }
718 : } catch (e, s) {
719 0 : Logs().e('[VOIP] Failed to getCallType', e, s);
720 : }
721 :
722 : return CallType.kVoice;
723 : }
724 :
725 2 : Future<List<Map<String, dynamic>>> getIceServers() async {
726 2 : if (_turnServerCredentials == null) {
727 : try {
728 6 : _turnServerCredentials = await client.getTurnServer();
729 : } catch (e) {
730 0 : Logs().v('[VOIP] getTurnServerCredentials error => ${e.toString()}');
731 : }
732 : }
733 :
734 2 : if (_turnServerCredentials == null) {
735 0 : return [];
736 : }
737 :
738 2 : return [
739 2 : {
740 4 : 'username': _turnServerCredentials!.username,
741 4 : 'credential': _turnServerCredentials!.password,
742 4 : 'urls': _turnServerCredentials!.uris,
743 : }
744 : ];
745 : }
746 :
747 : /// Make a P2P call to room
748 : ///
749 : /// Pretty important to set the userId, or all the users in the room get a call.
750 : /// Including your own other devices, so just set it to directChatMatrixId
751 : ///
752 : /// Setting the deviceId would make all other devices for that userId ignore the call
753 : /// Ideally only group calls would need setting both userId and deviceId to allow
754 : /// having 2 devices from the same user in a group call
755 : ///
756 : /// For p2p call, you want to have all the devices of the specified `userId` ring
757 2 : Future<CallSession> inviteToCall(
758 : Room room,
759 : CallType type, {
760 : String? userId,
761 : String? deviceId,
762 : }) async {
763 2 : final roomId = room.id;
764 2 : final callId = genCallID();
765 2 : if (currentGroupCID == null) {
766 4 : incomingCallRoomId[roomId] = callId;
767 : }
768 2 : final opts = CallOptions(
769 : callId: callId,
770 : type: type,
771 : dir: CallDirection.kOutgoing,
772 : room: room,
773 : voip: this,
774 2 : localPartyId: localPartyId,
775 2 : iceServers: await getIceServers(),
776 : );
777 2 : final newCall = createNewCall(opts);
778 :
779 2 : newCall.remoteUserId = userId;
780 2 : newCall.remoteDeviceId = deviceId;
781 :
782 4 : await delegate.registerListeners(newCall);
783 :
784 4 : currentCID = VoipId(roomId: roomId, callId: callId);
785 6 : await newCall.initOutboundCall(type).then((_) {
786 4 : delegate.handleNewCall(newCall);
787 : });
788 : return newCall;
789 : }
790 :
791 2 : CallSession createNewCall(CallOptions opts) {
792 2 : final call = CallSession(opts);
793 12 : calls[VoipId(roomId: opts.room.id, callId: opts.callId)] = call;
794 : return call;
795 : }
796 :
797 : /// Create a new group call in an existing room.
798 : ///
799 : /// [groupCallId] The room id to call
800 : ///
801 : /// [application] normal group call, thrirdroom, etc
802 : ///
803 : /// [scope] room, between specifc users, etc.
804 0 : Future<GroupCallSession> _newGroupCall(
805 : String groupCallId,
806 : Room room,
807 : CallBackend backend,
808 : String? application,
809 : String? scope,
810 : ) async {
811 0 : if (getGroupCallById(room.id, groupCallId) != null) {
812 0 : Logs().v('[VOIP] [$groupCallId] already exists.');
813 0 : return getGroupCallById(room.id, groupCallId)!;
814 : }
815 :
816 0 : final groupCall = GroupCallSession(
817 : groupCallId: groupCallId,
818 0 : client: client,
819 : room: room,
820 : voip: this,
821 : backend: backend,
822 : application: application,
823 : scope: scope,
824 : );
825 :
826 0 : setGroupCallById(groupCall);
827 :
828 : return groupCall;
829 : }
830 :
831 : /// Create a new group call in an existing room.
832 : ///
833 : /// [groupCallId] The room id to call
834 : ///
835 : /// [application] normal group call, thrirdroom, etc
836 : ///
837 : /// [scope] room, between specifc users, etc.
838 : ///
839 : /// [preShareKey] for livekit calls it creates and shares a key with other
840 : /// participants in the call without entering, useful on onboarding screens.
841 : /// does not do anything in mesh calls
842 :
843 0 : Future<GroupCallSession> fetchOrCreateGroupCall(
844 : String groupCallId,
845 : Room room,
846 : CallBackend backend,
847 : String? application,
848 : String? scope, {
849 : bool preShareKey = true,
850 : }) async {
851 : // somehow user were mising their powerlevels events and got stuck
852 : // with the exception below, this part just makes sure importantStateEvents
853 : // does not cause it.
854 0 : await room.postLoad();
855 :
856 0 : if (!room.groupCallsEnabledForEveryone) {
857 0 : await room.enableGroupCalls();
858 : }
859 :
860 0 : if (!room.canJoinGroupCall) {
861 0 : throw MatrixSDKVoipException(
862 : '''
863 0 : User ${client.userID}:${client.deviceID} is not allowed to join famedly calls in room ${room.id},
864 0 : canJoinGroupCall: ${room.canJoinGroupCall},
865 0 : groupCallsEnabledForEveryone: ${room.groupCallsEnabledForEveryone},
866 0 : needed: ${room.powerForChangingStateEvent(EventTypes.GroupCallMember)},
867 0 : own: ${room.ownPowerLevel}}
868 0 : plMap: ${room.getState(EventTypes.RoomPowerLevels)?.content}
869 0 : ''',
870 : );
871 : }
872 :
873 0 : GroupCallSession? groupCall = getGroupCallById(room.id, groupCallId);
874 :
875 0 : groupCall ??= await _newGroupCall(
876 : groupCallId,
877 : room,
878 : backend,
879 : application,
880 : scope,
881 : );
882 :
883 : if (preShareKey) {
884 0 : await groupCall.backend.preShareKey(groupCall);
885 : }
886 :
887 : return groupCall;
888 : }
889 :
890 0 : GroupCallSession? getGroupCallById(String roomId, String groupCallId) {
891 0 : return groupCalls[VoipId(roomId: roomId, callId: groupCallId)];
892 : }
893 :
894 2 : void setGroupCallById(GroupCallSession groupCallSession) {
895 6 : groupCalls[VoipId(
896 4 : roomId: groupCallSession.room.id,
897 2 : callId: groupCallSession.groupCallId,
898 : )] = groupCallSession;
899 : }
900 :
901 : /// Create a new group call from a room state event.
902 2 : Future<void> createGroupCallFromRoomStateEvent(
903 : CallMembership membership, {
904 : bool emitHandleNewGroupCall = true,
905 : }) async {
906 2 : if (membership.isExpired) {
907 4 : Logs().d(
908 4 : 'Ignoring expired membership in passive groupCall creator. ${membership.toJson()}',
909 : );
910 : return;
911 : }
912 :
913 6 : final room = client.getRoomById(membership.roomId);
914 :
915 : if (room == null) {
916 0 : Logs().w('Couldn\'t find room ${membership.roomId} for GroupCallSession');
917 : return;
918 : }
919 :
920 4 : if (membership.application != 'm.call' && membership.scope != 'm.room') {
921 0 : Logs().w('Received invalid group call application or scope.');
922 : return;
923 : }
924 :
925 2 : final groupCall = GroupCallSession(
926 2 : client: client,
927 : voip: this,
928 : room: room,
929 2 : backend: membership.backend,
930 2 : groupCallId: membership.callId,
931 2 : application: membership.application,
932 2 : scope: membership.scope,
933 : );
934 :
935 4 : if (groupCalls.containsKey(
936 6 : VoipId(roomId: membership.roomId, callId: membership.callId),
937 : )) {
938 : return;
939 : }
940 :
941 2 : setGroupCallById(groupCall);
942 :
943 4 : onIncomingGroupCall.add(groupCall);
944 : if (emitHandleNewGroupCall) {
945 4 : await delegate.handleNewGroupCall(groupCall);
946 : }
947 : }
948 :
949 0 : @Deprecated('Call `hasActiveGroupCall` on the room directly instead')
950 0 : bool hasActiveCall(Room room) => room.hasActiveGroupCall;
951 : }
|