import 'dart:async'; import 'dart:convert'; import 'dart:core'; import 'package:collection/collection.dart'; import 'package:sdp_transform/sdp_transform.dart' as sdp_transform; import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/utils/crypto/crypto.dart'; import 'package:matrix/src/voip/models/call_options.dart'; import 'package:matrix/src/voip/models/voip_id.dart'; import 'package:matrix/src/voip/utils/stream_helper.dart'; /// The parent highlevel voip class, this trnslates matrix events to webrtc methods via /// `CallSession` or `GroupCallSession` methods class VoIP { // used only for internal tests, all txids for call events will be overwritten to this static String? customTxid; /// set to true if you want to use the ratcheting mechanism with your keyprovider /// remember to set the window size correctly on your keyprovider /// /// at client level because reinitializing a `GroupCallSession` and its `KeyProvider` /// everytime this changed would be a pain final bool enableSFUE2EEKeyRatcheting; /// cached turn creds TurnServerCredentials? _turnServerCredentials; Map get calls => _calls; final Map _calls = {}; Map get groupCalls => _groupCalls; final Map _groupCalls = {}; /// The stream is used to prepare for incoming peer calls in a mesh call /// For example, registering listeners final CachedStreamController onIncomingCallSetup = CachedStreamController(); /// The stream is used to signal the start of an incoming peer call in a mesh call final CachedStreamController onIncomingCallStart = CachedStreamController(); VoipId? currentCID; VoipId? currentGroupCID; String get localPartyId => currentSessionId; final Client client; final WebRTCDelegate delegate; final StreamController onIncomingGroupCall = StreamController(); CallParticipant? get localParticipant => client.isLogged() ? CallParticipant( this, userId: client.userID!, deviceId: client.deviceID, ) : null; /// map of roomIds to the invites they are currently processing or in a call with /// used for handling glare in p2p calls Map get incomingCallRoomId => _incomingCallRoomId; final Map _incomingCallRoomId = {}; /// the current instance of voip, changing this will drop any ongoing mesh calls /// with that sessionId late String currentSessionId; /// the following parameters are only used in livekit calls, but cannot be /// in the LivekitBackend class because that could be created from a pre-existing state event /// controls how many key indices can you have before looping back to index 0 /// only used in livekit calls final int keyRingSize; // default values set in super constructor CallTimeouts? timeouts; VoIP( this.client, this.delegate, { this.enableSFUE2EEKeyRatcheting = false, this.keyRingSize = 16, this.timeouts, }) : super() { timeouts ??= CallTimeouts(); currentSessionId = base64Encode(secureRandomBytes(16)); Logs().v('set currentSessionId to $currentSessionId'); // to populate groupCalls with already present calls for (final room in client.rooms) { final memsList = room.getCallMembershipsFromRoom(this); for (final mems in memsList.values) { for (final mem in mems) { unawaited(createGroupCallFromRoomStateEvent(mem)); } } } /// handles events todevice and matrix events for invite, candidates, hangup, etc. client.onCallEvents.stream.listen((events) async { await _handleCallEvents(events); }); // handles the com.famedly.call events. client.onRoomState.stream.listen( (update) async { final event = update.state; if (event is! Event) return; if (event.room.membership != Membership.join) return; if (event.type != EventTypes.GroupCallMember) return; final mems = event.room.getCallMembershipsFromEvent(event, this); for (final mem in mems) { unawaited(createGroupCallFromRoomStateEvent(mem)); } for (final map in groupCalls.entries) { if (map.key.roomId == event.room.id) { // because we don't know which call got updated, just update all // group calls we have entered for that room await map.value.onMemberStateChanged(); } } }, ); delegate.mediaDevices.ondevicechange = _onDeviceChange; } Future _handleCallEvents(List callEvents) async { // Call invites should be omitted for a call that is already answered, // has ended, is rejectd or replaced. final callEventsCopy = List.from(callEvents); for (final callEvent in callEventsCopy) { final callId = callEvent.content.tryGet('call_id'); if (CallConstants.callEndedEventTypes.contains(callEvent.type)) { callEvents.removeWhere((event) { if (CallConstants.omitWhenCallEndedTypes.contains(event.type) && event.content.tryGet('call_id') == callId) { Logs().v( 'Ommit "${event.type}" event for an already terminated call', ); return true; } return false; }); } // checks for ended events and removes invites for that call id. if (callEvent is Event) { // removes expired invites final age = callEvent.unsigned?.tryGet('age') ?? (DateTime.now().millisecondsSinceEpoch - callEvent.originServerTs.millisecondsSinceEpoch); callEvents.removeWhere((element) { if (callEvent.type == EventTypes.CallInvite && age > (callEvent.content.tryGet('lifetime') ?? timeouts!.callInviteLifetime.inMilliseconds)) { Logs().w( '[VOIP] Ommiting invite event ${callEvent.eventId} as age was older than lifetime', ); return true; } return false; }); } } // and finally call the respective methods on the clean callEvents list for (final callEvent in callEvents) { await _handleCallEvent(callEvent); } } Future _handleCallEvent(BasicEventWithSender event) async { // member event updates handled in onRoomState for ease if (event.type == EventTypes.GroupCallMember) return; GroupCallSession? groupCallSession; Room? room; final remoteUserId = event.senderId; String? remoteDeviceId; if (event is Event) { room = event.room; if (event.type == EventTypes.GroupCallMemberReaction) { final isEphemeral = event.content.tryGet('is_ephemeral')!; if (isEphemeral && event.originServerTs.isBefore( DateTime.now().subtract(CallConstants.ephemeralReactionTimeout), )) { Logs().d( '[VOIP] Ignoring ephemeral group call emoji reaction event of type ${event.type} because it is older than ${CallConstants.ephemeralReactionTimeout}', ); return; } // well this is a bit of a mess, but we use normal Events for reactions, // therefore have to setup the deviceId here remoteDeviceId = event.content.tryGet('device_id'); } else { /// this can also be sent in p2p calls when they want to call a specific device remoteDeviceId = event.content.tryGet('invitee_device_id'); } } else if (event is ToDeviceEvent) { final roomId = event.content.tryGet('room_id'); final confId = event.content.tryGet('conf_id'); /// to-device events specifically, m.call.invite and encryption key sending and requesting remoteDeviceId = event.content.tryGet('device_id'); if (roomId != null && confId != null) { room = client.getRoomById(roomId); groupCallSession = groupCalls[VoipId(roomId: roomId, callId: confId)]; } else { Logs().w( '[VOIP] Ignoring to_device event of type ${event.type} but did not find group call for id: $confId', ); return; } if (!{ EventTypes.GroupCallMemberEncryptionKeys, EventTypes.GroupCallMemberEncryptionKeysRequest, }.contains(event.type)) { // livekit calls have their own session deduplication logic so ignore sessionId deduplication for them final destSessionId = event.content.tryGet('dest_session_id'); if (destSessionId != currentSessionId) { Logs().w( '[VOIP] Ignoring to_device event of type ${event.type} did not match currentSessionId: $currentSessionId, dest_session_id was set to $destSessionId', ); return; } } else if (groupCallSession == null || remoteDeviceId == null) { Logs().w( '[VOIP] _handleCallEvent ${event.type} recieved but either groupCall ${groupCallSession?.groupCallId} or deviceId $remoteDeviceId was null, ignoring', ); return; } } else { Logs().w( '[VOIP] _handleCallEvent can only handle Event or ToDeviceEvent, it got ${event.runtimeType}', ); return; } if (room == null) { Logs().w( '[VOIP] _handleCallEvent call event does not contain a room_id, ignoring', ); return; } final content = event.content; if (client.userID != null && client.deviceID != null && remoteUserId == client.userID && remoteDeviceId == client.deviceID) { // We don't want to ignore group call reactions from our own device because // we want to show them on the UI if (!{EventTypes.GroupCallMemberReaction}.contains(event.type)) { Logs().v( 'Ignoring call event ${event.type} for room ${room.id} from our own device', ); return; } } else if (!{ EventTypes.GroupCallMemberEncryptionKeys, EventTypes.GroupCallMemberEncryptionKeysRequest, EventTypes.GroupCallMemberReaction, EventTypes.Redaction, }.contains(event.type)) { // skip webrtc event checks on encryption_keys final callId = content['call_id'] as String?; final partyId = content['party_id'] as String?; if (callId == null && event.type.startsWith('m.call')) { Logs().w('Ignoring call event ${event.type} because call_id was null'); return; } if (callId != null) { final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call == null && !{EventTypes.CallInvite, EventTypes.GroupCallMemberInvite} .contains(event.type)) { Logs().w( 'Ignoring call event ${event.type} for room ${room.id} because we do not have the call', ); return; } else if (call != null) { // multiple checks to make sure the events sent are from the expected party if (call.room.id != room.id) { Logs().w( 'Ignoring call event ${event.type} for room ${room.id} claiming to be for call in room ${call.room.id}', ); return; } if (call.remoteUserId != null && call.remoteUserId != remoteUserId) { Logs().d( 'Ignoring call event ${event.type} for room ${room.id} from sender $remoteUserId, expected sender: ${call.remoteUserId}', ); return; } if (call.remotePartyId != null && call.remotePartyId != partyId) { Logs().w( 'Ignoring call event ${event.type} for room ${room.id} from sender with a different party_id $partyId, expected party_id: ${call.remotePartyId}', ); return; } if ((call.remotePartyId != null && call.remotePartyId == localPartyId)) { Logs().v( 'Ignoring call event ${event.type} for room ${room.id} from our own partyId', ); return; } } } } Logs().v( '[VOIP] Handling event of type: ${event.type}, content ${event.content} from sender ${event.senderId} rp: $remoteUserId:$remoteDeviceId', ); switch (event.type) { case EventTypes.CallInvite: case EventTypes.GroupCallMemberInvite: await onCallInvite(room, remoteUserId, remoteDeviceId, content); break; case EventTypes.CallAnswer: case EventTypes.GroupCallMemberAnswer: await onCallAnswer(room, remoteUserId, remoteDeviceId, content); break; case EventTypes.CallCandidates: case EventTypes.GroupCallMemberCandidates: await onCallCandidates(room, content); break; case EventTypes.CallHangup: case EventTypes.GroupCallMemberHangup: await onCallHangup(room, content); break; case EventTypes.CallReject: case EventTypes.GroupCallMemberReject: await onCallReject(room, content); break; case EventTypes.CallNegotiate: case EventTypes.GroupCallMemberNegotiate: await onCallNegotiate(room, content); break; // case EventTypes.CallReplaces: // await onCallReplaces(room, content); // break; case EventTypes.CallSelectAnswer: case EventTypes.GroupCallMemberSelectAnswer: await onCallSelectAnswer(room, content); break; case EventTypes.CallSDPStreamMetadataChanged: case EventTypes.CallSDPStreamMetadataChangedPrefix: case EventTypes.GroupCallMemberSDPStreamMetadataChanged: await onSDPStreamMetadataChangedReceived(room, content); break; case EventTypes.CallAssertedIdentity: case EventTypes.CallAssertedIdentityPrefix: case EventTypes.GroupCallMemberAssertedIdentity: await onAssertedIdentityReceived(room, content); break; case EventTypes.GroupCallMemberEncryptionKeys: await groupCallSession!.backend.onCallEncryption( groupCallSession, remoteUserId, remoteDeviceId!, content, ); break; case EventTypes.GroupCallMemberEncryptionKeysRequest: await groupCallSession!.backend.onCallEncryptionKeyRequest( groupCallSession, remoteUserId, remoteDeviceId!, content, ); break; case EventTypes.GroupCallMemberReaction: await _handleReactionEvent(room, event as MatrixEvent); break; case EventTypes.Redaction: await _handleRedactionEvent(room, event); break; } } Future _onDeviceChange(dynamic _) async { Logs().v('[VOIP] _onDeviceChange'); for (final call in calls.values) { if (call.state == CallState.kConnected && !call.isGroupCall) { await call.updateMediaDeviceForCall(); } } for (final groupCall in groupCalls.values) { if (groupCall.state == GroupCallState.entered) { await groupCall.backend.updateMediaDeviceForCalls(); } } } Future onCallInvite( Room room, String remoteUserId, String? remoteDeviceId, Map content, ) async { Logs().v( '[VOIP] onCallInvite $remoteUserId:$remoteDeviceId => ${client.userID}:${client.deviceID}, \ncontent => ${content.toString()}', ); final String callId = content['call_id']; final int lifetime = content['lifetime']; final String? confId = content['conf_id']; final call = calls[VoipId(roomId: room.id, callId: callId)]; Logs().d( '[glare] got new call ${content.tryGet('call_id')} and currently room id is mapped to ${incomingCallRoomId.tryGet(room.id)}', ); if (call != null && call.state != CallState.kEnded) { // Session already exist. Logs().v('[VOIP] onCallInvite: Session [$callId] already exist.'); return; } final inviteeUserId = content['invitee']; if (inviteeUserId != null && inviteeUserId != localParticipant?.userId) { Logs().w('[VOIP] Ignoring call, meant for user $inviteeUserId'); return; // This invite was meant for another user in the room } final inviteeDeviceId = content['invitee_device_id']; if (inviteeDeviceId != null && inviteeDeviceId != localParticipant?.deviceId) { Logs().w('[VOIP] Ignoring call, meant for device $inviteeDeviceId'); return; // This invite was meant for another device in the room } if (content['capabilities'] != null) { final capabilities = CallCapabilities.fromJson(content['capabilities']); Logs().v( '[VOIP] CallCapabilities: dtmf => ${capabilities.dtmf}, transferee => ${capabilities.transferee}', ); } var callType = CallType.kVoice; SDPStreamMetadata? sdpStreamMetadata; if (content[sdpStreamMetadataKey] != null) { sdpStreamMetadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]); sdpStreamMetadata.sdpStreamMetadatas .forEach((streamId, SDPStreamPurpose purpose) { Logs().v( '[VOIP] [$streamId] => purpose: ${purpose.purpose}, audioMuted: ${purpose.audio_muted}, videoMuted: ${purpose.video_muted}', ); if (!purpose.video_muted) { callType = CallType.kVideo; } }); } else { callType = getCallType(content['offer']['sdp']); } final opts = CallOptions( voip: this, callId: callId, groupCallId: confId, dir: CallDirection.kIncoming, type: callType, room: room, localPartyId: localPartyId, iceServers: await getIceServers(), ); final newCall = createNewCall(opts); /// both invitee userId and deviceId are set here because there can be /// multiple devices from same user in a call, so we specifiy who the /// invite is for newCall.remoteUserId = remoteUserId; newCall.remoteDeviceId = remoteDeviceId; newCall.remotePartyId = content['party_id']; newCall.remoteSessionId = content['sender_session_id']; // newCall.remoteSessionId = remoteParticipant.sessionId; if (!delegate.canHandleNewCall && (confId == null || currentGroupCID != VoipId(roomId: room.id, callId: confId))) { Logs().v( '[VOIP] onCallInvite: Unable to handle new calls, maybe user is busy.', ); // no need to emit here because handleNewCall was never triggered yet await newCall.reject(reason: CallErrorCode.userBusy, shouldEmit: false); await delegate.handleMissedCall(newCall); return; } final offer = RTCSessionDescription( content['offer']['sdp'], content['offer']['type'], ); /// play ringtone. We decided to play the ringtone before adding the call to /// the incoming call stream because getUserMedia from initWithInvite fails /// on firefox unless the tab is in focus. We should atleast be able to notify /// the user about an incoming call /// /// Autoplay on firefox still needs interaction, without which all notifications /// could be blocked. if (confId == null) { await delegate.playRingtone(); } // When getUserMedia throws an exception, we handle it by terminating the call, // and all this happens inside initWithInvite. If we set currentCID after // initWithInvite, we might set it to callId even after it was reset to null // by terminate. currentCID = VoipId(roomId: room.id, callId: callId); if (confId == null) { await delegate.registerListeners(newCall); } else { onIncomingCallSetup.add(newCall); } await newCall.initWithInvite( callType, offer, sdpStreamMetadata, lifetime, confId != null, ); // Popup CallingPage for incoming call. if (confId == null && !newCall.callHasEnded) { await delegate.handleNewCall(newCall); } if (confId != null) { onIncomingCallStart.add(newCall); } } Future onCallAnswer( Room room, String remoteUserId, String? remoteDeviceId, Map content, ) async { Logs().v('[VOIP] onCallAnswer => ${content.toString()}'); final String callId = content['call_id']; final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { if (!call.answeredByUs) { await delegate.stopRingtone(); } if (call.state == CallState.kRinging) { await call.onAnsweredElsewhere(); } if (call.room.id != room.id) { Logs().w( 'Ignoring call answer for room ${room.id} claiming to be for call in room ${call.room.id}', ); return; } if (call.remoteUserId == null) { Logs().i( '[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', ); call.remoteUserId = remoteUserId; } if (call.remoteDeviceId == null) { Logs().i( '[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', ); call.remoteDeviceId = remoteDeviceId; } if (call.remotePartyId != null) { Logs().d( 'Ignoring call answer from party ${content['party_id']}, we are already with ${call.remotePartyId}', ); return; } else { call.remotePartyId = content['party_id']; } final answer = RTCSessionDescription( content['answer']['sdp'], content['answer']['type'], ); SDPStreamMetadata? metadata; if (content[sdpStreamMetadataKey] != null) { metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]); } await call.onAnswerReceived(answer, metadata); } else { Logs().v('[VOIP] onCallAnswer: Session [$callId] not found!'); } } Future onCallCandidates(Room room, Map content) async { Logs().v('[VOIP] onCallCandidates => ${content.toString()}'); final String callId = content['call_id']; final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { await call.onCandidatesReceived(content['candidates']); } else { Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!'); } } Future onCallHangup(Room room, Map content) async { // stop play ringtone, if this is an incoming call await delegate.stopRingtone(); Logs().v('[VOIP] onCallHangup => ${content.toString()}'); final String callId = content['call_id']; final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { // hangup in any case, either if the other party hung up or we did on another device await call.terminate( CallParty.kRemote, CallErrorCode.values.firstWhereOrNull( (element) => element.reason == content['reason'], ) ?? CallErrorCode.userHangup, true, ); } else { Logs().v('[VOIP] onCallHangup: Session [$callId] not found!'); } if (callId == currentCID?.callId) { currentCID = null; } } Future onCallReject(Room room, Map content) async { final String callId = content['call_id']; Logs().d('Reject received for call ID $callId'); final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { await call.onRejectReceived( CallErrorCode.values.firstWhereOrNull( (element) => element.reason == content['reason'], ) ?? CallErrorCode.userHangup, ); } else { Logs().v('[VOIP] onCallReject: Session [$callId] not found!'); } } Future onCallSelectAnswer( Room room, Map content, ) async { final String callId = content['call_id']; Logs().d('SelectAnswer received for call ID $callId'); final String selectedPartyId = content['selected_party_id']; final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { if (call.room.id != room.id) { Logs().w( 'Ignoring call select answer for room ${room.id} claiming to be for call in room ${call.room.id}', ); return; } await call.onSelectAnswerReceived(selectedPartyId); } } Future onSDPStreamMetadataChangedReceived( Room room, Map content, ) async { final String callId = content['call_id']; Logs().d('SDP Stream metadata received for call ID $callId'); final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { if (content[sdpStreamMetadataKey] == null) { Logs().d('SDP Stream metadata is null'); return; } await call.onSDPStreamMetadataReceived( SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]), ); } } Future onAssertedIdentityReceived( Room room, Map content, ) async { final String callId = content['call_id']; Logs().d('Asserted identity received for call ID $callId'); final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { if (content['asserted_identity'] == null) { Logs().d('asserted_identity is null '); return; } call.onAssertedIdentityReceived( AssertedIdentity.fromJson(content['asserted_identity']), ); } } Future onCallNegotiate(Room room, Map content) async { final String callId = content['call_id']; Logs().d('Negotiate received for call ID $callId'); final call = calls[VoipId(roomId: room.id, callId: callId)]; if (call != null) { // ideally you also check the lifetime here and discard negotiation events // if age of the event was older than the lifetime but as to device events // do not have a unsigned age nor a origin_server_ts there's no easy way to // override this one function atm final description = content['description']; try { SDPStreamMetadata? metadata; if (content[sdpStreamMetadataKey] != null) { metadata = SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]); } await call.onNegotiateReceived( metadata, RTCSessionDescription(description['sdp'], description['type']), ); } catch (e, s) { Logs().e('[VOIP] Failed to complete negotiation', e, s); } } } Future _handleReactionEvent( Room room, MatrixEvent event, ) async { final content = event.content; final callId = content.tryGet('call_id'); if (callId == null) { Logs().w( '[VOIP] _handleReactionEvent: No call ID found in reaction content', ); return; } final groupCall = groupCalls[VoipId(roomId: room.id, callId: callId)]; if (groupCall == null) { Logs().w( '[VOIP] _handleReactionEvent: No respective group call found for room ${room.id}, call ID $callId', ); return; } final membershipEventId = content .tryGetMap('m.relates_to') ?.tryGet('event_id'); final deviceId = content.tryGet('device_id'); if (membershipEventId == null || deviceId == null) { Logs().w( '[VOIP] _handleReactionEvent: No event ID or device ID found in reaction content', ); return; } final reactionKey = content.tryGet('key'); final isEphemeral = content.tryGet('is_ephemeral') ?? false; if (reactionKey == null) { Logs().w( '[VOIP] _handleReactionEvent: No reaction key found in reaction content', ); return; } final memberships = room.getCallMembershipsForUser(event.senderId, deviceId, this); final membership = memberships.firstWhereOrNull( (m) => m.callId == callId && m.application == 'm.call' && m.scope == 'm.room', ); if (membership == null || membership.isExpired) { Logs().w( '[VOIP] _handleReactionEvent: No matching membership found or found expired for reaction from ${event.senderId}', ); return; } if (membership.eventId != membershipEventId) { Logs().w( '[VOIP] _handleReactionEvent: Event ID mismatch, ignoring reaction on old event from ${event.senderId}', ); return; } final participant = CallParticipant( this, userId: event.senderId, deviceId: deviceId, ); final reaction = CallReactionAddedEvent( participant: participant, reactionKey: reactionKey, membershipEventId: membershipEventId, reactionEventId: event.eventId, isEphemeral: isEphemeral, ); groupCall.matrixRTCEventStream.add(reaction); Logs().d( '[VOIP] _handleReactionEvent: Sent reaction event: $reaction', ); } Future _handleRedactionEvent( Room room, BasicEventWithSender event, ) async { final content = event.content; if (content.tryGet('redacts_type') != EventTypes.GroupCallMemberReaction) { // ignore it Logs().v( '[_handleRedactionEvent] Ignoring redaction event ${event.toJson()} because not a call reaction redaction', ); return; } final redactedEventId = content.tryGet('redacts'); if (redactedEventId == null) { Logs().v( '[VOIP] _handleRedactionEvent: Missing sender or redacted event ID', ); return; } final deviceId = event.content.tryGet('device_id'); if (deviceId == null) { Logs().w( '[VOIP] _handleRedactionEvent: Could not find device_id in redacted event $redactedEventId', ); return; } Logs().d( '[VOIP] _handleRedactionEvent: Device ID from redacted event: $deviceId', ); // Route to all active group calls in the room final groupCall = groupCalls.values.firstWhereOrNull( (m) => m.room.id == room.id && m.state == GroupCallState.entered && m.groupCallId == event.content.tryGet('call_id') && m.application == 'm.call' && m.scope == 'm.room', ); if (groupCall == null) { Logs().w( '[_handleRedactionEvent] could not find group call for event ${event.toJson()}', ); return; } final participant = CallParticipant( this, userId: event.senderId, deviceId: deviceId, ); // We don't know the specific reaction key from redaction events // The listeners can filter based on their current state final reactionEvent = CallReactionRemovedEvent( participant: participant, redactedEventId: redactedEventId, ); groupCall.matrixRTCEventStream.add(reactionEvent); } CallType getCallType(String sdp) { try { final session = sdp_transform.parse(sdp); if (session['media'].indexWhere((e) => e['type'] == 'video') != -1) { return CallType.kVideo; } } catch (e, s) { Logs().e('[VOIP] Failed to getCallType', e, s); } return CallType.kVoice; } Future>> getIceServers() async { if (_turnServerCredentials == null) { try { _turnServerCredentials = await client.getTurnServer(); } catch (e) { Logs().v('[VOIP] getTurnServerCredentials error => ${e.toString()}'); } } if (_turnServerCredentials == null) { return []; } return [ { 'username': _turnServerCredentials!.username, 'credential': _turnServerCredentials!.password, 'urls': _turnServerCredentials!.uris, } ]; } /// Make a P2P call to room /// /// Pretty important to set the userId, or all the users in the room get a call. /// Including your own other devices, so just set it to directChatMatrixId /// /// Setting the deviceId would make all other devices for that userId ignore the call /// Ideally only group calls would need setting both userId and deviceId to allow /// having 2 devices from the same user in a group call /// /// For p2p call, you want to have all the devices of the specified `userId` ring Future inviteToCall( Room room, CallType type, { String? userId, String? deviceId, }) async { final roomId = room.id; final callId = genCallID(); if (currentGroupCID == null) { incomingCallRoomId[roomId] = callId; } final opts = CallOptions( callId: callId, type: type, dir: CallDirection.kOutgoing, room: room, voip: this, localPartyId: localPartyId, iceServers: await getIceServers(), ); final newCall = createNewCall(opts); newCall.remoteUserId = userId; newCall.remoteDeviceId = deviceId; await delegate.registerListeners(newCall); currentCID = VoipId(roomId: roomId, callId: callId); await newCall.initOutboundCall(type).then((_) { unawaited(delegate.handleNewCall(newCall)); }); return newCall; } CallSession createNewCall(CallOptions opts) { final call = CallSession(opts); calls[VoipId(roomId: opts.room.id, callId: opts.callId)] = call; return call; } /// Create a new group call in an existing room. /// /// [groupCallId] The room id to call /// /// [application] normal group call, thrirdroom, etc /// /// [scope] room, between specifc users, etc. Future _newGroupCall( String groupCallId, Room room, CallBackend backend, String? application, String? scope, ) async { if (getGroupCallById(room.id, groupCallId) != null) { Logs().v('[VOIP] [$groupCallId] already exists.'); return getGroupCallById(room.id, groupCallId)!; } final groupCall = GroupCallSession( groupCallId: groupCallId, client: client, room: room, voip: this, backend: backend, application: application, scope: scope, ); setGroupCallById(groupCall); return groupCall; } /// Create a new group call in an existing room. /// /// [groupCallId] The room id to call /// /// [application] normal group call, thrirdroom, etc /// /// [scope] room, between specifc users, etc. /// /// [preShareKey] for livekit calls it creates and shares a key with other /// participants in the call without entering, useful on onboarding screens. /// does not do anything in mesh calls Future fetchOrCreateGroupCall( String groupCallId, Room room, CallBackend backend, String? application, String? scope, { bool preShareKey = true, }) async { // somehow user were mising their powerlevels events and got stuck // with the exception below, this part just makes sure importantStateEvents // does not cause it. await room.postLoad(); if (!room.groupCallsEnabledForEveryone) { await room.enableGroupCalls(); } if (!room.canJoinGroupCall) { throw MatrixSDKVoipException( ''' User ${client.userID}:${client.deviceID} is not allowed to join famedly calls in room ${room.id}, canJoinGroupCall: ${room.canJoinGroupCall}, groupCallsEnabledForEveryone: ${room.groupCallsEnabledForEveryone}, needed: ${room.powerForChangingStateEvent(EventTypes.GroupCallMember)}, own: ${room.ownPowerLevel}} plMap: ${room.getState(EventTypes.RoomPowerLevels)?.content} ''', ); } GroupCallSession? groupCall = getGroupCallById(room.id, groupCallId); groupCall ??= await _newGroupCall( groupCallId, room, backend, application, scope, ); if (preShareKey) { await groupCall.backend.preShareKey(groupCall); } return groupCall; } GroupCallSession? getGroupCallById(String roomId, String groupCallId) { return groupCalls[VoipId(roomId: roomId, callId: groupCallId)]; } void setGroupCallById(GroupCallSession groupCallSession) { groupCalls[VoipId( roomId: groupCallSession.room.id, callId: groupCallSession.groupCallId, )] = groupCallSession; } /// Create a new group call from a room state event. Future createGroupCallFromRoomStateEvent( CallMembership membership, { bool emitHandleNewGroupCall = true, }) async { if (membership.isExpired) return; final room = client.getRoomById(membership.roomId); if (room == null) { Logs().w('Couldn\'t find room ${membership.roomId} for GroupCallSession'); return; } if (membership.application != 'm.call' && membership.scope != 'm.room') { Logs().w('Received invalid group call application or scope.'); return; } final groupCall = GroupCallSession( client: client, voip: this, room: room, backend: membership.backend, groupCallId: membership.callId, application: membership.application, scope: membership.scope, ); if (groupCalls.containsKey( VoipId(roomId: membership.roomId, callId: membership.callId), )) { return; } setGroupCallById(groupCall); onIncomingGroupCall.add(groupCall); if (emitHandleNewGroupCall) { await delegate.handleNewGroupCall(groupCall); } } @Deprecated('Call `hasActiveGroupCall` on the room directly instead') bool hasActiveCall(Room room) => room.hasActiveGroupCall(this); }