779 lines
25 KiB
Dart
779 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:core';
|
|
|
|
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';
|
|
|
|
/// Delegate WebRTC basic functionality.
|
|
abstract class WebRTCDelegate {
|
|
MediaDevices get mediaDevices;
|
|
Future<RTCPeerConnection> createPeerConnection(
|
|
Map<String, dynamic> configuration,
|
|
[Map<String, dynamic> constraints = const {}]);
|
|
VideoRenderer createRenderer();
|
|
Future<void> playRingtone();
|
|
Future<void> stopRingtone();
|
|
Future<void> handleNewCall(CallSession session);
|
|
Future<void> handleCallEnded(CallSession session);
|
|
Future<void> handleMissedCall(CallSession session);
|
|
Future<void> handleNewGroupCall(GroupCall groupCall);
|
|
Future<void> handleGroupCallEnded(GroupCall groupCall);
|
|
bool get isWeb;
|
|
|
|
/// This should be set to false if any calls in the client are in kConnected
|
|
/// state. If another room tries to call you during a connected call this fires
|
|
/// a handleMissedCall
|
|
bool get canHandleNewCall => true;
|
|
}
|
|
|
|
class VoIP {
|
|
TurnServerCredentials? _turnServerCredentials;
|
|
Map<String, CallSession> calls = <String, CallSession>{};
|
|
Map<String, GroupCall> groupCalls = <String, GroupCall>{};
|
|
final CachedStreamController<CallSession> onIncomingCall =
|
|
CachedStreamController();
|
|
String? currentCID;
|
|
String? currentGroupCID;
|
|
String? get localPartyId => client.deviceID;
|
|
final Client client;
|
|
final WebRTCDelegate delegate;
|
|
final StreamController<GroupCall> onIncomingGroupCall = StreamController();
|
|
void _handleEvent(
|
|
Event event,
|
|
Function(String roomId, String senderId, Map<String, dynamic> content)
|
|
func) =>
|
|
func(event.roomId!, event.senderId, event.content);
|
|
Map<String, String> incomingCallRoomId = {};
|
|
|
|
VoIP(this.client, this.delegate) : super() {
|
|
// to populate groupCalls with already present calls
|
|
for (final room in client.rooms) {
|
|
if (room.activeGroupCallEvents.isNotEmpty) {
|
|
for (final groupCall in room.activeGroupCallEvents) {
|
|
createGroupCallFromRoomStateEvent(groupCall,
|
|
emitHandleNewGroupCall: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
client.onCallInvite.stream
|
|
.listen((event) => _handleEvent(event, onCallInvite));
|
|
client.onCallAnswer.stream
|
|
.listen((event) => _handleEvent(event, onCallAnswer));
|
|
client.onCallCandidates.stream
|
|
.listen((event) => _handleEvent(event, onCallCandidates));
|
|
client.onCallHangup.stream
|
|
.listen((event) => _handleEvent(event, onCallHangup));
|
|
client.onCallReject.stream
|
|
.listen((event) => _handleEvent(event, onCallReject));
|
|
client.onCallNegotiate.stream
|
|
.listen((event) => _handleEvent(event, onCallNegotiate));
|
|
client.onCallReplaces.stream
|
|
.listen((event) => _handleEvent(event, onCallReplaces));
|
|
client.onCallSelectAnswer.stream
|
|
.listen((event) => _handleEvent(event, onCallSelectAnswer));
|
|
client.onSDPStreamMetadataChangedReceived.stream.listen(
|
|
(event) => _handleEvent(event, onSDPStreamMetadataChangedReceived));
|
|
client.onAssertedIdentityReceived.stream
|
|
.listen((event) => _handleEvent(event, onAssertedIdentityReceived));
|
|
|
|
client.onRoomState.stream.listen(
|
|
(event) async {
|
|
if ([
|
|
EventTypes.GroupCallPrefix,
|
|
EventTypes.GroupCallMemberPrefix,
|
|
].contains(event.type)) {
|
|
Logs().v('[VOIP] onRoomState: type ${event.toJson()}.');
|
|
await onRoomStateChanged(event);
|
|
}
|
|
},
|
|
);
|
|
|
|
client.onToDeviceEvent.stream.listen((event) async {
|
|
Logs().v('[VOIP] onToDeviceEvent: type ${event.toJson()}.');
|
|
|
|
if (event.type == 'org.matrix.call_duplicate_session') {
|
|
Logs().v('[VOIP] onToDeviceEvent: duplicate session.');
|
|
return;
|
|
}
|
|
|
|
final confId = event.content['conf_id'];
|
|
final groupCall = groupCalls[confId];
|
|
if (groupCall == null) {
|
|
Logs().d('[VOIP] onToDeviceEvent: groupCall is null.');
|
|
return;
|
|
}
|
|
final roomId = groupCall.room.id;
|
|
final senderId = event.senderId;
|
|
final content = event.content;
|
|
switch (event.type) {
|
|
case EventTypes.CallInvite:
|
|
await onCallInvite(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallAnswer:
|
|
await onCallAnswer(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallCandidates:
|
|
await onCallCandidates(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallHangup:
|
|
await onCallHangup(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallReject:
|
|
await onCallReject(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallNegotiate:
|
|
await onCallNegotiate(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallReplaces:
|
|
await onCallReplaces(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallSelectAnswer:
|
|
await onCallSelectAnswer(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallSDPStreamMetadataChanged:
|
|
case EventTypes.CallSDPStreamMetadataChangedPrefix:
|
|
await onSDPStreamMetadataChangedReceived(roomId, senderId, content);
|
|
break;
|
|
case EventTypes.CallAssertedIdentity:
|
|
await onAssertedIdentityReceived(roomId, senderId, content);
|
|
break;
|
|
}
|
|
});
|
|
|
|
delegate.mediaDevices.ondevicechange = _onDeviceChange;
|
|
}
|
|
|
|
Future<void> _onDeviceChange(dynamic _) async {
|
|
Logs().v('[VOIP] _onDeviceChange');
|
|
for (final call in calls.values) {
|
|
if (call.state == CallState.kConnected && !call.isGroupCall) {
|
|
await call.updateAudioDevice();
|
|
}
|
|
}
|
|
for (final groupCall in groupCalls.values) {
|
|
if (groupCall.state == GroupCallState.Entered) {
|
|
await groupCall.updateAudioDevice();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> onCallInvite(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
|
|
Logs().v(
|
|
'[VOIP] onCallInvite $senderId => ${client.userID}, \ncontent => ${content.toString()}');
|
|
|
|
final String callId = content['call_id'];
|
|
final String partyId = content['party_id'];
|
|
final int lifetime = content['lifetime'];
|
|
final String? confId = content['conf_id'];
|
|
final String? deviceId = content['device_id'];
|
|
final call = calls[callId];
|
|
|
|
Logs().d(
|
|
'[glare] got new call ${content.tryGet('call_id')} and currently room id is mapped to ${incomingCallRoomId.tryGet(roomId)}');
|
|
|
|
if (call != null && call.state == CallState.kEnded) {
|
|
// Session already exist.
|
|
Logs().v('[VOIP] onCallInvite: Session [$callId] already exist.');
|
|
return;
|
|
}
|
|
|
|
if (content['invitee'] != null && content['invitee'] != client.userID) {
|
|
return; // This invite was meant for another user 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 room = client.getRoomById(roomId);
|
|
|
|
final opts = CallOptions()
|
|
..voip = this
|
|
..callId = callId
|
|
..groupCallId = confId
|
|
..dir = CallDirection.kIncoming
|
|
..type = callType
|
|
..room = room!
|
|
..localPartyId = localPartyId!
|
|
..iceServers = await getIceSevers();
|
|
|
|
final newCall = createNewCall(opts);
|
|
newCall.remotePartyId = partyId;
|
|
newCall.remoteUser = await room.requestUser(senderId);
|
|
newCall.opponentDeviceId = deviceId;
|
|
newCall.opponentSessionId = content['sender_session_id'];
|
|
if (!delegate.canHandleNewCall &&
|
|
(confId == null || confId != currentGroupCID)) {
|
|
Logs().v(
|
|
'[VOIP] onCallInvite: Unable to handle new calls, maybe user is busy.');
|
|
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();
|
|
}
|
|
|
|
await newCall.initWithInvite(
|
|
callType, offer, sdpStreamMetadata, lifetime, confId != null);
|
|
|
|
currentCID = callId;
|
|
|
|
// Popup CallingPage for incoming call.
|
|
if (confId == null && !newCall.callHasEnded) {
|
|
await delegate.handleNewCall(newCall);
|
|
}
|
|
|
|
onIncomingCall.add(newCall);
|
|
}
|
|
|
|
Future<void> onCallAnswer(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
Logs().v('[VOIP] onCallAnswer => ${content.toString()}');
|
|
final String callId = content['call_id'];
|
|
final String partyId = content['party_id'];
|
|
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
if (!call.answeredByUs) {
|
|
await delegate.stopRingtone();
|
|
}
|
|
if (call.state == CallState.kRinging) {
|
|
call.onAnsweredElsewhere();
|
|
}
|
|
return;
|
|
}
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call answer for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
call.remotePartyId = partyId;
|
|
call.remoteUser = await call.room.requestUser(senderId);
|
|
|
|
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(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
Logs().v('[VOIP] onCallCandidates => ${content.toString()}');
|
|
final String callId = content['call_id'];
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call candidates for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
await call.onCandidatesReceived(content['candidates']);
|
|
} else {
|
|
Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!');
|
|
}
|
|
}
|
|
|
|
Future<void> onCallHangup(String roomId, String _ /*senderId unused*/,
|
|
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[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call hangup for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
// hangup in any case, either if the other party hung up or we did on another device
|
|
await call.terminate(CallParty.kRemote,
|
|
content['reason'] ?? CallErrorCode.UserHangup, true);
|
|
} else {
|
|
Logs().v('[VOIP] onCallHangup: Session [$callId] not found!');
|
|
}
|
|
if (callId == currentCID) {
|
|
currentCID = null;
|
|
}
|
|
}
|
|
|
|
Future<void> onCallReject(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
final String callId = content['call_id'];
|
|
Logs().d('Reject received for call ID $callId');
|
|
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call reject for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
await call.onRejectReceived(content['reason']);
|
|
} else {
|
|
Logs().v('[VOIP] onCallHangup: Session [$callId] not found!');
|
|
}
|
|
}
|
|
|
|
Future<void> onCallReplaces(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
final String callId = content['call_id'];
|
|
Logs().d('onCallReplaces received for call ID $callId');
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call replace for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
//TODO: handle replaces
|
|
}
|
|
}
|
|
|
|
Future<void> onCallSelectAnswer(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
final String callId = content['call_id'];
|
|
Logs().d('SelectAnswer received for call ID $callId');
|
|
final call = calls[callId];
|
|
final String selectedPartyId = content['selected_party_id'];
|
|
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call select answer for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
call.onSelectAnswerReceived(selectedPartyId);
|
|
}
|
|
}
|
|
|
|
Future<void> onSDPStreamMetadataChangedReceived(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
final String callId = content['call_id'];
|
|
Logs().d('SDP Stream metadata received for call ID $callId');
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call sdp metadata change for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
|
|
if (content[sdpStreamMetadataKey] == null) {
|
|
Logs().d('SDP Stream metadata is null');
|
|
return;
|
|
}
|
|
await call.onSDPStreamMetadataReceived(
|
|
SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]));
|
|
}
|
|
}
|
|
|
|
Future<void> onAssertedIdentityReceived(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
final String callId = content['call_id'];
|
|
Logs().d('Asserted identity received for call ID $callId');
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call asserted identity for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
|
|
if (content['asserted_identity'] == null) {
|
|
Logs().d('asserted_identity is null ');
|
|
return;
|
|
}
|
|
call.onAssertedIdentityReceived(
|
|
AssertedIdentity.fromJson(content['asserted_identity']));
|
|
}
|
|
}
|
|
|
|
Future<void> onCallNegotiate(
|
|
String roomId, String senderId, Map<String, dynamic> content) async {
|
|
if (senderId == client.userID) {
|
|
// Ignore messages to yourself.
|
|
return;
|
|
}
|
|
final String callId = content['call_id'];
|
|
Logs().d('Negotiate received for call ID $callId');
|
|
final call = calls[callId];
|
|
if (call != null) {
|
|
if (call.room.id != roomId) {
|
|
Logs().w(
|
|
'Ignoring call negotiation for room $roomId claiming to be for call in room ${call.room.id}');
|
|
return;
|
|
}
|
|
|
|
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 (err) {
|
|
Logs().e('Failed to complete negotiation ${err.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
CallType getCallType(String sdp) {
|
|
try {
|
|
final session = sdp_transform.parse(sdp);
|
|
if (session['media'].indexWhere((e) => e['type'] == 'video') != -1) {
|
|
return CallType.kVideo;
|
|
}
|
|
} catch (err) {
|
|
Logs().e('Failed to getCallType ${err.toString()}');
|
|
}
|
|
|
|
return CallType.kVoice;
|
|
}
|
|
|
|
Future<bool> requestTurnServerCredentials() async {
|
|
return true;
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> getIceSevers() 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
|
|
///
|
|
/// [roomId] The room id to call
|
|
///
|
|
/// [type] The type of call to be made.
|
|
Future<CallSession> inviteToCall(String roomId, CallType type) async {
|
|
final room = client.getRoomById(roomId);
|
|
if (room == null) {
|
|
Logs().v('[VOIP] Invalid room id [$roomId].');
|
|
return Null as CallSession;
|
|
}
|
|
final callId = 'cid${DateTime.now().millisecondsSinceEpoch}';
|
|
if (currentGroupCID == null) {
|
|
incomingCallRoomId[roomId] = callId;
|
|
}
|
|
final opts = CallOptions()
|
|
..callId = callId
|
|
..type = type
|
|
..dir = CallDirection.kOutgoing
|
|
..room = room
|
|
..voip = this
|
|
..localPartyId = localPartyId!
|
|
..iceServers = await getIceSevers();
|
|
|
|
final newCall = createNewCall(opts);
|
|
currentCID = callId;
|
|
await newCall.initOutboundCall(type).then((_) {
|
|
delegate.handleNewCall(newCall);
|
|
});
|
|
currentCID = callId;
|
|
return newCall;
|
|
}
|
|
|
|
CallSession createNewCall(CallOptions opts) {
|
|
final call = CallSession(opts);
|
|
calls[opts.callId] = call;
|
|
return call;
|
|
}
|
|
|
|
/// Create a new group call in an existing room.
|
|
///
|
|
/// [roomId] The room id to call
|
|
///
|
|
/// [type] The type of call to be made.
|
|
///
|
|
/// [intent] The intent of the call.
|
|
Future<GroupCall?> newGroupCall(
|
|
String roomId, String type, String intent) async {
|
|
if (getGroupCallForRoom(roomId) != null) {
|
|
Logs().e('[VOIP] [$roomId] already has an existing group call.');
|
|
return null;
|
|
}
|
|
final room = client.getRoomById(roomId);
|
|
if (room == null) {
|
|
Logs().v('[VOIP] Invalid room id [$roomId].');
|
|
return null;
|
|
}
|
|
final groupId = genCallID();
|
|
final groupCall = GroupCall(
|
|
groupCallId: groupId,
|
|
client: client,
|
|
voip: this,
|
|
room: room,
|
|
type: type,
|
|
intent: intent,
|
|
).create();
|
|
groupCalls[groupId] = groupCall;
|
|
groupCalls[roomId] = groupCall;
|
|
return groupCall;
|
|
}
|
|
|
|
Future<GroupCall?> fetchOrCreateGroupCall(String roomId) async {
|
|
final groupCall = getGroupCallForRoom(roomId);
|
|
final room = client.getRoomById(roomId);
|
|
if (room == null) {
|
|
Logs().w('Not found room id = $roomId');
|
|
return null;
|
|
}
|
|
|
|
if (groupCall != null) {
|
|
if (!room.canJoinGroupCall) {
|
|
Logs().w('No permission to join group calls in room $roomId');
|
|
return null;
|
|
}
|
|
return groupCall;
|
|
}
|
|
|
|
if (!room.groupCallsEnabled) {
|
|
await room.enableGroupCalls();
|
|
}
|
|
|
|
if (room.canCreateGroupCall) {
|
|
// The call doesn't exist, but we can create it
|
|
|
|
final groupCall = await newGroupCall(
|
|
roomId, GroupCallType.Video, GroupCallIntent.Prompt);
|
|
if (groupCall != null) {
|
|
await groupCall.sendMemberStateEvent();
|
|
}
|
|
return groupCall;
|
|
}
|
|
|
|
final completer = Completer<GroupCall?>();
|
|
Timer? timer;
|
|
final subscription = onIncomingGroupCall.stream.listen((GroupCall call) {
|
|
if (call.room.id == roomId) {
|
|
timer?.cancel();
|
|
completer.complete(call);
|
|
}
|
|
});
|
|
|
|
timer = Timer(Duration(seconds: 30), () {
|
|
subscription.cancel();
|
|
completer.completeError('timeout');
|
|
});
|
|
|
|
return completer.future;
|
|
}
|
|
|
|
GroupCall? getGroupCallForRoom(String roomId) {
|
|
return groupCalls[roomId];
|
|
}
|
|
|
|
GroupCall? getGroupCallById(String groupCallId) {
|
|
return groupCalls[groupCallId];
|
|
}
|
|
|
|
Future<void> startGroupCalls() async {
|
|
final rooms = client.rooms;
|
|
for (final room in rooms) {
|
|
await createGroupCallForRoom(room);
|
|
}
|
|
}
|
|
|
|
Future<void> stopGroupCalls() async {
|
|
for (final groupCall in groupCalls.values) {
|
|
await groupCall.terminate();
|
|
}
|
|
groupCalls.clear();
|
|
}
|
|
|
|
/// Create a new group call in an existing room.
|
|
Future<void> createGroupCallForRoom(Room room) async {
|
|
final events = await client.getRoomState(room.id);
|
|
events.sort((a, b) => a.originServerTs.compareTo(b.originServerTs));
|
|
|
|
for (final event in events) {
|
|
if (event.type == EventTypes.GroupCallPrefix) {
|
|
if (event.content['m.terminated'] != null) {
|
|
return;
|
|
}
|
|
await createGroupCallFromRoomStateEvent(event);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/// Create a new group call from a room state event.
|
|
Future<GroupCall?> createGroupCallFromRoomStateEvent(MatrixEvent event,
|
|
{bool emitHandleNewGroupCall = true}) async {
|
|
final roomId = event.roomId;
|
|
final content = event.content;
|
|
|
|
final room = client.getRoomById(roomId!);
|
|
|
|
if (room == null) {
|
|
Logs().w('Couldn\'t find room $roomId for GroupCall');
|
|
return null;
|
|
}
|
|
|
|
final groupCallId = event.stateKey;
|
|
|
|
final callType = content['m.type'];
|
|
|
|
if (callType != GroupCallType.Video && callType != GroupCallType.Voice) {
|
|
Logs().w('Received invalid group call type $callType for room $roomId.');
|
|
return null;
|
|
}
|
|
|
|
final callIntent = content['m.intent'];
|
|
|
|
if (callIntent != GroupCallIntent.Prompt &&
|
|
callIntent != GroupCallIntent.Room &&
|
|
callIntent != GroupCallIntent.Ring) {
|
|
Logs()
|
|
.w('Received invalid group call intent $callType for room $roomId.');
|
|
return null;
|
|
}
|
|
|
|
final groupCall = GroupCall(
|
|
client: client,
|
|
voip: this,
|
|
room: room,
|
|
groupCallId: groupCallId,
|
|
type: callType as String,
|
|
intent: callIntent as String,
|
|
);
|
|
|
|
groupCalls[groupCallId!] = groupCall;
|
|
groupCalls[room.id] = groupCall;
|
|
|
|
onIncomingGroupCall.add(groupCall);
|
|
if (emitHandleNewGroupCall) {
|
|
await delegate.handleNewGroupCall(groupCall);
|
|
}
|
|
return groupCall;
|
|
}
|
|
|
|
Future<void> onRoomStateChanged(MatrixEvent event) async {
|
|
final eventType = event.type;
|
|
final roomId = event.roomId;
|
|
if (eventType == EventTypes.GroupCallPrefix) {
|
|
final groupCallId = event.stateKey;
|
|
final content = event.content;
|
|
final currentGroupCall = groupCalls[groupCallId];
|
|
if (currentGroupCall == null && content['m.terminated'] == null) {
|
|
await createGroupCallFromRoomStateEvent(event);
|
|
} else if (currentGroupCall != null &&
|
|
currentGroupCall.groupCallId == groupCallId) {
|
|
if (content['m.terminated'] != null) {
|
|
await currentGroupCall.terminate(emitStateEvent: false);
|
|
} else if (content['m.type'] != currentGroupCall.type) {
|
|
// TODO: Handle the callType changing when the room state changes
|
|
Logs().w(
|
|
'The group call type changed for room: $roomId. Changing the group call type is currently unsupported.');
|
|
}
|
|
} else if (currentGroupCall != null &&
|
|
currentGroupCall.groupCallId != groupCallId) {
|
|
// TODO: Handle new group calls and multiple group calls
|
|
Logs().w(
|
|
'Multiple group calls detected for room: $roomId. Multiple group calls are currently unsupported.');
|
|
}
|
|
} else if (eventType == EventTypes.GroupCallMemberPrefix) {
|
|
final groupCall = groupCalls[roomId];
|
|
if (groupCall == null) {
|
|
return;
|
|
}
|
|
await groupCall.onMemberStateChanged(event);
|
|
}
|
|
}
|
|
|
|
@Deprecated('Call `hasActiveGroupCall` on the room directly instead')
|
|
bool hasActiveCall(Room room) => room.hasActiveGroupCall;
|
|
}
|