290 lines
9.3 KiB
Dart
290 lines
9.3 KiB
Dart
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<CallParticipant> get participants => List.unmodifiable(_participants);
|
|
final Set<CallParticipant> _participants = {};
|
|
|
|
String groupCallId;
|
|
|
|
final CachedStreamController<GroupCallState> onGroupCallState =
|
|
CachedStreamController();
|
|
|
|
final CachedStreamController<GroupCallStateChange> onGroupCallEvent =
|
|
CachedStreamController();
|
|
|
|
final CachedStreamController<MatrixRTCCallEvent> 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<void> 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<void> 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<void> 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<void> 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<void> 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<CallParticipant> 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<CallParticipant>.from(newP);
|
|
final oldPcopy = Set<CallParticipant>.from(_participants);
|
|
final anyJoined = newPcopy.difference(oldPcopy);
|
|
final anyLeft = oldPcopy.difference(newPcopy);
|
|
|
|
if (anyJoined.isNotEmpty || anyLeft.isNotEmpty) {
|
|
if (anyJoined.isNotEmpty) {
|
|
final nonLocalAnyJoined = Set<CallParticipant>.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<CallParticipant>.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()}',
|
|
);
|
|
}
|
|
}
|
|
}
|