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:matrix/matrix.dart';
23 : import 'package:matrix/src/utils/cached_stream_controller.dart';
24 : import 'package:matrix/src/voip/models/call_membership.dart';
25 : import 'package:matrix/src/voip/models/voip_id.dart';
26 : import 'package:matrix/src/voip/utils/stream_helper.dart';
27 :
28 : /// Holds methods for managing a group call. This class is also responsible for
29 : /// holding and managing the individual `CallSession`s in a group call.
30 : class GroupCallSession {
31 : // Config
32 : final Client client;
33 : final VoIP voip;
34 : final Room room;
35 :
36 : /// is a list of backend to allow passing multiple backend in the future
37 : /// we use the first backend everywhere as of now
38 : final CallBackend backend;
39 :
40 : /// something like normal calls or thirdroom
41 : final String? application;
42 :
43 : /// either room scoped or user scoped calls
44 : final String? scope;
45 :
46 : GroupCallState state = GroupCallState.localCallFeedUninitialized;
47 :
48 0 : CallParticipant? get localParticipant => voip.localParticipant;
49 :
50 0 : List<CallParticipant> get participants => List.unmodifiable(_participants);
51 : final List<CallParticipant> _participants = [];
52 :
53 : String groupCallId;
54 :
55 : final CachedStreamController<GroupCallState> onGroupCallState =
56 : CachedStreamController();
57 :
58 : final CachedStreamController<GroupCallStateChange> onGroupCallEvent =
59 : CachedStreamController();
60 :
61 : Timer? _resendMemberStateEventTimer;
62 :
63 0 : factory GroupCallSession.withAutoGenId(
64 : Room room,
65 : VoIP voip,
66 : CallBackend backend,
67 : String? application,
68 : String? scope,
69 : String? groupCallId,
70 : ) {
71 0 : return GroupCallSession(
72 0 : client: room.client,
73 : room: room,
74 : voip: voip,
75 : backend: backend,
76 : application: application ?? 'm.call',
77 : scope: scope ?? 'm.room',
78 0 : groupCallId: groupCallId ?? genCallID(),
79 : );
80 : }
81 :
82 2 : GroupCallSession({
83 : required this.client,
84 : required this.room,
85 : required this.voip,
86 : required this.backend,
87 : required this.groupCallId,
88 : required this.application,
89 : required this.scope,
90 : });
91 :
92 0 : String get avatarName =>
93 0 : _getUser().calcDisplayname(mxidLocalPartFallback: false);
94 :
95 0 : String? get displayName => _getUser().displayName;
96 :
97 0 : User _getUser() {
98 0 : return room.unsafeGetUserFromMemoryOrFallback(client.userID!);
99 : }
100 :
101 0 : void setState(GroupCallState newState) {
102 0 : state = newState;
103 0 : onGroupCallState.add(newState);
104 0 : onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged);
105 : }
106 :
107 0 : bool hasLocalParticipant() {
108 0 : return _participants.contains(localParticipant);
109 : }
110 :
111 : /// enter the group call.
112 0 : Future<void> enter({WrappedMediaStream? stream}) async {
113 0 : if (!(state == GroupCallState.localCallFeedUninitialized ||
114 0 : state == GroupCallState.localCallFeedInitialized)) {
115 0 : throw Exception('Cannot enter call in the $state state');
116 : }
117 :
118 0 : if (state == GroupCallState.localCallFeedUninitialized) {
119 0 : await backend.initLocalStream(this, stream: stream);
120 : }
121 :
122 0 : await sendMemberStateEvent();
123 :
124 0 : setState(GroupCallState.entered);
125 :
126 0 : Logs().v('Entered group call $groupCallId');
127 :
128 : // Set up _participants for the members currently in the call.
129 : // Other members will be picked up by the RoomState.members event.
130 0 : await onMemberStateChanged();
131 :
132 0 : await backend.setupP2PCallsWithExistingMembers(this);
133 :
134 0 : voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId);
135 :
136 0 : await voip.delegate.handleNewGroupCall(this);
137 : }
138 :
139 0 : Future<void> leave() async {
140 0 : await removeMemberStateEvent();
141 0 : await backend.dispose(this);
142 0 : setState(GroupCallState.localCallFeedUninitialized);
143 0 : voip.currentGroupCID = null;
144 0 : _participants.clear();
145 0 : voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId));
146 0 : await voip.delegate.handleGroupCallEnded(this);
147 0 : _resendMemberStateEventTimer?.cancel();
148 0 : setState(GroupCallState.ended);
149 : }
150 :
151 0 : Future<void> sendMemberStateEvent() async {
152 0 : await room.updateFamedlyCallMemberStateEvent(
153 0 : CallMembership(
154 0 : userId: client.userID!,
155 0 : roomId: room.id,
156 0 : callId: groupCallId,
157 0 : application: application,
158 0 : scope: scope,
159 0 : backend: backend,
160 0 : deviceId: client.deviceID!,
161 0 : expiresTs: DateTime.now()
162 0 : .add(CallTimeouts.expireTsBumpDuration)
163 0 : .millisecondsSinceEpoch,
164 0 : membershipId: voip.currentSessionId,
165 0 : feeds: backend.getCurrentFeeds(),
166 : ),
167 : );
168 :
169 0 : if (_resendMemberStateEventTimer != null) {
170 0 : _resendMemberStateEventTimer!.cancel();
171 : }
172 0 : _resendMemberStateEventTimer = Timer.periodic(
173 0 : CallTimeouts.updateExpireTsTimerDuration, ((timer) async {
174 0 : Logs().d('sendMemberStateEvent updating member event with timer');
175 0 : if (state != GroupCallState.ended ||
176 0 : state != GroupCallState.localCallFeedUninitialized) {
177 0 : await sendMemberStateEvent();
178 : } else {
179 0 : Logs().d(
180 0 : '[VOIP] deteceted groupCall in state $state, removing state event');
181 0 : await removeMemberStateEvent();
182 : }
183 : }));
184 : }
185 :
186 0 : Future<void> removeMemberStateEvent() {
187 0 : if (_resendMemberStateEventTimer != null) {
188 0 : Logs().d('resend member event timer cancelled');
189 0 : _resendMemberStateEventTimer!.cancel();
190 0 : _resendMemberStateEventTimer = null;
191 : }
192 0 : return room.removeFamedlyCallMemberEvent(
193 0 : groupCallId,
194 0 : client.deviceID!,
195 0 : application: application,
196 0 : scope: scope,
197 : );
198 : }
199 :
200 : /// compltetely rebuilds the local _participants list
201 2 : Future<void> onMemberStateChanged() async {
202 4 : if (state != GroupCallState.entered) {
203 4 : Logs().d(
204 6 : '[VOIP] early return onMemberStateChanged, group call state is not Entered. Actual state: ${state.toString()} ');
205 : return;
206 : }
207 :
208 : // The member events may be received for another room, which we will ignore.
209 : final mems =
210 0 : room.getCallMembershipsFromRoom().values.expand((element) => element);
211 0 : final memsForCurrentGroupCall = mems.where((element) {
212 0 : return element.callId == groupCallId &&
213 0 : !element.isExpired &&
214 0 : element.application == application &&
215 0 : element.scope == scope &&
216 0 : element.roomId == room.id; // sanity checks
217 0 : }).toList();
218 :
219 : final ignoredMems =
220 0 : mems.where((element) => !memsForCurrentGroupCall.contains(element));
221 :
222 0 : for (final mem in ignoredMems) {
223 0 : Logs().w(
224 0 : '[VOIP] Ignored ${mem.userId}\'s mem event ${mem.toJson()} while updating _participants list for callId: $groupCallId, expiry status: ${mem.isExpired}');
225 : }
226 :
227 0 : final List<CallParticipant> newP = [];
228 :
229 0 : for (final mem in memsForCurrentGroupCall) {
230 0 : final rp = CallParticipant(
231 0 : voip,
232 0 : userId: mem.userId,
233 0 : deviceId: mem.deviceId,
234 : );
235 :
236 0 : newP.add(rp);
237 :
238 0 : if (rp.isLocal) continue;
239 :
240 0 : if (state != GroupCallState.entered) {
241 0 : Logs().w(
242 0 : '[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update');
243 : continue;
244 : }
245 :
246 0 : await backend.setupP2PCallWithNewMember(this, rp, mem);
247 : }
248 0 : final newPcopy = List<CallParticipant>.from(newP);
249 0 : final oldPcopy = List<CallParticipant>.from(_participants);
250 0 : final anyJoined = newPcopy.where((element) => !oldPcopy.contains(element));
251 0 : final anyLeft = oldPcopy.where((element) => !newPcopy.contains(element));
252 :
253 0 : if (anyJoined.isNotEmpty || anyLeft.isNotEmpty) {
254 0 : if (anyJoined.isNotEmpty) {
255 0 : Logs().d('anyJoined: ${anyJoined.map((e) => e.id).toString()}');
256 0 : _participants.addAll(anyJoined);
257 0 : await backend.onNewParticipant(this, anyJoined.toList());
258 : }
259 0 : if (anyLeft.isNotEmpty) {
260 0 : Logs().d('anyLeft: ${anyLeft.map((e) => e.id).toString()}');
261 0 : for (final leftp in anyLeft) {
262 0 : _participants.remove(leftp);
263 : }
264 0 : await backend.onLeftParticipant(this, anyLeft.toList());
265 : }
266 :
267 0 : onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
268 0 : Logs().d(
269 0 : '[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}');
270 : }
271 : }
272 : }
|