LCOV - code coverage report
Current view: top level - lib/src/voip - group_call_session.dart (source / functions) Coverage Total Hit
Test: merged.info Lines: 78.5 % 228 179
Test Date: 2025-10-13 02:23:18 Functions: - 0 0

            Line data    Source code
       1              : /*
       2              :  *   Famedly Matrix SDK
       3              :  *   Copyright (C) 2021 Famedly GmbH
       4              :  *
       5              :  *   This program is free software: you can redistribute it and/or modify
       6              :  *   it under the terms of the GNU Affero General License as
       7              :  *   published by the Free Software Foundation, either version 3 of the
       8              :  *   License, or (at your option) any later version.
       9              :  *
      10              :  *   This program is distributed in the hope that it will be useful,
      11              :  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
      12              :  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
      13              :  *   GNU Affero General License for more details.
      14              :  *
      15              :  *   You should have received a copy of the GNU Affero General License
      16              :  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
      17              :  */
      18              : 
      19              : import 'dart:async';
      20              : import 'dart:core';
      21              : 
      22              : import 'package:collection/collection.dart';
      23              : 
      24              : import 'package:matrix/matrix.dart';
      25              : import 'package:matrix/src/utils/cached_stream_controller.dart';
      26              : import 'package:matrix/src/voip/models/call_reaction_payload.dart';
      27              : import 'package:matrix/src/voip/models/voip_id.dart';
      28              : import 'package:matrix/src/voip/utils/stream_helper.dart';
      29              : 
      30              : /// Holds methods for managing a group call. This class is also responsible for
      31              : /// holding and managing the individual `CallSession`s in a group call.
      32              : class GroupCallSession {
      33              :   // Config
      34              :   final Client client;
      35              :   final VoIP voip;
      36              :   final Room room;
      37              : 
      38              :   /// is a list of backend to allow passing multiple backend in the future
      39              :   /// we use the first backend everywhere as of now
      40              :   final CallBackend backend;
      41              : 
      42              :   /// something like normal calls or thirdroom
      43              :   final String? application;
      44              : 
      45              :   /// either room scoped or user scoped calls
      46              :   final String? scope;
      47              : 
      48              :   GroupCallState state = GroupCallState.localCallFeedUninitialized;
      49              : 
      50           18 :   CallParticipant? get localParticipant => voip.localParticipant;
      51              : 
      52            0 :   List<CallParticipant> get participants => List.unmodifiable(_participants);
      53              :   final Set<CallParticipant> _participants = {};
      54              : 
      55              :   String groupCallId;
      56              : 
      57              :   @Deprecated('Use matrixRTCEventStream instead')
      58              :   final CachedStreamController<GroupCallState> onGroupCallState =
      59              :       CachedStreamController();
      60              : 
      61              :   @Deprecated('Use matrixRTCEventStream instead')
      62              :   final CachedStreamController<GroupCallStateChange> onGroupCallEvent =
      63              :       CachedStreamController();
      64              : 
      65              :   final CachedStreamController<MatrixRTCCallEvent> matrixRTCEventStream =
      66              :       CachedStreamController();
      67              : 
      68              :   Timer? _resendMemberStateEventTimer;
      69              : 
      70            2 :   factory GroupCallSession.withAutoGenId(
      71              :     Room room,
      72              :     VoIP voip,
      73              :     CallBackend backend,
      74              :     String? application,
      75              :     String? scope,
      76              :     String? groupCallId,
      77              :   ) {
      78            2 :     return GroupCallSession(
      79            2 :       client: room.client,
      80              :       room: room,
      81              :       voip: voip,
      82              :       backend: backend,
      83              :       application: application ?? 'm.call',
      84              :       scope: scope ?? 'm.room',
      85            0 :       groupCallId: groupCallId ?? genCallID(),
      86              :     );
      87              :   }
      88              : 
      89            6 :   GroupCallSession({
      90              :     required this.client,
      91              :     required this.room,
      92              :     required this.voip,
      93              :     required this.backend,
      94              :     required this.groupCallId,
      95              :     required this.application,
      96              :     required this.scope,
      97              :   });
      98              : 
      99            0 :   String get avatarName =>
     100            0 :       _getUser().calcDisplayname(mxidLocalPartFallback: false);
     101              : 
     102            0 :   String? get displayName => _getUser().displayName;
     103              : 
     104            0 :   User _getUser() {
     105            0 :     return room.unsafeGetUserFromMemoryOrFallback(client.userID!);
     106              :   }
     107              : 
     108            4 :   void setState(GroupCallState newState) {
     109            4 :     state = newState;
     110              :     // ignore: deprecated_member_use_from_same_package
     111            8 :     onGroupCallState.add(newState);
     112              :     // ignore: deprecated_member_use_from_same_package
     113            8 :     onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged);
     114           12 :     matrixRTCEventStream.add(GroupCallStateChanged(newState));
     115              :   }
     116              : 
     117            0 :   bool hasLocalParticipant() {
     118            0 :     return _participants.contains(localParticipant);
     119              :   }
     120              : 
     121              :   Timer? _reactionsTimer;
     122              :   int _reactionsTicker = 0;
     123              : 
     124              :   /// enter the group call.
     125            4 :   Future<void> enter({WrappedMediaStream? stream}) async {
     126            8 :     if (!(state == GroupCallState.localCallFeedUninitialized ||
     127            0 :         state == GroupCallState.localCallFeedInitialized)) {
     128            0 :       throw MatrixSDKVoipException('Cannot enter call in the $state state');
     129              :     }
     130              : 
     131            8 :     if (state == GroupCallState.localCallFeedUninitialized) {
     132            8 :       await backend.initLocalStream(this, stream: stream);
     133              :     }
     134              : 
     135            4 :     await sendMemberStateEvent();
     136              : 
     137            4 :     setState(GroupCallState.entered);
     138              : 
     139           16 :     Logs().v('Entered group call $groupCallId');
     140              : 
     141              :     // Set up _participants for the members currently in the call.
     142              :     // Other members will be picked up by the RoomState.members event.
     143            4 :     await onMemberStateChanged();
     144              : 
     145            8 :     await backend.setupP2PCallsWithExistingMembers(this);
     146              : 
     147           24 :     voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId);
     148              : 
     149           12 :     await voip.delegate.handleNewGroupCall(this);
     150              : 
     151           16 :     _reactionsTimer = Timer.periodic(Duration(seconds: 1), (_) {
     152           12 :       if (_reactionsTicker > 0) _reactionsTicker--;
     153              :     });
     154              :   }
     155              : 
     156            2 :   Future<void> leave() async {
     157            2 :     await removeMemberStateEvent();
     158            4 :     await backend.dispose(this);
     159            2 :     setState(GroupCallState.localCallFeedUninitialized);
     160            4 :     voip.currentGroupCID = null;
     161            4 :     _participants.clear();
     162           14 :     voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId));
     163            6 :     await voip.delegate.handleGroupCallEnded(this);
     164            2 :     _resendMemberStateEventTimer?.cancel();
     165            2 :     _reactionsTimer?.cancel();
     166            2 :     setState(GroupCallState.ended);
     167              :   }
     168              : 
     169            4 :   Future<void> sendMemberStateEvent() async {
     170              :     // Get current member event ID to preserve permanent reactions
     171            8 :     final currentMemberships = room.getCallMembershipsForUser(
     172            8 :       client.userID!,
     173            8 :       client.deviceID!,
     174            4 :       voip,
     175              :     );
     176              : 
     177            4 :     final currentMembership = currentMemberships.firstWhereOrNull(
     178            4 :       (m) =>
     179           12 :           m.callId == groupCallId &&
     180           16 :           m.deviceId == client.deviceID! &&
     181           12 :           m.application == application &&
     182           12 :           m.scope == scope &&
     183           16 :           m.roomId == room.id,
     184              :     );
     185              : 
     186              :     // Store permanent reactions from the current member event if it exists
     187            4 :     List<MatrixEvent> permanentReactions = [];
     188            4 :     final membershipExpired = currentMembership?.isExpired ?? false;
     189              : 
     190            4 :     if (currentMembership?.eventId != null && !membershipExpired) {
     191            4 :       permanentReactions = await _getPermanentReactionsForEvent(
     192            4 :         currentMembership!.eventId!,
     193              :       );
     194              :     }
     195              : 
     196            8 :     final newEventId = await room.updateFamedlyCallMemberStateEvent(
     197            4 :       CallMembership(
     198            8 :         userId: client.userID!,
     199            8 :         roomId: room.id,
     200            4 :         callId: groupCallId,
     201            4 :         application: application,
     202            4 :         scope: scope,
     203            4 :         backend: backend,
     204            8 :         deviceId: client.deviceID!,
     205            4 :         expiresTs: DateTime.now()
     206           16 :             .add(voip.timeouts!.expireTsBumpDuration)
     207            4 :             .millisecondsSinceEpoch,
     208            8 :         membershipId: voip.currentSessionId,
     209            8 :         feeds: backend.getCurrentFeeds(),
     210            4 :         voip: voip,
     211              :       ),
     212              :     );
     213              : 
     214              :     // Copy permanent reactions to the new member event
     215            4 :     if (permanentReactions.isNotEmpty && newEventId != null) {
     216            0 :       await _copyPermanentReactionsToNewEvent(
     217              :         permanentReactions,
     218              :         newEventId,
     219              :       );
     220              :     }
     221              : 
     222            4 :     if (_resendMemberStateEventTimer != null) {
     223            4 :       _resendMemberStateEventTimer!.cancel();
     224              :     }
     225            8 :     _resendMemberStateEventTimer = Timer.periodic(
     226           12 :       voip.timeouts!.updateExpireTsTimerDuration,
     227            0 :       ((timer) async {
     228            0 :         Logs().d('sendMemberStateEvent updating member event with timer');
     229            0 :         if (state != GroupCallState.ended ||
     230            0 :             state != GroupCallState.localCallFeedUninitialized) {
     231            0 :           await sendMemberStateEvent();
     232              :         } else {
     233            0 :           Logs().d(
     234            0 :             '[VOIP] deteceted groupCall in state $state, removing state event',
     235              :           );
     236            0 :           await removeMemberStateEvent();
     237              :         }
     238              :       }),
     239              :     );
     240              :   }
     241              : 
     242            2 :   Future<void> removeMemberStateEvent() {
     243            2 :     if (_resendMemberStateEventTimer != null) {
     244            0 :       Logs().d('resend member event timer cancelled');
     245            0 :       _resendMemberStateEventTimer!.cancel();
     246            0 :       _resendMemberStateEventTimer = null;
     247              :     }
     248            4 :     return room.removeFamedlyCallMemberEvent(
     249            2 :       groupCallId,
     250            2 :       voip,
     251            2 :       application: application,
     252            2 :       scope: scope,
     253              :     );
     254              :   }
     255              : 
     256              :   /// compltetely rebuilds the local _participants list
     257            6 :   Future<void> onMemberStateChanged() async {
     258              :     // The member events may be received for another room, which we will ignore.
     259            6 :     final mems = room
     260           12 :         .getCallMembershipsFromRoom(voip)
     261            6 :         .values
     262           12 :         .expand((element) => element);
     263           12 :     final memsForCurrentGroupCall = mems.where((element) {
     264           18 :       return element.callId == groupCallId &&
     265            6 :           !element.isExpired &&
     266           18 :           element.application == application &&
     267           18 :           element.scope == scope &&
     268           24 :           element.roomId == room.id; // sanity checks
     269            6 :     }).toList();
     270              : 
     271              :     final Set<CallParticipant> newP = {};
     272              : 
     273           12 :     for (final mem in memsForCurrentGroupCall) {
     274            6 :       final rp = CallParticipant(
     275            6 :         voip,
     276            6 :         userId: mem.userId,
     277            6 :         deviceId: mem.deviceId,
     278              :       );
     279              : 
     280            6 :       newP.add(rp);
     281              : 
     282            6 :       if (rp.isLocal) continue;
     283              : 
     284           12 :       if (state != GroupCallState.entered) continue;
     285              : 
     286            8 :       await backend.setupP2PCallWithNewMember(this, rp, mem);
     287              :     }
     288            6 :     final newPcopy = Set<CallParticipant>.from(newP);
     289           12 :     final oldPcopy = Set<CallParticipant>.from(_participants);
     290            6 :     final anyJoined = newPcopy.difference(oldPcopy);
     291            6 :     final anyLeft = oldPcopy.difference(newPcopy);
     292              : 
     293           12 :     if (anyJoined.isNotEmpty || anyLeft.isNotEmpty) {
     294            6 :       if (anyJoined.isNotEmpty) {
     295            6 :         final nonLocalAnyJoined = Set<CallParticipant>.from(anyJoined)
     296           12 :           ..remove(localParticipant);
     297           18 :         if (nonLocalAnyJoined.isNotEmpty && state == GroupCallState.entered) {
     298            4 :           Logs().v(
     299           16 :             'nonLocalAnyJoined: ${nonLocalAnyJoined.map((e) => e.id).toString()} roomId: ${room.id} groupCallId: $groupCallId',
     300              :           );
     301            6 :           await backend.onNewParticipant(this, nonLocalAnyJoined.toList());
     302              :         }
     303           12 :         _participants.addAll(anyJoined);
     304            6 :         matrixRTCEventStream
     305           18 :             .add(ParticipantsJoinEvent(participants: anyJoined.toList()));
     306              :       }
     307            6 :       if (anyLeft.isNotEmpty) {
     308            2 :         final nonLocalAnyLeft = Set<CallParticipant>.from(anyLeft)
     309            4 :           ..remove(localParticipant);
     310            6 :         if (nonLocalAnyLeft.isNotEmpty && state == GroupCallState.entered) {
     311            4 :           Logs().v(
     312           16 :             'nonLocalAnyLeft: ${nonLocalAnyLeft.map((e) => e.id).toString()} roomId: ${room.id} groupCallId: $groupCallId',
     313              :           );
     314            6 :           await backend.onLeftParticipant(this, nonLocalAnyLeft.toList());
     315              :         }
     316            4 :         _participants.removeAll(anyLeft);
     317            2 :         matrixRTCEventStream
     318            6 :             .add(ParticipantsLeftEvent(participants: anyLeft.toList()));
     319              :       }
     320              : 
     321              :       // ignore: deprecated_member_use_from_same_package
     322           12 :       onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
     323              :     }
     324              :   }
     325              : 
     326              :   /// Send a reaction event to the group call
     327              :   ///
     328              :   /// [emoji] - The reaction emoji (e.g., '🖐️' for hand raise)
     329              :   /// [name] - The reaction name (e.g., 'hand raise')
     330              :   /// [isEphemeral] - Whether the reaction is ephemeral (default: true)
     331              :   ///
     332              :   /// Returns the event ID of the sent reaction event
     333            2 :   Future<String> sendReactionEvent({
     334              :     required String emoji,
     335              :     bool isEphemeral = true,
     336              :   }) async {
     337            4 :     if (isEphemeral && _reactionsTicker > 10) {
     338            0 :       throw Exception(
     339              :         '[sendReactionEvent] manual throttling, too many ephemral reactions sent',
     340              :       );
     341              :     }
     342              : 
     343            6 :     Logs().d('Group call reaction selected: $emoji');
     344              : 
     345              :     final memberships =
     346           14 :         room.getCallMembershipsForUser(client.userID!, client.deviceID!, voip);
     347            2 :     final membership = memberships.firstWhereOrNull(
     348            2 :       (m) =>
     349            6 :           m.callId == groupCallId &&
     350            8 :           m.deviceId == client.deviceID! &&
     351            8 :           m.roomId == room.id &&
     352            6 :           m.application == application &&
     353            6 :           m.scope == scope,
     354              :     );
     355              : 
     356              :     if (membership == null) {
     357            0 :       throw Exception(
     358            0 :         '[sendReactionEvent] No matching membership found to send group call emoji reaction from ${client.userID!}',
     359              :       );
     360              :     }
     361              : 
     362            2 :     final payload = ReactionPayload(
     363              :       key: emoji,
     364              :       isEphemeral: isEphemeral,
     365            2 :       callId: groupCallId,
     366            4 :       deviceId: client.deviceID!,
     367              :       relType: RelationshipTypes.reference,
     368            2 :       eventId: membership.eventId!,
     369              :     );
     370              : 
     371              :     // Send reaction as unencrypted event to avoid decryption issues
     372            4 :     final txid = client.generateUniqueTransactionId();
     373            4 :     _reactionsTicker++;
     374            4 :     return await client.sendMessage(
     375            4 :       room.id,
     376              :       EventTypes.GroupCallMemberReaction,
     377              :       txid,
     378            2 :       payload.toJson(),
     379              :     );
     380              :   }
     381              : 
     382              :   /// Remove a reaction event from the group call
     383              :   ///
     384              :   /// [eventId] - The event ID of the reaction to remove
     385              :   ///
     386              :   /// Returns the event ID of the removed reaction event
     387            2 :   Future<String?> removeReactionEvent({required String eventId}) async {
     388            4 :     return await client.redactEventWithMetadata(
     389            4 :       room.id,
     390              :       eventId,
     391            4 :       client.generateUniqueTransactionId(),
     392            2 :       metadata: {
     393            4 :         'device_id': client.deviceID,
     394            2 :         'call_id': groupCallId,
     395              :         'redacts_type': EventTypes.GroupCallMemberReaction,
     396              :       },
     397              :     );
     398              :   }
     399              : 
     400              :   /// Get all reactions of a specific type for all participants in the call
     401              :   ///
     402              :   /// [emoji] - The reaction emoji to filter by (e.g., '🖐️')
     403              :   ///
     404              :   /// Returns a list of [MatrixEvent] objects representing the reactions
     405            2 :   Future<List<MatrixEvent>> getAllReactions({required String emoji}) async {
     406            2 :     final reactions = <MatrixEvent>[];
     407              : 
     408            2 :     final memberships = room
     409            2 :         .getCallMembershipsFromRoom(
     410            2 :           voip,
     411              :         )
     412            2 :         .values
     413            4 :         .expand((e) => e);
     414              : 
     415              :     final membershipsForCurrentGroupCall = memberships
     416            2 :         .where(
     417            2 :           (m) =>
     418            6 :               m.callId == groupCallId &&
     419            6 :               m.application == application &&
     420            6 :               m.scope == scope &&
     421            8 :               m.roomId == room.id,
     422              :         )
     423            2 :         .toList();
     424              : 
     425            4 :     for (final membership in membershipsForCurrentGroupCall) {
     426            2 :       if (membership.eventId == null) continue;
     427              : 
     428              :       // this could cause a problem in large calls because it would make
     429              :       // n number of /relations requests where n is the number of participants
     430              :       // but turns our synapse does not rate limit these so should be fine?
     431              :       final eventsToProcess =
     432            4 :           (await client.getRelatingEventsWithRelTypeAndEventType(
     433            4 :         room.id,
     434            2 :         membership.eventId!,
     435              :         RelationshipTypes.reference,
     436              :         EventTypes.GroupCallMemberReaction,
     437              :         recurse: false,
     438              :         limit: 100,
     439              :       ))
     440            2 :               .chunk;
     441              : 
     442            2 :       reactions.addAll(
     443            2 :         eventsToProcess.where((event) => event.content['key'] == emoji),
     444              :       );
     445              :     }
     446              : 
     447              :     return reactions;
     448              :   }
     449              : 
     450              :   /// Get all permanent reactions for a specific member event ID
     451              :   ///
     452              :   /// [eventId] - The member event ID to get reactions for
     453              :   ///
     454              :   /// Returns a list of [MatrixEvent] objects representing permanent reactions
     455            4 :   Future<List<MatrixEvent>> _getPermanentReactionsForEvent(
     456              :     String eventId,
     457              :   ) async {
     458            4 :     final permanentReactions = <MatrixEvent>[];
     459              : 
     460              :     try {
     461            8 :       final events = await client.getRelatingEventsWithRelTypeAndEventType(
     462            8 :         room.id,
     463              :         eventId,
     464              :         RelationshipTypes.reference,
     465              :         EventTypes.GroupCallMemberReaction,
     466              :         recurse: false,
     467              :         // makes sure that if you make too many reactions, permanent reactions don't miss out
     468              :         // hopefully 100 is a good value
     469              :         limit: 100,
     470              :       );
     471              : 
     472            4 :       for (final event in events.chunk) {
     473            0 :         final content = event.content;
     474            0 :         final isEphemeral = content['is_ephemeral'] as bool? ?? false;
     475            0 :         final isRedacted = event.redacts != null;
     476              : 
     477              :         if (!isEphemeral && !isRedacted) {
     478            0 :           permanentReactions.add(event);
     479            0 :           Logs().d(
     480            0 :             '[VOIP] Found permanent reaction to preserve: ${content['key']} from ${event.senderId}',
     481              :           );
     482              :         }
     483              :       }
     484              :     } catch (e, s) {
     485            0 :       Logs().e(
     486            0 :         '[VOIP] Failed to get permanent reactions for event $eventId',
     487              :         e,
     488              :         s,
     489              :       );
     490              :     }
     491              : 
     492              :     return permanentReactions;
     493              :   }
     494              : 
     495              :   /// Copy permanent reactions to the new member event
     496              :   ///
     497              :   /// [permanentReactions] - List of permanent reaction events to copy
     498              :   /// [newEventId] - The event ID of the new membership event
     499            0 :   Future<void> _copyPermanentReactionsToNewEvent(
     500              :     List<MatrixEvent> permanentReactions,
     501              :     String newEventId,
     502              :   ) async {
     503              :     // Re-send each permanent reaction with the new event ID
     504            0 :     for (final reactionEvent in permanentReactions) {
     505              :       try {
     506            0 :         final content = reactionEvent.content;
     507            0 :         final reactionKey = content['key'] as String?;
     508              : 
     509              :         if (reactionKey == null) {
     510            0 :           Logs().w(
     511              :             '[VOIP] Skipping permanent reaction copy: missing reaction key',
     512              :           );
     513              :           continue;
     514              :         }
     515              : 
     516              :         // Build new reaction event with updated event ID
     517            0 :         final payload = ReactionPayload(
     518              :           key: reactionKey,
     519              :           isEphemeral: false,
     520            0 :           callId: groupCallId,
     521            0 :           deviceId: client.deviceID!,
     522              :           relType: RelationshipTypes.reference,
     523              :           eventId: newEventId,
     524              :         );
     525              : 
     526              :         // Send the permanent reaction with new event ID
     527            0 :         final txid = client.generateUniqueTransactionId();
     528            0 :         await client.sendMessage(
     529            0 :           room.id,
     530              :           EventTypes.GroupCallMemberReaction,
     531              :           txid,
     532            0 :           payload.toJson(),
     533              :         );
     534              : 
     535            0 :         Logs().d(
     536            0 :           '[VOIP] Copied permanent reaction $reactionKey to new member event $newEventId',
     537              :         );
     538              :       } catch (e, s) {
     539            0 :         Logs().e(
     540              :           '[VOIP] Failed to copy permanent reaction',
     541              :           e,
     542              :           s,
     543              :         );
     544              :       }
     545              :     }
     546              :   }
     547              : }
        

Generated by: LCOV version 2.0-1