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