Merge pull request #1696 from famedly/td/fosdemDemoFork

feat: famedly calls
This commit is contained in:
td 2024-04-23 17:32:41 +05:30 committed by GitHub
commit 32a425a362
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 4066 additions and 3109 deletions

View File

@ -1,2 +1,2 @@
flutter_version=3.19.0
dart_version=3.3.0
flutter_version=3.16.9
dart_version=3.2.6

View File

@ -303,7 +303,7 @@ or before logout), excessive linebreaks in markdown messages and a few edge case
- fix: Check the max server file size after shrinking not before (Krille)
- fix: casting of a List<dynamic> to List<String> in getEventList and getEventIdList (td)
- fix: Skip rules with unknown conditions (Nicolas Werner)
- fix: allow passing a WrappedMediaStream to GroupCall.enter() to use as the local user media stream (td)
- fix: allow passing a WrappedMediaStream to GroupCallSession.enter() to use as the local user media stream (td)
## [0.19.0] - 21st April 2023

View File

@ -561,6 +561,8 @@ class OlmManager {
DateTime.now()
.subtract(Duration(hours: 1))
.isBefore(_restoredOlmSessionsTime[mapKey]!)) {
Logs().w(
'[OlmManager] Skipping restore session, one was restored in the past hour');
return;
}
_restoredOlmSessionsTime[mapKey] = DateTime.now();
@ -736,7 +738,7 @@ class OlmManager {
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
if (event.type == EventTypes.Dummy) {
// We receive dan encrypted m.dummy. This means that the other end was not able to
// We received an encrypted m.dummy. This means that the other end was not able to
// decrypt our last message. So, we re-send it.
final encryptedContent = event.encryptedContent;
if (encryptedContent == null || encryption.olmDatabase == null) {

View File

@ -30,13 +30,22 @@ export 'src/database/sqflite_encryption_helper.dart';
export 'src/event.dart';
export 'src/presence.dart';
export 'src/event_status.dart';
export 'src/voip/call.dart';
export 'src/voip/group_call.dart';
export 'src/voip/call_session.dart';
export 'src/voip/group_call_session.dart';
export 'src/voip/voip.dart';
export 'src/voip/voip_content.dart';
export 'src/voip/conn_tester.dart';
export 'src/voip/utils.dart';
export 'src/voip/voip_room_extension.dart';
export 'src/voip/backend/livekit_backend.dart';
export 'src/voip/backend/call_backend_model.dart';
export 'src/voip/backend/mesh_backend.dart';
export 'src/voip/models/call_events.dart';
export 'src/voip/models/webrtc_delegate.dart';
export 'src/voip/models/call_participant.dart';
export 'src/voip/models/key_provider.dart';
export 'src/voip/utils/conn_tester.dart';
export 'src/voip/utils/voip_constants.dart';
export 'src/voip/utils/rtc_candidate_extension.dart';
export 'src/voip/utils/famedly_call_extension.dart';
export 'src/voip/utils/types.dart';
export 'src/voip/utils/wrapped_media_stream.dart';
export 'src/room.dart';
export 'src/timeline.dart';
export 'src/user.dart';

View File

@ -59,8 +59,6 @@ abstract class EventTypes {
static const String CallAssertedIdentity = 'm.call.asserted_identity';
static const String CallAssertedIdentityPrefix =
'org.matrix.call.asserted_identity';
static const String GroupCallPrefix = 'org.matrix.msc3401.call';
static const String GroupCallMemberPrefix = 'org.matrix.msc3401.call.member';
static const String Unknown = 'm.unknown';
// To device event types
@ -94,6 +92,26 @@ abstract class EventTypes {
static String secretStorageKey(String keyId) => 'm.secret_storage.key.$keyId';
// Spaces
static const String spaceParent = 'm.space.parent';
static const String spaceChild = 'm.space.child';
static const String SpaceParent = 'm.space.parent';
static const String SpaceChild = 'm.space.child';
// MatrixRTC
static const String GroupCallMember = 'com.famedly.call.member';
static const String GroupCallMemberEncryptionKeys =
'$GroupCallMember.encryption_keys';
static const String GroupCallMemberEncryptionKeysRequest =
'$GroupCallMember.encryption_keys_request';
static const String GroupCallMemberCandidates = '$GroupCallMember.candidates';
static const String GroupCallMemberInvite = '$GroupCallMember.invite';
static const String GroupCallMemberAnswer = '$GroupCallMember.answer';
static const String GroupCallMemberHangup = '$GroupCallMember.hangup';
static const String GroupCallMemberSelectAnswer =
'$GroupCallMember.select_answer';
static const String GroupCallMemberReject = '$GroupCallMember.reject';
static const String GroupCallMemberNegotiate = '$GroupCallMember.negotiate';
static const String GroupCallMemberSDPStreamMetadataChanged =
'$GroupCallMember.sdp_stream_metadata_changed';
static const String GroupCallMemberReplaces = '$GroupCallMember.replaces';
static const String GroupCallMemberAssertedIdentity =
'$GroupCallMember.asserted_identity';
}

View File

@ -219,8 +219,8 @@ class Client extends MatrixApi {
EventTypes.Encryption,
EventTypes.RoomCanonicalAlias,
EventTypes.RoomTombstone,
EventTypes.spaceChild,
EventTypes.spaceParent,
EventTypes.SpaceChild,
EventTypes.SpaceParent,
EventTypes.RoomCreate,
]);
roomPreviewLastEvents.addAll([
@ -231,8 +231,7 @@ class Client extends MatrixApi {
EventTypes.CallAnswer,
EventTypes.CallReject,
EventTypes.CallHangup,
EventTypes.GroupCallPrefix,
EventTypes.GroupCallMemberPrefix,
EventTypes.GroupCallMember,
]);
// register all the default commands
@ -800,8 +799,7 @@ class Client extends MatrixApi {
if (groupCall) {
powerLevelContentOverride ??= {};
powerLevelContentOverride['events'] = <String, dynamic>{
EventTypes.GroupCallMemberPrefix: 0,
EventTypes.GroupCallPrefix: 0,
EventTypes.GroupCallMember: 0,
};
}
final roomId = await createRoom(
@ -1264,6 +1262,10 @@ class Client extends MatrixApi {
final CachedStreamController<ToDeviceEvent> onToDeviceEvent =
CachedStreamController();
/// Tells you about to-device and room call specific events in sync
final CachedStreamController<List<BasicEventWithSender>> onCallEvents =
CachedStreamController();
/// Called when the login state e.g. user gets logged out.
final CachedStreamController<LoginState> onLoginStateChanged =
CachedStreamController();
@ -1295,41 +1297,6 @@ class Client extends MatrixApi {
final CachedStreamController<BasicEvent> onAccountData =
CachedStreamController();
/// Will be called on call invites.
final CachedStreamController<Event> onCallInvite = CachedStreamController();
/// Will be called on call hangups.
final CachedStreamController<Event> onCallHangup = CachedStreamController();
/// Will be called on call candidates.
final CachedStreamController<Event> onCallCandidates =
CachedStreamController();
/// Will be called on call answers.
final CachedStreamController<Event> onCallAnswer = CachedStreamController();
/// Will be called on call replaces.
final CachedStreamController<Event> onCallReplaces = CachedStreamController();
/// Will be called on select answers.
final CachedStreamController<Event> onCallSelectAnswer =
CachedStreamController();
/// Will be called on rejects.
final CachedStreamController<Event> onCallReject = CachedStreamController();
/// Will be called on negotiates.
final CachedStreamController<Event> onCallNegotiate =
CachedStreamController();
/// Will be called on Asserted Identity received.
final CachedStreamController<Event> onAssertedIdentityReceived =
CachedStreamController();
/// Will be called on SDPStream Metadata changed.
final CachedStreamController<Event> onSDPStreamMetadataChangedReceived =
CachedStreamController();
/// Will be called when another device is requesting session keys for a room.
final CachedStreamController<RoomKeyRequest> onRoomKeyRequest =
CachedStreamController();
@ -1343,9 +1310,6 @@ class Client extends MatrixApi {
final CachedStreamController<UiaRequest> onUiaRequest =
CachedStreamController();
final CachedStreamController<Event> onGroupCallRequest =
CachedStreamController();
final CachedStreamController<Event> onGroupMember = CachedStreamController();
final CachedStreamController<Event> onRoomState = CachedStreamController();
@ -2069,6 +2033,7 @@ class Client extends MatrixApi {
Future<void> _handleToDeviceEvents(List<BasicEventWithSender> events) async {
final Map<String, List<String>> roomsWithNewKeyToSessionId = {};
final List<ToDeviceEvent> callToDeviceEvents = [];
for (final event in events) {
var toDeviceEvent = ToDeviceEvent.fromJson(event.toJson());
Logs().v('Got to_device event of type ${toDeviceEvent.type}');
@ -2089,9 +2054,16 @@ class Client extends MatrixApi {
}
await encryption?.handleToDeviceEvent(toDeviceEvent);
}
if (toDeviceEvent.type.startsWith(CallConstants.callEventsRegxp)) {
callToDeviceEvents.add(toDeviceEvent);
}
onToDeviceEvent.add(toDeviceEvent);
}
if (callToDeviceEvents.isNotEmpty) {
onCallEvents.add(callToDeviceEvents);
}
// emit updates for all events in the queue
for (final entry in roomsWithNewKeyToSessionId.entries) {
final roomId = entry.key;
@ -2257,7 +2229,7 @@ class Client extends MatrixApi {
{bool store = true}) async {
// Calling events can be omitted if they are outdated from the same sync. So
// we collect them first before we handle them.
final callEvents = <Event>{};
final callEvents = <Event>[];
for (final event in events) {
// The client must ignore any new m.room.encryption event to prevent
@ -2308,93 +2280,17 @@ class Client extends MatrixApi {
if (prevBatch != null &&
(type == EventUpdateType.timeline ||
type == EventUpdateType.decryptedTimelineQueue)) {
if ((update.content.tryGet<String>('type')?.startsWith('m.call.') ??
false) ||
(update.content
.tryGet<String>('type')
?.startsWith('org.matrix.call.') ??
false)) {
if ((update.content
.tryGet<String>('type')
?.startsWith(CallConstants.callEventsRegxp) ??
false)) {
final callEvent = Event.fromJson(update.content, room);
final callId = callEvent.content.tryGet<String>('call_id');
callEvents.add(callEvent);
// Call Invites should be omitted for a call that is already answered,
// has ended, is rejectd or replaced.
const callEndedEventTypes = {
EventTypes.CallAnswer,
EventTypes.CallHangup,
EventTypes.CallReject,
EventTypes.CallReplaces,
};
const ommitWhenCallEndedTypes = {
EventTypes.CallInvite,
EventTypes.CallCandidates,
EventTypes.CallNegotiate,
EventTypes.CallSDPStreamMetadataChanged,
EventTypes.CallSDPStreamMetadataChangedPrefix,
};
if (callEndedEventTypes.contains(callEvent.type)) {
callEvents.removeWhere((event) {
if (ommitWhenCallEndedTypes.contains(event.type) &&
event.content.tryGet<String>('call_id') == callId) {
Logs().v(
'Ommit "${event.type}" event for an already terminated call');
return true;
}
return false;
});
}
final age = callEvent.unsigned?.tryGet<int>('age') ??
(DateTime.now().millisecondsSinceEpoch -
callEvent.originServerTs.millisecondsSinceEpoch);
callEvents.removeWhere((element) {
if (callEvent.type == EventTypes.CallInvite &&
age >
(callEvent.content.tryGet<int>('lifetime') ??
CallTimeouts.callInviteLifetime.inMilliseconds)) {
Logs().v(
'Ommiting invite event ${callEvent.eventId} as age was older than lifetime');
return true;
}
return false;
});
}
}
}
callEvents.forEach(_callStreamByCallEvent);
}
void _callStreamByCallEvent(Event event) {
if (event.type == EventTypes.CallInvite) {
onCallInvite.add(event);
} else if (event.type == EventTypes.CallHangup) {
onCallHangup.add(event);
} else if (event.type == EventTypes.CallAnswer) {
onCallAnswer.add(event);
} else if (event.type == EventTypes.CallCandidates) {
onCallCandidates.add(event);
} else if (event.type == EventTypes.CallSelectAnswer) {
onCallSelectAnswer.add(event);
} else if (event.type == EventTypes.CallReject) {
onCallReject.add(event);
} else if (event.type == EventTypes.CallNegotiate) {
onCallNegotiate.add(event);
} else if (event.type == EventTypes.CallReplaces) {
onCallReplaces.add(event);
} else if (event.type == EventTypes.CallAssertedIdentity ||
event.type == EventTypes.CallAssertedIdentityPrefix) {
onAssertedIdentityReceived.add(event);
} else if (event.type == EventTypes.CallSDPStreamMetadataChanged ||
event.type == EventTypes.CallSDPStreamMetadataChangedPrefix) {
onSDPStreamMetadataChangedReceived.add(event);
// TODO(duan): Only used (org.matrix.msc3401.call) during the current test,
// need to add GroupCallPrefix in matrix_api_lite
} else if (event.type == EventTypes.GroupCallPrefix) {
onGroupCallRequest.add(event);
if (callEvents.isNotEmpty) {
onCallEvents.add(callEvents);
}
}

View File

@ -1835,7 +1835,7 @@ class Room {
return powerForChangingStateEvent(action) <= ownPowerLevel;
}
/// returns the powerlevel required for chaning the `action` defaults to
/// returns the powerlevel required for changing the `action` defaults to
/// state_default if `action` isn't specified in events override.
/// If there is no state_default in the m.room.power_levels event, the
/// state_default is 50. If the room contains no m.room.power_levels event,
@ -1850,25 +1850,18 @@ class Room {
50;
}
bool get canCreateGroupCall =>
canChangeStateEvent(EventTypes.GroupCallPrefix) && groupCallsEnabled;
bool get canJoinGroupCall =>
canChangeStateEvent(EventTypes.GroupCallMemberPrefix) &&
groupCallsEnabled;
/// if returned value is not null `org.matrix.msc3401.call.member` is present
/// if returned value is not null `EventTypes.GroupCallMember` is present
/// and group calls can be used
bool get groupCallsEnabled {
bool get groupCallsEnabledForEveryone {
final powerLevelMap = getState(EventTypes.RoomPowerLevels)?.content;
if (powerLevelMap == null) return false;
return powerForChangingStateEvent(EventTypes.GroupCallMemberPrefix) <=
getDefaultPowerLevel(powerLevelMap) &&
powerForChangingStateEvent(EventTypes.GroupCallPrefix) <=
getDefaultPowerLevel(powerLevelMap);
return powerForChangingStateEvent(EventTypes.GroupCallMember) <=
getDefaultPowerLevel(powerLevelMap);
}
/// sets the `org.matrix.msc3401.call.member` power level to users default for
bool get canJoinGroupCall => canChangeStateEvent(EventTypes.GroupCallMember);
/// sets the `EventTypes.GroupCallMember` power level to users default for
/// group calls, needs permissions to change power levels
Future<void> enableGroupCalls() async {
if (!canChangePowerLevel) return;
@ -1878,9 +1871,7 @@ class Room {
final eventsMap = newPowerLevelMap.tryGetMap<String, Object?>('events') ??
<String, Object?>{};
eventsMap.addAll({
EventTypes.GroupCallPrefix: getDefaultPowerLevel(currentPowerLevelsMap),
EventTypes.GroupCallMemberPrefix:
getDefaultPowerLevel(currentPowerLevelsMap)
EventTypes.GroupCallMember: getDefaultPowerLevel(currentPowerLevelsMap)
});
newPowerLevelMap.addAll({'events': eventsMap});
await client.setRoomStateWithKey(
@ -2229,14 +2220,14 @@ class Room {
/// `m.space`.
bool get isSpace =>
getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
RoomCreationTypes.mSpace; // TODO: Magic string!
RoomCreationTypes.mSpace;
/// The parents of this room. Currently this SDK doesn't yet set the canonical
/// flag and is not checking if this room is in fact a child of this space.
/// You should therefore not rely on this and always check the children of
/// the space.
List<SpaceParent> get spaceParents =>
states[EventTypes.spaceParent]
states[EventTypes.SpaceParent]
?.values
.map((state) => SpaceParent.fromState(state))
.where((child) => child.via.isNotEmpty)
@ -2249,7 +2240,7 @@ class Room {
/// sorted at the end of the list.
List<SpaceChild> get spaceChildren => !isSpace
? throw Exception('Room is not a space!')
: (states[EventTypes.spaceChild]
: (states[EventTypes.SpaceChild]
?.values
.map((state) => SpaceChild.fromState(state))
.where((child) => child.via.isNotEmpty)
@ -2268,12 +2259,12 @@ class Room {
}) async {
if (!isSpace) throw Exception('Room is not a space!');
via ??= [client.userID!.domain!];
await client.setRoomStateWithKey(id, EventTypes.spaceChild, roomId, {
await client.setRoomStateWithKey(id, EventTypes.SpaceChild, roomId, {
'via': via,
if (order != null) 'order': order,
if (suggested != null) 'suggested': suggested,
});
await client.setRoomStateWithKey(roomId, EventTypes.spaceParent, id, {
await client.setRoomStateWithKey(roomId, EventTypes.SpaceParent, id, {
'via': via,
});
return;

View File

@ -235,7 +235,6 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {
}
@override
// TODO: implement youAcceptedTheInvitation
String get youAcceptedTheInvitation => 'You accepted the invitation';
@override

View File

@ -26,7 +26,7 @@ class SpaceChild {
final bool? suggested;
SpaceChild.fromState(Event state)
: assert(state.type == EventTypes.spaceChild),
: assert(state.type == EventTypes.SpaceChild),
roomId = state.stateKey,
via = state.content.tryGetList<String>('via') ?? [],
order = state.content.tryGet<String>('order') ?? '',
@ -39,7 +39,7 @@ class SpaceParent {
final bool? canonical;
SpaceParent.fromState(Event state)
: assert(state.type == EventTypes.spaceParent),
: assert(state.type == EventTypes.SpaceParent),
roomId = state.stateKey,
via = state.content.tryGetList<String>('via') ?? [],
canonical = state.content.tryGet<bool>('canonical');

View File

@ -1,6 +1,14 @@
# VOIP for Matrix SDK
# Famedly Calls
1:1 and group calls
Supports
- 1:1 webrtc calls
- Group calls with:
- mesh webrtc calls
- just handling state of calls and signallnig for e2ee keys in sfu mode (check `isLivekitCall`)
Places where we diverted from spec afaik:
- To enable p2p calls between devices of the same user, pass a `invitee_device_id` to the `m.call.invite` method
- **to-device call events such as in msc3401 MUST `room_id` to map the event to a room**
## Overview
@ -8,13 +16,82 @@
`CallSession` objects are created by calling `inviteToCall` and `onCallInvite`.
`GroupCall` objects are created by calling `createGroupCall`.
`GroupCallSession` objects are created by calling `fetchOrCreateGroupCall`.
## Group Calls
All communication for group calls happens over to-device events except the `com.famedly.call.member` event.
Sends the `com.famedly.call.member` event to signal an active membership. The format has to be the following:
### Events -
```json5
"content": {
"memberships": [
{
"application": "m.call",
"backend": {
"type": "mesh"
},
"call_id": "!qoQQTYnzXOHSdEgqQp:im.staging.famedly.de",
"device_id": "YVGPEWNLDD",
"expires_ts": 1705152401042,
"scope": "m.room"
}
]
}
```
- **application**: could be anything f.ex `m.call`, `m.game` or `m.board`
- **backend**: see below
- **call_id**: the call id, currently setting it to the roomId makes the call for the whole room, this is to avoid parallel calls starting up. For user scoped calls in a room you could set this to `AuserId:BuserId`. The sdk does not restrict setting roomId for user scoped calls atm.
- **device_id**: The sdk supports calling between devices of same users, so this needs to be set to the sender device id.
- **expires_ts**: ms since epoch when this membership event should be considered expired. Check `lib/src/voip/utils/constants.dart` for current values of how long the inital period is and how often this gets autoupdated.
- **scope**: room scoped calls are `m.room`, user scoped can be `m.user`
#### The backend can be either `mesh` or `livekit`
##### Livekit -
```json5
"backend": {
"livekit_alias": "!qoQQTYnzXOHSdEgqQp:im.staging.famedly.de",
"livekit_service_url": "https://famedly-livekit-server.teedee.dev/jwt",
"type": "livekit"
},
```
##### Mesh -
```json5
"backend": {
"type": "mesh"
},
```
#### E2EE Events -
When in SFU/Livekit mode, the sdk can handle sending and requesting encryption keys. Currently it uses the following events:
- sending: `com.famedly.call.encryption_keys`
- requesting: `com.famedly.call.encryption_keys.request`
As usual remember to send the `party_id`/`sender_session_id` to map your keys to the right userId and deviceId
You need to implement `EncryptionKeyProvider` and set the override the methods to interact with your actual keyProvider. The main one as of now is `onSetEncryptionKey`.
You can request missing keys whenever needed using `groupCall.requestEncrytionKey(remoteParticipants)`.
## 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)
@ -79,11 +156,14 @@ We need to use the matrix roomId to initiate the call, the initial call can be
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.
You cannot call a whole room, please specify the userId you intend to call in `inviteToCall`
```dart
final voip = VoIP(client, MyVoipApp());
/// Create a new call
final newCall = await voip.inviteToCall(roomId, CallType.kVideo);
final newCall = await voip.inviteToCall(roomId, CallType.kVideo, userId);
newCall.onCallStateChanged.stream.listen((state) {
/// handle call state change event

View File

@ -0,0 +1,112 @@
import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
abstract class CallBackend {
String type;
CallBackend({
required this.type,
});
factory CallBackend.fromJson(Map<String, Object?> json) {
final String type = json['type'] as String;
if (type == 'mesh') {
return MeshBackend(
type: type,
);
} else if (type == 'livekit') {
return LiveKitBackend(
livekitAlias: json['livekit_alias'] as String,
livekitServiceUrl: json['livekit_service_url'] as String,
type: type,
);
} else {
throw ArgumentError('Invalid type: $type');
}
}
Map<String, Object?> toJson();
bool get e2eeEnabled;
CallParticipant? get activeSpeaker;
WrappedMediaStream? get localUserMediaStream;
WrappedMediaStream? get localScreenshareStream;
List<WrappedMediaStream> get userMediaStreams;
List<WrappedMediaStream> get screenShareStreams;
bool get isLocalVideoMuted;
bool get isMicrophoneMuted;
Future<WrappedMediaStream?> initLocalStream(
GroupCallSession groupCall, {
WrappedMediaStream? stream,
});
Future<void> updateMediaDeviceForCalls();
Future<void> setupP2PCallsWithExistingMembers(GroupCallSession groupCall);
Future<void> setupP2PCallWithNewMember(
GroupCallSession groupCall,
CallParticipant rp,
CallMembership mem,
);
Future<void> dispose(GroupCallSession groupCall);
Future<void> onNewParticipant(
GroupCallSession groupCall,
List<CallParticipant> anyJoined,
);
Future<void> onLeftParticipant(
GroupCallSession groupCall,
List<CallParticipant> anyLeft,
);
Future<void> requestEncrytionKey(
GroupCallSession groupCall,
List<CallParticipant> remoteParticipants,
);
Future<void> onCallEncryption(
GroupCallSession groupCall,
String userId,
String deviceId,
Map<String, dynamic> content,
);
Future<void> onCallEncryptionKeyRequest(
GroupCallSession groupCall,
String userId,
String deviceId,
Map<String, dynamic> content,
);
Future<void> setDeviceMuted(
GroupCallSession groupCall,
bool muted,
MediaInputKind kind,
);
Future<void> setScreensharingEnabled(
GroupCallSession groupCall,
bool enabled,
String desktopCapturerSourceId,
);
List<Map<String, String>>? getCurrentFeeds();
@override
bool operator ==(Object other);
@override
int get hashCode;
}

View File

@ -0,0 +1,506 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/crypto/crypto.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
class LiveKitBackend extends CallBackend {
final String livekitServiceUrl;
final String livekitAlias;
@override
final bool e2eeEnabled;
LiveKitBackend({
required this.livekitServiceUrl,
required this.livekitAlias,
super.type = 'livekit',
this.e2eeEnabled = true,
});
Timer? _memberLeaveEncKeyRotateDebounceTimer;
/// participant:keyIndex:keyBin
final Map<CallParticipant, Map<int, Uint8List>> _encryptionKeysMap = {};
final List<Future> _setNewKeyTimeouts = [];
int _indexCounter = 0;
/// used to send the key again incase someone `onCallEncryptionKeyRequest` but don't just send
/// the last one because you also cycle back in your window which means you
/// could potentially end up sharing a past key
int get latestLocalKeyIndex => _latestLocalKeyIndex;
int _latestLocalKeyIndex = 0;
/// the key currently being used by the local cryptor, can possibly not be the latest
/// key, check `latestLocalKeyIndex` for latest key
int get currentLocalKeyIndex => _currentLocalKeyIndex;
int _currentLocalKeyIndex = 0;
Map<int, Uint8List>? _getKeysForParticipant(CallParticipant participant) {
return _encryptionKeysMap[participant];
}
/// always chooses the next possible index, we cycle after 16 because
/// no real adv with infinite list
int _getNewEncryptionKeyIndex() {
final newIndex = _indexCounter % 16;
_indexCounter++;
return newIndex;
}
/// makes a new e2ee key for local user and sets it with a delay if specified
/// used on first join and when someone leaves
///
/// also does the sending for you
Future<void> _makeNewSenderKey(
GroupCallSession groupCall, bool delayBeforeUsingKeyOurself) async {
final key = secureRandomBytes(32);
final keyIndex = _getNewEncryptionKeyIndex();
Logs().i('[VOIP E2EE] Generated new key $key at index $keyIndex');
await _setEncryptionKey(
groupCall,
groupCall.localParticipant!,
keyIndex,
key,
delayBeforeUsingKeyOurself: delayBeforeUsingKeyOurself,
send: true,
);
}
/// also does the sending for you
Future<void> _ratchetLocalParticipantKey(
GroupCallSession groupCall,
List<CallParticipant> sendTo,
) async {
final keyProvider = groupCall.voip.delegate.keyProvider;
if (keyProvider == null) {
throw Exception('[VOIP] _ratchetKey called but KeyProvider was null');
}
final myKeys = _encryptionKeysMap[groupCall.localParticipant];
if (myKeys == null || myKeys.isEmpty) {
await _makeNewSenderKey(groupCall, false);
return;
}
Uint8List? ratchetedKey;
while (ratchetedKey == null || ratchetedKey.isEmpty) {
Logs().i('[VOIP E2EE] Ignoring empty ratcheted key');
ratchetedKey = await keyProvider.onRatchetKey(
groupCall.localParticipant!,
latestLocalKeyIndex,
);
}
Logs().i(
'[VOIP E2EE] Ratched latest key to $ratchetedKey at idx $latestLocalKeyIndex');
await _setEncryptionKey(
groupCall,
groupCall.localParticipant!,
latestLocalKeyIndex,
ratchetedKey,
delayBeforeUsingKeyOurself: false,
send: true,
sendTo: sendTo,
);
}
/// sets incoming keys and also sends the key if it was for the local user
/// if sendTo is null, its sent to all _participants, see `_sendEncryptionKeysEvent`
Future<void> _setEncryptionKey(
GroupCallSession groupCall,
CallParticipant participant,
int encryptionKeyIndex,
Uint8List encryptionKeyBin, {
bool delayBeforeUsingKeyOurself = false,
bool send = false,
List<CallParticipant>? sendTo,
}) async {
final encryptionKeys =
_encryptionKeysMap[participant] ?? <int, Uint8List>{};
encryptionKeys[encryptionKeyIndex] = encryptionKeyBin;
_encryptionKeysMap[participant] = encryptionKeys;
if (participant.isLocal) {
_latestLocalKeyIndex = encryptionKeyIndex;
}
if (send) {
await _sendEncryptionKeysEvent(
groupCall,
encryptionKeyIndex,
sendTo: sendTo,
);
}
if (delayBeforeUsingKeyOurself) {
// now wait for the key to propogate and then set it, hopefully users can
// stil decrypt everything
final useKeyTimeout = Future.delayed(CallTimeouts.useKeyDelay, () async {
Logs().i(
'[VOIP E2EE] setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin');
await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey(
participant, encryptionKeyBin, encryptionKeyIndex);
if (participant.isLocal) {
_currentLocalKeyIndex = encryptionKeyIndex;
}
});
_setNewKeyTimeouts.add(useKeyTimeout);
} else {
Logs().i(
'[VOIP E2EE] setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin');
await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey(
participant, encryptionKeyBin, encryptionKeyIndex);
if (participant.isLocal) {
_currentLocalKeyIndex = encryptionKeyIndex;
}
}
}
/// sends the enc key to the devices using todevice, passing a list of
/// sendTo only sends events to them
/// setting keyIndex to null will send the latestKey
Future<void> _sendEncryptionKeysEvent(
GroupCallSession groupCall,
int keyIndex, {
List<CallParticipant>? sendTo,
}) async {
Logs().i('Sending encryption keys event');
final myKeys = _getKeysForParticipant(groupCall.localParticipant!);
final myLatestKey = myKeys?[keyIndex];
final sendKeysTo =
sendTo ?? groupCall.participants.where((p) => !p.isLocal);
if (myKeys == null || myLatestKey == null) {
Logs().w(
'[VOIP E2EE] _sendEncryptionKeysEvent Tried to send encryption keys event but no keys found!');
await _makeNewSenderKey(groupCall, false);
await _sendEncryptionKeysEvent(
groupCall,
keyIndex,
sendTo: sendTo,
);
return;
}
try {
final keyContent = EncryptionKeysEventContent(
[EncryptionKeyEntry(keyIndex, base64Encode(myLatestKey))],
groupCall.groupCallId,
);
final Map<String, Object> data = {
...keyContent.toJson(),
// used to find group call in groupCalls when ToDeviceEvent happens,
// plays nicely with backwards compatibility for mesh calls
'conf_id': groupCall.groupCallId,
'device_id': groupCall.client.deviceID!,
'room_id': groupCall.room.id,
};
await _sendToDeviceEvent(
groupCall,
sendTo ?? sendKeysTo.toList(),
data,
EventTypes.GroupCallMemberEncryptionKeys,
);
} catch (e, s) {
Logs().e('[VOIP] Failed to send e2ee keys, retrying', e, s);
await _sendEncryptionKeysEvent(
groupCall,
keyIndex,
sendTo: sendTo,
);
}
}
Future<void> _sendToDeviceEvent(
GroupCallSession groupCall,
List<CallParticipant> remoteParticipants,
Map<String, Object> data,
String eventType,
) async {
Logs().v(
'[VOIP] _sendToDeviceEvent: sending ${data.toString()} to ${remoteParticipants.map((e) => e.id)} ');
final txid =
VoIP.customTxid ?? groupCall.client.generateUniqueTransactionId();
final mustEncrypt =
groupCall.room.encrypted && groupCall.client.encryptionEnabled;
// could just combine the two but do not want to rewrite the enc thingy
// wrappers here again.
final List<DeviceKeys> mustEncryptkeysToSendTo = [];
final Map<String, Map<String, Map<String, Object>>> unencryptedDataToSend =
{};
for (final participant in remoteParticipants) {
if (participant.deviceId == null) continue;
if (mustEncrypt) {
await groupCall.client.userDeviceKeysLoading;
final deviceKey = groupCall.client.userDeviceKeys[participant.userId]
?.deviceKeys[participant.deviceId];
if (deviceKey != null) {
mustEncryptkeysToSendTo.add(deviceKey);
}
} else {
unencryptedDataToSend.addAll({
participant.userId: {participant.deviceId!: data}
});
}
}
// prepped data, now we send
if (mustEncrypt) {
await groupCall.client.sendToDeviceEncrypted(
mustEncryptkeysToSendTo,
eventType,
data,
);
} else {
await groupCall.client.sendToDevice(
eventType,
txid,
unencryptedDataToSend,
);
}
}
@override
Map<String, Object?> toJson() {
return {
'type': type,
'livekit_service_url': livekitServiceUrl,
'livekit_alias': livekitAlias,
};
}
@override
Future<void> requestEncrytionKey(
GroupCallSession groupCall,
List<CallParticipant> remoteParticipants,
) async {
final Map<String, Object> data = {
'conf_id': groupCall.groupCallId,
'device_id': groupCall.client.deviceID!,
'room_id': groupCall.room.id,
};
await _sendToDeviceEvent(
groupCall,
remoteParticipants,
data,
EventTypes.GroupCallMemberEncryptionKeysRequest,
);
}
@override
Future<void> onCallEncryption(
GroupCallSession groupCall,
String userId,
String deviceId,
Map<String, dynamic> content,
) async {
if (!e2eeEnabled) {
Logs().w('[VOIP] got sframe key but we do not support e2ee');
return;
}
final keyContent = EncryptionKeysEventContent.fromJson(content);
final callId = keyContent.callId;
if (keyContent.keys.isEmpty) {
Logs().w(
'[VOIP E2EE] Received m.call.encryption_keys where keys is empty: callId=$callId');
return;
} else {
Logs().i(
'[VOIP E2EE]: onCallEncryption, got keys from $userId:$deviceId ${keyContent.toJson()}');
}
for (final key in keyContent.keys) {
final encryptionKey = key.key;
final encryptionKeyIndex = key.index;
await _setEncryptionKey(
groupCall,
CallParticipant(groupCall.voip, userId: userId, deviceId: deviceId),
encryptionKeyIndex,
// base64Decode here because we receive base64Encoded version
base64Decode(encryptionKey),
delayBeforeUsingKeyOurself: false,
send: false,
);
}
}
@override
Future<void> onCallEncryptionKeyRequest(
GroupCallSession groupCall,
String userId,
String deviceId,
Map<String, dynamic> content,
) async {
if (!e2eeEnabled) {
Logs().w('[VOIP] got sframe key request but we do not support e2ee');
return;
}
final mems = groupCall.room.getCallMembershipsForUser(userId);
if (mems
.where(
(mem) =>
mem.callId == groupCall.groupCallId &&
mem.userId == userId &&
mem.deviceId == deviceId &&
!mem.isExpired &&
// sanity checks
mem.backend.type == groupCall.backend.type &&
mem.roomId == groupCall.room.id &&
mem.application == groupCall.application,
)
.isNotEmpty) {
Logs().d(
'[VOIP] onCallEncryptionKeyRequest: request checks out, sending key on index: $latestLocalKeyIndex to $userId:$deviceId');
await _sendEncryptionKeysEvent(
groupCall,
_latestLocalKeyIndex,
sendTo: [
CallParticipant(
groupCall.voip,
userId: userId,
deviceId: deviceId,
)
],
);
}
}
@override
Future<void> onNewParticipant(
GroupCallSession groupCall,
List<CallParticipant> anyJoined,
) async {
if (!e2eeEnabled) return;
if (groupCall.voip.enableSFUE2EEKeyRatcheting) {
await _ratchetLocalParticipantKey(groupCall, anyJoined);
} else {
await _makeNewSenderKey(groupCall, true);
}
}
@override
Future<void> onLeftParticipant(
GroupCallSession groupCall,
List<CallParticipant> anyLeft,
) async {
_encryptionKeysMap.removeWhere((key, value) => anyLeft.contains(key));
// debounce it because people leave at the same time
if (_memberLeaveEncKeyRotateDebounceTimer != null) {
_memberLeaveEncKeyRotateDebounceTimer!.cancel();
}
_memberLeaveEncKeyRotateDebounceTimer =
Timer(CallTimeouts.makeKeyDelay, () async {
await _makeNewSenderKey(groupCall, true);
});
}
@override
Future<void> dispose(GroupCallSession groupCall) async {
// only remove our own, to save requesting if we join again, yes the other side
// will send it anyway but welp
_encryptionKeysMap.remove(groupCall.localParticipant!);
_currentLocalKeyIndex = 0;
_latestLocalKeyIndex = 0;
_memberLeaveEncKeyRotateDebounceTimer?.cancel();
}
@override
List<Map<String, String>>? getCurrentFeeds() {
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LiveKitBackend &&
type == other.type &&
livekitServiceUrl == other.livekitServiceUrl &&
livekitAlias == other.livekitAlias;
@override
int get hashCode =>
type.hashCode ^ livekitServiceUrl.hashCode ^ livekitAlias.hashCode;
/// get everything else from your livekit sdk in your client
@override
Future<WrappedMediaStream?> initLocalStream(GroupCallSession groupCall,
{WrappedMediaStream? stream}) async {
return null;
}
@override
CallParticipant? get activeSpeaker => null;
/// these are unimplemented on purpose so that you know you have
/// used the wrong method
@override
bool get isLocalVideoMuted =>
throw UnimplementedError('Use livekit sdk for this');
@override
bool get isMicrophoneMuted =>
throw UnimplementedError('Use livekit sdk for this');
@override
WrappedMediaStream? get localScreenshareStream =>
throw UnimplementedError('Use livekit sdk for this');
@override
WrappedMediaStream? get localUserMediaStream =>
throw UnimplementedError('Use livekit sdk for this');
@override
List<WrappedMediaStream> get screenShareStreams =>
throw UnimplementedError('Use livekit sdk for this');
@override
List<WrappedMediaStream> get userMediaStreams =>
throw UnimplementedError('Use livekit sdk for this');
@override
Future<void> setDeviceMuted(
GroupCallSession groupCall, bool muted, MediaInputKind kind) async {
return;
}
@override
Future<void> setScreensharingEnabled(GroupCallSession groupCall, bool enabled,
String desktopCapturerSourceId) async {
return;
}
@override
Future<void> setupP2PCallWithNewMember(GroupCallSession groupCall,
CallParticipant rp, CallMembership mem) async {
return;
}
@override
Future<void> setupP2PCallsWithExistingMembers(
GroupCallSession groupCall) async {
return;
}
@override
Future<void> updateMediaDeviceForCalls() async {
return;
}
}

View File

@ -0,0 +1,877 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
import 'package:matrix/src/voip/models/call_options.dart';
import 'package:matrix/src/voip/utils/stream_helper.dart';
import 'package:matrix/src/voip/utils/user_media_constraints.dart';
class MeshBackend extends CallBackend {
MeshBackend({
super.type = 'mesh',
});
final List<CallSession> _callSessions = [];
/// participant:volume
final Map<CallParticipant, double> _audioLevelsMap = {};
StreamSubscription<CallSession>? _callSubscription;
Timer? _activeSpeakerLoopTimeout;
final CachedStreamController<WrappedMediaStream> onStreamAdd =
CachedStreamController();
final CachedStreamController<WrappedMediaStream> onStreamRemoved =
CachedStreamController();
final CachedStreamController<GroupCallSession> onGroupCallFeedsChanged =
CachedStreamController();
@override
Map<String, Object?> toJson() {
return {
'type': type,
};
}
CallParticipant? _activeSpeaker;
WrappedMediaStream? _localUserMediaStream;
WrappedMediaStream? _localScreenshareStream;
final List<WrappedMediaStream> _userMediaStreams = [];
final List<WrappedMediaStream> _screenshareStreams = [];
List<WrappedMediaStream> _getLocalStreams() {
final feeds = <WrappedMediaStream>[];
if (localUserMediaStream != null) {
feeds.add(localUserMediaStream!);
}
if (localScreenshareStream != null) {
feeds.add(localScreenshareStream!);
}
return feeds;
}
Future<MediaStream> _getUserMedia(
GroupCallSession groupCall, CallType type) async {
final mediaConstraints = {
'audio': UserMediaConstraints.micMediaConstraints,
'video': type == CallType.kVideo
? UserMediaConstraints.camMediaConstraints
: false,
};
try {
return await groupCall.voip.delegate.mediaDevices
.getUserMedia(mediaConstraints);
} catch (e) {
groupCall.setState(GroupCallState.localCallFeedUninitialized);
rethrow;
}
}
Future<MediaStream> _getDisplayMedia(GroupCallSession groupCall) async {
final mediaConstraints = {
'audio': false,
'video': true,
};
try {
return await groupCall.voip.delegate.mediaDevices
.getDisplayMedia(mediaConstraints);
} catch (e, s) {
Logs().e('[VOIP] _getDisplayMedia failed because,', e, s);
rethrow;
}
}
CallSession? _getCallForParticipant(
GroupCallSession groupCall, CallParticipant participant) {
return _callSessions.singleWhereOrNull((call) =>
call.groupCallId == groupCall.groupCallId &&
CallParticipant(
groupCall.voip,
userId: call.remoteUserId!,
deviceId: call.remoteDeviceId,
) ==
participant);
}
Future<void> _addCall(GroupCallSession groupCall, CallSession call) async {
_callSessions.add(call);
await _initCall(groupCall, call);
groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged);
}
/// init a peer call from group calls.
Future<void> _initCall(GroupCallSession groupCall, CallSession call) async {
if (call.remoteUserId == null) {
throw Exception(
'Cannot init call without proper invitee user and device Id');
}
call.onCallStateChanged.stream.listen(((event) async {
await _onCallStateChanged(call, event);
}));
call.onCallReplaced.stream.listen((CallSession newCall) async {
await _replaceCall(groupCall, call, newCall);
});
call.onCallStreamsChanged.stream.listen((call) async {
await call.tryRemoveStopedStreams();
await _onStreamsChanged(groupCall, call);
});
call.onCallHangupNotifierForGroupCalls.stream.listen((event) async {
await _onCallHangup(groupCall, call);
});
call.onStreamAdd.stream.listen((stream) {
if (!stream.isLocal()) {
onStreamAdd.add(stream);
}
});
call.onStreamRemoved.stream.listen((stream) {
if (!stream.isLocal()) {
onStreamRemoved.add(stream);
}
});
}
Future<void> _replaceCall(
GroupCallSession groupCall,
CallSession existingCall,
CallSession replacementCall,
) async {
final existingCallIndex = _callSessions
.indexWhere((element) => element.callId == existingCall.callId);
if (existingCallIndex == -1) {
throw Exception('Couldn\'t find call to replace');
}
_callSessions.removeAt(existingCallIndex);
_callSessions.add(replacementCall);
await _disposeCall(groupCall, existingCall, CallErrorCode.replaced);
await _initCall(groupCall, replacementCall);
groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged);
}
/// Removes a peer call from group calls.
Future<void> _removeCall(GroupCallSession groupCall, CallSession call,
CallErrorCode hangupReason) async {
await _disposeCall(groupCall, call, hangupReason);
_callSessions.removeWhere((element) => call.callId == element.callId);
groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged);
}
Future<void> _disposeCall(GroupCallSession groupCall, CallSession call,
CallErrorCode hangupReason) async {
if (call.remoteUserId == null) {
throw Exception(
'Cannot init call without proper invitee user and device Id');
}
if (call.hangupReason == CallErrorCode.replaced) {
return;
}
if (call.state != CallState.kEnded) {
// no need to emit individual handleCallEnded on group calls
// also prevents a loop of hangup and onCallHangupNotifierForGroupCalls
await call.hangup(reason: hangupReason, shouldEmit: false);
}
final usermediaStream = _getUserMediaStreamByParticipantId(
CallParticipant(
groupCall.voip,
userId: call.remoteUserId!,
deviceId: call.remoteDeviceId,
).id,
);
if (usermediaStream != null) {
await _removeUserMediaStream(groupCall, usermediaStream);
}
final screenshareStream = _getScreenshareStreamByParticipantId(
CallParticipant(
groupCall.voip,
userId: call.remoteUserId!,
deviceId: call.remoteDeviceId,
).id,
);
if (screenshareStream != null) {
await _removeScreenshareStream(groupCall, screenshareStream);
}
}
Future<void> _onStreamsChanged(
GroupCallSession groupCall, CallSession call) async {
if (call.remoteUserId == null) {
throw Exception(
'Cannot init call without proper invitee user and device Id');
}
final currentUserMediaStream = _getUserMediaStreamByParticipantId(
CallParticipant(
groupCall.voip,
userId: call.remoteUserId!,
deviceId: call.remoteDeviceId,
).id,
);
final remoteUsermediaStream = call.remoteUserMediaStream;
final remoteStreamChanged = remoteUsermediaStream != currentUserMediaStream;
if (remoteStreamChanged) {
if (currentUserMediaStream == null && remoteUsermediaStream != null) {
await _addUserMediaStream(groupCall, remoteUsermediaStream);
} else if (currentUserMediaStream != null &&
remoteUsermediaStream != null) {
await _replaceUserMediaStream(
groupCall, currentUserMediaStream, remoteUsermediaStream);
} else if (currentUserMediaStream != null &&
remoteUsermediaStream == null) {
await _removeUserMediaStream(groupCall, currentUserMediaStream);
}
}
final currentScreenshareStream =
_getScreenshareStreamByParticipantId(CallParticipant(
groupCall.voip,
userId: call.remoteUserId!,
deviceId: call.remoteDeviceId,
).id);
final remoteScreensharingStream = call.remoteScreenSharingStream;
final remoteScreenshareStreamChanged =
remoteScreensharingStream != currentScreenshareStream;
if (remoteScreenshareStreamChanged) {
if (currentScreenshareStream == null &&
remoteScreensharingStream != null) {
_addScreenshareStream(groupCall, remoteScreensharingStream);
} else if (currentScreenshareStream != null &&
remoteScreensharingStream != null) {
await _replaceScreenshareStream(
groupCall, currentScreenshareStream, remoteScreensharingStream);
} else if (currentScreenshareStream != null &&
remoteScreensharingStream == null) {
await _removeScreenshareStream(groupCall, currentScreenshareStream);
}
}
onGroupCallFeedsChanged.add(groupCall);
}
WrappedMediaStream? _getUserMediaStreamByParticipantId(String participantId) {
final stream = _userMediaStreams
.where((stream) => stream.participant.id == participantId);
if (stream.isNotEmpty) {
return stream.first;
}
return null;
}
void _onActiveSpeakerLoop(GroupCallSession groupCall) async {
CallParticipant? nextActiveSpeaker;
// idc about screen sharing atm.
final userMediaStreamsCopyList =
List<WrappedMediaStream>.from(_userMediaStreams);
for (final stream in userMediaStreamsCopyList) {
if (stream.participant.isLocal && stream.pc == null) {
continue;
}
final List<StatsReport> statsReport = await stream.pc!.getStats();
statsReport
.removeWhere((element) => !element.values.containsKey('audioLevel'));
// https://www.w3.org/TR/webrtc-stats/#summary
final otherPartyAudioLevel = statsReport
.singleWhereOrNull((element) =>
element.type == 'inbound-rtp' &&
element.values['kind'] == 'audio')
?.values['audioLevel'];
if (otherPartyAudioLevel != null) {
_audioLevelsMap[stream.participant] = otherPartyAudioLevel;
}
// https://www.w3.org/TR/webrtc-stats/#dom-rtcstatstype-media-source
// firefox does not seem to have this though. Works on chrome and android
final ownAudioLevel = statsReport
.singleWhereOrNull((element) =>
element.type == 'media-source' &&
element.values['kind'] == 'audio')
?.values['audioLevel'];
if (groupCall.localParticipant != null &&
ownAudioLevel != null &&
_audioLevelsMap[groupCall.localParticipant] != ownAudioLevel) {
_audioLevelsMap[groupCall.localParticipant!] = ownAudioLevel;
}
}
double maxAudioLevel = double.negativeInfinity;
// TODO: we probably want a threshold here?
_audioLevelsMap.forEach((key, value) {
if (value > maxAudioLevel) {
nextActiveSpeaker = key;
maxAudioLevel = value;
}
});
if (nextActiveSpeaker != null && _activeSpeaker != nextActiveSpeaker) {
_activeSpeaker = nextActiveSpeaker;
groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged);
}
_activeSpeakerLoopTimeout?.cancel();
_activeSpeakerLoopTimeout = Timer(
CallConstants.activeSpeakerInterval,
() => _onActiveSpeakerLoop(groupCall),
);
}
WrappedMediaStream? _getScreenshareStreamByParticipantId(
String participantId) {
final stream = _screenshareStreams
.where((stream) => stream.participant.id == participantId);
if (stream.isNotEmpty) {
return stream.first;
}
return null;
}
void _addScreenshareStream(
GroupCallSession groupCall, WrappedMediaStream stream) {
_screenshareStreams.add(stream);
onStreamAdd.add(stream);
groupCall.onGroupCallEvent
.add(GroupCallStateChange.screenshareStreamsChanged);
}
Future<void> _replaceScreenshareStream(
GroupCallSession groupCall,
WrappedMediaStream existingStream,
WrappedMediaStream replacementStream,
) async {
final streamIndex = _screenshareStreams.indexWhere(
(stream) => stream.participant.id == existingStream.participant.id);
if (streamIndex == -1) {
throw Exception('Couldn\'t find screenshare stream to replace');
}
_screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]);
await existingStream.dispose();
groupCall.onGroupCallEvent
.add(GroupCallStateChange.screenshareStreamsChanged);
}
Future<void> _removeScreenshareStream(
GroupCallSession groupCall,
WrappedMediaStream stream,
) async {
final streamIndex = _screenshareStreams
.indexWhere((stream) => stream.participant.id == stream.participant.id);
if (streamIndex == -1) {
throw Exception('Couldn\'t find screenshare stream to remove');
}
_screenshareStreams.removeWhere(
(element) => element.participant.id == stream.participant.id);
onStreamRemoved.add(stream);
if (stream.isLocal()) {
await stopMediaStream(stream.stream);
}
groupCall.onGroupCallEvent
.add(GroupCallStateChange.screenshareStreamsChanged);
}
Future<void> _onCallStateChanged(CallSession call, CallState state) async {
final audioMuted = localUserMediaStream?.isAudioMuted() ?? true;
if (call.localUserMediaStream != null &&
call.isMicrophoneMuted != audioMuted) {
await call.setMicrophoneMuted(audioMuted);
}
final videoMuted = localUserMediaStream?.isVideoMuted() ?? true;
if (call.localUserMediaStream != null &&
call.isLocalVideoMuted != videoMuted) {
await call.setLocalVideoMuted(videoMuted);
}
}
Future<void> _onCallHangup(
GroupCallSession groupCall,
CallSession call,
) async {
if (call.hangupReason == CallErrorCode.replaced) {
return;
}
await _onStreamsChanged(groupCall, call);
await _removeCall(groupCall, call, call.hangupReason!);
}
Future<void> _addUserMediaStream(
GroupCallSession groupCall,
WrappedMediaStream stream,
) async {
_userMediaStreams.add(stream);
onStreamAdd.add(stream);
groupCall.onGroupCallEvent
.add(GroupCallStateChange.userMediaStreamsChanged);
}
Future<void> _replaceUserMediaStream(
GroupCallSession groupCall,
WrappedMediaStream existingStream,
WrappedMediaStream replacementStream,
) async {
final streamIndex = _userMediaStreams.indexWhere(
(stream) => stream.participant.id == existingStream.participant.id);
if (streamIndex == -1) {
throw Exception('Couldn\'t find user media stream to replace');
}
_userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]);
await existingStream.dispose();
groupCall.onGroupCallEvent
.add(GroupCallStateChange.userMediaStreamsChanged);
}
Future<void> _removeUserMediaStream(
GroupCallSession groupCall,
WrappedMediaStream stream,
) async {
final streamIndex = _userMediaStreams.indexWhere(
(element) => element.participant.id == stream.participant.id);
if (streamIndex == -1) {
throw Exception('Couldn\'t find user media stream to remove');
}
_userMediaStreams.removeWhere(
(element) => element.participant.id == stream.participant.id);
_audioLevelsMap.remove(stream.participant);
onStreamRemoved.add(stream);
if (stream.isLocal()) {
await stopMediaStream(stream.stream);
}
groupCall.onGroupCallEvent
.add(GroupCallStateChange.userMediaStreamsChanged);
if (_activeSpeaker == stream.participant && _userMediaStreams.isNotEmpty) {
_activeSpeaker = _userMediaStreams[0].participant;
groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged);
}
}
@override
bool get e2eeEnabled => false;
@override
CallParticipant? get activeSpeaker => _activeSpeaker;
@override
WrappedMediaStream? get localUserMediaStream => _localUserMediaStream;
@override
WrappedMediaStream? get localScreenshareStream => _localScreenshareStream;
@override
List<WrappedMediaStream> get userMediaStreams =>
List.unmodifiable(_userMediaStreams);
@override
List<WrappedMediaStream> get screenShareStreams =>
List.unmodifiable(_screenshareStreams);
@override
Future<void> updateMediaDeviceForCalls() async {
for (final call in _callSessions) {
await call.updateMediaDeviceForCall();
}
}
/// Initializes the local user media stream.
/// The media stream must be prepared before the group call enters.
/// if you allow the user to configure their camera and such ahead of time,
/// you can pass that `stream` on to this function.
/// This allows you to configure the camera before joining the call without
/// having to reopen the stream and possibly losing settings.
@override
Future<WrappedMediaStream?> initLocalStream(GroupCallSession groupCall,
{WrappedMediaStream? stream}) async {
if (groupCall.state != GroupCallState.localCallFeedUninitialized) {
throw Exception(
'Cannot initialize local call feed in the ${groupCall.state} state.');
}
groupCall.setState(GroupCallState.initializingLocalCallFeed);
WrappedMediaStream localWrappedMediaStream;
if (stream == null) {
MediaStream stream;
try {
stream = await _getUserMedia(groupCall, CallType.kVideo);
} catch (error) {
groupCall.setState(GroupCallState.localCallFeedUninitialized);
rethrow;
}
localWrappedMediaStream = WrappedMediaStream(
stream: stream,
participant: groupCall.localParticipant!,
room: groupCall.room,
client: groupCall.client,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: stream.getAudioTracks().isEmpty,
videoMuted: stream.getVideoTracks().isEmpty,
isGroupCall: true,
voip: groupCall.voip,
);
} else {
localWrappedMediaStream = stream;
}
_localUserMediaStream = localWrappedMediaStream;
await _addUserMediaStream(groupCall, localWrappedMediaStream);
groupCall.setState(GroupCallState.localCallFeedInitialized);
_activeSpeaker = null;
return localWrappedMediaStream;
}
@override
Future<void> setDeviceMuted(
GroupCallSession groupCall, bool muted, MediaInputKind kind) async {
if (!await hasMediaDevice(groupCall.voip.delegate, kind)) {
return;
}
if (localUserMediaStream != null) {
switch (kind) {
case MediaInputKind.audioinput:
localUserMediaStream!.setAudioMuted(muted);
setTracksEnabled(
localUserMediaStream!.stream!.getAudioTracks(), !muted);
for (final call in _callSessions) {
await call.setMicrophoneMuted(muted);
}
break;
case MediaInputKind.videoinput:
localUserMediaStream!.setVideoMuted(muted);
setTracksEnabled(
localUserMediaStream!.stream!.getVideoTracks(), !muted);
for (final call in _callSessions) {
await call.setLocalVideoMuted(muted);
}
break;
default:
}
}
groupCall.onGroupCallEvent.add(GroupCallStateChange.localMuteStateChanged);
return;
}
Future<void> _onIncomingCall(
GroupCallSession groupCall, CallSession newCall) async {
// The incoming calls may be for another room, which we will ignore.
if (newCall.room.id != groupCall.room.id) {
return;
}
if (newCall.state != CallState.kRinging) {
Logs().w('Incoming call no longer in ringing state. Ignoring.');
return;
}
if (newCall.groupCallId == null ||
newCall.groupCallId != groupCall.groupCallId) {
Logs().v(
'Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn\'t match the current group call');
await newCall.reject();
return;
}
final existingCall = _getCallForParticipant(
groupCall,
CallParticipant(
groupCall.voip,
userId: newCall.remoteUserId!,
deviceId: newCall.remoteDeviceId,
),
);
if (existingCall != null && existingCall.callId == newCall.callId) {
return;
}
Logs().v(
'GroupCallSession: incoming call from: ${newCall.remoteUserId}${newCall.remoteDeviceId}${newCall.remotePartyId}');
// Check if the user calling has an existing call and use this call instead.
if (existingCall != null) {
await _replaceCall(groupCall, existingCall, newCall);
} else {
await _addCall(groupCall, newCall);
}
await newCall.answerWithStreams(_getLocalStreams());
}
@override
Future<void> setScreensharingEnabled(
GroupCallSession groupCall,
bool enabled,
String desktopCapturerSourceId,
) async {
if (enabled == (localScreenshareStream != null)) {
return;
}
if (enabled) {
try {
Logs().v('Asking for screensharing permissions...');
final stream = await _getDisplayMedia(groupCall);
for (final track in stream.getTracks()) {
// screen sharing should only have 1 video track anyway, so this only
// fires once
track.onEnded = () async {
await setScreensharingEnabled(groupCall, false, '');
};
}
Logs().v(
'Screensharing permissions granted. Setting screensharing enabled on all calls');
_localScreenshareStream = WrappedMediaStream(
stream: stream,
participant: groupCall.localParticipant!,
room: groupCall.room,
client: groupCall.client,
purpose: SDPStreamMetadataPurpose.Screenshare,
audioMuted: stream.getAudioTracks().isEmpty,
videoMuted: stream.getVideoTracks().isEmpty,
isGroupCall: true,
voip: groupCall.voip,
);
_addScreenshareStream(groupCall, localScreenshareStream!);
groupCall.onGroupCallEvent
.add(GroupCallStateChange.localScreenshareStateChanged);
for (final call in _callSessions) {
await call.addLocalStream(
await localScreenshareStream!.stream!.clone(),
localScreenshareStream!.purpose);
}
await groupCall.sendMemberStateEvent();
return;
} catch (e, s) {
Logs().e('[VOIP] Enabling screensharing error', e, s);
groupCall.onGroupCallEvent.add(GroupCallStateChange.error);
return;
}
} else {
for (final call in _callSessions) {
await call.removeLocalStream(call.localScreenSharingStream!);
}
await stopMediaStream(localScreenshareStream?.stream);
await _removeScreenshareStream(groupCall, localScreenshareStream!);
_localScreenshareStream = null;
await groupCall.sendMemberStateEvent();
groupCall.onGroupCallEvent
.add(GroupCallStateChange.localMuteStateChanged);
return;
}
}
@override
Future<void> dispose(GroupCallSession groupCall) async {
if (localUserMediaStream != null) {
await _removeUserMediaStream(groupCall, localUserMediaStream!);
_localUserMediaStream = null;
}
if (localScreenshareStream != null) {
await stopMediaStream(localScreenshareStream!.stream);
await _removeScreenshareStream(groupCall, localScreenshareStream!);
_localScreenshareStream = null;
}
// removeCall removes it from `_callSessions` later.
final callsCopy = _callSessions.toList();
for (final call in callsCopy) {
await _removeCall(groupCall, call, CallErrorCode.userHangup);
}
_activeSpeaker = null;
_activeSpeakerLoopTimeout?.cancel();
await _callSubscription?.cancel();
}
@override
bool get isLocalVideoMuted {
if (localUserMediaStream != null) {
return localUserMediaStream!.isVideoMuted();
}
return true;
}
@override
bool get isMicrophoneMuted {
if (localUserMediaStream != null) {
return localUserMediaStream!.isAudioMuted();
}
return true;
}
@override
Future<void> setupP2PCallsWithExistingMembers(
GroupCallSession groupCall) async {
for (final call in _callSessions) {
await _onIncomingCall(groupCall, call);
}
_callSubscription = groupCall.voip.onIncomingCall.stream.listen(
(newCall) => _onIncomingCall(groupCall, newCall),
);
_onActiveSpeakerLoop(groupCall);
}
@override
Future<void> setupP2PCallWithNewMember(
GroupCallSession groupCall,
CallParticipant rp,
CallMembership mem,
) async {
final existingCall = _getCallForParticipant(groupCall, rp);
if (existingCall != null) {
if (existingCall.remoteSessionId != mem.membershipId) {
await existingCall.hangup(reason: CallErrorCode.unknownError);
} else {
Logs().e(
'[VOIP] onMemberStateChanged Not updating _participants list, already have a ongoing call with ${rp.id}');
return;
}
}
// Only initiate a call with a participant who has a id that is lexicographically
// less than your own. Otherwise, that user will call you.
if (groupCall.localParticipant!.id.compareTo(rp.id) > 0) {
Logs().i('[VOIP] Waiting for ${rp.id} to send call invite.');
return;
}
final opts = CallOptions(
callId: genCallID(),
room: groupCall.room,
voip: groupCall.voip,
dir: CallDirection.kOutgoing,
localPartyId: groupCall.voip.currentSessionId,
groupCallId: groupCall.groupCallId,
type: CallType.kVideo,
iceServers: await groupCall.voip.getIceServers(),
);
final newCall = groupCall.voip.createNewCall(opts);
/// both invitee userId and deviceId are set here because there can be
/// multiple devices from same user in a call, so we specifiy who the
/// invite is for
///
/// MOVE TO CREATENEWCALL?
newCall.remoteUserId = mem.userId;
newCall.remoteDeviceId = mem.deviceId;
// party id set to when answered
newCall.remoteSessionId = mem.membershipId;
await newCall.placeCallWithStreams(_getLocalStreams(),
requestScreenSharing: mem.feeds?.any((element) =>
element['purpose'] == SDPStreamMetadataPurpose.Screenshare) ??
false);
await _addCall(groupCall, newCall);
}
@override
List<Map<String, String>>? getCurrentFeeds() {
return _getLocalStreams()
.map((feed) => ({
'purpose': feed.purpose,
}))
.toList();
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is MeshBackend && type == other.type;
@override
int get hashCode => type.hashCode;
/// get everything is livekit specific mesh calls shouldn't be affected by these
@override
Future<void> onCallEncryption(GroupCallSession groupCall, String userId,
String deviceId, Map<String, dynamic> content) async {
return;
}
@override
Future<void> onCallEncryptionKeyRequest(GroupCallSession groupCall,
String userId, String deviceId, Map<String, dynamic> content) async {
return;
}
@override
Future<void> onLeftParticipant(
GroupCallSession groupCall, List<CallParticipant> anyLeft) async {
return;
}
@override
Future<void> onNewParticipant(
GroupCallSession groupCall, List<CallParticipant> anyJoined) async {
return;
}
@override
Future<void> requestEncrytionKey(GroupCallSession groupCall,
List<CallParticipant> remoteParticipants) async {
return;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,272 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General License for more details.
*
* You should have received a copy of the GNU Affero General License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:core';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
import 'package:matrix/src/voip/models/voip_id.dart';
import 'package:matrix/src/voip/utils/stream_helper.dart';
/// Holds methods for managing a group call. This class is also responsible for
/// holding and managing the individual `CallSession`s in a group call.
class GroupCallSession {
// Config
final Client client;
final VoIP voip;
final Room room;
/// is a list of backend to allow passing multiple backend in the future
/// we use the first backend everywhere as of now
final CallBackend backend;
/// something like normal calls or thirdroom
final String? application;
/// either room scoped or user scoped calls
final String? scope;
GroupCallState state = GroupCallState.localCallFeedUninitialized;
CallParticipant? get localParticipant => voip.localParticipant;
List<CallParticipant> get participants => List.unmodifiable(_participants);
final List<CallParticipant> _participants = [];
String groupCallId;
final CachedStreamController<GroupCallState> onGroupCallState =
CachedStreamController();
final CachedStreamController<GroupCallStateChange> onGroupCallEvent =
CachedStreamController();
Timer? _resendMemberStateEventTimer;
factory GroupCallSession.withAutoGenId(
Room room,
VoIP voip,
CallBackend backend,
String? application,
String? scope,
String? groupCallId,
) {
return GroupCallSession(
client: room.client,
room: room,
voip: voip,
backend: backend,
application: application ?? 'm.call',
scope: scope ?? 'm.room',
groupCallId: groupCallId ?? genCallID(),
);
}
GroupCallSession({
required this.client,
required this.room,
required this.voip,
required this.backend,
required this.groupCallId,
required this.application,
required this.scope,
});
String get avatarName =>
_getUser().calcDisplayname(mxidLocalPartFallback: false);
String? get displayName => _getUser().displayName;
User _getUser() {
return room.unsafeGetUserFromMemoryOrFallback(client.userID!);
}
void setState(GroupCallState newState) {
state = newState;
onGroupCallState.add(newState);
onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged);
}
bool hasLocalParticipant() {
return _participants.contains(localParticipant);
}
/// enter the group call.
Future<void> enter({WrappedMediaStream? stream}) async {
if (!(state == GroupCallState.localCallFeedUninitialized ||
state == GroupCallState.localCallFeedInitialized)) {
throw Exception('Cannot enter call in the $state state');
}
if (state == GroupCallState.localCallFeedUninitialized) {
await backend.initLocalStream(this, stream: stream);
}
await sendMemberStateEvent();
setState(GroupCallState.entered);
Logs().v('Entered group call $groupCallId');
// Set up _participants for the members currently in the call.
// Other members will be picked up by the RoomState.members event.
await onMemberStateChanged();
await backend.setupP2PCallsWithExistingMembers(this);
voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId);
await voip.delegate.handleNewGroupCall(this);
}
Future<void> leave() async {
await removeMemberStateEvent();
await backend.dispose(this);
setState(GroupCallState.localCallFeedUninitialized);
voip.currentGroupCID = null;
_participants.clear();
voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId));
await voip.delegate.handleGroupCallEnded(this);
_resendMemberStateEventTimer?.cancel();
setState(GroupCallState.ended);
}
Future<void> sendMemberStateEvent() async {
await room.updateFamedlyCallMemberStateEvent(
CallMembership(
userId: client.userID!,
roomId: room.id,
callId: groupCallId,
application: application,
scope: scope,
backend: backend,
deviceId: client.deviceID!,
expiresTs: DateTime.now()
.add(CallTimeouts.expireTsBumpDuration)
.millisecondsSinceEpoch,
membershipId: voip.currentSessionId,
feeds: backend.getCurrentFeeds(),
),
);
if (_resendMemberStateEventTimer != null) {
_resendMemberStateEventTimer!.cancel();
}
_resendMemberStateEventTimer = Timer.periodic(
CallTimeouts.updateExpireTsTimerDuration, ((timer) async {
Logs().d('sendMemberStateEvent updating member event with timer');
if (state != GroupCallState.ended ||
state != GroupCallState.localCallFeedUninitialized) {
await sendMemberStateEvent();
} else {
Logs().d(
'[VOIP] deteceted groupCall in state $state, removing state event');
await removeMemberStateEvent();
}
}));
}
Future<void> removeMemberStateEvent() {
if (_resendMemberStateEventTimer != null) {
Logs().d('resend member event timer cancelled');
_resendMemberStateEventTimer!.cancel();
_resendMemberStateEventTimer = null;
}
return room.removeFamedlyCallMemberEvent(
groupCallId,
client.deviceID!,
application: application,
scope: scope,
);
}
/// compltetely rebuilds the local _participants list
Future<void> onMemberStateChanged() async {
if (state != GroupCallState.entered) {
Logs().d(
'[VOIP] early return onMemberStateChanged, group call state is not Entered. Actual state: ${state.toString()} ');
return;
}
// The member events may be received for another room, which we will ignore.
final mems =
room.getCallMembershipsFromRoom().values.expand((element) => element);
final memsForCurrentGroupCall = mems.where((element) {
return element.callId == groupCallId &&
!element.isExpired &&
element.application == application &&
element.scope == scope &&
element.roomId == room.id; // sanity checks
}).toList();
final ignoredMems =
mems.where((element) => !memsForCurrentGroupCall.contains(element));
for (final mem in ignoredMems) {
Logs().w(
'[VOIP] Ignored ${mem.userId}\'s mem event ${mem.toJson()} while updating _participants list for callId: $groupCallId, expiry status: ${mem.isExpired}');
}
final List<CallParticipant> newP = [];
for (final mem in memsForCurrentGroupCall) {
final rp = CallParticipant(
voip,
userId: mem.userId,
deviceId: mem.deviceId,
);
newP.add(rp);
if (rp.isLocal) continue;
if (state != GroupCallState.entered) {
Logs().w(
'[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update');
continue;
}
await backend.setupP2PCallWithNewMember(this, rp, mem);
}
final newPcopy = List<CallParticipant>.from(newP);
final oldPcopy = List<CallParticipant>.from(_participants);
final anyJoined = newPcopy.where((element) => !oldPcopy.contains(element));
final anyLeft = oldPcopy.where((element) => !newPcopy.contains(element));
if (anyJoined.isNotEmpty || anyLeft.isNotEmpty) {
if (anyJoined.isNotEmpty) {
Logs().d('anyJoined: ${anyJoined.map((e) => e.id).toString()}');
_participants.addAll(anyJoined);
await backend.onNewParticipant(this, anyJoined.toList());
}
if (anyLeft.isNotEmpty) {
Logs().d('anyLeft: ${anyLeft.map((e) => e.id).toString()}');
for (final leftp in anyLeft) {
_participants.remove(leftp);
}
await backend.onLeftParticipant(this, anyLeft.toList());
}
onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
Logs().d(
'[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}');
}
}
}

View File

@ -60,12 +60,12 @@ class CallReplaces {
target_user: CallReplacesTarget.fromJson(json['target_user']),
);
Map<String, dynamic> toJson() => {
if (replacement_id != null) 'replacement_id': replacement_id,
Map<String, Object> toJson() => {
if (replacement_id != null) 'replacement_id': replacement_id!,
if (target_user != null) 'target_user': target_user!.toJson(),
if (create_call != null) 'create_call': create_call,
if (await_call != null) 'await_call': await_call,
if (target_room != null) 'target_room': target_room,
if (create_call != null) 'create_call': create_call!,
if (await_call != null) 'await_call': await_call!,
if (target_room != null) 'target_room': target_room!,
};
}

View File

@ -0,0 +1,124 @@
import 'package:matrix/matrix.dart';
class FamedlyCallMemberEvent {
final List<CallMembership> memberships;
FamedlyCallMemberEvent({required this.memberships});
Map<String, dynamic> toJson() {
return {'memberships': memberships.map((e) => e.toJson()).toList()};
}
factory FamedlyCallMemberEvent.fromJson(Event event) {
final List<CallMembership> callMemberships = [];
final memberships = event.content.tryGetList('memberships');
if (memberships != null && memberships.isNotEmpty) {
for (final mem in memberships) {
if (isValidMemEvent(mem)) {
final callMem =
CallMembership.fromJson(mem, event.senderId, event.room.id);
if (callMem != null) callMemberships.add(callMem);
}
}
}
return FamedlyCallMemberEvent(memberships: callMemberships);
}
}
class CallMembership {
final String userId;
final String callId;
final String? application;
final String? scope;
final CallBackend backend;
final String deviceId;
final int expiresTs;
final String membershipId;
final List? feeds;
final String roomId;
CallMembership({
required this.userId,
required this.callId,
required this.backend,
required this.deviceId,
required this.expiresTs,
required this.roomId,
required this.membershipId,
this.application = 'm.call',
this.scope = 'm.room',
this.feeds,
});
Map<String, dynamic> toJson() {
return {
'call_id': callId,
'application': application,
'scope': scope,
'foci_active': [backend.toJson()],
'device_id': deviceId,
'expires_ts': expiresTs,
'expires': 7200000, // element compatibiltiy remove asap
'membershipID': membershipId, // sessionId
if (feeds != null) 'feeds': feeds,
};
}
static CallMembership? fromJson(Map json, String userId, String roomId) {
try {
return CallMembership(
userId: userId,
roomId: roomId,
callId: json['call_id'],
application: json['application'],
scope: json['scope'],
backend: (json['foci_active'] as List)
.map((e) => CallBackend.fromJson(e))
.first,
deviceId: json['device_id'],
expiresTs: json['expires_ts'],
membershipId:
json['membershipID'] ?? 'someone_forgot_to_set_the_membershipID',
feeds: json['feeds'],
);
} catch (e, s) {
Logs().e('[VOIP] call membership parsing failed. $json', e, s);
return null;
}
}
@override
bool operator ==(other) =>
identical(this, other) ||
other is CallMembership &&
runtimeType == other.runtimeType &&
userId == other.userId &&
roomId == other.roomId &&
callId == other.callId &&
application == other.application &&
scope == other.scope &&
backend.type == other.backend.type &&
deviceId == other.deviceId &&
membershipId == other.membershipId;
@override
int get hashCode =>
userId.hashCode ^
roomId.hashCode ^
callId.hashCode ^
application.hashCode ^
scope.hashCode ^
backend.type.hashCode ^
deviceId.hashCode ^
membershipId.hashCode;
// with a buffer of 1 minute just incase we were slow to process a
// call event, if the device is actually dead it should
// get removed pretty soon
bool get isExpired =>
expiresTs <
DateTime.now()
.subtract(CallTimeouts.expireTsBumpDuration)
.millisecondsSinceEpoch;
}

View File

@ -0,0 +1,26 @@
import 'package:matrix/matrix.dart';
/// Initialization parameters of the call session.
class CallOptions {
final String callId;
final CallType type;
final CallDirection dir;
/// client.deviceID
final String localPartyId;
final VoIP voip;
final Room room;
final List<Map<String, dynamic>> iceServers;
final String? groupCallId;
CallOptions({
required this.callId,
required this.type,
required this.dir,
required this.localPartyId,
required this.voip,
required this.room,
required this.iceServers,
this.groupCallId,
});
}

View File

@ -0,0 +1,39 @@
import 'package:matrix/matrix.dart';
class CallParticipant {
final VoIP voip;
final String userId;
final String? deviceId;
CallParticipant(
this.voip, {
required this.userId,
this.deviceId,
});
bool get isLocal =>
userId == voip.client.userID && deviceId == voip.client.deviceID;
String get id {
String pid = userId;
if (deviceId != null) {
pid += ':$deviceId';
}
return pid;
}
@override
String toString() {
return id;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CallParticipant &&
userId == other.userId &&
deviceId == other.deviceId;
@override
int get hashCode => userId.hashCode ^ deviceId.hashCode;
}

View File

@ -0,0 +1,55 @@
import 'dart:typed_data';
import 'package:matrix/matrix.dart';
enum E2EEKeyMode {
kNone,
kSharedKey,
kPerParticipant,
}
abstract class EncryptionKeyProvider {
Future<void> onSetEncryptionKey(
CallParticipant participant, Uint8List key, int index);
Future<Uint8List> onRatchetKey(CallParticipant participant, int index);
Future<Uint8List> onExportKey(CallParticipant participant, int index);
}
class EncryptionKeyEntry {
final int index;
final String key;
EncryptionKeyEntry(this.index, this.key);
factory EncryptionKeyEntry.fromJson(Map<String, dynamic> json) =>
EncryptionKeyEntry(
json['index'] as int,
json['key'] as String,
);
Map<String, dynamic> toJson() => {
'index': index,
'key': key,
};
}
class EncryptionKeysEventContent {
// Get the participant info from todevice message params
final List<EncryptionKeyEntry> keys;
final String callId;
EncryptionKeysEventContent(this.keys, this.callId);
factory EncryptionKeysEventContent.fromJson(Map<String, dynamic> json) =>
EncryptionKeysEventContent(
(json['keys'] as List<dynamic>)
.map(
(e) => EncryptionKeyEntry.fromJson(e as Map<String, dynamic>))
.toList(),
json['call_id'] as String);
Map<String, dynamic> toJson() => {
'keys': keys.map((e) => e.toJson()).toList(),
'call_id': callId,
};
}

View File

@ -0,0 +1,24 @@
class VoipId {
final String roomId;
final String callId;
String get id => '$roomId:$callId';
factory VoipId.fromId(String id) {
final int lastIndex = id.lastIndexOf(':');
return VoipId(
roomId: id.substring(0, lastIndex),
callId: id.substring(lastIndex + 1),
);
}
VoipId({required this.roomId, required this.callId});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is VoipId && roomId == other.roomId && callId == other.callId;
@override
int get hashCode => roomId.hashCode ^ callId.hashCode;
}

View File

@ -0,0 +1,25 @@
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';
/// Delegate WebRTC basic functionality.
abstract class WebRTCDelegate {
MediaDevices get mediaDevices;
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration,
[Map<String, dynamic> constraints = const {}]);
Future<void> playRingtone();
Future<void> stopRingtone();
Future<void> handleNewCall(CallSession session);
Future<void> handleCallEnded(CallSession session);
Future<void> handleMissedCall(CallSession session);
Future<void> handleNewGroupCall(GroupCallSession groupCall);
Future<void> handleGroupCallEnded(GroupCallSession 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;
EncryptionKeyProvider? get keyProvider;
}

View File

@ -1,47 +0,0 @@
import 'dart:async';
import 'package:random_string/random_string.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';
Future<void> stopMediaStream(MediaStream? stream) async {
if (stream != null) {
for (final track in stream.getTracks()) {
try {
await track.stop();
} catch (e, s) {
Logs().e('[VOIP] stopping track ${track.id} failed', e, s);
}
}
try {
await stream.dispose();
} catch (e, s) {
Logs().e('[VOIP] disposing stream ${stream.id} failed', e, s);
}
}
}
void setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) {
for (final element in tracks) {
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)}';
}

View File

@ -12,7 +12,7 @@ class ConnectionTester {
TurnServerCredentials? _turnServerCredentials;
Future<bool> verifyTurnServer() async {
final iceServers = await getIceSevers();
final iceServers = await getIceServers();
final configuration = <String, dynamic>{
'iceServers': iceServers,
'sdpSemantics': 'unified-plan',
@ -95,7 +95,7 @@ class ConnectionTester {
return iterations;
}
Future<List<Map<String, dynamic>>> getIceSevers() async {
Future<List<Map<String, dynamic>>> getIceServers() async {
if (_turnServerCredentials == null) {
try {
_turnServerCredentials = await client.getTurnServer();

View File

@ -0,0 +1,167 @@
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
extension FamedlyCallMemberEventsExtension on Room {
/// a map of every users famedly call event, holds the memberships list
/// returns sorted according to originTs (oldest to newest)
Map<String, FamedlyCallMemberEvent> getFamedlyCallEvents() {
final Map<String, FamedlyCallMemberEvent> mappedEvents = {};
final famedlyCallMemberStates =
states.tryGetMap<String, Event>(EventTypes.GroupCallMember);
if (famedlyCallMemberStates == null) return {};
final sortedEvents = famedlyCallMemberStates.values
.sorted((a, b) => a.originServerTs.compareTo(b.originServerTs));
for (final element in sortedEvents) {
mappedEvents
.addAll({element.senderId: FamedlyCallMemberEvent.fromJson(element)});
}
return mappedEvents;
}
/// extracts memberships list form a famedly call event and maps it to a userid
/// returns sorted (oldest to newest)
Map<String, List<CallMembership>> getCallMembershipsFromRoom() {
final parsedMemberEvents = getFamedlyCallEvents();
final Map<String, List<CallMembership>> memberships = {};
for (final element in parsedMemberEvents.entries) {
memberships.addAll({element.key: element.value.memberships});
}
return memberships;
}
/// returns a list of memberships in the room for `user`
List<CallMembership> getCallMembershipsForUser(String userId) {
final parsedMemberEvents = getCallMembershipsFromRoom();
final mem = parsedMemberEvents.tryGet<List<CallMembership>>(userId);
return mem ?? [];
}
/// returns the user count (not sessions, yet) for the group call with id: `groupCallId`.
/// returns 0 if group call not found
int groupCallParticipantCount(String groupCallId) {
int participantCount = 0;
// userid:membership
final memberships = getCallMembershipsFromRoom();
memberships.forEach((key, value) {
for (final membership in value) {
if (membership.callId == groupCallId && !membership.isExpired) {
participantCount++;
}
}
});
return participantCount;
}
bool get hasActiveGroupCall {
if (activeGroupCallIds.isNotEmpty) {
return true;
}
return false;
}
/// list of active group call ids
List<String> get activeGroupCallIds {
final Set<String> ids = {};
final memberships = getCallMembershipsFromRoom();
memberships.forEach((key, value) {
for (final mem in value) {
if (!mem.isExpired) ids.add(mem.callId);
}
});
return ids.toList();
}
/// passing no `CallMembership` removes it from the state event.
Future<void> updateFamedlyCallMemberStateEvent(
CallMembership callMembership) async {
final ownMemberships = getCallMembershipsForUser(client.userID!);
// do not bother removing other deviceId expired events because we have no
// ownership over them
ownMemberships
.removeWhere((element) => client.deviceID! == element.deviceId);
ownMemberships.removeWhere((e) => e == callMembership);
ownMemberships.add(callMembership);
final newContent = {
'memberships': List.from(ownMemberships.map((e) => e.toJson()))
};
await setFamedlyCallMemberEvent(newContent);
}
Future<void> removeFamedlyCallMemberEvent(
String groupCallId,
String deviceId, {
String? application = 'm.call',
String? scope = 'm.room',
}) async {
final ownMemberships = getCallMembershipsForUser(client.userID!);
ownMemberships.removeWhere((mem) =>
mem.callId == groupCallId &&
mem.deviceId == deviceId &&
mem.application == application &&
mem.scope == scope);
final newContent = {
'memberships': List.from(ownMemberships.map((e) => e.toJson()))
};
await setFamedlyCallMemberEvent(newContent);
}
Future<void> setFamedlyCallMemberEvent(Map<String, List> newContent) async {
if (groupCallsEnabledForEveryone) {
await client.setRoomStateWithKey(
id,
EventTypes.GroupCallMember,
client.userID!,
newContent,
);
} else {
Logs().w(
'[VOIP] cannot send ${EventTypes.GroupCallMember} events in room: $id, fix your PLs');
}
}
/// returns a list of memberships from a famedly call matrix event
List<CallMembership> getCallMembershipsFromEvent(MatrixEvent event) {
if (event.roomId != id) return [];
return getCallMembershipsFromEventContent(
event.content, event.senderId, event.roomId!);
}
/// returns a list of memberships from a famedly call matrix event
List<CallMembership> getCallMembershipsFromEventContent(
Map<String, Object?> content, String senderId, String roomId) {
final mems = content.tryGetList<Map>('memberships');
final callMems = <CallMembership>[];
for (final m in mems ?? []) {
final mem = CallMembership.fromJson(m, senderId, roomId);
if (mem != null) callMems.add(mem);
}
return callMems;
}
}
bool isValidMemEvent(Map<String, Object?> event) {
if (event['call_id'] is String &&
event['device_id'] is String &&
event['expires_ts'] is num &&
event['foci_active'] is List) {
return true;
} else {
Logs()
.w('[VOIP] FamedlyCallMemberEvent ignoring unclean membership $event');
return false;
}
}

View File

@ -0,0 +1,9 @@
import 'package:webrtc_interface/webrtc_interface.dart';
extension RTCIceCandidateExt on RTCIceCandidate {
bool get isValid =>
sdpMLineIndex != null &&
sdpMid != null &&
candidate != null &&
candidate!.isNotEmpty;
}

View File

@ -0,0 +1,65 @@
import 'package:collection/collection.dart';
import 'package:random_string/random_string.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';
Future<void> stopMediaStream(MediaStream? stream) async {
if (stream != null) {
for (final track in stream.getTracks()) {
try {
await track.stop();
} catch (e, s) {
Logs().e('[VOIP] stopping track ${track.id} failed', e, s);
}
}
try {
await stream.dispose();
} catch (e, s) {
Logs().e('[VOIP] disposing stream ${stream.id} failed', e, s);
}
}
}
void setTracksEnabled(List<MediaStreamTrack> tracks, bool enabled) {
for (final element in tracks) {
element.enabled = enabled;
}
}
Future<bool> hasMediaDevice(
WebRTCDelegate delegate, MediaInputKind mediaInputKind) async {
final devices = await delegate.mediaDevices.enumerateDevices();
return devices
.where((device) => device.kind == mediaInputKind.name)
.isNotEmpty;
}
Future<void> updateMediaDevice(
WebRTCDelegate delegate,
MediaKind kind,
List<RTCRtpSender> userRtpSenders, [
MediaStreamTrack? track,
]) async {
final sender = userRtpSenders
.firstWhereOrNull((element) => element.track!.kind == kind.name);
await sender?.track?.stop();
if (track != null) {
await sender?.replaceTrack(track);
} else {
final stream = await delegate.mediaDevices.getUserMedia({kind.name: true});
MediaStreamTrack? track;
if (kind == MediaKind.audio) {
track = stream.getAudioTracks().firstOrNull;
} else if (kind == MediaKind.video) {
track = stream.getVideoTracks().firstOrNull;
}
if (track != null) {
await sender?.replaceTrack(track);
}
}
}
String genCallID() {
return '${DateTime.now().millisecondsSinceEpoch}${randomAlphaNumeric(16)}';
}

View File

@ -0,0 +1,187 @@
// ignore_for_file: constant_identifier_names
enum EncryptionKeyTypes { remote, local }
// Call state
enum CallState {
/// The call is inilalized but not yet started
kFledgling,
/// The first time an invite is sent, the local has createdOffer
kInviteSent,
/// getUserMedia or getDisplayMedia has been called,
/// but MediaStream has not yet been returned
kWaitLocalMedia,
/// The local has createdOffer
kCreateOffer,
/// Received a remote offer message and created a local Answer
kCreateAnswer,
/// Answer sdp is set, but ice is not connected
kConnecting,
/// WebRTC media stream is connected
kConnected,
/// The call was received, but no processing has been done yet.
kRinging,
/// Ending a call
kEnding,
/// End of call
kEnded,
}
enum CallErrorCode {
/// The user chose to end the call
userHangup('user_hangup'),
/// An error code when the local client failed to create an offer.
localOfferFailed('local_offer_failed'),
/// An error code when there is no local mic/camera to use. This may be because
/// the hardware isn't plugged in, or the user has explicitly denied access.
userMediaFailed('user_media_failed'),
/// Error code used when a call event failed to send
/// because unknown devices were present in the room
unknownDevice('unknown_device'),
/// An answer could not be created
createAnswer('create_answer'),
/// The session description from the other side could not be set
setRemoteDescription('set_remote_description'),
/// The session description from this side could not be set
setLocalDescription('set_local_description'),
/// A different device answered the call
answeredElsewhere('answered_elsewhere'),
/// No media connection could be established to the other party
iceFailed('ice_failed'),
/// The invite timed out whilst waiting for an answer
inviteTimeout('invite_timeout'),
/// The call was replaced by another call
replaced('replaced'),
/// Signalling for the call could not be sent (other than the initial invite)
iceTimeout('ice_timeout'),
/// The remote party is busy
userBusy('user_busy'),
/// We transferred the call off to somewhere else
transferred('transferred'),
/// Some other failure occurred that meant the client was unable to continue
/// the call rather than the user choosing to end it.
unknownError('unknown_error');
final String reason;
const CallErrorCode(this.reason);
}
class CallError extends Error {
final CallErrorCode code;
final String msg;
final dynamic err;
CallError(this.code, this.msg, this.err);
@override
String toString() {
return '[$code] $msg, err: ${err.toString()}';
}
}
enum CallStateChange {
/// The call was hangup by the local|remote user.
kHangup,
/// The call state has changed
kState,
/// The call got some error.
kError,
/// Call transfer
kReplaced,
/// The value of isLocalOnHold() has changed
kLocalHoldUnhold,
/// The value of isRemoteOnHold() has changed
kRemoteHoldUnhold,
/// Feeds have changed
kFeedsChanged,
/// For sip calls. support in the future.
kAssertedIdentityChanged,
}
enum CallType { kVoice, kVideo }
enum CallDirection { kIncoming, kOutgoing }
enum CallParty { kLocal, kRemote }
enum MediaInputKind { videoinput, audioinput }
enum MediaKind { video, audio }
enum GroupCallErrorCode {
/// An error code when there is no local mic/camera to use. This may be because
/// the hardware isn't plugged in, or the user has explicitly denied access.
userMediaFailed('user_media_failed'),
/// Some other failure occurred that meant the client was unable to continue
/// the call rather than the user choosing to end it.
unknownError('unknownError');
final String reason;
const GroupCallErrorCode(this.reason);
}
class GroupCallError extends Error {
final GroupCallErrorCode code;
final String msg;
final dynamic err;
GroupCallError(this.code, this.msg, this.err);
@override
String toString() {
return 'Group Call Error: [$code] $msg, err: ${err.toString()}';
}
}
enum GroupCallStateChange {
groupCallStateChanged,
activeSpeakerChanged,
callsChanged,
userMediaStreamsChanged,
screenshareStreamsChanged,
localScreenshareStateChanged,
localMuteStateChanged,
participantsChanged,
error
}
enum GroupCallState {
localCallFeedUninitialized,
initializingLocalCallFeed,
localCallFeedInitialized,
entering,
entered,
ended
}

View File

@ -0,0 +1,62 @@
import 'package:matrix/matrix.dart';
/// https://github.com/matrix-org/matrix-doc/pull/2746
/// version 1
const String voipProtoVersion = '1';
class CallTimeouts {
/// The default life time for call events, in millisecond.
static const defaultCallEventLifetime = Duration(seconds: 10);
/// The length of time a call can be ringing for.
static const callInviteLifetime = Duration(seconds: 60);
/// The delay for ice gathering.
static const iceGatheringDelay = Duration(milliseconds: 200);
/// Delay before createOffer.
static const delayBeforeOffer = Duration(milliseconds: 100);
/// How often to update the expiresTs
static const updateExpireTsTimerDuration = Duration(minutes: 2);
/// the expiresTs bump
static const expireTsBumpDuration = Duration(minutes: 6);
/// Update the active speaker value
static const activeSpeakerInterval = Duration(seconds: 5);
// source: element call?
/// A delay after a member leaves before we create and publish a new key, because people
/// tend to leave calls at the same time
static const makeKeyDelay = Duration(seconds: 2);
/// The delay between creating and sending a new key and starting to encrypt with it. This gives others
/// a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
/// The total time between a member leaving and the call switching to new keys is therefore
/// makeKeyDelay + useKeyDelay
static const useKeyDelay = Duration(seconds: 4);
}
class CallConstants {
static final callEventsRegxp = RegExp(
r'm.call.|org.matrix.call.|org.matrix.msc3401.call.|com.famedly.call.');
static const callEndedEventTypes = {
EventTypes.CallAnswer,
EventTypes.CallHangup,
EventTypes.CallReject,
EventTypes.CallReplaces,
};
static const omitWhenCallEndedTypes = {
EventTypes.CallInvite,
EventTypes.CallCandidates,
EventTypes.CallNegotiate,
EventTypes.CallSDPStreamMetadataChanged,
EventTypes.CallSDPStreamMetadataChangedPrefix,
};
static const updateExpireTsTimerDuration = Duration(seconds: 15);
static const expireTsBumpDuration = Duration(seconds: 45);
static const activeSpeakerInterval = Duration(seconds: 5);
}

View File

@ -0,0 +1,100 @@
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:matrix/src/voip/utils/stream_helper.dart';
/// Wrapped MediaStream, used to adapt Widget to display
class WrappedMediaStream {
MediaStream? stream;
final CallParticipant participant;
final Room room;
final VoIP voip;
/// Current stream type, usermedia or screen-sharing
String purpose;
bool audioMuted;
bool videoMuted;
final Client client;
final bool isGroupCall;
final RTCPeerConnection? pc;
/// for debug
String get title =>
'${client.userID!}:${client.deviceID!} $displayName:$purpose:a[$audioMuted]:v[$videoMuted]';
bool stopped = false;
final CachedStreamController<WrappedMediaStream> onMuteStateChanged =
CachedStreamController();
final CachedStreamController<MediaStream> onStreamChanged =
CachedStreamController();
WrappedMediaStream({
this.stream,
this.pc,
required this.room,
required this.participant,
required this.purpose,
required this.client,
required this.audioMuted,
required this.videoMuted,
required this.isGroupCall,
required this.voip,
});
String get id => '${stream?.id}: $title';
Future<void> dispose() async {
// AOT it
const isWeb = bool.fromEnvironment('dart.library.js_util');
// libwebrtc does not provide a way to clone MediaStreams. So stopping the
// local stream here would break calls with all other participants if anyone
// leaves. The local stream is manually disposed when user leaves. On web
// streams are actually cloned.
if (!isGroupCall || isWeb) {
await stopMediaStream(stream);
}
stream = null;
}
Uri? get avatarUrl => getUser().avatarUrl;
String get avatarName =>
getUser().calcDisplayname(mxidLocalPartFallback: false);
String? get displayName => getUser().displayName;
User getUser() {
return room.unsafeGetUserFromMemoryOrFallback(participant.userId);
}
bool isLocal() {
return participant == voip.localParticipant;
}
bool isAudioMuted() {
return (stream != null && stream!.getAudioTracks().isEmpty) || audioMuted;
}
bool isVideoMuted() {
return (stream != null && stream!.getVideoTracks().isEmpty) || videoMuted;
}
void setNewStream(MediaStream newStream) {
stream = newStream;
onStreamChanged.add(stream!);
}
void setAudioMuted(bool muted) {
audioMuted = muted;
onMuteStateChanged.add(this);
}
void setVideoMuted(bool muted) {
videoMuted = muted;
onMuteStateChanged.add(this);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,101 +0,0 @@
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
extension GroupCallUtils on Room {
/// returns the user count (not sessions, yet) for the group call with id: `groupCallId`.
/// returns 0 if group call not found
int? groupCallParticipantCount(String groupCallId) {
int participantCount = 0;
final groupCallMemberStates =
states.tryGetMap<String, Event>(EventTypes.GroupCallMemberPrefix);
if (groupCallMemberStates != null) {
groupCallMemberStates.forEach((userId, memberStateEvent) {
if (!callMemberStateForIdIsExpired(memberStateEvent, groupCallId)) {
participantCount++;
}
});
}
return participantCount;
}
bool get hasActiveGroupCall {
if (activeGroupCallEvents.isNotEmpty) {
return true;
}
return false;
}
/// list of active group calls
List<Event> get activeGroupCallEvents {
final groupCallStates =
states.tryGetMap<String, Event>(EventTypes.GroupCallPrefix);
if (groupCallStates != null) {
groupCallStates.values
.toList()
.sort((a, b) => a.originServerTs.compareTo(b.originServerTs));
return groupCallStates.values
.where((element) =>
!element.content.containsKey('m.terminated') &&
callMemberStateIsExpired(element))
.toList();
}
return [];
}
static const staleCallCheckerDuration = Duration(seconds: 30);
/// checks if a member event has any existing non-expired callId
bool callMemberStateIsExpired(MatrixEvent event) {
final callMemberState = IGroupCallRoomMemberState.fromJson(event);
final calls = callMemberState.calls;
return calls
.where((call) => call.devices.any((d) =>
(d.expires_ts ?? 0) +
staleCallCheckerDuration
.inMilliseconds > // buffer for sync glare
DateTime.now().millisecondsSinceEpoch))
.isEmpty;
}
/// checks if the member event has `groupCallId` unexpired, if not it checks if
/// the whole event is expired or not
bool callMemberStateForIdIsExpired(
MatrixEvent groupCallMemberStateEvent, String groupCallId) {
final callMemberState =
IGroupCallRoomMemberState.fromJson(groupCallMemberStateEvent);
final calls = callMemberState.calls;
if (calls.isNotEmpty) {
final call =
calls.singleWhereOrNull((call) => call.call_id == groupCallId);
if (call != null) {
return call.devices.where((device) => device.expires_ts != null).every(
(device) =>
(device.expires_ts ?? 0) +
staleCallCheckerDuration
.inMilliseconds < // buffer for sync glare
DateTime.now().millisecondsSinceEpoch);
} else {
Logs().d(
'[VOIP] Did not find $groupCallId in member events, probably sync glare');
return false;
}
} else {
// Last 30 seconds to get yourself together.
// This saves us from accidentally killing calls which were just created and
// whose state event we haven't recieved yet in sync.
// (option 2 was local echo member state events, but reverting them if anything
// fails sounds pain)
final expiredfr = groupCallMemberStateEvent.originServerTs
.add(staleCallCheckerDuration)
.millisecondsSinceEpoch <
DateTime.now().millisecondsSinceEpoch;
if (!expiredfr) {
Logs().d(
'[VOIP] Last 30 seconds for state event from ${groupCallMemberStateEvent.senderId}');
}
return expiredfr;
}
}
}

View File

@ -32,7 +32,7 @@ dependencies:
sqflite_common: ^2.4.5
sqlite3: ^2.1.0
typed_data: ^1.3.2
webrtc_interface: ^1.0.13
webrtc_interface: ^1.2.0
dev_dependencies:
build_runner: ^2.4.8
@ -43,7 +43,4 @@ dev_dependencies:
lints: ^3.0.0
sqflite_common_ffi: 2.3.2+1 # v2.3.3 doesn't support dart v3.2.x
test: ^1.15.7
#flutter_test: {sdk: flutter}
#dependency_overrides:
# matrix_api_lite:
# path: ../matrix_api_lite

View File

@ -2,6 +2,9 @@ import 'package:test/test.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
import 'package:matrix/src/voip/models/call_options.dart';
import 'package:matrix/src/voip/models/voip_id.dart';
import 'fake_client.dart';
import 'webrtc_stub.dart';
@ -13,25 +16,35 @@ void main() {
Logs().level = Level.info;
setUp(() async {
matrix = await getClient();
voip = VoIP(matrix, MockWebRTCDelegate());
VoIP.customTxid = '1234';
final id = '!calls:example.com';
room = matrix.getRoomById(id)!;
});
test('Test call methods', () async {
final call = CallSession(CallOptions()..room = room);
await call.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp',
final call = CallSession(
CallOptions(
callId: '1234',
type: CallType.kVoice,
dir: CallDirection.kOutgoing,
localPartyId: '4567',
voip: voip,
room: room,
iceServers: [],
),
);
await call.sendInviteToCall(room, '1234', 1234, '4567', 'sdp',
txid: '1234');
await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234');
await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234');
await call.sendSelectCallAnswer(room, '1234', '4567', '6789',
txid: '1234');
await call.sendCallReject(room, '1234', '4567', 'busy', txid: '1234');
await call.sendCallReject(room, '1234', '4567', txid: '1234');
await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp',
txid: '1234');
await call.sendHangupCall(room, '1234', '4567', 'user_hangup',
await call.sendHangupCall(room, '1234', '4567', 'userHangup',
txid: '1234');
await call.sendAssertedIdentity(
room,
@ -152,12 +165,14 @@ void main() {
)
]))
})));
while (voip.currentCID != 'originTsValidCall') {
while (voip.currentCID !=
VoipId(roomId: room.id, callId: 'originTsValidCall')) {
// call invite looks valid, call should be created now :D
await Future.delayed(Duration(milliseconds: 50));
Logs().d('Waiting for currentCID to update');
}
expect(voip.currentCID, 'originTsValidCall');
expect(voip.currentCID,
VoipId(roomId: room.id, callId: 'originTsValidCall'));
final call = voip.calls[voip.currentCID]!;
expect(call.state, CallState.kRinging);
await call.answer(txid: '1234');
@ -219,7 +234,7 @@ void main() {
expect(call.state, CallState.kConnected);
await call.hangup();
await call.hangup(reason: CallErrorCode.userHangup);
expect(call.state, CallState.kEnded);
expect(voip.currentCID, null);
});
@ -277,12 +292,14 @@ void main() {
)
]))
})));
while (voip.currentCID != 'answer_elseWhere') {
while (voip.currentCID !=
VoipId(roomId: room.id, callId: 'answer_elseWhere')) {
// call invite looks valid, call should be created now :D
await Future.delayed(Duration(milliseconds: 50));
Logs().d('Waiting for currentCID to update');
}
expect(voip.currentCID, 'answer_elseWhere');
expect(
voip.currentCID, VoipId(roomId: room.id, callId: 'answer_elseWhere'));
final call = voip.calls[voip.currentCID]!;
expect(call.state, CallState.kRinging);
@ -368,12 +385,13 @@ void main() {
)
]))
})));
while (voip.currentCID != 'reject_call') {
while (
voip.currentCID != VoipId(roomId: room.id, callId: 'reject_call')) {
// call invite looks valid, call should be created now :D
await Future.delayed(Duration(milliseconds: 50));
Logs().d('Waiting for currentCID to update');
}
expect(voip.currentCID, 'reject_call');
expect(voip.currentCID, VoipId(roomId: room.id, callId: 'reject_call'));
final call = voip.calls[voip.currentCID]!;
expect(call.state, CallState.kRinging);
@ -386,7 +404,11 @@ void main() {
test('Glare after invite was sent', () async {
expect(voip.currentCID, null);
final firstCall = await voip.inviteToCall(room.id, CallType.kVoice);
final firstCall = await voip.inviteToCall(
room,
CallType.kVoice,
userId: '@alice:testing.com',
);
await firstCall.pc!.onRenegotiationNeeded!.call();
expect(firstCall.state, CallState.kInviteSent);
// KABOOM YOU JUST GLARED
@ -412,12 +434,17 @@ void main() {
]))
})));
await Future.delayed(Duration(seconds: 3));
expect(voip.currentCID, firstCall.callId);
await firstCall.hangup();
expect(
voip.currentCID, VoipId(roomId: room.id, callId: firstCall.callId));
await firstCall.hangup(reason: CallErrorCode.userBusy);
});
test('Glare before invite was sent', () async {
expect(voip.currentCID, null);
final firstCall = await voip.inviteToCall(room.id, CallType.kVoice);
final firstCall = await voip.inviteToCall(
room,
CallType.kVoice,
userId: '@alice:testing.com',
);
expect(firstCall.state, CallState.kCreateOffer);
// KABOOM YOU JUST GLARED, but this tiem you were still preparing your call
// so just cancel that instead
@ -443,7 +470,306 @@ void main() {
]))
})));
await Future.delayed(Duration(seconds: 3));
expect(voip.currentCID, 'zzzz_glare_2nd_call');
expect(voip.currentCID,
VoipId(roomId: room.id, callId: 'zzzz_glare_2nd_call'));
});
test('getFamedlyCallEvents sort order', () {
room.setState(
Event(
content: {
'memberships': [
CallMembership(
userId: '@test1:example.com',
callId: '1111',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now()
.add(Duration(hours: 12))
.millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
type: EventTypes.GroupCallMember,
eventId: 'asdfasdf',
senderId: '@test1:example.com',
originServerTs: DateTime.now().add(Duration(hours: 12)),
room: room,
stateKey: '@test1:example.com',
),
);
room.setState(
Event(
content: {
'memberships': [
CallMembership(
userId: '@test2:example.com',
callId: '1111',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now().millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
type: EventTypes.GroupCallMember,
eventId: 'asdfasdf',
senderId: '@test2:example.com',
originServerTs: DateTime.now(),
room: room,
stateKey: '@test2:example.com',
),
);
room.setState(
Event(
content: {
'memberships': [
CallMembership(
userId: '@test2.0:example.com',
callId: '1111',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now().millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
type: EventTypes.GroupCallMember,
eventId: 'asdfasdf',
senderId: '@test2.0:example.com',
originServerTs: DateTime.now(),
room: room,
stateKey: '@test2.0:example.com',
),
);
room.setState(
Event(
content: {
'memberships': [
CallMembership(
userId: '@test3:example.com',
callId: '1111',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now()
.subtract(Duration(hours: 1))
.millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
type: EventTypes.GroupCallMember,
eventId: 'asdfasdf',
senderId: '@test3:example.com',
originServerTs: DateTime.now().subtract(Duration(hours: 1)),
room: room,
stateKey: '@test3:example.com',
),
);
expect(room.getFamedlyCallEvents().entries.elementAt(0).key,
'@test3:example.com');
expect(room.getFamedlyCallEvents().entries.elementAt(1).key,
'@test2:example.com');
expect(room.getFamedlyCallEvents().entries.elementAt(2).key,
'@test2.0:example.com');
expect(room.getFamedlyCallEvents().entries.elementAt(3).key,
'@test1:example.com');
expect(room.getCallMembershipsFromRoom().entries.elementAt(0).key,
'@test3:example.com');
expect(room.getCallMembershipsFromRoom().entries.elementAt(1).key,
'@test2:example.com');
expect(room.getCallMembershipsFromRoom().entries.elementAt(2).key,
'@test2.0:example.com');
expect(room.getCallMembershipsFromRoom().entries.elementAt(3).key,
'@test1:example.com');
});
test('Enabling group calls', () async {
// users default is 0 and so group calls not enabled
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123a',
content: {
'events': {EventTypes.GroupCallMember: 100},
'state_default': 50,
'users_default': 0
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.canJoinGroupCall, false);
expect(room.groupCallsEnabledForEveryone, false);
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123a',
content: {
'events': {EventTypes.GroupCallMember: 27},
'state_default': 50,
'users_default': 49
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.canJoinGroupCall, true);
expect(room.groupCallsEnabledForEveryone, true);
// state_default 50 and user_default 0, use enableGroupCall
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123',
content: {
'state_default': 50,
'users': {'@test:fakeServer.notExisting': 100},
'users_default': 0
},
originServerTs: DateTime.now(),
stateKey: ''),
);
expect(room.canJoinGroupCall, true); // because admin
expect(room.groupCallsEnabledForEveryone, false);
await room.enableGroupCalls();
expect(room.canJoinGroupCall, true);
expect(room.groupCallsEnabledForEveryone, true);
// state_default 50 and user_default unspecified, use enableGroupCall
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123',
content: {
'state_default': 50,
'users': {'@test:fakeServer.notExisting': 100},
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.canJoinGroupCall, true); // because admin
expect(room.groupCallsEnabledForEveryone, false);
await room.enableGroupCalls();
expect(room.canJoinGroupCall, true);
expect(room.groupCallsEnabledForEveryone, true);
// state_default is 0 so users should be able to send state events
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123',
content: {
'state_default': 0,
'users': {'@test:fakeServer.notExisting': 100},
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.canJoinGroupCall, true);
expect(room.groupCallsEnabledForEveryone, true);
});
test('group call participants count', () {
room.setState(
Event(
senderId: '@test1:example.com',
type: EventTypes.GroupCallMember,
room: room,
eventId: '1234177',
content: {
'memberships': [
CallMembership(
userId: '@test1:example.com',
callId: 'participants_count',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now()
.subtract(Duration(hours: 1))
.millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
originServerTs: DateTime.now(),
stateKey: '@test1:example.com'),
);
expect(room.groupCallParticipantCount('participants_count'), 0);
expect(room.hasActiveGroupCall, false);
room.setState(
Event(
senderId: '@test2:example.com',
type: EventTypes.GroupCallMember,
room: room,
eventId: '1234177',
content: {
'memberships': [
CallMembership(
userId: '@test2:example.com',
callId: 'participants_count',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now()
.add(Duration(hours: 1))
.millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
originServerTs: DateTime.now(),
stateKey: '@test2:example.com'),
);
expect(room.groupCallParticipantCount('participants_count'), 1);
expect(room.hasActiveGroupCall, true);
room.setState(
Event(
senderId: '@test3:example.com',
type: EventTypes.GroupCallMember,
room: room,
eventId: '1231234124123',
content: {
'memberships': [
CallMembership(
userId: '@test3:example.com',
callId: 'participants_count',
backend: MeshBackend(),
deviceId: '1111',
expiresTs: DateTime.now().millisecondsSinceEpoch,
roomId: room.id,
membershipId: voip.currentSessionId,
).toJson(),
]
},
originServerTs: DateTime.now(),
stateKey: '@test3:example.com'),
);
expect(room.groupCallParticipantCount('participants_count'), 2);
expect(room.hasActiveGroupCall, true);
});
});
}

View File

@ -2581,6 +2581,10 @@ class FakeMatrixApi extends BaseClient {
(var reqI) => {
'event_id': '42',
},
'/client/v3/rooms/!calls%3Aexample.com/state/m.room.power_levels':
(var reqI) => {
'event_id': '42',
},
'/client/v3/directory/list/room/!localpart%3Aexample.com': (var req) =>
{},
'/client/v3/room_keys/version/5': (var req) => {},
@ -2600,11 +2604,7 @@ class FakeMatrixApi extends BaseClient {
},
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device': (var _) => {
'device_id': 'DEHYDDEV',
},
'/client/v3/rooms/${Uri.encodeComponent("!localpart:server.abc")}/state/${Uri.encodeComponent("org.matrix.msc3401.call")}/${Uri.encodeComponent("1675856324414gzczMtfzTk0DKgEw")}':
(var req) => {
'event_id': 'groupCall',
},
}
},
'DELETE': {
'/unknown/token': (var req) => {'errcode': 'M_UNKNOWN_TOKEN'},

View File

@ -647,179 +647,10 @@ void main() {
expect(room.canChangeStateEvent('m.room.power_levels'), false);
expect(room.canChangeStateEvent('m.room.member'), false);
expect(room.canSendEvent('m.room.message'), true);
final resp = await room.setPower('@test:fakeServer.notExisting', 90);
final resp = await room.setPower('@test:fakeServer.notExisting', 0);
expect(resp, '42');
});
test('Enabling group calls', () async {
expect(room.groupCallsEnabled, false);
// users default is 0 and so group calls not enabled
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123a',
content: {
'events': {EventTypes.GroupCallMemberPrefix: 100},
'state_default': 50,
'users_default': 0
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.groupCallsEnabled, false);
// one of the group call permissions is unspecified in events override
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123a',
content: {
'events': {EventTypes.GroupCallMemberPrefix: 27},
'state_default': 50,
'users_default': 49
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.groupCallsEnabled, false);
// only override one of the group calls permission, other one still less
// than users_default and state_default
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123a',
content: {
'events': {
EventTypes.GroupCallMemberPrefix: 27,
EventTypes.GroupCallPrefix: 0
},
'state_default': 50,
'users_default': 2
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.groupCallsEnabled, false);
expect(room.canJoinGroupCall, false);
expect(room.canCreateGroupCall, false);
// state_default 50 and user_default 26, but override evnents present
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123a',
content: {
'events': {
EventTypes.GroupCallMemberPrefix: 25,
EventTypes.GroupCallPrefix: 25
},
'state_default': 50,
'users_default': 26
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.groupCallsEnabled, true);
expect(room.canJoinGroupCall, true);
expect(room.canCreateGroupCall, true);
// state_default 50 and user_default 0, use enableGroupCall
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123',
content: {
'state_default': 50,
'users': {'@test:fakeServer.notExisting': 100},
'users_default': 0
},
originServerTs: DateTime.now(),
stateKey: ''),
);
expect(room.groupCallsEnabled, false);
expect(room.canJoinGroupCall, false);
expect(room.canCreateGroupCall, false);
await room.enableGroupCalls();
expect(room.groupCallsEnabled, true);
// state_default 50 and user_default unspecified, use enableGroupCall
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123',
content: {
'state_default': 50,
'users': {'@test:fakeServer.notExisting': 100},
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
await room.enableGroupCalls();
expect(room.groupCallsEnabled, true);
expect(room.canJoinGroupCall, true);
expect(room.canCreateGroupCall, true);
// state_default is 0 so users should be able to send state events
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123',
content: {
'state_default': 0,
'users': {'@test:fakeServer.notExisting': 100},
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
expect(room.groupCallsEnabled, true);
expect(room.canJoinGroupCall, true);
expect(room.canCreateGroupCall, true);
room.setState(
Event(
senderId: '@test:example.com',
type: 'm.room.power_levels',
room: room,
eventId: '123abc',
content: {
'ban': 50,
'events': {'m.room.name': 0, 'm.room.power_levels': 100},
'events_default': 0,
'invite': 50,
'kick': 50,
'notifications': {'room': 20},
'redact': 50,
'state_default': 50,
'users': {},
'users_default': 0
},
originServerTs: DateTime.now(),
stateKey: '',
),
);
});
test('invite', () async {
await room.invite('Testname');
});
@ -1375,7 +1206,7 @@ void main() {
expect(room.isSpace, true);
expect(room.spaceParents.isEmpty, true);
room.states[EventTypes.spaceParent] = {
room.states[EventTypes.SpaceParent] = {
'!1234:example.invalid': Event.fromJson(
{
'content': {
@ -1385,7 +1216,7 @@ void main() {
'origin_server_ts': 1432735824653,
'room_id': '!jEsUZKDJdhlrceRyVU:example.org',
'sender': '@example:example.org',
'type': EventTypes.spaceParent,
'type': EventTypes.SpaceParent,
'unsigned': {'age': 1234},
'state_key': '!1234:example.invalid',
},
@ -1395,7 +1226,7 @@ void main() {
expect(room.spaceParents.length, 1);
expect(room.spaceChildren.isEmpty, true);
room.states[EventTypes.spaceChild] = {
room.states[EventTypes.SpaceChild] = {
'!b:example.invalid': Event.fromJson(
{
'content': {
@ -1406,7 +1237,7 @@ void main() {
'origin_server_ts': 1432735824653,
'room_id': '!jEsUZKDJdhlrceRyVU:example.org',
'sender': '@example:example.org',
'type': EventTypes.spaceChild,
'type': EventTypes.SpaceChild,
'unsigned': {'age': 1234},
'state_key': '!b:example.invalid',
},
@ -1422,7 +1253,7 @@ void main() {
'origin_server_ts': 1432735824653,
'room_id': '!jEsUZKDJdhlrceRyVU:example.org',
'sender': '@example:example.org',
'type': EventTypes.spaceChild,
'type': EventTypes.SpaceChild,
'unsigned': {'age': 1234},
'state_key': '!c:example.invalid',
},
@ -1437,7 +1268,7 @@ void main() {
'origin_server_ts': 1432735824653,
'room_id': '!jEsUZKDJdhlrceRyVU:example.org',
'sender': '@example:example.org',
'type': EventTypes.spaceChild,
'type': EventTypes.SpaceChild,
'unsigned': {'age': 1234},
'state_key': '!noorder:example.invalid',
},
@ -1453,7 +1284,7 @@ void main() {
'origin_server_ts': 1432735824653,
'room_id': '!jEsUZKDJdhlrceRyVU:example.org',
'sender': '@example:example.org',
'type': EventTypes.spaceChild,
'type': EventTypes.SpaceChild,
'unsigned': {'age': 1234},
'state_key': '!a:example.invalid',
},
@ -1485,7 +1316,6 @@ void main() {
test('inviteLink', () async {
// ensure we don't rerequest members
room.summary.mJoinedMemberCount = 4;
var matrixToLink = await room.matrixToInviteLink();
expect(matrixToLink.toString(),
'https://matrix.to/#/%23testalias%3Aexample.com');
@ -1504,181 +1334,6 @@ void main() {
'https://matrix.to/#/!localpart%3Aserver.abc?via=example.com&via=test.abc&via=example.org');
});
test('callMemberStateIsExpired', () {
expect(
room.callMemberStateForIdIsExpired(
Event(
senderId: '@test:example.com',
type: EventTypes.GroupCallMemberPrefix,
room: room,
eventId: '1231234124',
content: {
'm.calls': [
{
'm.call_id': '1674811248673789288k7d60n5976',
'm.devices': [
{
'device_id': 'ZEEGCGPTGI',
'session_id': 'cbAtVZdLBnJq',
'm.expires_ts': 1674813039415,
'feeds': [
{'purpose': 'm.usermedia'}
]
}
]
},
],
},
originServerTs: DateTime.now(),
stateKey: ''),
'1674811248673789288k7d60n5976'),
true);
expect(
room.callMemberStateForIdIsExpired(
Event(
senderId: '@test:example.com',
type: EventTypes.GroupCallMemberPrefix,
room: room,
eventId: '1231234124',
content: {
'm.calls': [
{
'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ',
'm.devices': [
{
'device_id': 'ZEEGCGPTGI',
'session_id': 'fhovqxwcasdfr',
'expires_ts': DateTime.now()
.add(Duration(minutes: 1))
.millisecondsSinceEpoch,
'feeds': [
{'purpose': 'm.usermedia'}
]
}
]
}
],
},
originServerTs: DateTime.now(),
stateKey: ''),
'1674811256006mfqnmsAbzqxjYtWZ'),
false);
});
test('group call participants count', () {
room.setState(
Event(
senderId: '@test:example.com',
type: EventTypes.GroupCallMemberPrefix,
room: room,
eventId: '1234177',
content: {
'm.calls': [
{
'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ',
'm.devices': [
{
'device_id': 'ZEEGCGPTGI',
'session_id': 'fhovqxwcasdfr',
'expires_ts': DateTime.now()
.add(Duration(minutes: 1))
.millisecondsSinceEpoch,
'feeds': [
{'purpose': 'm.usermedia'}
]
},
]
}
],
},
originServerTs: DateTime.now(),
stateKey: '@test:example.com'),
);
room.setState(
Event(
senderId: '@test0:example.com',
type: EventTypes.GroupCallMemberPrefix,
room: room,
eventId: '1234177',
content: {
'm.calls': [
{
'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ',
'm.devices': [
{
'device_id': 'ZEEGCGPTGI',
'session_id': 'fhovqxwcasdfr',
'expires_ts': DateTime.now()
.add(Duration(minutes: 2))
.millisecondsSinceEpoch,
'feeds': [
{'purpose': 'm.usermedia'}
]
},
]
}
],
},
originServerTs: DateTime.now(),
stateKey: '@test0:example.com'),
);
room.setState(
Event(
senderId: '@test2:example.com',
type: EventTypes.GroupCallMemberPrefix,
room: room,
eventId: '1231234124123',
content: {
'm.calls': [
{
'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ',
'm.devices': [
{
'device_id': 'ZEEGCGPTGI',
'session_id': 'fhovqxwcasdfr',
'feeds': [
{'purpose': 'm.usermedia'}
]
},
]
}
],
},
originServerTs: DateTime.now(),
stateKey: '@test2:example.com'),
);
room.setState(
Event(
senderId: '@test3:example.com',
type: EventTypes.GroupCallMemberPrefix,
room: room,
eventId: '123123412445',
content: {
'm.calls': [
{
'm.call_id': '1674811256006mfqnmsAbzqxjYtWZ',
'm.devices': [
{
'device_id': 'ZEEGCGPTGI',
'session_id': 'fhovqxwcasdfr',
'expires_ts': DateTime.now()
.subtract(Duration(minutes: 1))
.millisecondsSinceEpoch,
'feeds': [
{'purpose': 'm.usermedia'}
]
},
]
}
],
},
originServerTs: DateTime.now(),
stateKey: '@test3:example.com'),
);
expect(
room.groupCallParticipantCount('1674811256006mfqnmsAbzqxjYtWZ'), 2);
});
test('EventTooLarge on exceeding max PDU size', () async {
try {
await room.sendTextEvent('''

View File

@ -6,7 +6,6 @@ import 'package:matrix/matrix.dart';
class MockWebRTCDelegate implements WebRTCDelegate {
@override
// TODO: implement canHandleNewCall
bool get canHandleNewCall => true;
@override
@ -16,18 +15,13 @@ class MockWebRTCDelegate implements WebRTCDelegate {
]) async =>
MockRTCPeerConnection();
@override
VideoRenderer createRenderer() {
return MockVideoRenderer();
}
@override
Future<void> handleCallEnded(CallSession session) async {
Logs().i('handleCallEnded called in MockWebRTCDelegate');
}
@override
Future<void> handleGroupCallEnded(GroupCall groupCall) async {
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {
Logs().i('handleGroupCallEnded called in MockWebRTCDelegate');
}
@ -42,7 +36,7 @@ class MockWebRTCDelegate implements WebRTCDelegate {
}
@override
Future<void> handleNewGroupCall(GroupCall groupCall) async {
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {
Logs().i('handleNewGroupCall called in MockWebRTCDelegate');
}
@ -61,6 +55,27 @@ class MockWebRTCDelegate implements WebRTCDelegate {
Future<void> stopRingtone() async {
Logs().i('stopRingtone called in MockWebRTCDelegate');
}
@override
EncryptionKeyProvider? get keyProvider => MockEncryptionKeyProvider();
}
class MockEncryptionKeyProvider implements EncryptionKeyProvider {
@override
Future<void> onSetEncryptionKey(
CallParticipant participant, Uint8List key, int index) async {
Logs().i('Mock onSetEncryptionKey called for ${participant.id} ');
}
@override
Future<Uint8List> onExportKey(CallParticipant participant, int index) {
throw UnimplementedError();
}
@override
Future<Uint8List> onRatchetKey(CallParticipant participant, int index) {
throw UnimplementedError();
}
}
class MockMediaDevices implements MediaDevices {
@ -69,25 +84,21 @@ class MockMediaDevices implements MediaDevices {
@override
Future<List<MediaDeviceInfo>> enumerateDevices() {
// TODO: implement enumerateDevices
throw UnimplementedError();
}
@override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> mediaConstraints) {
// TODO: implement getDisplayMedia
throw UnimplementedError();
}
@override
Future<List> getSources() {
// TODO: implement getSources
throw UnimplementedError();
}
@override
MediaTrackSupportedConstraints getSupportedConstraints() {
// TODO: implement getSupportedConstraints
throw UnimplementedError();
}
@ -99,7 +110,6 @@ class MockMediaDevices implements MediaDevices {
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) {
// TODO: implement selectAudioOutput
throw UnimplementedError();
}
}
@ -347,15 +357,12 @@ class MockRTCPeerConnection implements RTCPeerConnection {
}
@override
// TODO: implement receivers
Future<List<RTCRtpReceiver>> get receivers => throw UnimplementedError();
@override
// TODO: implement senders
Future<List<RTCRtpSender>> get senders => throw UnimplementedError();
@override
// TODO: implement transceivers
Future<List<RTCRtpTransceiver>> get transceivers =>
throw UnimplementedError();
}
@ -415,65 +422,53 @@ class MockRTCRtpTransceiver implements RTCRtpTransceiver {
}
@override
// TODO: implement stoped
bool get stoped => throw UnimplementedError();
}
class MockRTCRtpSender implements RTCRtpSender {
@override
Future<void> dispose() {
// TODO: implement dispose
throw UnimplementedError();
}
@override
// TODO: implement dtmfSender
RTCDTMFSender get dtmfSender => throw UnimplementedError();
@override
Future<List<StatsReport>> getStats() {
// TODO: implement getStats
throw UnimplementedError();
}
@override
// TODO: implement ownsTrack
bool get ownsTrack => throw UnimplementedError();
@override
// TODO: implement parameters
RTCRtpParameters get parameters => throw UnimplementedError();
@override
Future<void> replaceTrack(MediaStreamTrack? track) {
// TODO: implement replaceTrack
throw UnimplementedError();
}
@override
// TODO: implement senderId
String get senderId => throw UnimplementedError();
@override
Future<bool> setParameters(RTCRtpParameters parameters) {
// TODO: implement setParameters
throw UnimplementedError();
}
@override
Future<void> setStreams(List<MediaStream> streams) {
// TODO: implement setStreams
throw UnimplementedError();
}
@override
Future<void> setTrack(MediaStreamTrack? track, {bool takeOwnership = true}) {
// TODO: implement setTrack
throw UnimplementedError();
}
@override
// TODO: implement track
MediaStreamTrack? get track => throw UnimplementedError();
// Mock implementation for RTCRtpSender
}
@ -485,20 +480,16 @@ class MockRTCRtpReceiver implements RTCRtpReceiver {
@override
Future<List<StatsReport>> getStats() {
// TODO: implement getStats
throw UnimplementedError();
}
@override
// TODO: implement parameters
RTCRtpParameters get parameters => throw UnimplementedError();
@override
// TODO: implement receiverId
String get receiverId => throw UnimplementedError();
@override
// TODO: implement track
MediaStreamTrack? get track => throw UnimplementedError();
// Mock implementation for RTCRtpReceiver
}