Merge pull request #1696 from famedly/td/fosdemDemoFork
feat: famedly calls
This commit is contained in:
commit
32a425a362
|
|
@ -1,2 +1,2 @@
|
|||
flutter_version=3.19.0
|
||||
dart_version=3.3.0
|
||||
flutter_version=3.16.9
|
||||
dart_version=3.2.6
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -235,7 +235,6 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {
|
|||
}
|
||||
|
||||
@override
|
||||
// TODO: implement youAcceptedTheInvitation
|
||||
String get youAcceptedTheInvitation => 'You accepted the invitation';
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)}';
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)}';
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
|
|
|
|||
|
|
@ -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('''
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue