Support group call.
This commit is contained in:
parent
6cc99bdad6
commit
e2efa3e758
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -633,7 +640,13 @@ class Client extends MatrixApi {
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
### 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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue