/* * Famedly Matrix SDK * Copyright (C) 2021 Famedly GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General License for more details. * * You should have received a copy of the GNU Affero General License * along with this program. If not, see . */ import 'dart:async'; import 'dart:core'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/voip/models/call_membership.dart'; import 'package:matrix/src/voip/models/voip_id.dart'; import 'package:matrix/src/voip/utils/stream_helper.dart'; /// Holds methods for managing a group call. This class is also responsible for /// holding and managing the individual `CallSession`s in a group call. class GroupCallSession { // Config final Client client; final VoIP voip; final Room room; /// is a list of backend to allow passing multiple backend in the future /// we use the first backend everywhere as of now final CallBackend backend; /// something like normal calls or thirdroom final String? application; /// either room scoped or user scoped calls final String? scope; GroupCallState state = GroupCallState.localCallFeedUninitialized; CallParticipant? get localParticipant => voip.localParticipant; List get participants => List.unmodifiable(_participants); final Set _participants = {}; String groupCallId; final CachedStreamController onGroupCallState = CachedStreamController(); final CachedStreamController onGroupCallEvent = CachedStreamController(); final CachedStreamController matrixRTCEventStream = CachedStreamController(); Timer? _resendMemberStateEventTimer; factory GroupCallSession.withAutoGenId( Room room, VoIP voip, CallBackend backend, String? application, String? scope, String? groupCallId, ) { return GroupCallSession( client: room.client, room: room, voip: voip, backend: backend, application: application ?? 'm.call', scope: scope ?? 'm.room', groupCallId: groupCallId ?? genCallID(), ); } GroupCallSession({ required this.client, required this.room, required this.voip, required this.backend, required this.groupCallId, required this.application, required this.scope, }); String get avatarName => _getUser().calcDisplayname(mxidLocalPartFallback: false); String? get displayName => _getUser().displayName; User _getUser() { return room.unsafeGetUserFromMemoryOrFallback(client.userID!); } void setState(GroupCallState newState) { state = newState; onGroupCallState.add(newState); onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged); } bool hasLocalParticipant() { return _participants.contains(localParticipant); } /// enter the group call. Future enter({WrappedMediaStream? stream}) async { if (!(state == GroupCallState.localCallFeedUninitialized || state == GroupCallState.localCallFeedInitialized)) { throw MatrixSDKVoipException('Cannot enter call in the $state state'); } if (state == GroupCallState.localCallFeedUninitialized) { await backend.initLocalStream(this, stream: stream); } await sendMemberStateEvent(); setState(GroupCallState.entered); Logs().v('Entered group call $groupCallId'); // Set up _participants for the members currently in the call. // Other members will be picked up by the RoomState.members event. await onMemberStateChanged(); await backend.setupP2PCallsWithExistingMembers(this); voip.currentGroupCID = VoipId(roomId: room.id, callId: groupCallId); await voip.delegate.handleNewGroupCall(this); } Future leave() async { await removeMemberStateEvent(); await backend.dispose(this); setState(GroupCallState.localCallFeedUninitialized); voip.currentGroupCID = null; _participants.clear(); voip.groupCalls.remove(VoipId(roomId: room.id, callId: groupCallId)); await voip.delegate.handleGroupCallEnded(this); _resendMemberStateEventTimer?.cancel(); setState(GroupCallState.ended); } Future sendMemberStateEvent() async { await room.updateFamedlyCallMemberStateEvent( CallMembership( userId: client.userID!, roomId: room.id, callId: groupCallId, application: application, scope: scope, backend: backend, deviceId: client.deviceID!, expiresTs: DateTime.now() .add(CallTimeouts.expireTsBumpDuration) .millisecondsSinceEpoch, membershipId: voip.currentSessionId, feeds: backend.getCurrentFeeds(), ), ); if (_resendMemberStateEventTimer != null) { _resendMemberStateEventTimer!.cancel(); } _resendMemberStateEventTimer = Timer.periodic( CallTimeouts.updateExpireTsTimerDuration, ((timer) async { Logs().d('sendMemberStateEvent updating member event with timer'); if (state != GroupCallState.ended || state != GroupCallState.localCallFeedUninitialized) { await sendMemberStateEvent(); } else { Logs().d( '[VOIP] deteceted groupCall in state $state, removing state event', ); await removeMemberStateEvent(); } }), ); } Future removeMemberStateEvent() { if (_resendMemberStateEventTimer != null) { Logs().d('resend member event timer cancelled'); _resendMemberStateEventTimer!.cancel(); _resendMemberStateEventTimer = null; } return room.removeFamedlyCallMemberEvent( groupCallId, client.deviceID!, application: application, scope: scope, ); } /// compltetely rebuilds the local _participants list Future onMemberStateChanged() async { // The member events may be received for another room, which we will ignore. final mems = room.getCallMembershipsFromRoom().values.expand((element) => element); final memsForCurrentGroupCall = mems.where((element) { return element.callId == groupCallId && !element.isExpired && element.application == application && element.scope == scope && element.roomId == room.id; // sanity checks }).toList(); final ignoredMems = mems.where((element) => !memsForCurrentGroupCall.contains(element)); for (final mem in ignoredMems) { Logs().v( '[VOIP] Ignored ${mem.userId}\'s mem event ${mem.toJson()} while updating _participants list for callId: $groupCallId, expiry status: ${mem.isExpired}', ); } final Set newP = {}; for (final mem in memsForCurrentGroupCall) { final rp = CallParticipant( voip, userId: mem.userId, deviceId: mem.deviceId, ); newP.add(rp); if (rp.isLocal) continue; if (state != GroupCallState.entered) { Logs().w( '[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update', ); continue; } await backend.setupP2PCallWithNewMember(this, rp, mem); } final newPcopy = Set.from(newP); final oldPcopy = Set.from(_participants); final anyJoined = newPcopy.difference(oldPcopy); final anyLeft = oldPcopy.difference(newPcopy); if (anyJoined.isNotEmpty || anyLeft.isNotEmpty) { if (anyJoined.isNotEmpty) { final nonLocalAnyJoined = Set.from(anyJoined) ..remove(localParticipant); if (nonLocalAnyJoined.isNotEmpty && state == GroupCallState.entered) { Logs().v( 'nonLocalAnyJoined: ${nonLocalAnyJoined.map((e) => e.id).toString()} roomId: ${room.id} groupCallId: $groupCallId', ); await backend.onNewParticipant(this, nonLocalAnyJoined.toList()); } _participants.addAll(anyJoined); matrixRTCEventStream .add(ParticipantsJoinEvent(participants: anyJoined.toList())); } if (anyLeft.isNotEmpty) { final nonLocalAnyLeft = Set.from(anyLeft) ..remove(localParticipant); if (nonLocalAnyLeft.isNotEmpty && state == GroupCallState.entered) { Logs().v( 'nonLocalAnyLeft: ${nonLocalAnyLeft.map((e) => e.id).toString()} roomId: ${room.id} groupCallId: $groupCallId', ); await backend.onLeftParticipant(this, nonLocalAnyLeft.toList()); } _participants.removeAll(anyLeft); matrixRTCEventStream .add(ParticipantsLeftEvent(participants: anyLeft.toList())); } onGroupCallEvent.add(GroupCallStateChange.participantsChanged); Logs().d( '[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}', ); } } }