matrix-dart-sdk/lib/src/voip/voip.dart

952 lines
31 KiB
Dart

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_membership.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<VoipId, CallSession> get calls => _calls;
final Map<VoipId, CallSession> _calls = {};
Map<VoipId, GroupCallSession> get groupCalls => _groupCalls;
final Map<VoipId, GroupCallSession> _groupCalls = {};
/// The stream is used to prepare for incoming peer calls in a mesh call
/// For example, registering listeners
final CachedStreamController<CallSession> onIncomingCallSetup =
CachedStreamController();
/// The stream is used to signal the start of an incoming peer call in a mesh call
final CachedStreamController<CallSession> onIncomingCallStart =
CachedStreamController();
VoipId? currentCID;
VoipId? currentGroupCID;
String get localPartyId => currentSessionId;
final Client client;
final WebRTCDelegate delegate;
final StreamController<GroupCallSession> 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<String, String> get incomingCallRoomId => _incomingCallRoomId;
final Map<String, String> _incomingCallRoomId = {};
/// the current instance of voip, changing this will drop any ongoing mesh calls
/// with that sessionId
late String currentSessionId;
VoIP(
this.client,
this.delegate, {
this.enableSFUE2EEKeyRatcheting = false,
}) : super() {
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();
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;
Logs().v('[VOIP] onRoomState: type ${event.toJson()}');
final mems = event.room.getCallMembershipsFromEvent(event);
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<void> _handleCallEvents(List<BasicEventWithSender> callEvents) async {
// Call invites should be omitted for a call that is already answered,
// has ended, is rejectd or replaced.
final callEventsCopy = List<BasicEventWithSender>.from(callEvents);
for (final callEvent in callEventsCopy) {
final callId = callEvent.content.tryGet<String>('call_id');
if (CallConstants.callEndedEventTypes.contains(callEvent.type)) {
callEvents.removeWhere((event) {
if (CallConstants.omitWhenCallEndedTypes.contains(event.type) &&
event.content.tryGet<String>('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<int>('age') ??
(DateTime.now().millisecondsSinceEpoch -
callEvent.originServerTs.millisecondsSinceEpoch);
callEvents.removeWhere((element) {
if (callEvent.type == EventTypes.CallInvite &&
age >
(callEvent.content.tryGet<int>('lifetime') ??
CallTimeouts.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<void> _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;
/// this can also be sent in p2p calls when they want to call a specific device
remoteDeviceId = event.content.tryGet<String>('invitee_device_id');
} else if (event is ToDeviceEvent) {
final roomId = event.content.tryGet<String>('room_id');
final confId = event.content.tryGet<String>('conf_id');
/// to-device events specifically, m.call.invite and encryption key sending and requesting
remoteDeviceId = event.content.tryGet<String>('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 (!event.type.startsWith(EventTypes.GroupCallMemberEncryptionKeys)) {
// livekit calls have their own session deduplication logic so ignore sessionId deduplication for them
final destSessionId = event.content.tryGet<String>('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;
}
final content = event.content;
if (room == null) {
Logs().w(
'[VOIP] _handleCallEvent call event does not contain a room_id, ignoring',
);
return;
} else if (client.userID != null &&
client.deviceID != null &&
remoteUserId == client.userID &&
remoteDeviceId == client.deviceID) {
Logs().v(
'Ignoring call event ${event.type} for room ${room.id} from our own device',
);
return;
} else if (!event.type
.startsWith(EventTypes.GroupCallMemberEncryptionKeys)) {
// 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 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;
}
}
Future<void> _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<void> onCallInvite(
Room room,
String remoteUserId,
String? remoteDeviceId,
Map<String, dynamic> 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<void> onCallAnswer(
Room room,
String remoteUserId,
String? remoteDeviceId,
Map<String, dynamic> 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<void> onCallCandidates(Room room, Map<String, dynamic> 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<void> onCallHangup(Room room, Map<String, dynamic> 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<void> onCallReject(Room room, Map<String, dynamic> 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<void> onCallSelectAnswer(
Room room,
Map<String, dynamic> 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<void> onSDPStreamMetadataChangedReceived(
Room room,
Map<String, dynamic> 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<void> onAssertedIdentityReceived(
Room room,
Map<String, dynamic> 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<void> onCallNegotiate(Room room, Map<String, dynamic> 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);
}
}
}
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<List<Map<String, dynamic>>> 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<CallSession> 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((_) {
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<GroupCallSession> _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<GroupCallSession> 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<void> createGroupCallFromRoomStateEvent(
CallMembership membership, {
bool emitHandleNewGroupCall = true,
}) async {
if (membership.isExpired) {
Logs().d(
'Ignoring expired membership in passive groupCall creator. ${membership.toJson()}',
);
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;
}