diff --git a/lib/src/voip/call.dart b/lib/src/voip/call.dart index f08e3ce2..80880001 100644 --- a/lib/src/voip/call.dart +++ b/lib/src/voip/call.dart @@ -30,18 +30,18 @@ import 'package:matrix/src/utils/cached_stream_controller.dart'; /// version 1 const String voipProtoVersion = '1'; -class Timeouts { +class CallTimeouts { /// The default life time for call events, in millisecond. - static const lifetimeMs = 10 * 1000; + static const defaultCallEventLifetime = Duration(seconds: 10); /// The length of time a call can be ringing for. - static const callTimeoutSec = 60; + static const callInviteLifetime = Duration(seconds: 60); /// The delay for ice gathering. - static const iceGatheringDelayMs = 200; + static const iceGatheringDelay = Duration(milliseconds: 200); /// Delay before createOffer. - static const delayBeforeOfferMs = 100; + static const delayBeforeOffer = Duration(milliseconds: 100); } extension RTCIceCandidateExt on RTCIceCandidate { @@ -504,7 +504,7 @@ class CallSession { setCallState(CallState.kRinging); - ringingTimer = Timer(Duration(seconds: 30), () { + ringingTimer = Timer(CallTimeouts.callInviteLifetime, () { if (state == CallState.kRinging) { Logs().v('[VOIP] Call invite has expired. Hanging up.'); hangupParty = CallParty.kRemote; // effectively @@ -621,8 +621,7 @@ class CallSession { } /// Send select_answer event. - await sendSelectCallAnswer( - opts.room, callId, Timeouts.lifetimeMs, localPartyId, remotePartyId!); + await sendSelectCallAnswer(opts.room, callId, localPartyId, remotePartyId!); } Future onNegotiateReceived( @@ -659,7 +658,11 @@ class CallSession { } await sendCallNegotiate( - room, callId, Timeouts.lifetimeMs, localPartyId, answer.sdp!, + room, + callId, + CallTimeouts.defaultCallEventLifetime.inMilliseconds, + localPartyId, + answer.sdp!, type: answer.type!); await pc!.setLocalDescription(answer); } @@ -983,7 +986,7 @@ class CallSession { if (localUserMediaStream != null && localUserMediaStream!.stream != null) { final stream = await _getUserMedia(CallType.kVideo); if (stream != null) { - Logs().e('[VOIP] running replaceTracks() on stream: ${stream.id}'); + Logs().d('[VOIP] running replaceTracks() on stream: ${stream.id}'); _setTracksEnabled(stream.getVideoTracks(), true); // replace local tracks for (final track in localUserMediaStream!.stream!.getTracks()) { @@ -1145,8 +1148,7 @@ class CallSession { Logs().d('[VOIP] Rejecting call: $callId'); await terminate(CallParty.kLocal, CallErrorCode.UserHangup, shouldEmit); if (shouldEmit) { - await sendCallReject( - room, callId, Timeouts.lifetimeMs, localPartyId, reason); + await sendCallReject(room, callId, localPartyId, reason); } } @@ -1257,8 +1259,7 @@ class CallSession { if (pc!.iceGatheringState == RTCIceGatheringState.RTCIceGatheringStateGathering) { // Allow a short time for initial candidates to be gathered - await Future.delayed( - Duration(milliseconds: Timeouts.iceGatheringDelayMs)); + await Future.delayed(CallTimeouts.iceGatheringDelay); } if (callHasEnded) return; @@ -1271,8 +1272,14 @@ class CallSession { Logs().d('[glare] new invite sent about to be called'); await sendInviteToCall( - room, callId, Timeouts.lifetimeMs, localPartyId, null, offer.sdp!, - capabilities: callCapabilities, metadata: metadata); + room, + callId, + CallTimeouts.callInviteLifetime.inMilliseconds, + localPartyId, + null, + offer.sdp!, + capabilities: callCapabilities, + metadata: metadata); // just incase we ended the call but already sent the invite if (state == CallState.kEnded) { await hangup(CallErrorCode.Replaced, false); @@ -1287,7 +1294,7 @@ class CallSession { setCallState(CallState.kInviteSent); - inviteTimer = Timer(Duration(seconds: Timeouts.callTimeoutSec), () { + inviteTimer = Timer(CallTimeouts.callInviteLifetime, () { if (state == CallState.kInviteSent) { hangup(CallErrorCode.InviteTimeout); } @@ -1296,7 +1303,11 @@ class CallSession { }); } else { await sendCallNegotiate( - room, callId, Timeouts.lifetimeMs, localPartyId, offer.sdp!, + room, + callId, + CallTimeouts.defaultCallEventLifetime.inMilliseconds, + localPartyId, + offer.sdp!, type: offer.type!, capabilities: callCapabilities, metadata: metadata); @@ -1311,7 +1322,7 @@ class CallSession { // onNegotiationNeeded, which causes creatOffer to only include // audio m-line, add delay and wait for video track to be added, // then createOffer can get audio/video m-line correctly. - await Future.delayed(Duration(milliseconds: Timeouts.delayBeforeOfferMs)); + await Future.delayed(CallTimeouts.delayBeforeOffer); final offer = await pc!.createOffer({}); await _gotLocalOffer(offer); } catch (e) { @@ -1703,8 +1714,8 @@ class CallSession { /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1. /// [party_id] The party ID for call, Can be set to client.deviceId. /// [selected_party_id] The party ID for the selected answer. - Future sendSelectCallAnswer(Room room, String callId, int lifetime, - String party_id, String selected_party_id, + Future sendSelectCallAnswer( + Room room, String callId, String party_id, String selected_party_id, {String version = voipProtoVersion, String? txid}) async { txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}'; @@ -1713,7 +1724,6 @@ class CallSession { 'party_id': party_id, if (groupCallId != null) 'conf_id': groupCallId, 'version': version, - 'lifetime': lifetime, 'selected_party_id': selected_party_id, }; @@ -1730,7 +1740,7 @@ class CallSession { /// [version] is the version of the VoIP specification this message adheres to. This specification is version 1. /// [party_id] The party ID for call, Can be set to client.deviceId. Future sendCallReject( - Room room, String callId, int lifetime, String party_id, String? reason, + Room room, String callId, String party_id, String? reason, {String version = voipProtoVersion, String? txid}) async { txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}'; @@ -1740,7 +1750,6 @@ class CallSession { if (groupCallId != null) 'conf_id': groupCallId, if (reason != null) 'reason': reason, 'version': version, - 'lifetime': lifetime, }; return await _sendContent( @@ -1972,6 +1981,9 @@ class CallSession { }) async { txid ??= client.generateUniqueTransactionId(); final mustEncrypt = room.encrypted && client.encryptionEnabled; + + // opponentDeviceId is only set for a few events during group calls, + // therefore only group calls use to-device messages for some events if (opponentDeviceId != null) { final toDeviceSeq = this.toDeviceSeq++; diff --git a/lib/src/voip/group_call.dart b/lib/src/voip/group_call.dart index b6fe62ac..1187b86d 100644 --- a/lib/src/voip/group_call.dart +++ b/lib/src/voip/group_call.dart @@ -192,7 +192,7 @@ class GroupCall { WrappedMediaStream? localUserMediaStream; WrappedMediaStream? localScreenshareStream; String? localDesktopCapturerSourceId; - List calls = []; + List callSessions = []; List participants = []; List userMediaStreams = []; List screenshareStreams = []; @@ -404,7 +404,7 @@ class GroupCall { final stream = await voip.delegate.mediaDevices.getUserMedia({'audio': true}); final audioTrack = stream.getAudioTracks().first; - for (final call in calls) { + for (final call in callSessions) { await call.updateAudioDevice(audioTrack); } } @@ -441,7 +441,7 @@ class GroupCall { _callSubscription = voip.onIncomingCall.stream.listen(onIncomingCall); - for (final call in calls) { + for (final call in callSessions) { await onIncomingCall(call); } @@ -478,7 +478,7 @@ class GroupCall { await removeMemberStateEvent(); - final callsCopy = calls.toList(); + final callsCopy = callSessions.toList(); for (final call in callsCopy) { await removeCall(call, CallErrorCode.UserHangup); @@ -553,7 +553,7 @@ class GroupCall { setTracksEnabled(localUserMediaStream!.stream!.getAudioTracks(), !muted); } - for (final call in calls) { + for (final call in callSessions) { await call.setMicrophoneMuted(muted); } @@ -571,7 +571,7 @@ class GroupCall { setTracksEnabled(localUserMediaStream!.stream!.getVideoTracks(), !muted); } - for (final call in calls) { + for (final call in callSessions) { await call.setLocalVideoMuted(muted); } @@ -620,7 +620,7 @@ class GroupCall { await localScreenshareStream!.initialize(); onGroupCallEvent.add(GroupCallEvent.LocalScreenshareStateChanged); - for (final call in calls) { + for (final call in callSessions) { await call.addLocalStream( await localScreenshareStream!.stream!.clone(), localScreenshareStream!.purpose); @@ -637,7 +637,7 @@ class GroupCall { return false; } } else { - for (final call in calls) { + for (final call in callSessions) { await call.removeLocalStream(call.localScreenSharingStream!); } @@ -935,7 +935,7 @@ class GroupCall { } CallSession? getCallByUserId(String userId) { - final value = calls.where((item) => item.remoteUser!.id == userId); + final value = callSessions.where((item) => item.remoteUser!.id == userId); if (value.isNotEmpty) { return value.first; } @@ -943,7 +943,7 @@ class GroupCall { } Future addCall(CallSession call) async { - calls.add(call); + callSessions.add(call); await initCall(call); onGroupCallEvent.add(GroupCallEvent.CallsChanged); } @@ -951,14 +951,14 @@ class GroupCall { Future replaceCall( CallSession existingCall, CallSession replacementCall) async { final existingCallIndex = - calls.indexWhere((element) => element == existingCall); + callSessions.indexWhere((element) => element == existingCall); if (existingCallIndex == -1) { throw Exception('Couldn\'t find call to replace'); } - calls.removeAt(existingCallIndex); - calls.add(replacementCall); + callSessions.removeAt(existingCallIndex); + callSessions.add(replacementCall); await disposeCall(existingCall, CallErrorCode.Replaced); await initCall(replacementCall); @@ -970,7 +970,7 @@ class GroupCall { Future removeCall(CallSession call, String hangupReason) async { await disposeCall(call, hangupReason); - calls.removeWhere((element) => call.callId == element.callId); + callSessions.removeWhere((element) => call.callId == element.callId); onGroupCallEvent.add(GroupCallEvent.CallsChanged); } @@ -1287,7 +1287,7 @@ class GroupCall { onGroupCallEvent.add(GroupCallEvent.ParticipantsChanged); - final callsCopylist = List.from(calls); + final callsCopylist = List.from(callSessions); for (final call in callsCopylist) { await call.updateMuteStatus(); @@ -1305,7 +1305,7 @@ class GroupCall { onGroupCallEvent.add(GroupCallEvent.ParticipantsChanged); - final callsCopylist = List.from(calls); + final callsCopylist = List.from(callSessions); for (final call in callsCopylist) { await call.updateMuteStatus(); diff --git a/lib/src/voip/voip.dart b/lib/src/voip/voip.dart index 824cd508..ada9668d 100644 --- a/lib/src/voip/voip.dart +++ b/lib/src/voip/voip.dart @@ -176,7 +176,11 @@ class VoIP { final String partyId = content['party_id']; final int lifetime = content['lifetime']; final String? confId = content['conf_id']; + + // msc3401 group call invites send deviceId and senderSessionId in to device messages final String? deviceId = content['device_id']; + final String? senderSessionId = content['sender_session_id']; + final call = calls[callId]; Logs().d( @@ -232,7 +236,7 @@ class VoIP { newCall.remotePartyId = partyId; newCall.remoteUser = await room.requestUser(senderId); newCall.opponentDeviceId = deviceId; - newCall.opponentSessionId = content['sender_session_id']; + newCall.opponentSessionId = senderSessionId; if (!delegate.canHandleNewCall && (confId == null || confId != currentGroupCID)) { Logs().v( @@ -263,16 +267,22 @@ class VoIP { // initWithInvite, we might set it to callId even after it was reset to null // by terminate. currentCID = callId; - - await newCall.initWithInvite( - callType, offer, sdpStreamMetadata, lifetime, confId != null); + try { + await newCall.initWithInvite( + callType, offer, sdpStreamMetadata, lifetime, confId != null); + } catch (e, s) { + Logs().e('[VOIP] initWithInvite failed', e, s); + } // Popup CallingPage for incoming call. if (confId == null && !newCall.callHasEnded) { await delegate.handleNewCall(newCall); } - onIncomingCall.add(newCall); + if (confId != null) { + // the stream is used to monitor incoming peer calls in a mesh call + onIncomingCall.add(newCall); + } } Future onCallAnswer( @@ -494,6 +504,19 @@ class VoIP { 'Ignoring call negotiation for room $roomId claiming to be for call in room ${call.room.id}'); return; } + if (content['party_id'] != call.remotePartyId) { + Logs().w('Ignoring call negotiation, wrong partyId detected'); + return; + } + if (content['party_id'] == call.localPartyId) { + Logs().w('Ignoring call negotiation echo'); + return; + } + + // ideally you also check the lifetime here and discard negotiation events + // if age of the event was older than the lifetime but as to device events + // do not have a unsigned age nor a origin_server_ts there's no easy way to + // override this one function atm final description = content['description']; try {