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