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 : }
|