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