import 'package:test/test.dart'; import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/voip/models/call_options.dart'; import 'package:matrix/src/voip/models/voip_id.dart'; import 'fake_client.dart'; import 'webrtc_stub.dart'; void main() { late Client matrix; late Room room; late VoIP voip; group('Call tests', () { Logs().level = Level.info; setUp(() async { matrix = await getClient(); await matrix.abortSync(); voip = VoIP(matrix, MockWebRTCDelegate()); VoIP.customTxid = '1234'; final id = '!calls:example.com'; room = matrix.getRoomById(id)!; }); test('Test call methods', () async { final call = CallSession( CallOptions( callId: '1234', type: CallType.kVoice, dir: CallDirection.kOutgoing, localPartyId: '4567', voip: voip, room: room, iceServers: [], ), ); await call.sendInviteToCall( room, '1234', 1234, '4567', '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', txid: '1234'); await call.sendCallNegotiate( room, '1234', 1234, '4567', 'sdp', txid: '1234', ); await call.sendHangupCall( room, '1234', '4567', 'userHangup', 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('Call lifetime and age', () async { expect(voip.currentCID, null); 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: 2)); // 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); 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: 2)); // 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); }); test('Call connection and hanging up', () async { expect(voip.currentCID, null); 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': 'GHTYAJCE_caller', 'version': '1', 'offer': {'type': 'offer', 'sdp': 'sdp'}, }, senderId: '@alice:testing.com', eventId: 'callerInviteEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); await matrix.handleSync( SyncUpdate( nextBatch: 'something', rooms: RoomsUpdate( join: { room.id: JoinedRoomUpdate( timeline: TimelineUpdate( events: [ MatrixEvent( type: 'm.call.candidates', content: { 'call_id': 'originTsValidCall', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'candidates': [ { 'candidate': 'candidate:01UDP2122252543uwu50184typhost', 'sdpMid': '0', 'sdpMLineIndex': 0, }, { 'candidate': 'candidate:31TCP2105524479uwu9typhosttcptypeactive', 'sdpMid': '0', 'sdpMLineIndex': 0, } ], }, senderId: '@alice:testing.com', eventId: 'callerCallCandidatesEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); while (voip.currentCID != VoipId(roomId: room.id, callId: '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, VoipId(roomId: room.id, callId: 'originTsValidCall'), ); final call = voip.calls[voip.currentCID]!; expect(call.state, CallState.kRinging); await call.answer(txid: '1234'); call.pc!.onIceGatheringState! .call(RTCIceGatheringState.RTCIceGatheringStateComplete); // we send them manually anyway because our stub sends empty list of // candidates await call.sendCallCandidates( room, 'originTsValidCall', 'GHTYAJCE', [ { 'candidate': 'candidate:0 1 UDP 2122252543 uwu 50184 typ host', 'sdpMid': '0', 'sdpMLineIndex': 0, }, { 'candidate': 'candidate:3 1 TCP 2105524479 uwu 9 typ host tcptype active', 'sdpMid': '0', 'sdpMLineIndex': 0, } ], txid: '1234', ); expect(call.state, CallState.kConnecting); // caller sends select answer await matrix.handleSync( SyncUpdate( nextBatch: 'something', rooms: RoomsUpdate( join: { room.id: JoinedRoomUpdate( timeline: TimelineUpdate( events: [ MatrixEvent( type: 'm.call.select_answer', content: { 'call_id': 'originTsValidCall', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'lifetime': 10000, 'selected_party_id': 'GHTYAJCE', }, senderId: '@alice:testing.com', eventId: 'callerSelectAnswerEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); call.pc!.onIceConnectionState! .call(RTCIceConnectionState.RTCIceConnectionStateChecking); call.pc!.onIceConnectionState! .call(RTCIceConnectionState.RTCIceConnectionStateConnected); // just to make sure there are no errors after running functions // that are supposed to run once iceConnectionState is connected await Future.delayed(Duration(seconds: 2)); expect(call.state, CallState.kConnected); await call.hangup(reason: CallErrorCode.userHangup); expect(call.state, CallState.kEnded); expect(voip.currentCID, null); }); test('Call answered elsewhere', () async { expect(voip.currentCID, null); 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': 'answer_elseWhere', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'offer': {'type': 'offer', 'sdp': 'sdp'}, }, senderId: '@alice:testing.com', eventId: 'callerInviteEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); await matrix.handleSync( SyncUpdate( nextBatch: 'something', rooms: RoomsUpdate( join: { room.id: JoinedRoomUpdate( timeline: TimelineUpdate( events: [ MatrixEvent( type: 'm.call.candidates', content: { 'call_id': 'answer_elseWhere', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'candidates': [ { 'candidate': 'candidate:01UDP2122252543uwu50184typhost', 'sdpMid': '0', 'sdpMLineIndex': 0, }, { 'candidate': 'candidate:31TCP2105524479uwu9typhosttcptypeactive', 'sdpMid': '0', 'sdpMLineIndex': 0, } ], }, senderId: '@alice:testing.com', eventId: 'callerCallCandidatesEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); while (voip.currentCID != VoipId(roomId: room.id, callId: 'answer_elseWhere')) { // 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, VoipId(roomId: room.id, callId: 'answer_elseWhere'), ); final call = voip.calls[voip.currentCID]!; expect(call.state, CallState.kRinging); // caller sends select answer await matrix.handleSync( SyncUpdate( nextBatch: 'something', rooms: RoomsUpdate( join: { room.id: JoinedRoomUpdate( timeline: TimelineUpdate( events: [ MatrixEvent( type: 'm.call.select_answer', content: { 'call_id': 'answer_elseWhere', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'lifetime': 10000, 'selected_party_id': 'not_us', // selected some other device for answer }, senderId: '@alice:testing.com', eventId: 'callerSelectAnswerEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); // wait for select answer to end the call await Future.delayed(Duration(seconds: 2)); // call ended because answered elsewhere expect(call.state, CallState.kEnded); expect(voip.currentCID, null); }); test('Reject incoming call', () async { expect(voip.currentCID, null); 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': 'reject_call', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'offer': {'type': 'offer', 'sdp': 'sdp'}, }, senderId: '@alice:testing.com', eventId: 'callerInviteEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); await matrix.handleSync( SyncUpdate( nextBatch: 'something', rooms: RoomsUpdate( join: { room.id: JoinedRoomUpdate( timeline: TimelineUpdate( events: [ MatrixEvent( type: 'm.call.candidates', content: { 'call_id': 'reject_call', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'candidates': [ { 'candidate': 'candidate:01UDP2122252543uwu50184typhost', 'sdpMid': '0', 'sdpMLineIndex': 0, }, { 'candidate': 'candidate:31TCP2105524479uwu9typhosttcptypeactive', 'sdpMid': '0', 'sdpMLineIndex': 0, } ], }, senderId: '@alice:testing.com', eventId: 'callerCallCandidatesEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); while ( voip.currentCID != VoipId(roomId: room.id, callId: 'reject_call')) { // 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, VoipId(roomId: room.id, callId: 'reject_call')); final call = voip.calls[voip.currentCID]!; expect(call.state, CallState.kRinging); await call.reject(); // call ended because answered elsewhere expect(call.state, CallState.kEnded); expect(voip.currentCID, null); }); test('Glare after invite was sent', () async { expect(voip.currentCID, null); final firstCall = await voip.inviteToCall( room, CallType.kVoice, userId: '@alice:testing.com', ); await firstCall.pc!.onRenegotiationNeeded!.call(); expect(firstCall.state, CallState.kInviteSent); // KABOOM YOU JUST GLARED await Future.delayed(Duration(seconds: 3)); 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': 'zzzz_glare_2nd_call', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'offer': {'type': 'offer', 'sdp': 'sdp'}, }, senderId: '@alice:testing.com', eventId: 'callerInviteEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); await Future.delayed(Duration(seconds: 3)); expect( voip.currentCID, VoipId(roomId: room.id, callId: firstCall.callId), ); await firstCall.hangup(reason: CallErrorCode.userBusy); }); test('Glare before invite was sent', () async { expect(voip.currentCID, null); final firstCall = await voip.inviteToCall( room, CallType.kVoice, userId: '@alice:testing.com', ); expect(firstCall.state, CallState.kCreateOffer); // KABOOM YOU JUST GLARED, but this tiem you were still preparing your call // so just cancel that instead await Future.delayed(Duration(seconds: 3)); 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': 'zzzz_glare_2nd_call', 'party_id': 'GHTYAJCE_caller', 'version': '1', 'offer': {'type': 'offer', 'sdp': 'sdp'}, }, senderId: '@alice:testing.com', eventId: 'callerInviteEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); await Future.delayed(Duration(seconds: 3)); expect( voip.currentCID, VoipId(roomId: room.id, callId: 'zzzz_glare_2nd_call'), ); }); test('getFamedlyCallEvents sort order', () { room.setState( Event( content: { 'memberships': [ CallMembership( userId: '@test1:example.com', callId: '1111', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now() .add(Duration(hours: 12)) .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, type: EventTypes.GroupCallMember, eventId: 'asdfasdf', senderId: '@test1:example.com', originServerTs: DateTime.now().add(Duration(hours: 12)), room: room, stateKey: '@test1:example.com', ), ); room.setState( Event( content: { 'memberships': [ CallMembership( userId: '@test2:example.com', callId: '1111', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now().millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, type: EventTypes.GroupCallMember, eventId: 'asdfasdf', senderId: '@test2:example.com', originServerTs: DateTime.now(), room: room, stateKey: '@test2:example.com', ), ); room.setState( Event( content: { 'memberships': [ CallMembership( userId: '@test2.0:example.com', callId: '1111', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now().millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, type: EventTypes.GroupCallMember, eventId: 'asdfasdf', senderId: '@test2.0:example.com', originServerTs: DateTime.now(), room: room, stateKey: '@test2.0:example.com', ), ); room.setState( Event( content: { 'memberships': [ CallMembership( userId: '@test3:example.com', callId: '1111', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now() .subtract(Duration(hours: 1)) .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, type: EventTypes.GroupCallMember, eventId: 'asdfasdf', senderId: '@test3:example.com', originServerTs: DateTime.now().subtract(Duration(hours: 1)), room: room, stateKey: '@test3:example.com', ), ); expect( room.getFamedlyCallEvents(voip).entries.elementAt(0).key, '@test3:example.com', ); expect( room.getFamedlyCallEvents(voip).entries.elementAt(1).key, '@test2:example.com', ); expect( room.getFamedlyCallEvents(voip).entries.elementAt(2).key, '@test2.0:example.com', ); expect( room.getFamedlyCallEvents(voip).entries.elementAt(3).key, '@test1:example.com', ); expect( room.getCallMembershipsFromRoom(voip).entries.elementAt(0).key, '@test3:example.com', ); expect( room.getCallMembershipsFromRoom(voip).entries.elementAt(1).key, '@test2:example.com', ); expect( room.getCallMembershipsFromRoom(voip).entries.elementAt(2).key, '@test2.0:example.com', ); expect( room.getCallMembershipsFromRoom(voip).entries.elementAt(3).key, '@test1:example.com', ); }); test('Enabling group calls', () async { // users default is 0 and so group calls not enabled room.setState( Event( senderId: '@test:example.com', type: 'm.room.power_levels', room: room, eventId: '123a', content: { 'events': {EventTypes.GroupCallMember: 100}, 'state_default': 50, 'users_default': 0, }, originServerTs: DateTime.now(), stateKey: '', ), ); expect(room.canJoinGroupCall, false); expect(room.groupCallsEnabledForEveryone, false); room.setState( Event( senderId: '@test:example.com', type: 'm.room.power_levels', room: room, eventId: '123a', content: { 'events': {EventTypes.GroupCallMember: 27}, 'state_default': 50, 'users_default': 49, }, originServerTs: DateTime.now(), stateKey: '', ), ); expect(room.canJoinGroupCall, true); expect(room.groupCallsEnabledForEveryone, true); // state_default 50 and user_default 0, use enableGroupCall room.setState( Event( senderId: '@test:example.com', type: 'm.room.power_levels', room: room, eventId: '123', content: { 'state_default': 50, 'users': {'@test:fakeServer.notExisting': 100}, 'users_default': 0, }, originServerTs: DateTime.now(), stateKey: '', ), ); expect(room.canJoinGroupCall, true); // because admin expect(room.groupCallsEnabledForEveryone, false); await room.enableGroupCalls(); expect(room.canJoinGroupCall, true); expect(room.groupCallsEnabledForEveryone, true); // state_default 50 and user_default unspecified, use enableGroupCall room.setState( Event( senderId: '@test:example.com', type: 'm.room.power_levels', room: room, eventId: '123', content: { 'state_default': 50, 'users': {'@test:fakeServer.notExisting': 100}, }, originServerTs: DateTime.now(), stateKey: '', ), ); expect(room.canJoinGroupCall, true); // because admin expect(room.groupCallsEnabledForEveryone, false); await room.enableGroupCalls(); expect(room.canJoinGroupCall, true); expect(room.groupCallsEnabledForEveryone, true); // state_default is 0 so users should be able to send state events room.setState( Event( senderId: '@test:example.com', type: 'm.room.power_levels', room: room, eventId: '123', content: { 'state_default': 0, 'users': {'@test:fakeServer.notExisting': 100}, }, originServerTs: DateTime.now(), stateKey: '', ), ); expect(room.canJoinGroupCall, true); expect(room.groupCallsEnabledForEveryone, true); }); test('group call participants count', () { room.setState( Event( senderId: '@test1:example.com', type: EventTypes.GroupCallMember, room: room, eventId: '1234177', content: { 'memberships': [ CallMembership( userId: '@test1:example.com', callId: 'participants_count', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now() .subtract(Duration(hours: 1)) .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, originServerTs: DateTime.now(), stateKey: '@test1:example.com', ), ); expect(room.groupCallParticipantCount('participants_count', voip), 0); expect(room.hasActiveGroupCall(voip), false); room.setState( Event( senderId: '@test2:example.com', type: EventTypes.GroupCallMember, room: room, eventId: '1234177', content: { 'memberships': [ CallMembership( userId: '@test2:example.com', callId: 'participants_count', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now() .add(Duration(hours: 1)) .millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, originServerTs: DateTime.now(), stateKey: '@test2:example.com', ), ); expect(room.groupCallParticipantCount('participants_count', voip), 1); expect(room.hasActiveGroupCall(voip), true); room.setState( Event( senderId: '@test3:example.com', type: EventTypes.GroupCallMember, room: room, eventId: '1231234124123', content: { 'memberships': [ CallMembership( userId: '@test3:example.com', callId: 'participants_count', backend: MeshBackend(), deviceId: '1111', expiresTs: DateTime.now().millisecondsSinceEpoch, roomId: room.id, membershipId: voip.currentSessionId, voip: voip, ).toJson(), ], }, originServerTs: DateTime.now(), stateKey: '@test3:example.com', ), ); expect(room.groupCallParticipantCount('participants_count', voip), 2); expect(room.hasActiveGroupCall(voip), true); }); test('call persists after sending invite', () async { CallSession? incomingCall; // incoming call should not be created yet incomingCall = voip.calls[voip.currentCID]; expect(incomingCall, isNull); expect(incomingCall?.pc, isNull); // send invite for the call final outgoingCall = await voip.inviteToCall( room, CallType.kVoice, userId: '@alice:testing.com', ); // acknowledge the invite 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': outgoingCall.callId, 'party_id': outgoingCall.localPartyId, 'version': '1', 'offer': {'type': 'offer', 'sdp': 'sdp'}, }, senderId: '@alice:testing.com', eventId: 'outgoingCallInviteEvent', originServerTs: DateTime.now(), ), ], ), ), }, ), ), ); // incoming call pc should be created // if this fails, the call was not properly created incomingCall = voip.calls[voip.currentCID]; expect(incomingCall, isNotNull); expect(incomingCall?.pc, isNotNull); }); }); }