diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index b8383ee7..3c0ab0f1 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -37,7 +37,7 @@ jobs: coverage_without_olm: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 env: NO_OLM: 1 steps: @@ -60,7 +60,7 @@ jobs: coverage: #runs-on: arm-ubuntu-latest-16core runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - run: cat .github/workflows/versions.env >> $GITHUB_ENV diff --git a/lib/matrix.dart b/lib/matrix.dart index 81477825..da5aadeb 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -41,6 +41,7 @@ export 'src/voip/models/webrtc_delegate.dart'; export 'src/voip/models/call_participant.dart'; export 'src/voip/models/key_provider.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/voip_constants.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_3814_dehydrated_devices/msc_3814_dehydrated_devices.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' if (dart.library.html) 'src/utils/web_worker/web_worker.dart'; diff --git a/lib/msc_extensions/msc_4140_delayed_events/api.dart b/lib/msc_extensions/msc_4140_delayed_events/api.dart new file mode 100644 index 00000000..1f0934a4 --- /dev/null +++ b/lib/msc_extensions/msc_4140_delayed_events/api.dart @@ -0,0 +1,2 @@ +export 'msc_4140_delayed_events.dart'; +export 'models.dart'; diff --git a/lib/msc_extensions/msc_4140_delayed_events/models.dart b/lib/msc_extensions/msc_4140_delayed_events/models.dart new file mode 100644 index 00000000..17c016db --- /dev/null +++ b/lib/msc_extensions/msc_4140_delayed_events/models.dart @@ -0,0 +1,64 @@ +class ScheduledDelayedEventsResponse { + final List scheduledEvents; + final String? nextBatch; + + ScheduledDelayedEventsResponse({ + required this.scheduledEvents, + this.nextBatch, + }); + + factory ScheduledDelayedEventsResponse.fromJson(Map json) { + final list = json['delayed_events'] ?? json['scheduled'] as List; + final List 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 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 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, + ); + } + + Map toJson() { + return { + 'delay_id': delayId, + 'room_id': roomId, + 'type': type, + 'state_key': stateKey, + 'delay': delay, + 'running_since': runningSince, + 'content': content, + }; + } +} diff --git a/lib/msc_extensions/msc_4140_delayed_events/msc_4140_delayed_events.dart b/lib/msc_extensions/msc_4140_delayed_events/msc_4140_delayed_events.dart new file mode 100644 index 00000000..a924555a --- /dev/null +++ b/lib/msc_extensions/msc_4140_delayed_events/msc_4140_delayed_events.dart @@ -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 ``, `` and `` 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 setRoomStateWithKeyWithDelay( + String roomId, + String eventType, + String stateKey, + int? delayInMs, + Map 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 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 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 + _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 +} diff --git a/lib/src/client.dart b/lib/src/client.dart index db490235..8384edfb 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1265,12 +1265,16 @@ class Client extends MatrixApi { final _versionsCache = AsyncCache(const Duration(hours: 1)); + Future get versionsResponse => + _versionsCache.tryFetch(() => getVersions()); + Future authenticatedMediaSupported() async { - final versionsResponse = await _versionsCache.tryFetch(() => getVersions()); - return versionsResponse.versions.any( - (v) => isVersionGreaterThanOrEqualTo(v, 'v1.11'), - ) || - versionsResponse.unstableFeatures?['org.matrix.msc3916.stable'] == true; + return (await versionsResponse).versions.any( + (v) => isVersionGreaterThanOrEqualTo(v, 'v1.11'), + ) || + (await versionsResponse) + .unstableFeatures?['org.matrix.msc3916.stable'] == + true; } final _serverConfigCache = AsyncCache(const Duration(hours: 1)); diff --git a/lib/src/voip/backend/call_backend_model.dart b/lib/src/voip/backend/call_backend_model.dart index e0873a74..bb8bdf5c 100644 --- a/lib/src/voip/backend/call_backend_model.dart +++ b/lib/src/voip/backend/call_backend_model.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:matrix/matrix.dart'; -import 'package:matrix/src/voip/models/call_membership.dart'; abstract class CallBackend { String type; diff --git a/lib/src/voip/backend/livekit_backend.dart b/lib/src/voip/backend/livekit_backend.dart index e9cdbea6..51461585 100644 --- a/lib/src/voip/backend/livekit_backend.dart +++ b/lib/src/voip/backend/livekit_backend.dart @@ -4,22 +4,11 @@ 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; - /// 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 final bool e2eeEnabled; @@ -28,8 +17,6 @@ class LiveKitBackend extends CallBackend { required this.livekitAlias, super.type = 'livekit', this.e2eeEnabled = true, - this.makeKeyDelay = CallTimeouts.makeKeyDelay, - this.useKeyDelay = CallTimeouts.useKeyDelay, }); Timer? _memberLeaveEncKeyRotateDebounceTimer; @@ -44,9 +31,14 @@ class LiveKitBackend extends CallBackend { /// 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 + /// we don't really care about what if setting or sending fails right now int get latestLocalKeyIndex => _latestLocalKeyIndex; 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 /// key, check `latestLocalKeyIndex` for latest key int get currentLocalKeyIndex => _currentLocalKeyIndex; @@ -58,8 +50,8 @@ class LiveKitBackend extends CallBackend { /// always chooses the next possible index, we cycle after 16 because /// no real adv with infinite list - int _getNewEncryptionKeyIndex() { - final newIndex = _indexCounter % 16; + int _getNewEncryptionKeyIndex(int keyRingSize) { + final newIndex = _indexCounter % keyRingSize; _indexCounter++; return newIndex; } @@ -76,10 +68,26 @@ class LiveKitBackend extends CallBackend { /// also does the sending for you Future _makeNewSenderKey( GroupCallSession groupCall, - bool delayBeforeUsingKeyOurself, - ) async { + bool delayBeforeUsingKeyOurself, { + 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 keyIndex = _getNewEncryptionKeyIndex(); + final keyIndex = _getNewEncryptionKeyIndex(groupCall.voip.keyRingSize); Logs().i('[VOIP E2EE] Generated new key $key at index $keyIndex'); await _setEncryptionKey( @@ -96,6 +104,9 @@ class LiveKitBackend extends CallBackend { Future _ratchetLocalParticipantKey( GroupCallSession groupCall, List sendTo, + + /// only used for makeSenderKey fallback + bool delayBeforeUsingKeyOurself, ) async { final keyProvider = groupCall.voip.delegate.keyProvider; @@ -114,17 +125,28 @@ class LiveKitBackend extends CallBackend { Uint8List? ratchetedKey; - while (ratchetedKey == null || ratchetedKey.isEmpty) { - Logs().i('[VOIP E2EE] Ignoring empty ratcheted key'); + int ratchetTryCounter = 0; + + while (ratchetTryCounter <= 8 && + (ratchetedKey == null || ratchetedKey.isEmpty)) { + Logs().d( + '[VOIP E2EE] Ignoring empty ratcheted key, ratchetTryCounter: $ratchetTryCounter', + ); + ratchetedKey = await keyProvider.onRatchetKey( groupCall.localParticipant!, latestLocalKeyIndex, ); + ratchetTryCounter++; } - Logs().i( - '[VOIP E2EE] Ratched latest key to $ratchetedKey at idx $latestLocalKeyIndex', - ); + if (ratchetedKey == null || ratchetedKey.isEmpty) { + Logs().d( + '[VOIP E2EE] ratcheting failed, falling back to creating a new key', + ); + await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself); + return; + } await _setEncryptionKey( groupCall, @@ -133,6 +155,7 @@ class LiveKitBackend extends CallBackend { ratchetedKey, delayBeforeUsingKeyOurself: false, send: true, + setKey: false, sendTo: sendTo, ); } @@ -144,7 +167,11 @@ class LiveKitBackend extends CallBackend { ) async { if (!e2eeEnabled) return; if (groupCall.voip.enableSFUE2EEKeyRatcheting) { - await _ratchetLocalParticipantKey(groupCall, anyJoined); + await _ratchetLocalParticipantKey( + groupCall, + anyJoined, + delayBeforeUsingKeyOurself, + ); } else { await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself); } @@ -159,6 +186,9 @@ class LiveKitBackend extends CallBackend { Uint8List encryptionKeyBin, { bool delayBeforeUsingKeyOurself = false, bool send = false, + + /// ratchet seems to set on call, so no need to set manually + bool setKey = true, List? sendTo, }) async { final encryptionKeys = @@ -168,6 +198,7 @@ class LiveKitBackend extends CallBackend { _encryptionKeysMap[participant] = encryptionKeys; if (participant.isLocal) { _latestLocalKeyIndex = encryptionKeyIndex; + _lastNewKeyTime = DateTime.now(); } 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) { + 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 // stil decrypt everything - final useKeyTimeout = Future.delayed(useKeyDelay, () async { + final useKeyTimeout = + Future.delayed(groupCall.voip.timeouts!.useKeyDelay, () async { 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( participant, @@ -257,7 +299,7 @@ class LiveKitBackend extends CallBackend { EventTypes.GroupCallMemberEncryptionKeys, ); } 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( groupCall, keyIndex, @@ -274,7 +316,7 @@ class LiveKitBackend extends CallBackend { ) async { if (remoteParticipants.isEmpty) return; 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 = VoIP.customTxid ?? groupCall.client.generateUniqueTransactionId(); @@ -355,7 +397,7 @@ class LiveKitBackend extends CallBackend { Map content, ) async { 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; } final keyContent = EncryptionKeysEventContent.fromJson(content); @@ -398,37 +440,65 @@ class LiveKitBackend extends CallBackend { Map content, ) async { 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; } - 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', + + Future checkPartcipantStatusAndRequestKey() async { + final mems = groupCall.room.getCallMembershipsForUser( + userId, + deviceId, + groupCall.voip, ); - await _sendEncryptionKeysEvent( - groupCall, - _latestLocalKeyIndex, - sendTo: [ - CallParticipant( - groupCall.voip, - userId: userId, - deviceId: deviceId, - ), - ], + + 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 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) { _memberLeaveEncKeyRotateDebounceTimer!.cancel(); } - _memberLeaveEncKeyRotateDebounceTimer = Timer(makeKeyDelay, () async { - await _makeNewSenderKey(groupCall, true); + _memberLeaveEncKeyRotateDebounceTimer = + 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, + ); }); } diff --git a/lib/src/voip/backend/mesh_backend.dart b/lib/src/voip/backend/mesh_backend.dart index 1ea640e6..e6ca667b 100644 --- a/lib/src/voip/backend/mesh_backend.dart +++ b/lib/src/voip/backend/mesh_backend.dart @@ -5,7 +5,6 @@ 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'; diff --git a/lib/src/voip/call_session.dart b/lib/src/voip/call_session.dart index 45205c6b..6aa332db 100644 --- a/lib/src/voip/call_session.dart +++ b/lib/src/voip/call_session.dart @@ -283,7 +283,7 @@ class CallSession { setCallState(CallState.kRinging); - _ringingTimer = Timer(CallTimeouts.callInviteLifetime, () { + _ringingTimer = Timer(voip.timeouts!.callInviteLifetime, () { if (state == CallState.kRinging) { Logs().v('[VOIP] Call invite has expired. Hanging up.'); @@ -457,7 +457,7 @@ class CallSession { await sendCallNegotiate( room, callId, - CallTimeouts.defaultCallEventLifetime.inMilliseconds, + voip.timeouts!.defaultCallEventLifetime.inMilliseconds, localPartyId, answer.sdp!, type: answer.type!, @@ -1081,7 +1081,7 @@ class CallSession { if (pc!.iceGatheringState == RTCIceGatheringState.RTCIceGatheringStateGathering) { // Allow a short time for initial candidates to be gathered - await Future.delayed(CallTimeouts.iceGatheringDelay); + await Future.delayed(voip.timeouts!.iceGatheringDelay); } if (callHasEnded) return; @@ -1094,7 +1094,7 @@ class CallSession { await sendInviteToCall( room, callId, - CallTimeouts.callInviteLifetime.inMilliseconds, + voip.timeouts!.callInviteLifetime.inMilliseconds, localPartyId, offer.sdp!, capabilities: callCapabilities, @@ -1115,7 +1115,7 @@ class CallSession { setCallState(CallState.kInviteSent); - _inviteTimer = Timer(CallTimeouts.callInviteLifetime, () { + _inviteTimer = Timer(voip.timeouts!.callInviteLifetime, () { if (state == CallState.kInviteSent) { unawaited(hangup(reason: CallErrorCode.inviteTimeout)); } @@ -1126,7 +1126,7 @@ class CallSession { await sendCallNegotiate( room, callId, - CallTimeouts.defaultCallEventLifetime.inMilliseconds, + voip.timeouts!.defaultCallEventLifetime.inMilliseconds, localPartyId, offer.sdp!, type: offer.type!, @@ -1144,7 +1144,7 @@ class CallSession { // onNegotiationNeeded, which causes creatOffer to only include // audio m-line, add delay and wait for video track to be added, // 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({}); await _gotLocalOffer(offer); } catch (e) { diff --git a/lib/src/voip/group_call_session.dart b/lib/src/voip/group_call_session.dart index 1d804f9a..545ba8e7 100644 --- a/lib/src/voip/group_call_session.dart +++ b/lib/src/voip/group_call_session.dart @@ -21,7 +21,6 @@ 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'; @@ -162,10 +161,11 @@ class GroupCallSession { backend: backend, deviceId: client.deviceID!, expiresTs: DateTime.now() - .add(CallTimeouts.expireTsBumpDuration) + .add(voip.timeouts!.expireTsBumpDuration) .millisecondsSinceEpoch, membershipId: voip.currentSessionId, feeds: backend.getCurrentFeeds(), + voip: voip, ), ); @@ -173,7 +173,7 @@ class GroupCallSession { _resendMemberStateEventTimer!.cancel(); } _resendMemberStateEventTimer = Timer.periodic( - CallTimeouts.updateExpireTsTimerDuration, + voip.timeouts!.updateExpireTsTimerDuration, ((timer) async { Logs().d('sendMemberStateEvent updating member event with timer'); if (state != GroupCallState.ended || @@ -198,6 +198,7 @@ class GroupCallSession { return room.removeFamedlyCallMemberEvent( groupCallId, client.deviceID!, + voip, application: application, scope: scope, ); @@ -206,8 +207,10 @@ class GroupCallSession { /// compltetely rebuilds the local _participants list Future onMemberStateChanged() async { // The member events may be received for another room, which we will ignore. - final mems = - room.getCallMembershipsFromRoom().values.expand((element) => element); + final mems = room + .getCallMembershipsFromRoom(voip) + .values + .expand((element) => element); final memsForCurrentGroupCall = mems.where((element) { return element.callId == groupCallId && !element.isExpired && @@ -216,15 +219,6 @@ class GroupCallSession { element.roomId == room.id; // sanity checks }).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 newP = {}; for (final mem in memsForCurrentGroupCall) { @@ -238,12 +232,7 @@ class GroupCallSession { if (rp.isLocal) continue; - if (state != GroupCallState.entered) { - Logs().w( - '[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update', - ); - continue; - } + if (state != GroupCallState.entered) continue; await backend.setupP2PCallWithNewMember(this, rp, mem); } @@ -281,9 +270,6 @@ class GroupCallSession { } onGroupCallEvent.add(GroupCallStateChange.participantsChanged); - Logs().d( - '[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}', - ); } } } diff --git a/lib/src/voip/models/call_membership.dart b/lib/src/voip/models/call_membership.dart index 3e7c55fb..39fc6efc 100644 --- a/lib/src/voip/models/call_membership.dart +++ b/lib/src/voip/models/call_membership.dart @@ -9,14 +9,18 @@ class FamedlyCallMemberEvent { return {'memberships': memberships.map((e) => e.toJson()).toList()}; } - factory FamedlyCallMemberEvent.fromJson(Event event) { + factory FamedlyCallMemberEvent.fromJson(Event event, VoIP voip) { final List 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); + final callMem = CallMembership.fromJson( + mem, + event.senderId, + event.room.id, + voip, + ); if (callMem != null) callMemberships.add(callMem); } } @@ -35,7 +39,7 @@ class CallMembership { final int expiresTs; final String membershipId; final List? feeds; - + final VoIP voip; final String roomId; CallMembership({ @@ -46,6 +50,7 @@ class CallMembership { required this.expiresTs, required this.roomId, required this.membershipId, + required this.voip, this.application = 'm.call', this.scope = 'm.room', 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 { return CallMembership( userId: userId, @@ -81,6 +91,7 @@ class CallMembership { membershipId: json['membershipID'] ?? 'someone_forgot_to_set_the_membershipID', feeds: json['feeds'], + voip: voip, ); } catch (e, s) { Logs().e('[VOIP] call membership parsing failed. $json', e, s); @@ -120,6 +131,6 @@ class CallMembership { bool get isExpired => expiresTs < DateTime.now() - .subtract(CallTimeouts.expireTsBumpDuration) + .subtract(voip.timeouts!.expireTsBumpDuration) .millisecondsSinceEpoch; } diff --git a/lib/src/voip/utils/famedly_call_extension.dart b/lib/src/voip/utils/famedly_call_extension.dart index ae634d86..195f1fe8 100644 --- a/lib/src/voip/utils/famedly_call_extension.dart +++ b/lib/src/voip/utils/famedly_call_extension.dart @@ -1,12 +1,17 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; -import 'package:matrix/src/voip/models/call_membership.dart'; + +String? _delayedLeaveEventId; + +Timer? _restartDelayedLeaveEventTimer; 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 getFamedlyCallEvents() { + Map getFamedlyCallEvents(VoIP voip) { final Map mappedEvents = {}; final famedlyCallMemberStates = states.tryGetMap(EventTypes.GroupCallMember); @@ -16,16 +21,17 @@ extension FamedlyCallMemberEventsExtension on Room { .sorted((a, b) => a.originServerTs.compareTo(b.originServerTs)); for (final element in sortedEvents) { - mappedEvents - .addAll({element.senderId: FamedlyCallMemberEvent.fromJson(element)}); + mappedEvents.addAll( + {element.stateKey!: FamedlyCallMemberEvent.fromJson(element, voip)}, + ); } return mappedEvents; } /// extracts memberships list form a famedly call event and maps it to a userid /// returns sorted (oldest to newest) - Map> getCallMembershipsFromRoom() { - final parsedMemberEvents = getFamedlyCallEvents(); + Map> getCallMembershipsFromRoom(VoIP voip) { + final parsedMemberEvents = getFamedlyCallEvents(voip); final Map> memberships = {}; for (final element in parsedMemberEvents.entries) { 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` - List getCallMembershipsForUser(String userId) { - final parsedMemberEvents = getCallMembershipsFromRoom(); - final mem = parsedMemberEvents.tryGet>(userId); + /// if room version is org.matrix.msc3757.11 it also uses the deviceId + List getCallMembershipsForUser( + String userId, + String deviceId, + VoIP voip, + ) { + final stateKey = (roomVersion?.contains('msc3757') ?? false) + ? '${userId}_$deviceId' + : userId; + final parsedMemberEvents = getCallMembershipsFromRoom(voip); + final mem = parsedMemberEvents.tryGet>(stateKey); 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 groupCallParticipantCount( + String groupCallId, + VoIP voip, + ) { int participantCount = 0; // userid:membership - final memberships = getCallMembershipsFromRoom(); + final memberships = getCallMembershipsFromRoom(voip); memberships.forEach((key, value) { for (final membership in value) { @@ -58,17 +75,17 @@ extension FamedlyCallMemberEventsExtension on Room { return participantCount; } - bool get hasActiveGroupCall { - if (activeGroupCallIds.isNotEmpty) { + bool hasActiveGroupCall(VoIP voip) { + if (activeGroupCallIds(voip).isNotEmpty) { return true; } return false; } /// list of active group call ids - List get activeGroupCallIds { + List activeGroupCallIds(VoIP voip) { final Set ids = {}; - final memberships = getCallMembershipsFromRoom(); + final memberships = getCallMembershipsFromRoom(voip); memberships.forEach((key, value) { for (final mem in value) { @@ -82,7 +99,11 @@ extension FamedlyCallMemberEventsExtension on Room { Future updateFamedlyCallMemberStateEvent( CallMembership callMembership, ) 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 // ownership over them @@ -97,16 +118,24 @@ extension FamedlyCallMemberEventsExtension on Room { 'memberships': List.from(ownMemberships.map((e) => e.toJson())), }; - await setFamedlyCallMemberEvent(newContent); + await setFamedlyCallMemberEvent( + newContent, + callMembership.voip, + ); } Future removeFamedlyCallMemberEvent( String groupCallId, - String deviceId, { + String deviceId, + VoIP voip, { String? application = 'm.call', String? scope = 'm.room', }) async { - final ownMemberships = getCallMembershipsForUser(client.userID!); + final ownMemberships = getCallMembershipsForUser( + client.userID!, + client.deviceID!, + voip, + ); ownMemberships.removeWhere( (mem) => @@ -119,15 +148,85 @@ extension FamedlyCallMemberEventsExtension on Room { final newContent = { '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 setFamedlyCallMemberEvent(Map newContent) async { + Future setFamedlyCallMemberEvent( + Map newContent, + VoIP voip, + ) async { 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 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( id, EventTypes.GroupCallMember, - client.userID!, + stateKey, newContent, ); } else { @@ -145,12 +244,16 @@ extension FamedlyCallMemberEventsExtension on Room { } /// returns a list of memberships from a famedly call matrix event - List getCallMembershipsFromEvent(MatrixEvent event) { + List getCallMembershipsFromEvent( + MatrixEvent event, + VoIP voip, + ) { if (event.roomId != id) return []; return getCallMembershipsFromEventContent( event.content, event.senderId, event.roomId!, + voip, ); } @@ -159,11 +262,12 @@ extension FamedlyCallMemberEventsExtension on Room { Map content, String senderId, String roomId, + VoIP voip, ) { final mems = content.tryGetList('memberships'); final callMems = []; 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); } return callMems; diff --git a/lib/src/voip/utils/voip_constants.dart b/lib/src/voip/utils/voip_constants.dart index 37aacfb9..f12ab8dd 100644 --- a/lib/src/voip/utils/voip_constants.dart +++ b/lib/src/voip/utils/voip_constants.dart @@ -6,36 +6,64 @@ const String voipProtoVersion = '1'; class CallTimeouts { /// 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. - static const callInviteLifetime = Duration(seconds: 60); + final Duration callInviteLifetime; /// The delay for ice gathering. - static const iceGatheringDelay = Duration(milliseconds: 200); + final Duration iceGatheringDelay; /// Delay before createOffer. - static const delayBeforeOffer = Duration(milliseconds: 100); + final Duration delayBeforeOffer; /// How often to update the expiresTs - static const updateExpireTsTimerDuration = Duration(minutes: 2); + final Duration updateExpireTsTimerDuration; /// the expiresTs bump - static const expireTsBumpDuration = Duration(minutes: 6); + final Duration expireTsBumpDuration; /// Update the active speaker value - static const activeSpeakerInterval = Duration(seconds: 5); + final Duration activeSpeakerInterval; // 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: 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 /// 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); + 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 { diff --git a/lib/src/voip/voip.dart b/lib/src/voip/voip.dart index 24c07787..d72b2843 100644 --- a/lib/src/voip/voip.dart +++ b/lib/src/voip/voip.dart @@ -9,7 +9,6 @@ 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/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/voip_id.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 /// with that sessionId 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( this.client, this.delegate, { this.enableSFUE2EEKeyRatcheting = false, + this.keyRingSize = 16, + this.timeouts, }) : super() { + timeouts ??= CallTimeouts(); currentSessionId = base64Encode(secureRandomBytes(16)); Logs().v('set currentSessionId to $currentSessionId'); // to populate groupCalls with already present calls for (final room in client.rooms) { - final memsList = room.getCallMembershipsFromRoom(); + final memsList = room.getCallMembershipsFromRoom(this); for (final mems in memsList.values) { for (final mem in mems) { unawaited(createGroupCallFromRoomStateEvent(mem)); @@ -101,8 +114,7 @@ class VoIP { if (event.room.membership != Membership.join) return; if (event.type != EventTypes.GroupCallMember) return; - Logs().v('[VOIP] onRoomState: type ${event.toJson()}'); - final mems = event.room.getCallMembershipsFromEvent(event); + final mems = event.room.getCallMembershipsFromEvent(event, this); for (final mem in mems) { unawaited(createGroupCallFromRoomStateEvent(mem)); } @@ -151,7 +163,7 @@ class VoIP { if (callEvent.type == EventTypes.CallInvite && age > (callEvent.content.tryGet('lifetime') ?? - CallTimeouts.callInviteLifetime.inMilliseconds)) { + timeouts!.callInviteLifetime.inMilliseconds)) { Logs().w( '[VOIP] Ommiting invite event ${callEvent.eventId} as age was older than lifetime', ); @@ -903,12 +915,7 @@ class VoIP { CallMembership membership, { bool emitHandleNewGroupCall = true, }) async { - if (membership.isExpired) { - Logs().d( - 'Ignoring expired membership in passive groupCall creator. ${membership.toJson()}', - ); - return; - } + if (membership.isExpired) return; final room = client.getRoomById(membership.roomId); @@ -947,5 +954,5 @@ class VoIP { } @Deprecated('Call `hasActiveGroupCall` on the room directly instead') - bool hasActiveCall(Room room) => room.hasActiveGroupCall; + bool hasActiveCall(Room room) => room.hasActiveGroupCall(this); } diff --git a/test/calls_test.dart b/test/calls_test.dart index b19688a9..c65661ea 100644 --- a/test/calls_test.dart +++ b/test/calls_test.dart @@ -2,7 +2,6 @@ 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'; @@ -630,6 +629,7 @@ void main() { .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -653,6 +653,7 @@ void main() { expiresTs: DateTime.now().millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -676,6 +677,7 @@ void main() { expiresTs: DateTime.now().millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -701,6 +703,7 @@ void main() { .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -713,35 +716,35 @@ void main() { ), ); expect( - room.getFamedlyCallEvents().entries.elementAt(0).key, + room.getFamedlyCallEvents(voip).entries.elementAt(0).key, '@test3:example.com', ); expect( - room.getFamedlyCallEvents().entries.elementAt(1).key, + room.getFamedlyCallEvents(voip).entries.elementAt(1).key, '@test2:example.com', ); expect( - room.getFamedlyCallEvents().entries.elementAt(2).key, + room.getFamedlyCallEvents(voip).entries.elementAt(2).key, '@test2.0:example.com', ); expect( - room.getFamedlyCallEvents().entries.elementAt(3).key, + room.getFamedlyCallEvents(voip).entries.elementAt(3).key, '@test1:example.com', ); expect( - room.getCallMembershipsFromRoom().entries.elementAt(0).key, + room.getCallMembershipsFromRoom(voip).entries.elementAt(0).key, '@test3:example.com', ); expect( - room.getCallMembershipsFromRoom().entries.elementAt(1).key, + room.getCallMembershipsFromRoom(voip).entries.elementAt(1).key, '@test2:example.com', ); expect( - room.getCallMembershipsFromRoom().entries.elementAt(2).key, + room.getCallMembershipsFromRoom(voip).entries.elementAt(2).key, '@test2.0:example.com', ); expect( - room.getCallMembershipsFromRoom().entries.elementAt(3).key, + room.getCallMembershipsFromRoom(voip).entries.elementAt(3).key, '@test1:example.com', ); }); @@ -866,6 +869,7 @@ void main() { .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -874,8 +878,8 @@ void main() { ), ); - expect(room.groupCallParticipantCount('participants_count'), 0); - expect(room.hasActiveGroupCall, false); + expect(room.groupCallParticipantCount('participants_count', voip), 0); + expect(room.hasActiveGroupCall(voip), false); room.setState( Event( @@ -895,6 +899,7 @@ void main() { .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -902,8 +907,8 @@ void main() { stateKey: '@test2:example.com', ), ); - expect(room.groupCallParticipantCount('participants_count'), 1); - expect(room.hasActiveGroupCall, true); + expect(room.groupCallParticipantCount('participants_count', voip), 1); + expect(room.hasActiveGroupCall(voip), true); room.setState( Event( @@ -921,6 +926,7 @@ void main() { expiresTs: DateTime.now().millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, + voip: voip, ).toJson(), ], }, @@ -929,8 +935,8 @@ void main() { ), ); - expect(room.groupCallParticipantCount('participants_count'), 2); - expect(room.hasActiveGroupCall, true); + expect(room.groupCallParticipantCount('participants_count', voip), 2); + expect(room.hasActiveGroupCall(voip), true); }); test('call persists after sending invite', () async {