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

Generated by: LCOV version 2.0-1