diff --git a/lib/src/client.dart b/lib/src/client.dart index d7dd3ac6..dee3b12b 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2142,6 +2142,22 @@ class Client extends MatrixApi { return false; }); } + + final age = callEvent.unsigned?.tryGet('age') ?? + (DateTime.now().millisecondsSinceEpoch - + callEvent.originServerTs.millisecondsSinceEpoch); + + callEvents.removeWhere((element) { + if (callEvent.type == EventTypes.CallInvite && + age > + (callEvent.content.tryGet('lifetime') ?? + CallTimeouts.callInviteLifetime.inMilliseconds)) { + Logs().v( + 'Ommiting invite event ${callEvent.eventId} as age was older than lifetime'); + return true; + } + return false; + }); } } } diff --git a/lib/src/voip/call.dart b/lib/src/voip/call.dart index f08e3ce2..9b42884c 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()) { @@ -1083,7 +1086,7 @@ class CallSession { return callOnHold; } - Future answer() async { + Future answer({String? txid}) async { if (inviteOrAnswerSent) { return; } @@ -1121,10 +1124,16 @@ class CallSession { // Allow a short time for initial candidates to be gathered await Future.delayed(Duration(milliseconds: 200)); - final res = await sendAnswerCall(room, callId, answer.sdp!, localPartyId, - type: answer.type!, - capabilities: callCapabilities, - metadata: metadata); + final res = await sendAnswerCall( + room, + callId, + answer.sdp!, + localPartyId, + type: answer.type!, + capabilities: callCapabilities, + metadata: metadata, + txid: txid, + ); Logs().v('[VOIP] answer res => $res'); inviteOrAnswerSent = true; @@ -1145,8 +1154,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 +1265,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 +1278,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 +1300,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 +1309,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 +1328,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 +1720,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 +1730,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 +1746,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 +1756,6 @@ class CallSession { if (groupCallId != null) 'conf_id': groupCallId, if (reason != null) 'reason': reason, 'version': version, - 'lifetime': lifetime, }; return await _sendContent( @@ -1972,6 +1987,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..d873545b 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( @@ -272,7 +276,10 @@ class VoIP { 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 +501,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 { diff --git a/test/calls_test.dart b/test/calls_test.dart new file mode 100644 index 00000000..25fcf831 --- /dev/null +++ b/test/calls_test.dart @@ -0,0 +1,141 @@ +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'fake_client.dart'; +import 'webrtc_stub.dart'; + +void main() { + late Client matrix; + late Room room; + + group('Call Tests', () { + Logs().level = Level.info; + + test('Login', () async { + matrix = await getClient(); + }); + + test('Create from json', () async { + final id = '!localpart:server.abc'; + final membership = Membership.join; + + room = Room( + client: matrix, + id: id, + membership: membership, + prev_batch: '', + ); + }); + + test('Test call methods', () async { + final call = CallSession(CallOptions()..room = room); + await call.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp', + txid: '1234'); + await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234'); + await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234'); + await call.sendSelectCallAnswer(room, '1234', '4567', '6789', + txid: '1234'); + await call.sendCallReject(room, '1234', '4567', 'busy', txid: '1234'); + await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp', + txid: '1234'); + await call.sendHangupCall(room, '1234', '4567', 'user_hangup', + txid: '1234'); + await call.sendAssertedIdentity( + room, + '1234', + '4567', + AssertedIdentity() + ..displayName = 'name' + ..id = 'some_id', + txid: '1234'); + await call.sendCallReplaces(room, '1234', '4567', CallReplaces(), + txid: '1234'); + await call.sendSDPStreamMetadataChanged( + room, '1234', '4567', SDPStreamMetadata({}), + txid: '1234'); + }); + test('Test call lifetime', () async { + final voip = VoIP(matrix, MockWebRTCDelegate()); + expect(voip.currentCID, null); + // persist normal room messages + await matrix.handleSync(SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate(join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate(events: [ + MatrixEvent( + type: 'm.call.invite', + content: { + 'lifetime': 60000, + 'call_id': '1702472924955oq1uQbNAfU7wAaEA', + 'party_id': 'DPCIPPBGPO', + 'offer': {'type': 'offer', 'sdp': 'sdp'} + }, + senderId: '@alice:testing.com', + eventId: 'newevent', + originServerTs: DateTime.utc(1969), + ) + ])) + }))); + await Future.delayed(Duration(seconds: 3)); + // confirm that no call got created after 3 seconds, which is + // expected in this case because the originTs was old asf + expect(voip.currentCID, null); + + // persist normal room messages + await matrix.handleSync(SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate(join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate(events: [ + MatrixEvent( + unsigned: {'age': 60001}, + type: 'm.call.invite', + content: { + 'lifetime': 60000, + 'call_id': 'unsignedTsInvalidCall', + 'party_id': 'DPCIPPBGPO', + 'offer': {'type': 'offer', 'sdp': 'sdp'} + }, + senderId: '@alice:testing.com', + eventId: 'newevent', + originServerTs: DateTime.now(), + ) + ])) + }))); + await Future.delayed(Duration(seconds: 3)); + // confirm that no call got created after 3 seconds, which is + // expected in this case because age was older than lifetime + expect(voip.currentCID, null); + // persist normal room messages + await matrix.handleSync(SyncUpdate( + nextBatch: 'something', + rooms: RoomsUpdate(join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate(events: [ + MatrixEvent( + type: 'm.call.invite', + content: { + 'lifetime': 60000, + 'call_id': 'originTsValidCall', + 'party_id': 'DPCIPPBGPO', + 'offer': {'type': 'offer', 'sdp': 'sdp'} + }, + senderId: '@alice:testing.com', + eventId: 'newevent', + originServerTs: DateTime.now(), + ) + ])) + }))); + while (voip.currentCID != 'originTsValidCall') { + // call invite looks valid, call should be created now :D + await Future.delayed(Duration(milliseconds: 50)); + Logs().d('Waiting for currentCID to update'); + } + expect(voip.currentCID, 'originTsValidCall'); + final call = voip.calls[voip.currentCID]!; + await call.answer(txid: '1234'); + expect(call.state, CallState.kConnecting); + }); + }); +} diff --git a/test/room_test.dart b/test/room_test.dart index 1d204c6f..6b636dea 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -1052,35 +1052,6 @@ void main() { expect(room.pushRuleState, PushRuleState.dontNotify); }); - test('Test call methods', () async { - final call = CallSession(CallOptions()..room = room); - await call.sendInviteToCall(room, '1234', 1234, '4567', '7890', 'sdp', - txid: '1234'); - await call.sendAnswerCall(room, '1234', 'sdp', '4567', txid: '1234'); - await call.sendCallCandidates(room, '1234', '4567', [], txid: '1234'); - await call.sendSelectCallAnswer(room, '1234', 1234, '4567', '6789', - txid: '1234'); - await call.sendCallReject(room, '1234', 1234, '4567', 'busy', - txid: '1234'); - await call.sendCallNegotiate(room, '1234', 1234, '4567', 'sdp', - txid: '1234'); - await call.sendHangupCall(room, '1234', '4567', 'user_hangup', - txid: '1234'); - await call.sendAssertedIdentity( - room, - '1234', - '4567', - AssertedIdentity() - ..displayName = 'name' - ..id = 'some_id', - txid: '1234'); - await call.sendCallReplaces(room, '1234', '4567', CallReplaces(), - txid: '1234'); - await call.sendSDPStreamMetadataChanged( - room, '1234', '4567', SDPStreamMetadata({}), - txid: '1234'); - }); - test('enableEncryption', () async { await room.enableEncryption(); }); diff --git a/test/webrtc_stub.dart b/test/webrtc_stub.dart new file mode 100644 index 00000000..dee2449a --- /dev/null +++ b/test/webrtc_stub.dart @@ -0,0 +1,824 @@ +import 'dart:typed_data'; + +import 'package:webrtc_interface/webrtc_interface.dart'; + +import 'package:matrix/matrix.dart'; + +class MockWebRTCDelegate implements WebRTCDelegate { + @override + // TODO: implement canHandleNewCall + bool get canHandleNewCall => true; + + @override + Future createPeerConnection( + Map configuration, [ + Map constraints = const {}, + ]) async => + MockRTCPeerConnection(); + + @override + VideoRenderer createRenderer() { + return MockVideoRenderer(); + } + + @override + Future handleCallEnded(CallSession session) async { + Logs().i('handleCallEnded called in MockWebRTCDelegate'); + } + + @override + Future handleGroupCallEnded(GroupCall groupCall) async { + Logs().i('handleGroupCallEnded called in MockWebRTCDelegate'); + } + + @override + Future handleMissedCall(CallSession session) async { + Logs().i('handleMissedCall called in MockWebRTCDelegate'); + } + + @override + Future handleNewCall(CallSession session) async { + Logs().i('handleNewCall called in MockWebRTCDelegate'); + } + + @override + Future handleNewGroupCall(GroupCall groupCall) async { + Logs().i('handleNewGroupCall called in MockWebRTCDelegate'); + } + + @override + bool get isWeb => false; + + @override + MediaDevices get mediaDevices => MockMediaDevices(); + + @override + Future playRingtone() async { + Logs().i('playRingtone called in MockWebRTCDelegate'); + } + + @override + Future stopRingtone() async { + Logs().i('stopRingtone called in MockWebRTCDelegate'); + } +} + +class MockMediaDevices implements MediaDevices { + @override + Function(dynamic event)? ondevicechange; + + @override + Future> enumerateDevices() { + // TODO: implement enumerateDevices + throw UnimplementedError(); + } + + @override + Future getDisplayMedia(Map mediaConstraints) { + // TODO: implement getDisplayMedia + throw UnimplementedError(); + } + + @override + Future getSources() { + // TODO: implement getSources + throw UnimplementedError(); + } + + @override + MediaTrackSupportedConstraints getSupportedConstraints() { + // TODO: implement getSupportedConstraints + throw UnimplementedError(); + } + + @override + Future getUserMedia( + Map mediaConstraints) async { + return MockMediaStream('', ''); + } + + @override + Future selectAudioOutput([AudioOutputOptions? options]) { + // TODO: implement selectAudioOutput + throw UnimplementedError(); + } +} + +class MockRTCPeerConnection implements RTCPeerConnection { + @override + Function(RTCSignalingState state)? onSignalingState; + + @override + Function(RTCPeerConnectionState state)? onConnectionState; + + @override + Function(RTCIceGatheringState state)? onIceGatheringState; + + @override + Function(RTCIceConnectionState state)? onIceConnectionState; + + @override + Function(RTCIceCandidate candidate)? onIceCandidate; + + @override + Function(MediaStream stream)? onAddStream; + + @override + Function(MediaStream stream)? onRemoveStream; + + @override + Function(MediaStream stream, MediaStreamTrack track)? onAddTrack; + + @override + Function(MediaStream stream, MediaStreamTrack track)? onRemoveTrack; + + @override + Function(RTCDataChannel channel)? onDataChannel; + + @override + Function()? onRenegotiationNeeded; + + @override + Function(RTCTrackEvent event)? onTrack; + + @override + RTCSignalingState? get signalingState => throw UnimplementedError(); + + @override + Future getSignalingState() async { + return signalingState; + } + + @override + RTCIceGatheringState? get iceGatheringState => throw UnimplementedError(); + + @override + Future getIceGatheringState() async { + return iceGatheringState; + } + + @override + RTCIceConnectionState? get iceConnectionState => throw UnimplementedError(); + + @override + Future getIceConnectionState() async { + return iceConnectionState; + } + + @override + RTCPeerConnectionState? get connectionState => throw UnimplementedError(); + + @override + Future getConnectionState() async { + return connectionState; + } + + @override + Future dispose() async { + // Mock implementation for disposing the connection + Logs().i('Mock: Disposing RTCPeerConnection'); + } + + @override + Map get getConfiguration => throw UnimplementedError(); + + @override + Future setConfiguration(Map configuration) async { + // Mock implementation for setting configuration + Logs().i('Mock: Setting RTCPeerConnection configuration'); + } + + @override + Future createOffer( + [Map? constraints]) { + // Mock implementation for creating an offer + Logs().i('Mock: Creating offer'); + return Future.value(RTCSessionDescription('', '')); + } + + @override + Future createAnswer( + [Map? constraints]) { + // Mock implementation for creating an answer + Logs().i('Mock: Creating answer'); + return Future.value(RTCSessionDescription('', '')); + } + + @override + Future addStream(MediaStream stream) async { + // Mock implementation for adding a stream + Logs().i('Mock: Adding stream'); + } + + @override + Future removeStream(MediaStream stream) async { + // Mock implementation for removing a stream + Logs().i('Mock: Removing stream'); + } + + @override + Future getLocalDescription() async { + // Mock implementation for getting local description + Logs().i('Mock: Getting local description'); + return RTCSessionDescription('', ''); + } + + @override + Future setLocalDescription(RTCSessionDescription description) async { + // Mock implementation for setting local description + Logs().i('Mock: Setting local description'); + } + + @override + Future getRemoteDescription() async { + // Mock implementation for getting remote description + Logs().i('Mock: Getting remote description'); + return RTCSessionDescription('', ''); + } + + @override + Future setRemoteDescription(RTCSessionDescription description) async { + // Mock implementation for setting remote description + Logs().i('Mock: Setting remote description'); + } + + @override + Future addCandidate(RTCIceCandidate candidate) async { + // Mock implementation for adding a candidate + Logs().i('Mock: Adding ICE candidate'); + } + + @override + Future> getStats([MediaStreamTrack? track]) async { + // Mock implementation for getting stats + Logs().i('Mock: Getting stats'); + return []; + } + + @override + List getLocalStreams() { + // Mock implementation for getting local streams + Logs().i('Mock: Getting local streams'); + return []; + } + + @override + List getRemoteStreams() { + // Mock implementation for getting remote streams + Logs().i('Mock: Getting remote streams'); + return []; + } + + @override + Future createDataChannel( + String label, RTCDataChannelInit dataChannelDict) async { + // Mock implementation for creating a data channel + Logs().i('Mock: Creating data channel'); + return MockRTCDataChannel(); + } + + @override + Future restartIce() async { + // Mock implementation for restarting ICE + Logs().i('Mock: Restarting ICE'); + } + + @override + Future close() async { + // Mock implementation for closing the connection + Logs().i('Mock: Closing RTCPeerConnection'); + } + + @override + RTCDTMFSender createDtmfSender(MediaStreamTrack track) { + // Mock implementation for creating a DTMF sender + Logs().i('Mock: Creating DTMF sender'); + return MockRTCDTMFSender(); + } + + @override + Future> getSenders() async { + // Mock implementation for getting senders + Logs().i('Mock: Getting senders'); + return []; + } + + @override + Future> getReceivers() async { + // Mock implementation for getting receivers + Logs().i('Mock: Getting receivers'); + return []; + } + + @override + Future> getTransceivers() async { + // Mock implementation for getting transceivers + Logs().i('Mock: Getting transceivers'); + return []; + } + + @override + Future addTrack(MediaStreamTrack track, + [MediaStream? stream]) async { + // Mock implementation for adding a track + Logs().i('Mock: Adding track'); + return MockRTCRtpSender(); + } + + @override + Future removeTrack(RTCRtpSender sender) async { + // Mock implementation for removing a track + Logs().i('Mock: Removing track'); + return true; + } + + @override + Future addTransceiver( + {MediaStreamTrack? track, + RTCRtpMediaType? kind, + RTCRtpTransceiverInit? init}) async { + // Mock implementation for adding a transceiver + Logs().i('Mock: Adding transceiver'); + return MockRTCRtpTransceiver(); + } + + @override + // TODO: implement receivers + Future> get receivers => throw UnimplementedError(); + + @override + // TODO: implement senders + Future> get senders => throw UnimplementedError(); + + @override + // TODO: implement transceivers + Future> get transceivers => + throw UnimplementedError(); +} + +class MockRTCRtpTransceiver implements RTCRtpTransceiver { + @override + Future getCurrentDirection() async { + // Mock implementation for getting current direction + Logs().i('Mock: Getting current direction'); + return TransceiverDirection.SendRecv; + } + + @override + Future setDirection(TransceiverDirection direction) async { + // Mock implementation for setting direction + Logs().i('Mock: Setting direction'); + } + + @override + Future getDirection() async { + // Mock implementation for getting direction + Logs().i('Mock: Getting direction'); + return TransceiverDirection.SendRecv; + } + + @override + Future setCodecPreferences(List codecs) async { + // Mock implementation for setting codec preferences + Logs().i('Mock: Setting codec preferences'); + } + + @override + String get mid => 'mock_mid'; + + @override + RTCRtpSender get sender => MockRTCRtpSender(); + + @override + RTCRtpReceiver get receiver => MockRTCRtpReceiver(); + + bool get stopped => false; + + @override + String get transceiverId => 'mock_transceiver_id'; + + @override + Future stop() async { + // Mock implementation for stopping transceiver + Logs().i('Mock: Stopping transceiver'); + } + + @override + TransceiverDirection get currentDirection { + // Deprecated method, should be replaced with `await getCurrentDirection` + throw UnimplementedError( + 'Need to be call asynchronously from native SDK, so the method is deprecated'); + } + + @override + // TODO: implement stoped + bool get stoped => throw UnimplementedError(); +} + +class MockRTCRtpSender implements RTCRtpSender { + @override + Future dispose() { + // TODO: implement dispose + throw UnimplementedError(); + } + + @override + // TODO: implement dtmfSender + RTCDTMFSender get dtmfSender => throw UnimplementedError(); + + @override + Future> getStats() { + // TODO: implement getStats + throw UnimplementedError(); + } + + @override + // TODO: implement ownsTrack + bool get ownsTrack => throw UnimplementedError(); + + @override + // TODO: implement parameters + RTCRtpParameters get parameters => throw UnimplementedError(); + + @override + Future replaceTrack(MediaStreamTrack? track) { + // TODO: implement replaceTrack + throw UnimplementedError(); + } + + @override + // TODO: implement senderId + String get senderId => throw UnimplementedError(); + + @override + Future setParameters(RTCRtpParameters parameters) { + // TODO: implement setParameters + throw UnimplementedError(); + } + + @override + Future setStreams(List streams) { + // TODO: implement setStreams + throw UnimplementedError(); + } + + @override + Future setTrack(MediaStreamTrack? track, {bool takeOwnership = true}) { + // TODO: implement setTrack + throw UnimplementedError(); + } + + @override + // TODO: implement track + MediaStreamTrack? get track => throw UnimplementedError(); + // Mock implementation for RTCRtpSender +} + +class MockRTCRtpReceiver implements RTCRtpReceiver { + @override + Function(RTCRtpReceiver rtpReceiver, RTCRtpMediaType mediaType)? + onFirstPacketReceived; + + @override + Future> getStats() { + // TODO: implement getStats + throw UnimplementedError(); + } + + @override + // TODO: implement parameters + RTCRtpParameters get parameters => throw UnimplementedError(); + + @override + // TODO: implement receiverId + String get receiverId => throw UnimplementedError(); + + @override + // TODO: implement track + MediaStreamTrack? get track => throw UnimplementedError(); + // Mock implementation for RTCRtpReceiver +} + +typedef StreamTrackCallback = void Function(); + +class MockMediaStreamTrack implements MediaStreamTrack { + @override + String? get id => 'mock_id'; + + @override + String? get label => 'mock_label'; + + @override + String? get kind => 'mock_kind'; + + @override + StreamTrackCallback? onMute; + + @override + StreamTrackCallback? onUnMute; + + @override + StreamTrackCallback? onEnded; + + @override + bool get enabled => true; + + @override + set enabled(bool b) { + // Mock implementation for setting enable state + Logs().i('Mock: Setting MediaStreamTrack enable state'); + } + + @override + bool? get muted => false; + + @override + Map getConstraints() { + throw UnimplementedError(); + } + + @override + Future applyConstraints([Map? constraints]) async { + throw UnimplementedError(); + } + + @override + Future clone() async { + throw UnimplementedError(); + } + + @override + Future stop() async { + // Mock implementation for stopping the track + Logs().i('Mock: Stopping MediaStreamTrack'); + } + + @override + Map getSettings() { + throw UnimplementedError(); + } + + @override + Future switchCamera() async { + throw UnimplementedError(); + } + + @override + Future adaptRes(int width, int height) async { + throw UnimplementedError(); + } + + @override + void enableSpeakerphone(bool enable) { + throw UnimplementedError(); + } + + @override + Future captureFrame() async { + throw UnimplementedError(); + } + + @override + Future hasTorch() async { + throw UnimplementedError(); + } + + @override + Future setTorch(bool torch) async { + throw UnimplementedError(); + } + + @override + @Deprecated('use stop() instead') + Future dispose() async { + // Mock implementation for disposing the track + Logs().i('Mock: Disposing MediaStreamTrack'); + } + + @override + String toString() { + return 'Track(id: $id, kind: $kind, label: $label, enabled: $enabled, muted: $muted)'; + } +} + +class MockRTCDTMFSender implements RTCDTMFSender { + @override + Future insertDTMF(String tones, + {int duration = 100, int interToneGap = 70}) async { + // Mock implementation for inserting DTMF tones + Logs().i( + 'Mock: Inserting DTMF tones: $tones, Duration: $duration, InterToneGap: $interToneGap'); + } + + @override + @Deprecated('Use method insertDTMF instead') + Future sendDtmf(String tones, + {int duration = 100, int interToneGap = 70}) async { + return insertDTMF(tones, duration: duration, interToneGap: interToneGap); + } + + @override + Future canInsertDtmf() async { + // Mock implementation for checking if DTMF can be inserted + Logs().i('Mock: Checking if DTMF can be inserted'); + return true; + } +} + +class MockRTCDataChannel implements RTCDataChannel { + @override + Function(RTCDataChannelState state)? onDataChannelState; + + @override + Function(RTCDataChannelMessage data)? onMessage; + + @override + Function(int currentAmount, int changedAmount)? onBufferedAmountChange; + + @override + Function(int currentAmount)? onBufferedAmountLow; + + @override + RTCDataChannelState? get state => RTCDataChannelState.RTCDataChannelOpen; + + @override + int? get id => 1; + + @override + String? get label => 'mock_label'; + + @override + int? get bufferedAmount => 0; + + @override + int? bufferedAmountLowThreshold; + + @override + late Stream stateChangeStream; + + @override + late Stream messageStream; + + @override + Future send(RTCDataChannelMessage message) async { + // Mock implementation for sending a message + Logs().i('Mock: Sending RTCDataChannelMessage: $message'); + } + + @override + Future close() async { + // Mock implementation for closing the data channel + Logs().i('Mock: Closing RTCDataChannel'); + } +} + +class MockMediaStream implements MediaStream { + final String _id; + final String _ownerTag; + bool _isActive = true; // Initially set as active + + MockMediaStream(this._id, this._ownerTag); + + @override + Function(MediaStreamTrack track)? onAddTrack; + + @override + Function(MediaStreamTrack track)? onRemoveTrack; + + @override + String get id => _id; + + @override + String get ownerTag => _ownerTag; + + @override + bool? get active => _isActive; + + @override + Future getMediaTracks() async { + // Mock implementation for getting media tracks + Logs().i('Mock: Getting media tracks'); + } + + @override + Future addTrack(MediaStreamTrack track, + {bool addToNative = true}) async { + // Mock implementation for adding a track + Logs().i('Mock: Adding track to MediaStream: $track'); + onAddTrack?.call(track); + } + + @override + Future removeTrack(MediaStreamTrack track, + {bool removeFromNative = true}) async { + // Mock implementation for removing a track + Logs().i('Mock: Removing track from MediaStream: $track'); + onRemoveTrack?.call(track); + } + + @override + List getTracks() { + // Mock implementation for getting all tracks + Logs().i('Mock: Getting all tracks'); + return []; + } + + @override + List getAudioTracks() { + // Mock implementation for getting audio tracks + Logs().i('Mock: Getting audio tracks'); + return []; + } + + @override + List getVideoTracks() { + // Mock implementation for getting video tracks + Logs().i('Mock: Getting video tracks'); + return []; + } + + @override + MediaStreamTrack? getTrackById(String trackId) { + // Mock implementation for getting a track by ID + Logs().i('Mock: Getting track by ID: $trackId'); + return null; + } + + @override + Future clone() async { + // Mock implementation for cloning the media stream + Logs().i('Mock: Cloning MediaStream'); + return MockMediaStream('${_id}_clone', _ownerTag); + } + + @override + Future dispose() async { + // Mock implementation for disposing the media stream + Logs().i('Mock: Disposing MediaStream'); + _isActive = false; + } +} + +class MockVideoRenderer implements VideoRenderer { + @override + Function? onResize; + @override + Function? onFirstFrameRendered; + final int _videoWidth = 0; + final int _videoHeight = 0; + bool _muted = false; + final bool _renderVideo = true; + int? _textureId; + MediaStream? _srcObject; + + @override + int get videoWidth => _videoWidth; + + @override + int get videoHeight => _videoHeight; + + @override + bool get muted => _muted; + + @override + set muted(bool mute) { + _muted = mute; + // Mock implementation for muting/unmuting + Logs().i('Mock: Setting mute state: $_muted'); + } + + @override + Future audioOutput(String deviceId) async { + // Mock implementation for changing audio output + Logs().i('Mock: Changing audio output to device ID: $deviceId'); + return true; // Mocking successful audio output change + } + + @override + bool get renderVideo => _renderVideo; + + @override + int? get textureId => _textureId; + + @override + Future initialize() async { + // Mock implementation for initialization + Logs().i('Mock: Initializing VideoRenderer'); + } + + @override + MediaStream? get srcObject => _srcObject; + + @override + set srcObject(MediaStream? stream) { + _srcObject = stream; + // Mock implementation for setting source object + Logs().i('Mock: Setting source object for VideoRenderer'); + } + + @override + Future dispose() async { + // Mock implementation for disposing VideoRenderer + Logs().i('Mock: Disposing VideoRenderer'); + } +}