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 2 : } else if (!event.type
219 2 : .startsWith(EventTypes.GroupCallMemberEncryptionKeys)) {
220 : // skip webrtc event checks on encryption_keys
221 2 : final callId = content['call_id'] as String?;
222 2 : final partyId = content['party_id'] as String?;
223 0 : if (callId == null && event.type.startsWith('m.call')) {
224 0 : Logs().w('Ignoring call event ${event.type} because call_id was null');
225 : return;
226 : }
227 : if (callId != null) {
228 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
229 : if (call == null &&
230 4 : !{EventTypes.CallInvite, EventTypes.GroupCallMemberInvite}
231 4 : .contains(event.type)) {
232 0 : Logs().w(
233 0 : 'Ignoring call event ${event.type} because we do not have the call');
234 : return;
235 : } else if (call != null) {
236 : // multiple checks to make sure the events sent are from the the
237 : // expected party
238 8 : if (call.room.id != room.id) {
239 0 : Logs().w(
240 0 : 'Ignoring call event ${event.type} for room ${room.id} claiming to be for call in room ${call.room.id}');
241 : return;
242 : }
243 6 : if (call.remoteUserId != null && call.remoteUserId != remoteUserId) {
244 0 : Logs().w(
245 0 : 'Ignoring call event ${event.type} from sender $remoteUserId, expected sender: ${call.remoteUserId}');
246 : return;
247 : }
248 6 : if (call.remotePartyId != null && call.remotePartyId != partyId) {
249 0 : Logs().w(
250 0 : 'Ignoring call event ${event.type} from sender with a different party_id $partyId, expected party_id: ${call.remotePartyId}');
251 : return;
252 : }
253 2 : if ((call.remotePartyId != null &&
254 6 : call.remotePartyId == localPartyId) ||
255 6 : (remoteUserId == client.userID &&
256 0 : remoteDeviceId == client.deviceID!)) {
257 0 : Logs().w('Ignoring call event ${event.type} from ourself');
258 : return;
259 : }
260 : }
261 : }
262 : }
263 4 : Logs().v(
264 8 : '[VOIP] Handling event of type: ${event.type}, content ${event.content} from sender ${event.senderId} rp: $remoteUserId:$remoteDeviceId');
265 :
266 2 : switch (event.type) {
267 2 : case EventTypes.CallInvite:
268 2 : case EventTypes.GroupCallMemberInvite:
269 2 : await onCallInvite(room, remoteUserId, remoteDeviceId, content);
270 : break;
271 2 : case EventTypes.CallAnswer:
272 2 : case EventTypes.GroupCallMemberAnswer:
273 0 : await onCallAnswer(room, remoteUserId, remoteDeviceId, content);
274 : break;
275 2 : case EventTypes.CallCandidates:
276 2 : case EventTypes.GroupCallMemberCandidates:
277 2 : await onCallCandidates(room, content);
278 : break;
279 2 : case EventTypes.CallHangup:
280 2 : case EventTypes.GroupCallMemberHangup:
281 0 : await onCallHangup(room, content);
282 : break;
283 2 : case EventTypes.CallReject:
284 2 : case EventTypes.GroupCallMemberReject:
285 0 : await onCallReject(room, content);
286 : break;
287 2 : case EventTypes.CallNegotiate:
288 2 : case EventTypes.GroupCallMemberNegotiate:
289 0 : await onCallNegotiate(room, content);
290 : break;
291 : // case EventTypes.CallReplaces:
292 : // await onCallReplaces(room, content);
293 : // break;
294 2 : case EventTypes.CallSelectAnswer:
295 0 : case EventTypes.GroupCallMemberSelectAnswer:
296 2 : await onCallSelectAnswer(room, content);
297 : break;
298 0 : case EventTypes.CallSDPStreamMetadataChanged:
299 0 : case EventTypes.CallSDPStreamMetadataChangedPrefix:
300 0 : case EventTypes.GroupCallMemberSDPStreamMetadataChanged:
301 0 : await onSDPStreamMetadataChangedReceived(room, content);
302 : break;
303 0 : case EventTypes.CallAssertedIdentity:
304 0 : case EventTypes.CallAssertedIdentityPrefix:
305 0 : case EventTypes.GroupCallMemberAssertedIdentity:
306 0 : await onAssertedIdentityReceived(room, content);
307 : break;
308 0 : case EventTypes.GroupCallMemberEncryptionKeys:
309 0 : await groupCallSession!.backend.onCallEncryption(
310 : groupCallSession, remoteUserId, remoteDeviceId!, content);
311 : break;
312 0 : case EventTypes.GroupCallMemberEncryptionKeysRequest:
313 0 : await groupCallSession!.backend.onCallEncryptionKeyRequest(
314 : groupCallSession, remoteUserId, remoteDeviceId!, content);
315 : break;
316 : }
317 : }
318 :
319 0 : Future<void> _onDeviceChange(dynamic _) async {
320 0 : Logs().v('[VOIP] _onDeviceChange');
321 0 : for (final call in calls.values) {
322 0 : if (call.state == CallState.kConnected && !call.isGroupCall) {
323 0 : await call.updateMediaDeviceForCall();
324 : }
325 : }
326 0 : for (final groupCall in groupCalls.values) {
327 0 : if (groupCall.state == GroupCallState.entered) {
328 0 : await groupCall.backend.updateMediaDeviceForCalls();
329 : }
330 : }
331 : }
332 :
333 2 : Future<void> onCallInvite(Room room, String remoteUserId,
334 : String? remoteDeviceId, Map<String, dynamic> content) async {
335 4 : Logs().v(
336 12 : '[VOIP] onCallInvite $remoteUserId:$remoteDeviceId => ${client.userID}:${client.deviceID}, \ncontent => ${content.toString()}');
337 :
338 2 : final String callId = content['call_id'];
339 2 : final int lifetime = content['lifetime'];
340 2 : final String? confId = content['conf_id'];
341 :
342 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
343 :
344 4 : Logs().d(
345 10 : '[glare] got new call ${content.tryGet('call_id')} and currently room id is mapped to ${incomingCallRoomId.tryGet(room.id)}');
346 :
347 0 : if (call != null && call.state == CallState.kEnded) {
348 : // Session already exist.
349 0 : Logs().v('[VOIP] onCallInvite: Session [$callId] already exist.');
350 : return;
351 : }
352 :
353 2 : final inviteeUserId = content['invitee'];
354 0 : if (inviteeUserId != null && inviteeUserId != localParticipant?.userId) {
355 0 : Logs().w('[VOIP] Ignoring call, meant for user $inviteeUserId');
356 : return; // This invite was meant for another user in the room
357 : }
358 2 : final inviteeDeviceId = content['invitee_device_id'];
359 : if (inviteeDeviceId != null &&
360 0 : inviteeDeviceId != localParticipant?.deviceId) {
361 0 : Logs().w('[VOIP] Ignoring call, meant for device $inviteeDeviceId');
362 : return; // This invite was meant for another device in the room
363 : }
364 :
365 2 : if (content['capabilities'] != null) {
366 0 : final capabilities = CallCapabilities.fromJson(content['capabilities']);
367 0 : Logs().v(
368 0 : '[VOIP] CallCapabilities: dtmf => ${capabilities.dtmf}, transferee => ${capabilities.transferee}');
369 : }
370 :
371 : var callType = CallType.kVoice;
372 : SDPStreamMetadata? sdpStreamMetadata;
373 2 : if (content[sdpStreamMetadataKey] != null) {
374 : sdpStreamMetadata =
375 0 : SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]);
376 0 : sdpStreamMetadata.sdpStreamMetadatas
377 0 : .forEach((streamId, SDPStreamPurpose purpose) {
378 0 : Logs().v(
379 0 : '[VOIP] [$streamId] => purpose: ${purpose.purpose}, audioMuted: ${purpose.audio_muted}, videoMuted: ${purpose.video_muted}');
380 :
381 0 : if (!purpose.video_muted) {
382 : callType = CallType.kVideo;
383 : }
384 : });
385 : } else {
386 6 : callType = getCallType(content['offer']['sdp']);
387 : }
388 :
389 2 : final opts = CallOptions(
390 : voip: this,
391 : callId: callId,
392 : groupCallId: confId,
393 : dir: CallDirection.kIncoming,
394 : type: callType,
395 : room: room,
396 2 : localPartyId: localPartyId,
397 2 : iceServers: await getIceServers(),
398 : );
399 :
400 2 : final newCall = createNewCall(opts);
401 :
402 : /// both invitee userId and deviceId are set here because there can be
403 : /// multiple devices from same user in a call, so we specifiy who the
404 : /// invite is for
405 2 : newCall.remoteUserId = remoteUserId;
406 2 : newCall.remoteDeviceId = remoteDeviceId;
407 4 : newCall.remotePartyId = content['party_id'];
408 4 : newCall.remoteSessionId = content['sender_session_id'];
409 :
410 : // newCall.remoteSessionId = remoteParticipant.sessionId;
411 :
412 4 : if (!delegate.canHandleNewCall &&
413 : (confId == null ||
414 0 : currentGroupCID != VoipId(roomId: room.id, callId: confId))) {
415 0 : Logs().v(
416 : '[VOIP] onCallInvite: Unable to handle new calls, maybe user is busy.');
417 : // no need to emit here because handleNewCall was never triggered yet
418 0 : await newCall.reject(reason: CallErrorCode.userBusy, shouldEmit: false);
419 0 : await delegate.handleMissedCall(newCall);
420 : return;
421 : }
422 :
423 2 : final offer = RTCSessionDescription(
424 4 : content['offer']['sdp'],
425 4 : content['offer']['type'],
426 : );
427 :
428 : /// play ringtone. We decided to play the ringtone before adding the call to
429 : /// the incoming call stream because getUserMedia from initWithInvite fails
430 : /// on firefox unless the tab is in focus. We should atleast be able to notify
431 : /// the user about an incoming call
432 : ///
433 : /// Autoplay on firefox still needs interaction, without which all notifications
434 : /// could be blocked.
435 : if (confId == null) {
436 4 : await delegate.playRingtone();
437 : }
438 :
439 : // When getUserMedia throws an exception, we handle it by terminating the call,
440 : // and all this happens inside initWithInvite. If we set currentCID after
441 : // initWithInvite, we might set it to callId even after it was reset to null
442 : // by terminate.
443 6 : currentCID = VoipId(roomId: room.id, callId: callId);
444 :
445 2 : await newCall.initWithInvite(
446 : callType, offer, sdpStreamMetadata, lifetime, confId != null);
447 :
448 : // Popup CallingPage for incoming call.
449 2 : if (confId == null && !newCall.callHasEnded) {
450 4 : await delegate.handleNewCall(newCall);
451 : }
452 :
453 : if (confId != null) {
454 : // the stream is used to monitor incoming peer calls in a mesh call
455 0 : onIncomingCall.add(newCall);
456 : }
457 : }
458 :
459 0 : Future<void> onCallAnswer(Room room, String remoteUserId,
460 : String? remoteDeviceId, Map<String, dynamic> content) async {
461 0 : Logs().v('[VOIP] onCallAnswer => ${content.toString()}');
462 0 : final String callId = content['call_id'];
463 :
464 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
465 : if (call != null) {
466 0 : if (!call.answeredByUs) {
467 0 : await delegate.stopRingtone();
468 : }
469 0 : if (call.state == CallState.kRinging) {
470 0 : await call.onAnsweredElsewhere();
471 : }
472 :
473 0 : if (call.room.id != room.id) {
474 0 : Logs().w(
475 0 : 'Ignoring call answer for room ${room.id} claiming to be for call in room ${call.room.id}');
476 : return;
477 : }
478 :
479 0 : if (call.remoteUserId == null) {
480 0 : Logs().i(
481 : '[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');
482 0 : call.remoteUserId = remoteUserId;
483 : }
484 :
485 0 : if (call.remoteDeviceId == 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.remoteDeviceId = remoteDeviceId;
489 : }
490 0 : if (call.remotePartyId != null) {
491 0 : Logs().d(
492 0 : 'Ignoring call answer from party ${content['party_id']}, we are already with ${call.remotePartyId}');
493 : return;
494 : } else {
495 0 : call.remotePartyId = content['party_id'];
496 : }
497 :
498 0 : final answer = RTCSessionDescription(
499 0 : content['answer']['sdp'], content['answer']['type']);
500 :
501 : SDPStreamMetadata? metadata;
502 0 : if (content[sdpStreamMetadataKey] != null) {
503 0 : metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]);
504 : }
505 0 : await call.onAnswerReceived(answer, metadata);
506 : } else {
507 0 : Logs().v('[VOIP] onCallAnswer: Session [$callId] not found!');
508 : }
509 : }
510 :
511 2 : Future<void> onCallCandidates(Room room, Map<String, dynamic> content) async {
512 8 : Logs().v('[VOIP] onCallCandidates => ${content.toString()}');
513 2 : final String callId = content['call_id'];
514 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
515 : if (call != null) {
516 4 : await call.onCandidatesReceived(content['candidates']);
517 : } else {
518 0 : Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!');
519 : }
520 : }
521 :
522 0 : Future<void> onCallHangup(Room room, Map<String, dynamic> content) async {
523 : // stop play ringtone, if this is an incoming call
524 0 : await delegate.stopRingtone();
525 0 : Logs().v('[VOIP] onCallHangup => ${content.toString()}');
526 0 : final String callId = content['call_id'];
527 :
528 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
529 : if (call != null) {
530 : // hangup in any case, either if the other party hung up or we did on another device
531 0 : await call.terminate(
532 : CallParty.kRemote,
533 0 : CallErrorCode.values.firstWhereOrNull(
534 0 : (element) => element.reason == content['reason']) ??
535 : CallErrorCode.userHangup,
536 : true);
537 : } else {
538 0 : Logs().v('[VOIP] onCallHangup: Session [$callId] not found!');
539 : }
540 0 : if (callId == currentCID?.callId) {
541 0 : currentCID = null;
542 : }
543 : }
544 :
545 0 : Future<void> onCallReject(Room room, Map<String, dynamic> content) async {
546 0 : final String callId = content['call_id'];
547 0 : Logs().d('Reject received for call ID $callId');
548 :
549 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
550 : if (call != null) {
551 0 : await call.onRejectReceived(
552 0 : CallErrorCode.values.firstWhereOrNull(
553 0 : (element) => element.reason == content['reason']) ??
554 : CallErrorCode.userHangup,
555 : );
556 : } else {
557 0 : Logs().v('[VOIP] onCallReject: Session [$callId] not found!');
558 : }
559 : }
560 :
561 2 : Future<void> onCallSelectAnswer(
562 : Room room, Map<String, dynamic> content) async {
563 2 : final String callId = content['call_id'];
564 6 : Logs().d('SelectAnswer received for call ID $callId');
565 2 : final String selectedPartyId = content['selected_party_id'];
566 :
567 8 : final call = calls[VoipId(roomId: room.id, callId: callId)];
568 : if (call != null) {
569 8 : if (call.room.id != room.id) {
570 0 : Logs().w(
571 0 : 'Ignoring call select answer for room ${room.id} claiming to be for call in room ${call.room.id}');
572 : return;
573 : }
574 2 : await call.onSelectAnswerReceived(selectedPartyId);
575 : }
576 : }
577 :
578 0 : Future<void> onSDPStreamMetadataChangedReceived(
579 : Room room, Map<String, dynamic> content) async {
580 0 : final String callId = content['call_id'];
581 0 : Logs().d('SDP Stream metadata received for call ID $callId');
582 :
583 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
584 : if (call != null) {
585 0 : if (content[sdpStreamMetadataKey] == null) {
586 0 : Logs().d('SDP Stream metadata is null');
587 : return;
588 : }
589 0 : await call.onSDPStreamMetadataReceived(
590 0 : SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]));
591 : }
592 : }
593 :
594 0 : Future<void> onAssertedIdentityReceived(
595 : Room room, Map<String, dynamic> content) async {
596 0 : final String callId = content['call_id'];
597 0 : Logs().d('Asserted identity received for call ID $callId');
598 :
599 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
600 : if (call != null) {
601 0 : if (content['asserted_identity'] == null) {
602 0 : Logs().d('asserted_identity is null ');
603 : return;
604 : }
605 0 : call.onAssertedIdentityReceived(
606 0 : AssertedIdentity.fromJson(content['asserted_identity']));
607 : }
608 : }
609 :
610 0 : Future<void> onCallNegotiate(Room room, Map<String, dynamic> content) async {
611 0 : final String callId = content['call_id'];
612 0 : Logs().d('Negotiate received for call ID $callId');
613 :
614 0 : final call = calls[VoipId(roomId: room.id, callId: callId)];
615 : if (call != null) {
616 : // ideally you also check the lifetime here and discard negotiation events
617 : // if age of the event was older than the lifetime but as to device events
618 : // do not have a unsigned age nor a origin_server_ts there's no easy way to
619 : // override this one function atm
620 :
621 0 : final description = content['description'];
622 : try {
623 : SDPStreamMetadata? metadata;
624 0 : if (content[sdpStreamMetadataKey] != null) {
625 0 : metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]);
626 : }
627 0 : await call.onNegotiateReceived(metadata,
628 0 : RTCSessionDescription(description['sdp'], description['type']));
629 : } catch (e, s) {
630 0 : Logs().e('[VOIP] Failed to complete negotiation', e, s);
631 : }
632 : }
633 : }
634 :
635 2 : CallType getCallType(String sdp) {
636 : try {
637 2 : final session = sdp_transform.parse(sdp);
638 8 : if (session['media'].indexWhere((e) => e['type'] == 'video') != -1) {
639 : return CallType.kVideo;
640 : }
641 : } catch (e, s) {
642 0 : Logs().e('[VOIP] Failed to getCallType', e, s);
643 : }
644 :
645 : return CallType.kVoice;
646 : }
647 :
648 2 : Future<List<Map<String, dynamic>>> getIceServers() async {
649 2 : if (_turnServerCredentials == null) {
650 : try {
651 6 : _turnServerCredentials = await client.getTurnServer();
652 : } catch (e) {
653 0 : Logs().v('[VOIP] getTurnServerCredentials error => ${e.toString()}');
654 : }
655 : }
656 :
657 2 : if (_turnServerCredentials == null) {
658 0 : return [];
659 : }
660 :
661 2 : return [
662 2 : {
663 4 : 'username': _turnServerCredentials!.username,
664 4 : 'credential': _turnServerCredentials!.password,
665 4 : 'urls': _turnServerCredentials!.uris
666 : }
667 : ];
668 : }
669 :
670 : /// Make a P2P call to room
671 : ///
672 : /// Pretty important to set the userId, or all the users in the room get a call.
673 : /// Including your own other devices, so just set it to directChatMatrixId
674 : ///
675 : /// Setting the deviceId would make all other devices for that userId ignore the call
676 : /// Ideally only group calls would need setting both userId and deviceId to allow
677 : /// having 2 devices from the same user in a group call
678 : ///
679 : /// For p2p call, you want to have all the devices of the specified `userId` ring
680 2 : Future<CallSession> inviteToCall(
681 : Room room,
682 : CallType type, {
683 : String? userId,
684 : String? deviceId,
685 : }) async {
686 2 : final roomId = room.id;
687 2 : final callId = genCallID();
688 2 : if (currentGroupCID == null) {
689 4 : incomingCallRoomId[roomId] = callId;
690 : }
691 2 : final opts = CallOptions(
692 : callId: callId,
693 : type: type,
694 : dir: CallDirection.kOutgoing,
695 : room: room,
696 : voip: this,
697 2 : localPartyId: localPartyId,
698 2 : iceServers: await getIceServers(),
699 : );
700 2 : final newCall = createNewCall(opts);
701 :
702 2 : newCall.remoteUserId = userId;
703 2 : newCall.remoteDeviceId = deviceId;
704 :
705 4 : currentCID = VoipId(roomId: roomId, callId: callId);
706 6 : await newCall.initOutboundCall(type).then((_) {
707 4 : delegate.handleNewCall(newCall);
708 : });
709 : return newCall;
710 : }
711 :
712 2 : CallSession createNewCall(CallOptions opts) {
713 2 : final call = CallSession(opts);
714 12 : calls[VoipId(roomId: opts.room.id, callId: opts.callId)] = call;
715 : return call;
716 : }
717 :
718 : /// Create a new group call in an existing room.
719 : ///
720 : /// [groupCallId] The room id to call
721 : ///
722 : /// [application] normal group call, thrirdroom, etc
723 : ///
724 : /// [scope] room, between specifc users, etc.
725 0 : Future<GroupCallSession> _newGroupCall(
726 : String groupCallId,
727 : Room room,
728 : CallBackend backend,
729 : String? application,
730 : String? scope,
731 : ) async {
732 0 : if (getGroupCallById(room.id, groupCallId) != null) {
733 0 : Logs().v('[VOIP] [$groupCallId] already exists.');
734 0 : return getGroupCallById(room.id, groupCallId)!;
735 : }
736 :
737 0 : final groupCall = GroupCallSession(
738 : groupCallId: groupCallId,
739 0 : client: client,
740 : room: room,
741 : voip: this,
742 : backend: backend,
743 : application: application,
744 : scope: scope,
745 : );
746 :
747 0 : setGroupCallById(groupCall);
748 :
749 : return groupCall;
750 : }
751 :
752 : /// Create a new group call in an existing room.
753 : ///
754 : /// [groupCallId] The room id to call
755 : ///
756 : /// [application] normal group call, thrirdroom, etc
757 : ///
758 : /// [scope] room, between specifc users, etc.
759 :
760 0 : Future<GroupCallSession> fetchOrCreateGroupCall(
761 : String groupCallId,
762 : Room room,
763 : CallBackend backend,
764 : String? application,
765 : String? scope,
766 : ) async {
767 0 : final groupCall = getGroupCallById(room.id, groupCallId);
768 :
769 : if (groupCall != null) {
770 0 : if (!room.canJoinGroupCall) {
771 0 : throw Exception(
772 : 'User is not allowed to join famedly calls in the room');
773 : }
774 : return groupCall;
775 : }
776 :
777 0 : if (!room.groupCallsEnabledForEveryone) {
778 0 : await room.enableGroupCalls();
779 : }
780 :
781 : // The call doesn't exist, but we can create it
782 0 : return await _newGroupCall(
783 : groupCallId,
784 : room,
785 : backend,
786 : application,
787 : scope,
788 : );
789 : }
790 :
791 0 : GroupCallSession? getGroupCallById(String roomId, String groupCallId) {
792 0 : return groupCalls[VoipId(roomId: roomId, callId: groupCallId)];
793 : }
794 :
795 2 : void setGroupCallById(GroupCallSession groupCallSession) {
796 6 : groupCalls[VoipId(
797 4 : roomId: groupCallSession.room.id,
798 2 : callId: groupCallSession.groupCallId,
799 : )] = groupCallSession;
800 : }
801 :
802 : /// Create a new group call from a room state event.
803 2 : Future<void> createGroupCallFromRoomStateEvent(
804 : CallMembership membership, {
805 : bool emitHandleNewGroupCall = true,
806 : }) async {
807 2 : if (membership.isExpired) {
808 4 : Logs().d(
809 4 : 'Ignoring expired membership in passive groupCall creator. ${membership.toJson()}');
810 : return;
811 : }
812 :
813 6 : final room = client.getRoomById(membership.roomId);
814 :
815 : if (room == null) {
816 0 : Logs().w('Couldn\'t find room ${membership.roomId} for GroupCallSession');
817 : return;
818 : }
819 :
820 4 : if (membership.application != 'm.call' && membership.scope != 'm.room') {
821 0 : Logs().w('Received invalid group call application or scope.');
822 : return;
823 : }
824 :
825 2 : final groupCall = GroupCallSession(
826 2 : client: client,
827 : voip: this,
828 : room: room,
829 2 : backend: membership.backend,
830 2 : groupCallId: membership.callId,
831 2 : application: membership.application,
832 2 : scope: membership.scope,
833 : );
834 :
835 4 : if (groupCalls.containsKey(
836 6 : VoipId(roomId: membership.roomId, callId: membership.callId))) {
837 : return;
838 : }
839 :
840 2 : setGroupCallById(groupCall);
841 :
842 4 : onIncomingGroupCall.add(groupCall);
843 : if (emitHandleNewGroupCall) {
844 4 : await delegate.handleNewGroupCall(groupCall);
845 : }
846 : }
847 :
848 0 : @Deprecated('Call `hasActiveGroupCall` on the room directly instead')
849 0 : bool hasActiveCall(Room room) => room.hasActiveGroupCall;
850 : }
|