Support group call.

This commit is contained in:
Duan Weiwei 2022-06-13 15:26:25 +00:00 committed by Nicolas Werner
parent 6cc99bdad6
commit e2efa3e758
11 changed files with 2505 additions and 528 deletions

View File

@ -29,8 +29,11 @@ export 'src/database/hive_collections_database.dart';
export 'src/event.dart';
export 'src/presence.dart';
export 'src/event_status.dart';
export 'src/voip.dart';
export 'src/voip_content.dart';
export 'src/voip/call.dart';
export 'src/voip/group_call.dart';
export 'src/voip/voip.dart';
export 'src/voip/voip_content.dart';
export 'src/voip/utils.dart';
export 'src/room.dart';
export 'src/timeline.dart';
export 'src/user.dart';
@ -50,7 +53,6 @@ export 'src/utils/sync_update_extension.dart';
export 'src/utils/to_device_event.dart';
export 'src/utils/uia_request.dart';
export 'src/utils/uri_extension.dart';
export 'src/voip_content.dart';
export 'msc_extensions/extension_recent_emoji/recent_emoji.dart';
export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart';

View File

@ -27,6 +27,7 @@ import 'package:matrix/src/utils/sync_update_item_count.dart';
import 'package:mime/mime.dart';
import 'package:olm/olm.dart' as olm;
import 'package:collection/collection.dart' show IterableExtension;
import 'package:random_string/random_string.dart';
import '../encryption.dart';
import '../matrix.dart';
@ -221,6 +222,11 @@ class Client extends MatrixApi {
String? get deviceName => _deviceName;
String? _deviceName;
// for group calls
// A unique identifier used for resolving duplicate group call sessions from a given device. When the session_id field changes from an incoming m.call.member event, any existing calls from this device in this call should be terminated. The id is generated once per client load.
String? get groupCallSessionId => _groupCallSessionId;
String? _groupCallSessionId;
/// Returns the current login state.
LoginState get loginState => __loginState;
LoginState __loginState;
@ -613,6 +619,7 @@ class Client extends MatrixApi {
List<StateEvent>? initialState,
Visibility? visibility,
bool waitForSync = true,
bool groupCall = false,
}) async {
enableEncryption ??=
encryptionEnabled && preset != CreateRoomPreset.publicChat;
@ -628,12 +635,18 @@ class Client extends MatrixApi {
}
}
final roomId = await createRoom(
invite: invite,
preset: preset,
name: groupName,
initialState: initialState,
visibility: visibility,
);
invite: invite,
preset: preset,
name: groupName,
initialState: initialState,
visibility: visibility,
powerLevelContentOverride: groupCall
? <String, dynamic>{
'events': <String, dynamic>{
'org.matrix.msc3401.call.member': 0,
},
}
: null);
if (waitForSync) {
if (getRoomById(roomId) == null) {
@ -1026,6 +1039,11 @@ class Client extends MatrixApi {
final StreamController<UiaRequest> onUiaRequest =
StreamController.broadcast();
final StreamController<Event> onGroupCallRequest =
StreamController.broadcast();
final StreamController<Event> onGroupMember = StreamController.broadcast();
/// How long should the app wait until it retrys the synchronisation after
/// an error?
int syncErrorTimeoutSec = 3;
@ -1258,6 +1276,8 @@ class Client extends MatrixApi {
);
}
_groupCallSessionId = randomAlpha(12);
String? olmAccount;
String? accessToken;
String? _userID;
@ -1844,6 +1864,10 @@ class Client extends MatrixApi {
EventTypes.CallSDPStreamMetadataChangedPrefix) {
onSDPStreamMetadataChangedReceived
.add(Event.fromJson(rawUnencryptedEvent, room));
// TODO(duan): Only used (org.matrix.msc3401.call) during the current test,
// need to add GroupCallPrefix in matrix_api_lite
} else if (rawUnencryptedEvent['type'] == EventTypes.GroupCallPrefix) {
onGroupCallRequest.add(Event.fromJson(rawUnencryptedEvent, room));
}
}
}

175
lib/src/voip/README.md Normal file
View File

@ -0,0 +1,175 @@
# VOIP for Matrix SDK
1:1 and group calls
## Overview
`VoIP` is a module that provides a simple API for making 1:1 and group calls.
`CallSession` objects are created by calling `inviteToCall` and `onCallInvite`.
`GroupCall` objects are created by calling `createGroupCall`.
## 1:1 calls
### 1. Basic call flow
This flow explains the code flow for a 1v1 call.
This code flow is still used in group call, the only difference is that group call uses `toDevice` message to send `m.call.*` events
![1v1 call](images/famedly-1v1-call.drawio.png)
### 2.Implement the event handlers
The code here is to adapt to the difference between `flutter app` and `dart web app` and prevent importing `flutter` dependencies in `dart` app.
We need to import `dart_webrtc` or `flutter_webrtc`, and map the platform-specific API `(mediaDevices, createPeerConnection, createRenderer)`
implementations to the corresponding packages.
In addition, we can respond to the call to start and end in this delegate, start or turn off the incoming call ringing
``` dart
// for dart app
import 'package:dart_webrtc/dart_webrtc.dart' as webrtc_impl;
// for flutter app
// import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl;
class MyVoipApp implements WebRTCDelegate {
@override
MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices;
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration,
[Map<String, dynamic> constraints = const {}]) =>
webrtc_impl.createPeerConnection(configuration, constraints);
@override
VideoRenderer createRenderer() => RTCVideoRenderer();
@override
void playRingtone(){
// play ringtone
}
void stopRingtone() {
// stop ringtone
}
void handleNewCall(CallSession session) {
// handle new call incoming or outgoing
switch(session.direction) {
case CallDirection.kIncoming:
// show incoming call window
break;
case CallDirection.kOutgoing:
// show outgoing call window
break;
}
}
void handleCallEnded(CallSession session) {
// handle call ended by local or remote
}
}
```
### 3.Start a outgoing call
When the delegate is set we can initiate a new outgoing call.
We need to use the matrix roomId to initiate the call, the initial call can be
`CallType.kVoice` or `CallType.kVideo`.
After the call is sent, you can use `onCallStateChanged` to listen the call state events. These events are used to change the display of the call UI state, for example, change the control buttons, display `Hangup (cancel)` button before connecting, and display `mute mic, mute cam, hold/unhold, hangup` buttons after connected.
```dart
final voip = VoIP(client, MyVoipApp());
/// Create a new call
final newCall = await voip.inviteToCall(roomId, CallType.kVideo);
newCall.onCallStateChanged.stream.listen((state) {
/// handle call state change event
/// You can change UI state here, such as Ringing,
/// Connecting, Connected, Disconnected, etc.
});
/// Then you can pop up the incoming call window at MyVoipApp.handleNewCall.
class MyVoipApp implements WebRTCDelegate {
...
void handleNewCall(CallSession session) {
switch(session.direction) {
case CallDirection.kOutgoing:
// show outgoing call window
break;
}
}
...
/// end the call by local
newCall.hangup();
```
### 4.Answer a incoming call
When a new incoming call comes in, handleNewCall will be called, and the answering interface can pop up at this time, and use `onCallStateChanged` to listen to the call state.
The incoming call window need display `answer` and `reject` buttons, by calling `newCall.answer();` or `newCall.reject();` to decide whether to connect the call.
```dart
...
void handleNewCall(CallSession newCall) {
switch(newCall.direction) {
case CallDirection.kIncoming:
/// show incoming call window
newCall.onCallStateChanged.stream.listen((state) {
/// handle call state change event
});
break;
}
}
...
/// Answer the call
newCall.answer();
// or reject the call
newCall.reject();
```
### 5.Render media stream
The basic process of rendering a video stream is as follow code.
```dart
class RemoteVideoView extends Widget {
VideoElement get videoElement => renderer.element;
RTCVideoRenderer get renderer => remoteStream.renderer as RTCVideoRenderer;
final WrappedMediaStream remoteStream;
RemoteVideoView(this.remoteStream){
renderer.srcObject = remoteStream.mediaStream;
}
...
@override
Element build() {
return divElement(
children: [
...
videoElement,
...
]);
}
...
}
```
Usually there are four media streams in a 1v1 call, which are
* `localUserMediaStream`
* `localScreenSharingStream`
* `remoteUserMediaStream`
* `remoteScreenSharingStream`
They can be get by the methods of `CallSession`. the `newCall.onCallStreamsChanged` event is fired when these streams are added or removed.
When the media stream changes, we can change the UI display according to the priority.
`remoteScreenSharingStream` always needs to be displayed first, followed by `remoteUserMediaStream`

File diff suppressed because it is too large Load Diff

1242
lib/src/voip/group_call.dart Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

32
lib/src/voip/utils.dart Normal file
View File

@ -0,0 +1,32 @@
import 'package:random_string/random_string.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
void stopMediaStream(MediaStream? stream) async {
stream?.getTracks().forEach((element) async {
await element.stop();
});
}
void setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) {
tracks.forEach((element) {
element.enabled = enabled;
});
}
Future<bool> hasAudioDevice() async {
//TODO(duan): implement this, check if there is any audio device
return true;
}
Future<bool> hasVideoDevice() async {
//TODO(duan): implement this, check if there is any video device
return true;
}
String roomAliasFromRoomName(String roomName) {
return roomName.trim().replaceAll('-', '').toLowerCase();
}
String genCallID() {
return '${DateTime.now().millisecondsSinceEpoch}' + randomAlphaNumeric(16);
}

690
lib/src/voip/voip.dart Normal file
View File

@ -0,0 +1,690 @@
import 'dart:async';
import 'dart:core';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:sdp_transform/sdp_transform.dart' as sdp_transform;
import '../../matrix.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();
void playRingtone();
void stopRingtone();
void handleNewCall(CallSession session);
void handleCallEnded(CallSession session);
void handleNewGroupCall(GroupCall groupCall);
void handleGroupCallEnded(GroupCall groupCall);
bool get isBackgroud;
bool get isWeb;
}
class VoIP {
TurnServerCredentials? _turnServerCredentials;
Map<String, CallSession> calls = <String, CallSession>{};
Map<String, GroupCall> groupCalls = <String, GroupCall>{};
final StreamController<CallSession> onIncomingCall =
StreamController.broadcast();
String? currentCID;
String? currentGroupCID;
String? get localPartyId => client.deviceID;
final Client client;
final WebRTCDelegate delegate;
void _handleEvent(
Event event,
Function(String roomId, String senderId, Map<String, dynamic> content)
func) =>
func(event.roomId!, event.senderId, event.content);
VoIP(this.client, this.delegate) : super() {
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.onGroupCallRequest.stream.listen((event) {
Logs().v('[VOIP] onGroupCallRequest: type ${event.toJson()}.');
onRoomStateChanged(event);
});
client.onToDeviceEvent.stream.listen((event) {
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().e('[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:
onCallInvite(roomId, senderId, content);
break;
case EventTypes.CallAnswer:
onCallAnswer(roomId, senderId, content);
break;
case EventTypes.CallCandidates:
onCallCandidates(roomId, senderId, content);
break;
case EventTypes.CallHangup:
onCallHangup(roomId, senderId, content);
break;
case EventTypes.CallReject:
onCallReject(roomId, senderId, content);
break;
case EventTypes.CallNegotiate:
onCallNegotiate(roomId, senderId, content);
break;
case EventTypes.CallReplaces:
onCallReplaces(roomId, senderId, content);
break;
case EventTypes.CallSelectAnswer:
onCallSelectAnswer(roomId, senderId, content);
break;
case EventTypes.CallSDPStreamMetadataChanged:
case EventTypes.CallSDPStreamMetadataChangedPrefix:
onSDPStreamMetadataChangedReceived(roomId, senderId, content);
break;
case EventTypes.CallAssertedIdentity:
onAssertedIdentityReceived(roomId, senderId, content);
break;
}
});
}
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];
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'];
final offer = RTCSessionDescription(
content['offer']['sdp'],
content['offer']['type'],
);
await newCall
.initWithInvite(callType, offer, sdpStreamMetadata, lifetime)
.then((_) {
// Popup CallingPage for incoming call.
if (!delegate.isBackgroud && confId == null) {
delegate.handleNewCall(newCall);
}
onIncomingCall.add(newCall);
});
currentCID = callId;
if (delegate.isBackgroud) {
/// Forced to enable signaling synchronization until the end of the call.
client.backgroundSync = true;
///TODO: notify the callkeep that the call is incoming.
}
// Play ringtone
delegate.playRingtone();
}
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) {
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]);
}
call.onAnswerReceived(answer, metadata);
} else {
Logs().v('[VOIP] onCallAnswer: Session [$callId] not found!');
}
}
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;
}
call.onCandidatesReceived(content['candidates']);
} else {
Logs().v('[VOIP] onCallCandidates: Session [$callId] not found!');
}
}
void onCallHangup(String roomId, String _ /*senderId unused*/,
Map<String, dynamic> content) async {
// stop play ringtone, if this is an incoming call
if (!delegate.isBackgroud) {
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
call.terminate(CallParty.kRemote,
content['reason'] ?? CallErrorCode.UserHangup, true);
} else {
Logs().v('[VOIP] onCallHangup: Session [$callId] not found!');
}
currentCID = null;
}
void onCallReject(
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('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;
}
call.onRejectReceived(content['reason']);
} else {
Logs().v('[VOIP] onCallHangup: Session [$callId] not found!');
}
}
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
}
}
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);
}
}
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;
}
call.onSDPStreamMetadataReceived(
SDPStreamMetadata.fromJson(content[sdpStreamMetadataKey]));
}
}
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']));
}
}
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]);
}
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,
'url': _turnServerCredentials!.uris[0]
}
];
}
/// 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}';
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((_) {
if (!delegate.isBackgroud) {
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.
///
/// [dataChannelsEnabled] Whether data channels are enabled.
///
/// [dataChannelOptions] The data channel options.
Future<GroupCall?> newGroupCall(String roomId, String type, String intent,
[bool? dataChannelsEnabled,
RTCDataChannelInit? dataChannelOptions]) async {
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,
dataChannelsEnabled: dataChannelsEnabled ?? false,
dataChannelOptions: dataChannelOptions ?? RTCDataChannelInit(),
).create();
groupCalls[groupId] = groupCall;
return groupCall;
}
GroupCall? getGroupCallForRoom(String roomId) {
return groupCalls[roomId];
}
GroupCall? getGroupCallById(String groupCallId) {
return groupCalls[groupCallId];
}
void startGroupCalls() async {
final rooms = client.rooms;
rooms.forEach((element) {
createGroupCallForRoom(element);
});
}
void stopGroupCalls() {
groupCalls.forEach((_, groupCall) {
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));
events.forEach((element) async {
if (element.type == EventTypes.GroupCallPrefix) {
if (element.content['m.terminated'] != null) {
return;
}
await createGroupCallFromRoomStateEvent(element);
}
});
return;
}
/// Create a new group call from a room state event.
Future<GroupCall?> createGroupCallFromRoomStateEvent(
MatrixEvent event) 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 dataChannelOptionsMap = content['m.data_channel_options'];
var dataChannelsEnabled = false;
final dataChannelOptions = RTCDataChannelInit();
if (dataChannelOptionsMap != null) {
dataChannelsEnabled =
dataChannelOptionsMap['dataChannelsEnabled'] as bool;
dataChannelOptions.ordered = dataChannelOptionsMap['ordered'] as bool;
dataChannelOptions.maxRetransmits =
dataChannelOptionsMap['maxRetransmits'] as int;
dataChannelOptions.maxRetransmits =
dataChannelOptionsMap['maxRetransmits'] as int;
dataChannelOptions.protocol = dataChannelOptionsMap['protocol'] as String;
}
final groupCall = GroupCall(
client: client,
voip: this,
room: room,
groupCallId: groupCallId,
type: callType,
intent: callIntent,
dataChannelsEnabled: dataChannelsEnabled,
dataChannelOptions: dataChannelOptions);
groupCalls[groupCallId!] = groupCall;
groupCalls[room.id] = groupCall;
delegate.handleNewGroupCall(groupCall);
return groupCall;
}
void onRoomStateChanged(MatrixEvent event) {
final eventType = event.type;
final roomId = event.roomId;
if (eventType == EventTypes.GroupCallPrefix) {
final groupCallId = event.content['groupCallId'];
final content = event.content;
final currentGroupCall = groupCalls[groupCallId];
if (currentGroupCall == null && content['m.terminated'] == null) {
createGroupCallFromRoomStateEvent(event);
} else if (currentGroupCall != null &&
currentGroupCall.groupCallId == groupCallId) {
if (content['m.terminated'] != null) {
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;
}
groupCall.onMemberStateChanged(event);
}
}
}

View File

@ -1,52 +0,0 @@
import 'package:matrix/src/voip.dart';
import 'package:webrtc_interface/src/rtc_video_renderer.dart';
import 'package:webrtc_interface/src/rtc_peerconnection.dart';
import 'package:webrtc_interface/src/mediadevices.dart';
class FakeVoIPDelegate extends WebRTCDelegate {
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration,
[Map<String, dynamic> constraints = const {}]) {
// TODO: implement createPeerConnection
throw UnimplementedError();
}
@override
VideoRenderer createRenderer() {
// TODO: implement createRenderer
throw UnimplementedError();
}
@override
void handleCallEnded(CallSession session) {
// TODO: implement handleCallEnded
}
@override
void handleNewCall(CallSession session) {
// TODO: implement handleNewCall
}
@override
// TODO: implement isBackgroud
bool get isBackgroud => throw UnimplementedError();
@override
// TODO: implement isWeb
bool get isWeb => throw UnimplementedError();
@override
// TODO: implement mediaDevices
MediaDevices get mediaDevices => throw UnimplementedError();
@override
void playRingtone() {
// TODO: implement playRingtone
}
@override
void stopRingtone() {
// TODO: implement stopRingtone
}
}

View File

@ -25,19 +25,16 @@ import 'package:test/test.dart';
import 'fake_client.dart';
import 'fake_matrix_api.dart';
import 'fake_voip_delegate.dart';
void main() {
late Client matrix;
late Room room;
late VoIP voip;
/// All Tests related to the Event
group('Room', () {
Logs().level = Level.error;
test('Login', () async {
matrix = await getClient();
voip = VoIP(matrix, FakeVoIPDelegate());
});
test('Create from json', () async {
@ -761,18 +758,19 @@ void main() {
});
test('Test call methods', () async {
await voip.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp',
final call = CallSession(CallOptions()..room = room);
await call.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp',
txid: '1234');
await voip.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234');
await voip.sendCallCandidates(room, '1234', '4567', [], txid: '1234');
await voip.sendSelectCallAnswer(room, '1234', 1234, '4567', '6789',
await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234');
await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234');
await call.sendSelectCallAnswer(room, '1234', 1234, '4567', '6789',
txid: '1234');
await voip.sendCallReject(room, '1234', 1234, '4567', txid: '1234');
await voip.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp',
await call.sendCallReject(room, '1234', 1234, '4567', txid: '1234');
await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp',
txid: '1234');
await voip.sendHangupCall(room, '1234', '4567', 'user_hangup',
await call.sendHangupCall(room, '1234', '4567', 'user_hangup',
txid: '1234');
await voip.sendAssertedIdentity(
await call.sendAssertedIdentity(
room,
'1234',
'4567',
@ -780,9 +778,9 @@ void main() {
..displayName = 'name'
..id = 'some_id',
txid: '1234');
await voip.sendCallReplaces(room, '1234', '4567', CallReplaces(),
await call.sendCallReplaces(room, '1234', '4567', CallReplaces(),
txid: '1234');
await voip.sendSDPStreamMetadataChanged(
await call.sendSDPStreamMetadataChanged(
room, '1234', '4567', SDPStreamMetadata({}),
txid: '1234');
});