diff --git a/lib/src/voip/group_call.dart b/lib/src/voip/group_call.dart index cfada213..49ab07be 100644 --- a/lib/src/voip/group_call.dart +++ b/lib/src/voip/group_call.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'dart:core'; +import 'package:collection/collection.dart'; import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:matrix/matrix.dart'; @@ -92,10 +93,14 @@ class IGroupCallRoomMemberFeed { class IGroupCallRoomMemberDevice { String? device_id; String? session_id; + int? expires_ts; + List feeds = []; IGroupCallRoomMemberDevice.fromJson(Map json) { device_id = json['device_id']; session_id = json['session_id']; + expires_ts = json['expires_ts']; + if (json['feeds'] != null) { feeds = (json['feeds'] as List) .map((feed) => IGroupCallRoomMemberFeed.fromJson(feed)) @@ -107,6 +112,7 @@ class IGroupCallRoomMemberDevice { final data = {}; data['device_id'] = device_id; data['session_id'] = session_id; + data['expires_ts'] = expires_ts; data['feeds'] = feeds.map((feed) => feed.toJson()).toList(); return data; } @@ -116,7 +122,7 @@ class IGroupCallRoomMemberCallState { String? call_id; List? foci; List devices = []; - IGroupCallRoomMemberCallState.formJson(Map json) { + IGroupCallRoomMemberCallState.fromJson(Map json) { call_id = json['m.call_id']; if (json['m.foci'] != null) { foci = (json['m.foci'] as List).cast(); @@ -141,17 +147,12 @@ class IGroupCallRoomMemberCallState { } class IGroupCallRoomMemberState { - final DEFAULT_EXPIRE_TS = Duration(seconds: 300); - late int expireTs; List calls = []; IGroupCallRoomMemberState.fromJson(MatrixEvent event) { if (event.content['m.calls'] != null) { (event.content['m.calls'] as List).forEach( - (call) => calls.add(IGroupCallRoomMemberCallState.formJson(call))); + (call) => calls.add(IGroupCallRoomMemberCallState.fromJson(call))); } - - expireTs = event.content['m.expires_ts'] ?? - event.originServerTs.add(DEFAULT_EXPIRE_TS).millisecondsSinceEpoch; } } @@ -264,15 +265,10 @@ class GroupCall { return room.unsafeGetUserFromMemoryOrFallback(client.userID!); } - bool callMemberStateIsExpired(MatrixEvent event) { - final callMemberState = IGroupCallRoomMemberState.fromJson(event); - return callMemberState.expireTs < DateTime.now().millisecondsSinceEpoch; - } - Event? getMemberStateEvent(String userId) { final event = room.getState(EventTypes.GroupCallMemberPrefix, userId); if (event != null) { - return callMemberStateIsExpired(event) ? null : event; + return voip.callMemberStateIsExpired(event, groupCallId) ? null : event; } return null; } @@ -283,7 +279,7 @@ class GroupCall { roomStates.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); roomStates.forEach((value) { if (value.type == EventTypes.GroupCallMemberPrefix && - !callMemberStateIsExpired(value)) { + !voip.callMemberStateIsExpired(value, groupCallId)) { events.add(value); } }); @@ -685,22 +681,29 @@ class GroupCall { Future sendMemberStateEvent() async { final deviceId = client.deviceID; - await updateMemberCallState(IGroupCallRoomMemberCallState.formJson({ - 'm.call_id': groupCallId, - 'm.devices': [ + await updateMemberCallState( + IGroupCallRoomMemberCallState.fromJson( { - 'device_id': deviceId, - 'session_id': client.groupCallSessionId, - 'feeds': getLocalStreams() - .map((feed) => ({ - 'purpose': feed.purpose, - })) - .toList(), - // TODO: Add data channels + 'm.call_id': groupCallId, + 'm.devices': [ + { + 'device_id': deviceId, + 'session_id': client.groupCallSessionId, + 'expires_ts': DateTime.now() + .add(expireTsBumpDuration) + .millisecondsSinceEpoch, + 'feeds': getLocalStreams() + .map((feed) => ({ + 'purpose': feed.purpose, + })) + .toList(), + // TODO: Add data channels + }, + ], + // TODO 'm.foci' }, - ], - // TODO 'm.foci' - })); + ), + ); if (resendMemberStateEventTimer != null) { resendMemberStateEventTimer!.cancel(); @@ -726,7 +729,6 @@ class GroupCall { final localUserId = client.userID; final currentStateEvent = getMemberStateEvent(localUserId!); - final eventContent = currentStateEvent?.content ?? {}; var calls = []; if (currentStateEvent != null) { @@ -750,9 +752,6 @@ class GroupCall { } final content = { 'm.calls': calls.map((e) => e.toJson()).toList(), - 'm.expires_ts': calls.isEmpty - ? eventContent.tryGet('m.expires_ts') - : DateTime.now().add(expireTsBumpDuration).millisecondsSinceEpoch }; await client.setRoomStateWithKey( @@ -1153,17 +1152,27 @@ class GroupCall { statsReport .removeWhere((element) => !element.values.containsKey('audioLevel')); + // https://www.w3.org/TR/webrtc-stats/#summary + final otherPartyAudioLevel = statsReport + .singleWhereOrNull((element) => + element.type == 'inbound-rtp' && + element.values['kind'] == 'audio') + ?.values['audioLevel']; + if (otherPartyAudioLevel != null) { + audioLevelsMap[callFeed.userId] = otherPartyAudioLevel; + } + // https://www.w3.org/TR/webrtc-stats/#dom-rtcstatstype-media-source // firefox does not seem to have this though. Works on chrome and android - audioLevelsMap[client.userID!] = statsReport - .lastWhere((element) => + final ownAudioLevel = statsReport + .singleWhereOrNull((element) => element.type == 'media-source' && element.values['kind'] == 'audio') - .values['audioLevel']; - // works everywhere? - audioLevelsMap[callFeed.userId] = statsReport - .lastWhere((element) => element.type == 'inbound-rtp') - .values['audioLevel']; + ?.values['audioLevel']; + if (ownAudioLevel != null && + audioLevelsMap[client.userID] != ownAudioLevel) { + audioLevelsMap[client.userID!] = ownAudioLevel; + } } double maxAudioLevel = double.negativeInfinity; diff --git a/lib/src/voip/voip.dart b/lib/src/voip/voip.dart index 707264cf..15343a26 100644 --- a/lib/src/voip/voip.dart +++ b/lib/src/voip/voip.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:core'; +import 'package:collection/collection.dart'; import 'package:sdp_transform/sdp_transform.dart' as sdp_transform; import 'package:webrtc_interface/webrtc_interface.dart'; @@ -832,6 +833,23 @@ class VoIP { static const staleCallCheckerDuration = Duration(seconds: 30); + bool callMemberStateIsExpired( + MatrixEvent groupCallMemberStateEvent, String groupCallId) { + final callMemberState = + IGroupCallRoomMemberState.fromJson(groupCallMemberStateEvent); + final calls = callMemberState.calls; + if (calls.isNotEmpty) { + final call = + calls.singleWhereOrNull((call) => call.call_id == groupCallId); + if (call != null) { + return call.devices.where((device) => device.expires_ts != null).every( + (device) => + device.expires_ts! < DateTime.now().millisecondsSinceEpoch); + } + } + return true; + } + /// checks for stale calls in a room and sends `m.terminated` if all the /// expires_ts are expired. Call when opening a room void startStaleCallsChecker(String roomId) async { @@ -855,28 +873,26 @@ class VoIP { 'found non terminated group call with id $groupCallId'); // call is not empty but check for stale participants (gone offline) // with expire_ts - final Map participants = {}; + bool callExpired = true; // assume call is expired final callMemberEvents = room.states.tryGetMap( EventTypes.GroupCallMemberPrefix); if (callMemberEvents != null) { - callMemberEvents.forEach((userId, memberEvent) async { - final callMemberEvent = groupCallEvent.room.getState( - EventTypes.GroupCallMemberPrefix, - userId, - ); - if (callMemberEvent != null) { - final event = - IGroupCallRoomMemberState.fromJson(callMemberEvent); - participants[userId] = event.expireTs; - } - }); - } + for (var i = 0; i < callMemberEvents.length; i++) { + final groupCallMemberEventMap = + callMemberEvents.entries.toList()[i]; - if (!participants.values.any((expire_ts) => - expire_ts > DateTime.now().millisecondsSinceEpoch)) { + final groupCallMemberEvent = + groupCallMemberEventMap.value; + callExpired = callMemberStateIsExpired( + groupCallMemberEvent, groupCallId); + // no need to iterate further even if one participant says call isn't expired + if (!callExpired) break; + } + } + if (callExpired) { Logs().i( - 'Group call with expired timestamps detected, terminating'); + 'Group call with only expired timestamps detected, terminating'); await sendGroupCallTerminateEvent(room, groupCallId); } }