feat: (BREAKING CHANGE) delayed and device owned state events support for group calls

feat: allow setting keyring size

feat: allow setting custom call timeout values, you will have to pass the voip class to a bunch of existing call related methods though

feat: also debounce join key rotation
This commit is contained in:
td 2025-09-09 14:33:01 +02:00
parent 7bdcfb2b27
commit 19df680dee
No known key found for this signature in database
GPG Key ID: 62A30523D4D6CE28
16 changed files with 596 additions and 163 deletions

View File

@ -37,7 +37,7 @@ jobs:
coverage_without_olm: coverage_without_olm:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 20
env: env:
NO_OLM: 1 NO_OLM: 1
steps: steps:
@ -60,7 +60,7 @@ jobs:
coverage: coverage:
#runs-on: arm-ubuntu-latest-16core #runs-on: arm-ubuntu-latest-16core
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: cat .github/workflows/versions.env >> $GITHUB_ENV - run: cat .github/workflows/versions.env >> $GITHUB_ENV

View File

@ -41,6 +41,7 @@ export 'src/voip/models/webrtc_delegate.dart';
export 'src/voip/models/call_participant.dart'; export 'src/voip/models/call_participant.dart';
export 'src/voip/models/key_provider.dart'; export 'src/voip/models/key_provider.dart';
export 'src/voip/models/matrixrtc_call_event.dart'; export 'src/voip/models/matrixrtc_call_event.dart';
export 'src/voip/models/call_membership.dart';
export 'src/voip/utils/conn_tester.dart'; export 'src/voip/utils/conn_tester.dart';
export 'src/voip/utils/voip_constants.dart'; export 'src/voip/utils/voip_constants.dart';
export 'src/voip/utils/rtc_candidate_extension.dart'; export 'src/voip/utils/rtc_candidate_extension.dart';
@ -80,6 +81,7 @@ export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart';
export 'msc_extensions/msc_2835_uia_login/msc_2835_uia_login.dart'; export 'msc_extensions/msc_2835_uia_login/msc_2835_uia_login.dart';
export 'msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart'; export 'msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart';
export 'msc_extensions/extension_timeline_export/timeline_export.dart'; export 'msc_extensions/extension_timeline_export/timeline_export.dart';
export 'msc_extensions/msc_4140_delayed_events/api.dart';
export 'src/utils/web_worker/web_worker_stub.dart' export 'src/utils/web_worker/web_worker_stub.dart'
if (dart.library.html) 'src/utils/web_worker/web_worker.dart'; if (dart.library.html) 'src/utils/web_worker/web_worker.dart';

View File

@ -0,0 +1,2 @@
export 'msc_4140_delayed_events.dart';
export 'models.dart';

View File

@ -0,0 +1,64 @@
class ScheduledDelayedEventsResponse {
final List<ScheduledDelayedEvent> scheduledEvents;
final String? nextBatch;
ScheduledDelayedEventsResponse({
required this.scheduledEvents,
this.nextBatch,
});
factory ScheduledDelayedEventsResponse.fromJson(Map<String, dynamic> json) {
final list = json['delayed_events'] ?? json['scheduled'] as List;
final List<ScheduledDelayedEvent> scheduledEvents =
list.map((e) => ScheduledDelayedEvent.fromJson(e)).toList();
return ScheduledDelayedEventsResponse(
scheduledEvents: scheduledEvents,
nextBatch: json['next_batch'] as String?,
);
}
}
class ScheduledDelayedEvent {
final String delayId;
final String roomId;
final String type;
final String? stateKey;
final int delay;
final int runningSince;
final Map<String, Object?> content;
ScheduledDelayedEvent({
required this.delayId,
required this.roomId,
required this.type,
this.stateKey,
required this.delay,
required this.runningSince,
required this.content,
});
factory ScheduledDelayedEvent.fromJson(Map<String, dynamic> json) {
return ScheduledDelayedEvent(
delayId: json['delay_id'] as String,
roomId: json['room_id'] as String,
type: json['type'] as String,
stateKey: json['state_key'] as String?,
delay: json['delay'] as int,
runningSince: json['running_since'] as int,
content: json['content'] as Map<String, Object?>,
);
}
Map<String, dynamic> toJson() {
return {
'delay_id': delayId,
'room_id': roomId,
'type': type,
'state_key': stateKey,
'delay': delay,
'running_since': runningSince,
'content': content,
};
}
}

View File

@ -0,0 +1,144 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
enum DelayedEventAction { send, cancel, restart }
extension DelayedEventsHandler on Client {
static const _delayedEventsEndpoint =
'_matrix/client/unstable/org.matrix.msc4140/delayed_events';
/// State events can be sent using this endpoint. These events will be
/// overwritten if `<room id>`, `<event type>` and `<state key>` all
/// match.
///
/// Requests to this endpoint **cannot use transaction IDs**
/// like other `PUT` paths because they cannot be differentiated from the
/// `state_key`. Furthermore, `POST` is unsupported on state paths.
///
/// The body of the request should be the content object of the event; the
/// fields in this object will vary depending on the type of event. See
/// [Room Events](https://spec.matrix.org/unstable/client-server-api/#room-events) for the `m.` event specification.
///
/// If the event type being sent is `m.room.canonical_alias` servers
/// SHOULD ensure that any new aliases being listed in the event are valid
/// per their grammar/syntax and that they point to the room ID where the
/// state event is to be sent. Servers do not validate aliases which are
/// being removed or are already present in the state event.
///
///
/// [roomId] The room to set the state in
///
/// [eventType] The type of event to send.
///
/// [stateKey] The state_key for the state to send. Defaults to the empty string. When
/// an empty string, the trailing slash on this endpoint is optional.
///
/// [delayInMs] Optional number of milliseconds the homeserver should wait before sending the event.
/// If no delay is provided, the event is sent immediately as normal.
///
/// [body]
///
/// returns `event_id`:
/// A unique identifier for the event.
/// If a delay is provided, the homeserver schedules the event to be sent with the specified delay
/// and responds with an opaque delay_id field (omitting the event_id as it is not available)
Future<String> setRoomStateWithKeyWithDelay(
String roomId,
String eventType,
String stateKey,
int? delayInMs,
Map<String, Object?> body,
) async {
final requestUri = Uri(
path:
'_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/state/${Uri.encodeComponent(eventType)}/${Uri.encodeComponent(stateKey)}',
queryParameters: {
if (delayInMs != null) 'org.matrix.msc4140.delay': delayInMs.toString(),
},
);
final request = http.Request('PUT', baseUri!.resolveUri(requestUri));
request.headers['authorization'] = 'Bearer ${bearerToken!}';
request.headers['content-type'] = 'application/json';
request.bodyBytes = utf8.encode(jsonEncode(body));
final response = await httpClient.send(request);
final responseBody = await response.stream.toBytes();
if (response.statusCode != 200) unexpectedResponse(response, responseBody);
final responseString = utf8.decode(responseBody);
final json = jsonDecode(responseString);
return json['event_id'] ?? json['delay_id'] as String;
}
Future<void> manageDelayedEvent(
String delayedId,
DelayedEventAction delayedEventAction,
) async {
final requestUri = Uri(
path: '$_delayedEventsEndpoint/$delayedId',
);
final request = http.Request('POST', baseUri!.resolveUri(requestUri));
request.headers['authorization'] = 'Bearer ${bearerToken!}';
request.headers['content-type'] = 'application/json';
request.bodyBytes = utf8.encode(
jsonEncode({
'action': delayedEventAction.name,
}),
);
final response = await httpClient.send(request);
final responseBody = await response.stream.toBytes();
if (response.statusCode != 200) unexpectedResponse(response, responseBody);
}
// This should use the /delayed_events/scheduled endpoint
// but synapse implementation uses the /delayed_events
Future<ScheduledDelayedEventsResponse> getScheduledDelayedEvents({
String? from,
}) async {
final requestUri = Uri(
path: _delayedEventsEndpoint,
queryParameters: {if (from != null) 'from': from},
);
final request = http.Request('GET', baseUri!.resolveUri(requestUri));
request.headers['authorization'] = 'Bearer ${bearerToken!}';
request.headers['content-type'] = 'application/json';
final response = await httpClient.send(request);
final responseBody = await response.stream.toBytes();
if (response.statusCode != 200) {
return await _getScheduledDelayedEventsAccordingToSpec(from: from);
}
final responseString = utf8.decode(responseBody);
final json = jsonDecode(responseString);
final res = ScheduledDelayedEventsResponse.fromJson(json);
return res;
}
// maybe the synapse impl changes, I don't want stuff to break
Future<ScheduledDelayedEventsResponse>
_getScheduledDelayedEventsAccordingToSpec({
String? from,
}) async {
final requestUri = Uri(
path: '$_delayedEventsEndpoint/scheduled',
queryParameters: {if (from != null) 'from': from},
);
final request = http.Request('GET', baseUri!.resolveUri(requestUri));
request.headers['authorization'] = 'Bearer ${bearerToken!}';
request.headers['content-type'] = 'application/json';
final response = await httpClient.send(request);
final responseBody = await response.stream.toBytes();
if (response.statusCode != 200) unexpectedResponse(response, responseBody);
final responseString = utf8.decode(responseBody);
final json = jsonDecode(responseString);
final res = ScheduledDelayedEventsResponse.fromJson(json);
return res;
}
/// TODO: implement the remaining APIs
/// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events/finalised
}

View File

@ -1265,12 +1265,16 @@ class Client extends MatrixApi {
final _versionsCache = final _versionsCache =
AsyncCache<GetVersionsResponse>(const Duration(hours: 1)); AsyncCache<GetVersionsResponse>(const Duration(hours: 1));
Future<GetVersionsResponse> get versionsResponse =>
_versionsCache.tryFetch(() => getVersions());
Future<bool> authenticatedMediaSupported() async { Future<bool> authenticatedMediaSupported() async {
final versionsResponse = await _versionsCache.tryFetch(() => getVersions()); return (await versionsResponse).versions.any(
return versionsResponse.versions.any( (v) => isVersionGreaterThanOrEqualTo(v, 'v1.11'),
(v) => isVersionGreaterThanOrEqualTo(v, 'v1.11'), ) ||
) || (await versionsResponse)
versionsResponse.unstableFeatures?['org.matrix.msc3916.stable'] == true; .unstableFeatures?['org.matrix.msc3916.stable'] ==
true;
} }
final _serverConfigCache = AsyncCache<MediaConfig>(const Duration(hours: 1)); final _serverConfigCache = AsyncCache<MediaConfig>(const Duration(hours: 1));

View File

@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
abstract class CallBackend { abstract class CallBackend {
String type; String type;

View File

@ -4,22 +4,11 @@ import 'dart:typed_data';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/crypto/crypto.dart'; import 'package:matrix/src/utils/crypto/crypto.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
class LiveKitBackend extends CallBackend { class LiveKitBackend extends CallBackend {
final String livekitServiceUrl; final String livekitServiceUrl;
final String livekitAlias; final String livekitAlias;
/// A delay after a member leaves before we create and publish a new key, because people
/// tend to leave calls at the same time
final Duration makeKeyDelay;
/// 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
final Duration useKeyDelay;
@override @override
final bool e2eeEnabled; final bool e2eeEnabled;
@ -28,8 +17,6 @@ class LiveKitBackend extends CallBackend {
required this.livekitAlias, required this.livekitAlias,
super.type = 'livekit', super.type = 'livekit',
this.e2eeEnabled = true, this.e2eeEnabled = true,
this.makeKeyDelay = CallTimeouts.makeKeyDelay,
this.useKeyDelay = CallTimeouts.useKeyDelay,
}); });
Timer? _memberLeaveEncKeyRotateDebounceTimer; Timer? _memberLeaveEncKeyRotateDebounceTimer;
@ -44,9 +31,14 @@ class LiveKitBackend extends CallBackend {
/// used to send the key again incase someone `onCallEncryptionKeyRequest` but don't just send /// 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 /// the last one because you also cycle back in your window which means you
/// could potentially end up sharing a past key /// could potentially end up sharing a past key
/// we don't really care about what if setting or sending fails right now
int get latestLocalKeyIndex => _latestLocalKeyIndex; int get latestLocalKeyIndex => _latestLocalKeyIndex;
int _latestLocalKeyIndex = 0; int _latestLocalKeyIndex = 0;
/// stores when the last new key was made (makeNewSenderKey), is not used
/// for ratcheted keys at the moment
DateTime _lastNewKeyTime = DateTime(1900);
/// the key currently being used by the local cryptor, can possibly not be the latest /// the key currently being used by the local cryptor, can possibly not be the latest
/// key, check `latestLocalKeyIndex` for latest key /// key, check `latestLocalKeyIndex` for latest key
int get currentLocalKeyIndex => _currentLocalKeyIndex; int get currentLocalKeyIndex => _currentLocalKeyIndex;
@ -58,8 +50,8 @@ class LiveKitBackend extends CallBackend {
/// always chooses the next possible index, we cycle after 16 because /// always chooses the next possible index, we cycle after 16 because
/// no real adv with infinite list /// no real adv with infinite list
int _getNewEncryptionKeyIndex() { int _getNewEncryptionKeyIndex(int keyRingSize) {
final newIndex = _indexCounter % 16; final newIndex = _indexCounter % keyRingSize;
_indexCounter++; _indexCounter++;
return newIndex; return newIndex;
} }
@ -76,10 +68,26 @@ class LiveKitBackend extends CallBackend {
/// also does the sending for you /// also does the sending for you
Future<void> _makeNewSenderKey( Future<void> _makeNewSenderKey(
GroupCallSession groupCall, GroupCallSession groupCall,
bool delayBeforeUsingKeyOurself, bool delayBeforeUsingKeyOurself, {
) async { bool skipJoinDebounce = false,
}) async {
if (_lastNewKeyTime
.add(groupCall.voip.timeouts!.makeKeyOnJoinDelay)
.isAfter(DateTime.now()) &&
!skipJoinDebounce) {
Logs().d(
'_makeNewSenderKey using previous key because last created at ${_lastNewKeyTime.toString()}',
);
// still a fairly new key, just send that
await _sendEncryptionKeysEvent(
groupCall,
_latestLocalKeyIndex,
);
return;
}
final key = secureRandomBytes(32); final key = secureRandomBytes(32);
final keyIndex = _getNewEncryptionKeyIndex(); final keyIndex = _getNewEncryptionKeyIndex(groupCall.voip.keyRingSize);
Logs().i('[VOIP E2EE] Generated new key $key at index $keyIndex'); Logs().i('[VOIP E2EE] Generated new key $key at index $keyIndex');
await _setEncryptionKey( await _setEncryptionKey(
@ -96,6 +104,9 @@ class LiveKitBackend extends CallBackend {
Future<void> _ratchetLocalParticipantKey( Future<void> _ratchetLocalParticipantKey(
GroupCallSession groupCall, GroupCallSession groupCall,
List<CallParticipant> sendTo, List<CallParticipant> sendTo,
/// only used for makeSenderKey fallback
bool delayBeforeUsingKeyOurself,
) async { ) async {
final keyProvider = groupCall.voip.delegate.keyProvider; final keyProvider = groupCall.voip.delegate.keyProvider;
@ -114,17 +125,28 @@ class LiveKitBackend extends CallBackend {
Uint8List? ratchetedKey; Uint8List? ratchetedKey;
while (ratchetedKey == null || ratchetedKey.isEmpty) { int ratchetTryCounter = 0;
Logs().i('[VOIP E2EE] Ignoring empty ratcheted key');
while (ratchetTryCounter <= 8 &&
(ratchetedKey == null || ratchetedKey.isEmpty)) {
Logs().d(
'[VOIP E2EE] Ignoring empty ratcheted key, ratchetTryCounter: $ratchetTryCounter',
);
ratchetedKey = await keyProvider.onRatchetKey( ratchetedKey = await keyProvider.onRatchetKey(
groupCall.localParticipant!, groupCall.localParticipant!,
latestLocalKeyIndex, latestLocalKeyIndex,
); );
ratchetTryCounter++;
} }
Logs().i( if (ratchetedKey == null || ratchetedKey.isEmpty) {
'[VOIP E2EE] Ratched latest key to $ratchetedKey at idx $latestLocalKeyIndex', Logs().d(
); '[VOIP E2EE] ratcheting failed, falling back to creating a new key',
);
await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself);
return;
}
await _setEncryptionKey( await _setEncryptionKey(
groupCall, groupCall,
@ -133,6 +155,7 @@ class LiveKitBackend extends CallBackend {
ratchetedKey, ratchetedKey,
delayBeforeUsingKeyOurself: false, delayBeforeUsingKeyOurself: false,
send: true, send: true,
setKey: false,
sendTo: sendTo, sendTo: sendTo,
); );
} }
@ -144,7 +167,11 @@ class LiveKitBackend extends CallBackend {
) async { ) async {
if (!e2eeEnabled) return; if (!e2eeEnabled) return;
if (groupCall.voip.enableSFUE2EEKeyRatcheting) { if (groupCall.voip.enableSFUE2EEKeyRatcheting) {
await _ratchetLocalParticipantKey(groupCall, anyJoined); await _ratchetLocalParticipantKey(
groupCall,
anyJoined,
delayBeforeUsingKeyOurself,
);
} else { } else {
await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself); await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself);
} }
@ -159,6 +186,9 @@ class LiveKitBackend extends CallBackend {
Uint8List encryptionKeyBin, { Uint8List encryptionKeyBin, {
bool delayBeforeUsingKeyOurself = false, bool delayBeforeUsingKeyOurself = false,
bool send = false, bool send = false,
/// ratchet seems to set on call, so no need to set manually
bool setKey = true,
List<CallParticipant>? sendTo, List<CallParticipant>? sendTo,
}) async { }) async {
final encryptionKeys = final encryptionKeys =
@ -168,6 +198,7 @@ class LiveKitBackend extends CallBackend {
_encryptionKeysMap[participant] = encryptionKeys; _encryptionKeysMap[participant] = encryptionKeys;
if (participant.isLocal) { if (participant.isLocal) {
_latestLocalKeyIndex = encryptionKeyIndex; _latestLocalKeyIndex = encryptionKeyIndex;
_lastNewKeyTime = DateTime.now();
} }
if (send) { if (send) {
@ -178,12 +209,23 @@ class LiveKitBackend extends CallBackend {
); );
} }
if (!setKey) {
Logs().i(
'[VOIP E2EE] sent ratchetd key $encryptionKeyBin but not setting',
);
return;
}
if (delayBeforeUsingKeyOurself) { if (delayBeforeUsingKeyOurself) {
Logs().d(
'[VOIP E2EE] starting delayed set for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin, current idx $currentLocalKeyIndex key ${encryptionKeys[currentLocalKeyIndex]}',
);
// now wait for the key to propogate and then set it, hopefully users can // now wait for the key to propogate and then set it, hopefully users can
// stil decrypt everything // stil decrypt everything
final useKeyTimeout = Future.delayed(useKeyDelay, () async { final useKeyTimeout =
Future.delayed(groupCall.voip.timeouts!.useKeyDelay, () async {
Logs().i( Logs().i(
'[VOIP E2EE] setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin', '[VOIP E2EE] delayed setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin',
); );
await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey( await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey(
participant, participant,
@ -257,7 +299,7 @@ class LiveKitBackend extends CallBackend {
EventTypes.GroupCallMemberEncryptionKeys, EventTypes.GroupCallMemberEncryptionKeys,
); );
} catch (e, s) { } catch (e, s) {
Logs().e('[VOIP] Failed to send e2ee keys, retrying', e, s); Logs().e('[VOIP E2EE] Failed to send e2ee keys, retrying', e, s);
await _sendEncryptionKeysEvent( await _sendEncryptionKeysEvent(
groupCall, groupCall,
keyIndex, keyIndex,
@ -274,7 +316,7 @@ class LiveKitBackend extends CallBackend {
) async { ) async {
if (remoteParticipants.isEmpty) return; if (remoteParticipants.isEmpty) return;
Logs().v( Logs().v(
'[VOIP] _sendToDeviceEvent: sending ${data.toString()} to ${remoteParticipants.map((e) => e.id)} ', '[VOIP E2EE] _sendToDeviceEvent: sending ${data.toString()} to ${remoteParticipants.map((e) => e.id)} ',
); );
final txid = final txid =
VoIP.customTxid ?? groupCall.client.generateUniqueTransactionId(); VoIP.customTxid ?? groupCall.client.generateUniqueTransactionId();
@ -355,7 +397,7 @@ class LiveKitBackend extends CallBackend {
Map<String, dynamic> content, Map<String, dynamic> content,
) async { ) async {
if (!e2eeEnabled) { if (!e2eeEnabled) {
Logs().w('[VOIP] got sframe key but we do not support e2ee'); Logs().w('[VOIP E2EE] got sframe key but we do not support e2ee');
return; return;
} }
final keyContent = EncryptionKeysEventContent.fromJson(content); final keyContent = EncryptionKeysEventContent.fromJson(content);
@ -398,37 +440,65 @@ class LiveKitBackend extends CallBackend {
Map<String, dynamic> content, Map<String, dynamic> content,
) async { ) async {
if (!e2eeEnabled) { if (!e2eeEnabled) {
Logs().w('[VOIP] got sframe key request but we do not support e2ee'); Logs().w('[VOIP E2EE] got sframe key request but we do not support e2ee');
return; return;
} }
final mems = groupCall.room.getCallMembershipsForUser(userId);
if (mems Future<bool> checkPartcipantStatusAndRequestKey() async {
.where( final mems = groupCall.room.getCallMembershipsForUser(
(mem) => userId,
mem.callId == groupCall.groupCallId && deviceId,
mem.userId == userId && groupCall.voip,
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, if (mems
_latestLocalKeyIndex, .where(
sendTo: [ (mem) =>
CallParticipant( mem.callId == groupCall.groupCallId &&
groupCall.voip, mem.userId == userId &&
userId: userId, mem.deviceId == deviceId &&
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 E2EE] onCallEncryptionKeyRequest: request checks out, sending key on index: $latestLocalKeyIndex to $userId:$deviceId',
);
await _sendEncryptionKeysEvent(
groupCall,
_latestLocalKeyIndex,
sendTo: [
CallParticipant(
groupCall.voip,
userId: userId,
deviceId: deviceId,
),
],
);
return true;
} else {
return false;
}
}
if ((!await checkPartcipantStatusAndRequestKey())) {
Logs().i(
'[VOIP E2EE] onCallEncryptionKeyRequest: checkPartcipantStatusAndRequestKey returned false, therefore retrying by getting state from server and rebuilding participant list for sanity',
); );
final stateKey =
(groupCall.room.roomVersion?.contains('msc3757') ?? false)
? '${userId}_$deviceId'
: userId;
await groupCall.room.client.getRoomStateWithKey(
groupCall.room.id,
EventTypes.GroupCallMember,
stateKey,
);
await groupCall.onMemberStateChanged();
await checkPartcipantStatusAndRequestKey();
} }
} }
@ -450,8 +520,15 @@ class LiveKitBackend extends CallBackend {
if (_memberLeaveEncKeyRotateDebounceTimer != null) { if (_memberLeaveEncKeyRotateDebounceTimer != null) {
_memberLeaveEncKeyRotateDebounceTimer!.cancel(); _memberLeaveEncKeyRotateDebounceTimer!.cancel();
} }
_memberLeaveEncKeyRotateDebounceTimer = Timer(makeKeyDelay, () async { _memberLeaveEncKeyRotateDebounceTimer =
await _makeNewSenderKey(groupCall, true); Timer(groupCall.voip.timeouts!.makeKeyOnLeaveDelay, () async {
// we skipJoinDebounce here because we want to make sure a new key is generated
// and that the join debounce does not block us from making a new key
await _makeNewSenderKey(
groupCall,
true,
skipJoinDebounce: true,
);
}); });
} }

View File

@ -5,7 +5,6 @@ import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.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/models/call_options.dart';
import 'package:matrix/src/voip/utils/stream_helper.dart'; import 'package:matrix/src/voip/utils/stream_helper.dart';
import 'package:matrix/src/voip/utils/user_media_constraints.dart'; import 'package:matrix/src/voip/utils/user_media_constraints.dart';

View File

@ -283,7 +283,7 @@ class CallSession {
setCallState(CallState.kRinging); setCallState(CallState.kRinging);
_ringingTimer = Timer(CallTimeouts.callInviteLifetime, () { _ringingTimer = Timer(voip.timeouts!.callInviteLifetime, () {
if (state == CallState.kRinging) { if (state == CallState.kRinging) {
Logs().v('[VOIP] Call invite has expired. Hanging up.'); Logs().v('[VOIP] Call invite has expired. Hanging up.');
@ -457,7 +457,7 @@ class CallSession {
await sendCallNegotiate( await sendCallNegotiate(
room, room,
callId, callId,
CallTimeouts.defaultCallEventLifetime.inMilliseconds, voip.timeouts!.defaultCallEventLifetime.inMilliseconds,
localPartyId, localPartyId,
answer.sdp!, answer.sdp!,
type: answer.type!, type: answer.type!,
@ -1081,7 +1081,7 @@ class CallSession {
if (pc!.iceGatheringState == if (pc!.iceGatheringState ==
RTCIceGatheringState.RTCIceGatheringStateGathering) { RTCIceGatheringState.RTCIceGatheringStateGathering) {
// Allow a short time for initial candidates to be gathered // Allow a short time for initial candidates to be gathered
await Future.delayed(CallTimeouts.iceGatheringDelay); await Future.delayed(voip.timeouts!.iceGatheringDelay);
} }
if (callHasEnded) return; if (callHasEnded) return;
@ -1094,7 +1094,7 @@ class CallSession {
await sendInviteToCall( await sendInviteToCall(
room, room,
callId, callId,
CallTimeouts.callInviteLifetime.inMilliseconds, voip.timeouts!.callInviteLifetime.inMilliseconds,
localPartyId, localPartyId,
offer.sdp!, offer.sdp!,
capabilities: callCapabilities, capabilities: callCapabilities,
@ -1115,7 +1115,7 @@ class CallSession {
setCallState(CallState.kInviteSent); setCallState(CallState.kInviteSent);
_inviteTimer = Timer(CallTimeouts.callInviteLifetime, () { _inviteTimer = Timer(voip.timeouts!.callInviteLifetime, () {
if (state == CallState.kInviteSent) { if (state == CallState.kInviteSent) {
unawaited(hangup(reason: CallErrorCode.inviteTimeout)); unawaited(hangup(reason: CallErrorCode.inviteTimeout));
} }
@ -1126,7 +1126,7 @@ class CallSession {
await sendCallNegotiate( await sendCallNegotiate(
room, room,
callId, callId,
CallTimeouts.defaultCallEventLifetime.inMilliseconds, voip.timeouts!.defaultCallEventLifetime.inMilliseconds,
localPartyId, localPartyId,
offer.sdp!, offer.sdp!,
type: offer.type!, type: offer.type!,
@ -1144,7 +1144,7 @@ class CallSession {
// onNegotiationNeeded, which causes creatOffer to only include // onNegotiationNeeded, which causes creatOffer to only include
// audio m-line, add delay and wait for video track to be added, // audio m-line, add delay and wait for video track to be added,
// then createOffer can get audio/video m-line correctly. // then createOffer can get audio/video m-line correctly.
await Future.delayed(CallTimeouts.delayBeforeOffer); await Future.delayed(voip.timeouts!.delayBeforeOffer);
final offer = await pc!.createOffer({}); final offer = await pc!.createOffer({});
await _gotLocalOffer(offer); await _gotLocalOffer(offer);
} catch (e) { } catch (e) {

View File

@ -21,7 +21,6 @@ import 'dart:core';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.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/models/voip_id.dart';
import 'package:matrix/src/voip/utils/stream_helper.dart'; import 'package:matrix/src/voip/utils/stream_helper.dart';
@ -162,10 +161,11 @@ class GroupCallSession {
backend: backend, backend: backend,
deviceId: client.deviceID!, deviceId: client.deviceID!,
expiresTs: DateTime.now() expiresTs: DateTime.now()
.add(CallTimeouts.expireTsBumpDuration) .add(voip.timeouts!.expireTsBumpDuration)
.millisecondsSinceEpoch, .millisecondsSinceEpoch,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
feeds: backend.getCurrentFeeds(), feeds: backend.getCurrentFeeds(),
voip: voip,
), ),
); );
@ -173,7 +173,7 @@ class GroupCallSession {
_resendMemberStateEventTimer!.cancel(); _resendMemberStateEventTimer!.cancel();
} }
_resendMemberStateEventTimer = Timer.periodic( _resendMemberStateEventTimer = Timer.periodic(
CallTimeouts.updateExpireTsTimerDuration, voip.timeouts!.updateExpireTsTimerDuration,
((timer) async { ((timer) async {
Logs().d('sendMemberStateEvent updating member event with timer'); Logs().d('sendMemberStateEvent updating member event with timer');
if (state != GroupCallState.ended || if (state != GroupCallState.ended ||
@ -198,6 +198,7 @@ class GroupCallSession {
return room.removeFamedlyCallMemberEvent( return room.removeFamedlyCallMemberEvent(
groupCallId, groupCallId,
client.deviceID!, client.deviceID!,
voip,
application: application, application: application,
scope: scope, scope: scope,
); );
@ -206,8 +207,10 @@ class GroupCallSession {
/// compltetely rebuilds the local _participants list /// compltetely rebuilds the local _participants list
Future<void> onMemberStateChanged() async { Future<void> onMemberStateChanged() async {
// The member events may be received for another room, which we will ignore. // The member events may be received for another room, which we will ignore.
final mems = final mems = room
room.getCallMembershipsFromRoom().values.expand((element) => element); .getCallMembershipsFromRoom(voip)
.values
.expand((element) => element);
final memsForCurrentGroupCall = mems.where((element) { final memsForCurrentGroupCall = mems.where((element) {
return element.callId == groupCallId && return element.callId == groupCallId &&
!element.isExpired && !element.isExpired &&
@ -216,15 +219,6 @@ class GroupCallSession {
element.roomId == room.id; // sanity checks element.roomId == room.id; // sanity checks
}).toList(); }).toList();
final ignoredMems =
mems.where((element) => !memsForCurrentGroupCall.contains(element));
for (final mem in ignoredMems) {
Logs().v(
'[VOIP] Ignored ${mem.userId}\'s mem event ${mem.toJson()} while updating _participants list for callId: $groupCallId, expiry status: ${mem.isExpired}',
);
}
final Set<CallParticipant> newP = {}; final Set<CallParticipant> newP = {};
for (final mem in memsForCurrentGroupCall) { for (final mem in memsForCurrentGroupCall) {
@ -238,12 +232,7 @@ class GroupCallSession {
if (rp.isLocal) continue; if (rp.isLocal) continue;
if (state != GroupCallState.entered) { if (state != GroupCallState.entered) continue;
Logs().w(
'[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update',
);
continue;
}
await backend.setupP2PCallWithNewMember(this, rp, mem); await backend.setupP2PCallWithNewMember(this, rp, mem);
} }
@ -281,9 +270,6 @@ class GroupCallSession {
} }
onGroupCallEvent.add(GroupCallStateChange.participantsChanged); onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
Logs().d(
'[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}',
);
} }
} }
} }

View File

@ -9,14 +9,18 @@ class FamedlyCallMemberEvent {
return {'memberships': memberships.map((e) => e.toJson()).toList()}; return {'memberships': memberships.map((e) => e.toJson()).toList()};
} }
factory FamedlyCallMemberEvent.fromJson(Event event) { factory FamedlyCallMemberEvent.fromJson(Event event, VoIP voip) {
final List<CallMembership> callMemberships = []; final List<CallMembership> callMemberships = [];
final memberships = event.content.tryGetList('memberships'); final memberships = event.content.tryGetList('memberships');
if (memberships != null && memberships.isNotEmpty) { if (memberships != null && memberships.isNotEmpty) {
for (final mem in memberships) { for (final mem in memberships) {
if (isValidMemEvent(mem)) { if (isValidMemEvent(mem)) {
final callMem = final callMem = CallMembership.fromJson(
CallMembership.fromJson(mem, event.senderId, event.room.id); mem,
event.senderId,
event.room.id,
voip,
);
if (callMem != null) callMemberships.add(callMem); if (callMem != null) callMemberships.add(callMem);
} }
} }
@ -35,7 +39,7 @@ class CallMembership {
final int expiresTs; final int expiresTs;
final String membershipId; final String membershipId;
final List? feeds; final List? feeds;
final VoIP voip;
final String roomId; final String roomId;
CallMembership({ CallMembership({
@ -46,6 +50,7 @@ class CallMembership {
required this.expiresTs, required this.expiresTs,
required this.roomId, required this.roomId,
required this.membershipId, required this.membershipId,
required this.voip,
this.application = 'm.call', this.application = 'm.call',
this.scope = 'm.room', this.scope = 'm.room',
this.feeds, this.feeds,
@ -65,7 +70,12 @@ class CallMembership {
}; };
} }
static CallMembership? fromJson(Map json, String userId, String roomId) { static CallMembership? fromJson(
Map json,
String userId,
String roomId,
VoIP voip,
) {
try { try {
return CallMembership( return CallMembership(
userId: userId, userId: userId,
@ -81,6 +91,7 @@ class CallMembership {
membershipId: membershipId:
json['membershipID'] ?? 'someone_forgot_to_set_the_membershipID', json['membershipID'] ?? 'someone_forgot_to_set_the_membershipID',
feeds: json['feeds'], feeds: json['feeds'],
voip: voip,
); );
} catch (e, s) { } catch (e, s) {
Logs().e('[VOIP] call membership parsing failed. $json', e, s); Logs().e('[VOIP] call membership parsing failed. $json', e, s);
@ -120,6 +131,6 @@ class CallMembership {
bool get isExpired => bool get isExpired =>
expiresTs < expiresTs <
DateTime.now() DateTime.now()
.subtract(CallTimeouts.expireTsBumpDuration) .subtract(voip.timeouts!.expireTsBumpDuration)
.millisecondsSinceEpoch; .millisecondsSinceEpoch;
} }

View File

@ -1,12 +1,17 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/voip/models/call_membership.dart';
String? _delayedLeaveEventId;
Timer? _restartDelayedLeaveEventTimer;
extension FamedlyCallMemberEventsExtension on Room { extension FamedlyCallMemberEventsExtension on Room {
/// a map of every users famedly call event, holds the memberships list /// a map of every users famedly call event, holds the memberships list
/// returns sorted according to originTs (oldest to newest) /// returns sorted according to originTs (oldest to newest)
Map<String, FamedlyCallMemberEvent> getFamedlyCallEvents() { Map<String, FamedlyCallMemberEvent> getFamedlyCallEvents(VoIP voip) {
final Map<String, FamedlyCallMemberEvent> mappedEvents = {}; final Map<String, FamedlyCallMemberEvent> mappedEvents = {};
final famedlyCallMemberStates = final famedlyCallMemberStates =
states.tryGetMap<String, Event>(EventTypes.GroupCallMember); states.tryGetMap<String, Event>(EventTypes.GroupCallMember);
@ -16,16 +21,17 @@ extension FamedlyCallMemberEventsExtension on Room {
.sorted((a, b) => a.originServerTs.compareTo(b.originServerTs)); .sorted((a, b) => a.originServerTs.compareTo(b.originServerTs));
for (final element in sortedEvents) { for (final element in sortedEvents) {
mappedEvents mappedEvents.addAll(
.addAll({element.senderId: FamedlyCallMemberEvent.fromJson(element)}); {element.stateKey!: FamedlyCallMemberEvent.fromJson(element, voip)},
);
} }
return mappedEvents; return mappedEvents;
} }
/// extracts memberships list form a famedly call event and maps it to a userid /// extracts memberships list form a famedly call event and maps it to a userid
/// returns sorted (oldest to newest) /// returns sorted (oldest to newest)
Map<String, List<CallMembership>> getCallMembershipsFromRoom() { Map<String, List<CallMembership>> getCallMembershipsFromRoom(VoIP voip) {
final parsedMemberEvents = getFamedlyCallEvents(); final parsedMemberEvents = getFamedlyCallEvents(voip);
final Map<String, List<CallMembership>> memberships = {}; final Map<String, List<CallMembership>> memberships = {};
for (final element in parsedMemberEvents.entries) { for (final element in parsedMemberEvents.entries) {
memberships.addAll({element.key: element.value.memberships}); memberships.addAll({element.key: element.value.memberships});
@ -34,18 +40,29 @@ extension FamedlyCallMemberEventsExtension on Room {
} }
/// returns a list of memberships in the room for `user` /// returns a list of memberships in the room for `user`
List<CallMembership> getCallMembershipsForUser(String userId) { /// if room version is org.matrix.msc3757.11 it also uses the deviceId
final parsedMemberEvents = getCallMembershipsFromRoom(); List<CallMembership> getCallMembershipsForUser(
final mem = parsedMemberEvents.tryGet<List<CallMembership>>(userId); String userId,
String deviceId,
VoIP voip,
) {
final stateKey = (roomVersion?.contains('msc3757') ?? false)
? '${userId}_$deviceId'
: userId;
final parsedMemberEvents = getCallMembershipsFromRoom(voip);
final mem = parsedMemberEvents.tryGet<List<CallMembership>>(stateKey);
return mem ?? []; return mem ?? [];
} }
/// returns the user count (not sessions, yet) for the group call with id: `groupCallId`. /// returns the user count (not sessions, yet) for the group call with id: `groupCallId`.
/// returns 0 if group call not found /// returns 0 if group call not found
int groupCallParticipantCount(String groupCallId) { int groupCallParticipantCount(
String groupCallId,
VoIP voip,
) {
int participantCount = 0; int participantCount = 0;
// userid:membership // userid:membership
final memberships = getCallMembershipsFromRoom(); final memberships = getCallMembershipsFromRoom(voip);
memberships.forEach((key, value) { memberships.forEach((key, value) {
for (final membership in value) { for (final membership in value) {
@ -58,17 +75,17 @@ extension FamedlyCallMemberEventsExtension on Room {
return participantCount; return participantCount;
} }
bool get hasActiveGroupCall { bool hasActiveGroupCall(VoIP voip) {
if (activeGroupCallIds.isNotEmpty) { if (activeGroupCallIds(voip).isNotEmpty) {
return true; return true;
} }
return false; return false;
} }
/// list of active group call ids /// list of active group call ids
List<String> get activeGroupCallIds { List<String> activeGroupCallIds(VoIP voip) {
final Set<String> ids = {}; final Set<String> ids = {};
final memberships = getCallMembershipsFromRoom(); final memberships = getCallMembershipsFromRoom(voip);
memberships.forEach((key, value) { memberships.forEach((key, value) {
for (final mem in value) { for (final mem in value) {
@ -82,7 +99,11 @@ extension FamedlyCallMemberEventsExtension on Room {
Future<void> updateFamedlyCallMemberStateEvent( Future<void> updateFamedlyCallMemberStateEvent(
CallMembership callMembership, CallMembership callMembership,
) async { ) async {
final ownMemberships = getCallMembershipsForUser(client.userID!); final ownMemberships = getCallMembershipsForUser(
client.userID!,
client.deviceID!,
callMembership.voip,
);
// do not bother removing other deviceId expired events because we have no // do not bother removing other deviceId expired events because we have no
// ownership over them // ownership over them
@ -97,16 +118,24 @@ extension FamedlyCallMemberEventsExtension on Room {
'memberships': List.from(ownMemberships.map((e) => e.toJson())), 'memberships': List.from(ownMemberships.map((e) => e.toJson())),
}; };
await setFamedlyCallMemberEvent(newContent); await setFamedlyCallMemberEvent(
newContent,
callMembership.voip,
);
} }
Future<void> removeFamedlyCallMemberEvent( Future<void> removeFamedlyCallMemberEvent(
String groupCallId, String groupCallId,
String deviceId, { String deviceId,
VoIP voip, {
String? application = 'm.call', String? application = 'm.call',
String? scope = 'm.room', String? scope = 'm.room',
}) async { }) async {
final ownMemberships = getCallMembershipsForUser(client.userID!); final ownMemberships = getCallMembershipsForUser(
client.userID!,
client.deviceID!,
voip,
);
ownMemberships.removeWhere( ownMemberships.removeWhere(
(mem) => (mem) =>
@ -119,15 +148,85 @@ extension FamedlyCallMemberEventsExtension on Room {
final newContent = { final newContent = {
'memberships': List.from(ownMemberships.map((e) => e.toJson())), 'memberships': List.from(ownMemberships.map((e) => e.toJson())),
}; };
await setFamedlyCallMemberEvent(newContent); await setFamedlyCallMemberEvent(newContent, voip);
_restartDelayedLeaveEventTimer?.cancel();
if (_delayedLeaveEventId != null) {
await client.manageDelayedEvent(
_delayedLeaveEventId!,
DelayedEventAction.cancel,
);
_delayedLeaveEventId = null;
}
} }
Future<void> setFamedlyCallMemberEvent(Map<String, List> newContent) async { Future<void> setFamedlyCallMemberEvent(
Map<String, List> newContent,
VoIP voip,
) async {
if (canJoinGroupCall) { if (canJoinGroupCall) {
final stateKey = (roomVersion?.contains('msc3757') ?? false)
? '${client.userID!}_${client.deviceID!}'
: client.userID!;
final useDelayedEvents = (await client.versionsResponse)
.unstableFeatures?['org.matrix.msc4140'] ??
false;
/// can use delayed events and haven't used it yet
if (useDelayedEvents && _delayedLeaveEventId == null) {
// get existing ones
final List<ScheduledDelayedEvent> alreadyScheduledEvents = [];
String? nextBatch;
final sEvents = await client.getScheduledDelayedEvents();
alreadyScheduledEvents.addAll(sEvents.scheduledEvents);
nextBatch = sEvents.nextBatch;
while (nextBatch != null || (nextBatch?.isNotEmpty ?? false)) {
final res = await client.getScheduledDelayedEvents();
alreadyScheduledEvents.addAll(
res.scheduledEvents,
);
nextBatch = res.nextBatch;
}
final toCancelEvents = alreadyScheduledEvents.where(
(element) => element.stateKey == stateKey,
);
for (final toCancelEvent in toCancelEvents) {
await client.manageDelayedEvent(
toCancelEvent.delayId,
DelayedEventAction.cancel,
);
}
_delayedLeaveEventId = await client.setRoomStateWithKeyWithDelay(
id,
EventTypes.GroupCallMember,
stateKey,
voip.timeouts!.delayedEventApplyLeave.inMilliseconds,
{
'memberships': [],
},
);
_restartDelayedLeaveEventTimer = Timer.periodic(
voip.timeouts!.delayedEventRestart,
((timer) async {
Logs()
.v('[_restartDelayedLeaveEventTimer] heartbeat delayed event');
await client.manageDelayedEvent(
_delayedLeaveEventId!,
DelayedEventAction.restart,
);
}),
);
}
await client.setRoomStateWithKey( await client.setRoomStateWithKey(
id, id,
EventTypes.GroupCallMember, EventTypes.GroupCallMember,
client.userID!, stateKey,
newContent, newContent,
); );
} else { } else {
@ -145,12 +244,16 @@ extension FamedlyCallMemberEventsExtension on Room {
} }
/// returns a list of memberships from a famedly call matrix event /// returns a list of memberships from a famedly call matrix event
List<CallMembership> getCallMembershipsFromEvent(MatrixEvent event) { List<CallMembership> getCallMembershipsFromEvent(
MatrixEvent event,
VoIP voip,
) {
if (event.roomId != id) return []; if (event.roomId != id) return [];
return getCallMembershipsFromEventContent( return getCallMembershipsFromEventContent(
event.content, event.content,
event.senderId, event.senderId,
event.roomId!, event.roomId!,
voip,
); );
} }
@ -159,11 +262,12 @@ extension FamedlyCallMemberEventsExtension on Room {
Map<String, Object?> content, Map<String, Object?> content,
String senderId, String senderId,
String roomId, String roomId,
VoIP voip,
) { ) {
final mems = content.tryGetList<Map>('memberships'); final mems = content.tryGetList<Map>('memberships');
final callMems = <CallMembership>[]; final callMems = <CallMembership>[];
for (final m in mems ?? []) { for (final m in mems ?? []) {
final mem = CallMembership.fromJson(m, senderId, roomId); final mem = CallMembership.fromJson(m, senderId, roomId, voip);
if (mem != null) callMems.add(mem); if (mem != null) callMems.add(mem);
} }
return callMems; return callMems;

View File

@ -6,36 +6,64 @@ const String voipProtoVersion = '1';
class CallTimeouts { class CallTimeouts {
/// The default life time for call events, in millisecond. /// The default life time for call events, in millisecond.
static const defaultCallEventLifetime = Duration(seconds: 10); final Duration defaultCallEventLifetime;
/// The length of time a call can be ringing for. /// The length of time a call can be ringing for.
static const callInviteLifetime = Duration(seconds: 60); final Duration callInviteLifetime;
/// The delay for ice gathering. /// The delay for ice gathering.
static const iceGatheringDelay = Duration(milliseconds: 200); final Duration iceGatheringDelay;
/// Delay before createOffer. /// Delay before createOffer.
static const delayBeforeOffer = Duration(milliseconds: 100); final Duration delayBeforeOffer;
/// How often to update the expiresTs /// How often to update the expiresTs
static const updateExpireTsTimerDuration = Duration(minutes: 2); final Duration updateExpireTsTimerDuration;
/// the expiresTs bump /// the expiresTs bump
static const expireTsBumpDuration = Duration(minutes: 6); final Duration expireTsBumpDuration;
/// Update the active speaker value /// Update the active speaker value
static const activeSpeakerInterval = Duration(seconds: 5); final Duration activeSpeakerInterval;
// source: element call? // source: element call?
/// A delay after a member leaves before we create and publish a new key, because people /// A delay after a member leaves before we create and publish a new key, because people
/// tend to leave calls at the same time /// tend to leave calls at the same time
static const makeKeyDelay = Duration(seconds: 4); final Duration makeKeyOnLeaveDelay;
/// A delay used for joins, only creates new keys if last new created key was before
/// $makeKeyDelay duration, or it was recently made and it's safe to send that
/// The bigger this is the easier key sharing would be, but also less secure
/// Not used if ratcheting is enabled
final Duration makeKeyOnJoinDelay;
/// The delay between creating and sending a new key and starting to encrypt with it. This gives others /// 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. /// 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 /// The total time between a member leaving and the call switching to new keys is therefore
/// makeKeyDelay + useKeyDelay /// makeKeyDelay + useKeyDelay
static const useKeyDelay = Duration(seconds: 4); final Duration useKeyDelay;
/// After how long the homeserver should send the delayed leave event which
/// gracefully leaves you from the call
final Duration delayedEventApplyLeave;
/// How often the delayed event should be restarted on the homeserver
final Duration delayedEventRestart;
CallTimeouts({
this.defaultCallEventLifetime = const Duration(seconds: 10),
this.callInviteLifetime = const Duration(seconds: 60),
this.iceGatheringDelay = const Duration(milliseconds: 200),
this.delayBeforeOffer = const Duration(milliseconds: 100),
this.updateExpireTsTimerDuration = const Duration(minutes: 2),
this.expireTsBumpDuration = const Duration(minutes: 6),
this.activeSpeakerInterval = const Duration(seconds: 5),
this.makeKeyOnLeaveDelay = const Duration(seconds: 4),
this.makeKeyOnJoinDelay = const Duration(seconds: 8),
this.useKeyDelay = const Duration(seconds: 4),
this.delayedEventApplyLeave = const Duration(seconds: 18),
this.delayedEventRestart = const Duration(seconds: 4),
});
} }
class CallConstants { class CallConstants {

View File

@ -9,7 +9,6 @@ import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:matrix/src/utils/crypto/crypto.dart'; import 'package:matrix/src/utils/crypto/crypto.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/call_options.dart';
import 'package:matrix/src/voip/models/voip_id.dart'; import 'package:matrix/src/voip/models/voip_id.dart';
import 'package:matrix/src/voip/utils/stream_helper.dart'; import 'package:matrix/src/voip/utils/stream_helper.dart';
@ -71,16 +70,30 @@ class VoIP {
/// the current instance of voip, changing this will drop any ongoing mesh calls /// the current instance of voip, changing this will drop any ongoing mesh calls
/// with that sessionId /// with that sessionId
late String currentSessionId; late String currentSessionId;
/// the following parameters are only used in livekit calls, but cannot be
/// in the LivekitBackend class because that could be created from a pre-existing state event
/// controls how many key indices can you have before looping back to index 0
/// only used in livekit calls
final int keyRingSize;
// default values set in super constructor
CallTimeouts? timeouts;
VoIP( VoIP(
this.client, this.client,
this.delegate, { this.delegate, {
this.enableSFUE2EEKeyRatcheting = false, this.enableSFUE2EEKeyRatcheting = false,
this.keyRingSize = 16,
this.timeouts,
}) : super() { }) : super() {
timeouts ??= CallTimeouts();
currentSessionId = base64Encode(secureRandomBytes(16)); currentSessionId = base64Encode(secureRandomBytes(16));
Logs().v('set currentSessionId to $currentSessionId'); Logs().v('set currentSessionId to $currentSessionId');
// to populate groupCalls with already present calls // to populate groupCalls with already present calls
for (final room in client.rooms) { for (final room in client.rooms) {
final memsList = room.getCallMembershipsFromRoom(); final memsList = room.getCallMembershipsFromRoom(this);
for (final mems in memsList.values) { for (final mems in memsList.values) {
for (final mem in mems) { for (final mem in mems) {
unawaited(createGroupCallFromRoomStateEvent(mem)); unawaited(createGroupCallFromRoomStateEvent(mem));
@ -101,8 +114,7 @@ class VoIP {
if (event.room.membership != Membership.join) return; if (event.room.membership != Membership.join) return;
if (event.type != EventTypes.GroupCallMember) return; if (event.type != EventTypes.GroupCallMember) return;
Logs().v('[VOIP] onRoomState: type ${event.toJson()}'); final mems = event.room.getCallMembershipsFromEvent(event, this);
final mems = event.room.getCallMembershipsFromEvent(event);
for (final mem in mems) { for (final mem in mems) {
unawaited(createGroupCallFromRoomStateEvent(mem)); unawaited(createGroupCallFromRoomStateEvent(mem));
} }
@ -151,7 +163,7 @@ class VoIP {
if (callEvent.type == EventTypes.CallInvite && if (callEvent.type == EventTypes.CallInvite &&
age > age >
(callEvent.content.tryGet<int>('lifetime') ?? (callEvent.content.tryGet<int>('lifetime') ??
CallTimeouts.callInviteLifetime.inMilliseconds)) { timeouts!.callInviteLifetime.inMilliseconds)) {
Logs().w( Logs().w(
'[VOIP] Ommiting invite event ${callEvent.eventId} as age was older than lifetime', '[VOIP] Ommiting invite event ${callEvent.eventId} as age was older than lifetime',
); );
@ -903,12 +915,7 @@ class VoIP {
CallMembership membership, { CallMembership membership, {
bool emitHandleNewGroupCall = true, bool emitHandleNewGroupCall = true,
}) async { }) async {
if (membership.isExpired) { if (membership.isExpired) return;
Logs().d(
'Ignoring expired membership in passive groupCall creator. ${membership.toJson()}',
);
return;
}
final room = client.getRoomById(membership.roomId); final room = client.getRoomById(membership.roomId);
@ -947,5 +954,5 @@ class VoIP {
} }
@Deprecated('Call `hasActiveGroupCall` on the room directly instead') @Deprecated('Call `hasActiveGroupCall` on the room directly instead')
bool hasActiveCall(Room room) => room.hasActiveGroupCall; bool hasActiveCall(Room room) => room.hasActiveGroupCall(this);
} }

View File

@ -2,7 +2,6 @@ import 'package:test/test.dart';
import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.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/call_options.dart';
import 'package:matrix/src/voip/models/voip_id.dart'; import 'package:matrix/src/voip/models/voip_id.dart';
import 'fake_client.dart'; import 'fake_client.dart';
@ -630,6 +629,7 @@ void main() {
.millisecondsSinceEpoch, .millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -653,6 +653,7 @@ void main() {
expiresTs: DateTime.now().millisecondsSinceEpoch, expiresTs: DateTime.now().millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -676,6 +677,7 @@ void main() {
expiresTs: DateTime.now().millisecondsSinceEpoch, expiresTs: DateTime.now().millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -701,6 +703,7 @@ void main() {
.millisecondsSinceEpoch, .millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -713,35 +716,35 @@ void main() {
), ),
); );
expect( expect(
room.getFamedlyCallEvents().entries.elementAt(0).key, room.getFamedlyCallEvents(voip).entries.elementAt(0).key,
'@test3:example.com', '@test3:example.com',
); );
expect( expect(
room.getFamedlyCallEvents().entries.elementAt(1).key, room.getFamedlyCallEvents(voip).entries.elementAt(1).key,
'@test2:example.com', '@test2:example.com',
); );
expect( expect(
room.getFamedlyCallEvents().entries.elementAt(2).key, room.getFamedlyCallEvents(voip).entries.elementAt(2).key,
'@test2.0:example.com', '@test2.0:example.com',
); );
expect( expect(
room.getFamedlyCallEvents().entries.elementAt(3).key, room.getFamedlyCallEvents(voip).entries.elementAt(3).key,
'@test1:example.com', '@test1:example.com',
); );
expect( expect(
room.getCallMembershipsFromRoom().entries.elementAt(0).key, room.getCallMembershipsFromRoom(voip).entries.elementAt(0).key,
'@test3:example.com', '@test3:example.com',
); );
expect( expect(
room.getCallMembershipsFromRoom().entries.elementAt(1).key, room.getCallMembershipsFromRoom(voip).entries.elementAt(1).key,
'@test2:example.com', '@test2:example.com',
); );
expect( expect(
room.getCallMembershipsFromRoom().entries.elementAt(2).key, room.getCallMembershipsFromRoom(voip).entries.elementAt(2).key,
'@test2.0:example.com', '@test2.0:example.com',
); );
expect( expect(
room.getCallMembershipsFromRoom().entries.elementAt(3).key, room.getCallMembershipsFromRoom(voip).entries.elementAt(3).key,
'@test1:example.com', '@test1:example.com',
); );
}); });
@ -866,6 +869,7 @@ void main() {
.millisecondsSinceEpoch, .millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -874,8 +878,8 @@ void main() {
), ),
); );
expect(room.groupCallParticipantCount('participants_count'), 0); expect(room.groupCallParticipantCount('participants_count', voip), 0);
expect(room.hasActiveGroupCall, false); expect(room.hasActiveGroupCall(voip), false);
room.setState( room.setState(
Event( Event(
@ -895,6 +899,7 @@ void main() {
.millisecondsSinceEpoch, .millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -902,8 +907,8 @@ void main() {
stateKey: '@test2:example.com', stateKey: '@test2:example.com',
), ),
); );
expect(room.groupCallParticipantCount('participants_count'), 1); expect(room.groupCallParticipantCount('participants_count', voip), 1);
expect(room.hasActiveGroupCall, true); expect(room.hasActiveGroupCall(voip), true);
room.setState( room.setState(
Event( Event(
@ -921,6 +926,7 @@ void main() {
expiresTs: DateTime.now().millisecondsSinceEpoch, expiresTs: DateTime.now().millisecondsSinceEpoch,
roomId: room.id, roomId: room.id,
membershipId: voip.currentSessionId, membershipId: voip.currentSessionId,
voip: voip,
).toJson(), ).toJson(),
], ],
}, },
@ -929,8 +935,8 @@ void main() {
), ),
); );
expect(room.groupCallParticipantCount('participants_count'), 2); expect(room.groupCallParticipantCount('participants_count', voip), 2);
expect(room.hasActiveGroupCall, true); expect(room.hasActiveGroupCall(voip), true);
}); });
test('call persists after sending invite', () async { test('call persists after sending invite', () async {