diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index c2651629e..5dffda6d3 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -2753,6 +2753,14 @@ class FakeMatrixApi extends BaseClient { (var reqI) => { 'event_id': '42', }, + '/client/v3/rooms/!calls%3Aexample.com/state/com.famedly.call.member/%40test%3AfakeServer.notExisting': + (var reqI) => { + 'event_id': 'call_member_42', + }, + '/client/v3/rooms/!calls%3Aexample.com/state/com.famedly.call.member/%40remoteuser%3Aexample.com': + (var reqI) => { + 'event_id': 'call_member_remote_42', + }, '/client/v3/directory/list/room/!localpart%3Aexample.com': (var req) => {}, '/client/v3/room_keys/version/5': (var req) => {}, diff --git a/lib/src/voip/backend/mesh_backend.dart b/lib/src/voip/backend/mesh_backend.dart index e6ca667ba..ba1795dfa 100644 --- a/lib/src/voip/backend/mesh_backend.dart +++ b/lib/src/voip/backend/mesh_backend.dart @@ -133,7 +133,9 @@ class MeshBackend extends CallBackend { Future _addCall(GroupCallSession groupCall, CallSession call) async { _callSessions.add(call); _initCall(groupCall, call); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); + groupCall.matrixRTCEventStream.add(CallAddedEvent(call)); } /// init a peer call from group calls. @@ -183,7 +185,10 @@ class MeshBackend extends CallBackend { _registerListenersBeforeCallAdd(replacementCall); _initCall(groupCall, replacementCall); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); + groupCall.matrixRTCEventStream + .add(CallReplacedEvent(existingCall, replacementCall)); } /// Removes a peer call from group calls. @@ -196,7 +201,9 @@ class MeshBackend extends CallBackend { _callSessions.removeWhere((element) => call.callId == element.callId); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.callsChanged); + groupCall.matrixRTCEventStream.add(CallRemovedEvent(call)); } Future _disposeCall( @@ -375,7 +382,10 @@ class MeshBackend extends CallBackend { if (nextActiveSpeaker != null && _activeSpeaker != nextActiveSpeaker) { _activeSpeaker = nextActiveSpeaker; + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); + groupCall.matrixRTCEventStream + .add(GroupCallActiveSpeakerChanged(_activeSpeaker!)); } _activeSpeakerLoopTimeout?.cancel(); _activeSpeakerLoopTimeout = Timer( @@ -401,8 +411,12 @@ class MeshBackend extends CallBackend { ) { _screenshareStreams.add(stream); onStreamAdd.add(stream); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.screenshareStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamAdded(GroupCallStreamType.screenshare)); } Future _replaceScreenshareStream( @@ -423,8 +437,12 @@ class MeshBackend extends CallBackend { _screenshareStreams.replaceRange(streamIndex, 1, [replacementStream]); await existingStream.dispose(); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.screenshareStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamReplaced(GroupCallStreamType.screenshare)); } Future _removeScreenshareStream( @@ -450,8 +468,12 @@ class MeshBackend extends CallBackend { await stopMediaStream(stream.stream); } + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.screenshareStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamRemoved(GroupCallStreamType.screenshare)); } Future _onCallStateChanged(CallSession call, CallState state) async { @@ -486,8 +508,12 @@ class MeshBackend extends CallBackend { ) async { _userMediaStreams.add(stream); onStreamAdd.add(stream); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.userMediaStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamAdded(GroupCallStreamType.userMedia)); } Future _replaceUserMediaStream( @@ -508,8 +534,12 @@ class MeshBackend extends CallBackend { _userMediaStreams.replaceRange(streamIndex, 1, [replacementStream]); await existingStream.dispose(); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.userMediaStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamReplaced(GroupCallStreamType.userMedia)); } Future _removeUserMediaStream( @@ -536,12 +566,19 @@ class MeshBackend extends CallBackend { await stopMediaStream(stream.stream); } + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.userMediaStreamsChanged); + groupCall.matrixRTCEventStream + .add(GroupCallStreamRemoved(GroupCallStreamType.userMedia)); if (_activeSpeaker == stream.participant && _userMediaStreams.isNotEmpty) { _activeSpeaker = _userMediaStreams[0].participant; + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.activeSpeakerChanged); + groupCall.matrixRTCEventStream + .add(GroupCallActiveSpeakerChanged(_activeSpeaker!)); } } @@ -663,7 +700,9 @@ class MeshBackend extends CallBackend { } } + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.localMuteStateChanged); + groupCall.matrixRTCEventStream.add(GroupCallLocalMutedChanged(muted, kind)); return; } @@ -799,8 +838,12 @@ class MeshBackend extends CallBackend { _addScreenshareStream(groupCall, localScreenshareStream!); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.localScreenshareStateChanged); + groupCall.matrixRTCEventStream + .add(GroupCallLocalScreenshareStateChanged(true)); for (final call in _callSessions) { await call.addLocalStream( await localScreenshareStream!.stream!.clone(), @@ -813,7 +856,10 @@ class MeshBackend extends CallBackend { return; } catch (e, s) { Logs().e('[VOIP] Enabling screensharing error', e, s); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent.add(GroupCallStateChange.error); + groupCall.matrixRTCEventStream + .add(GroupCallStateError(e.toString(), s)); return; } } else { @@ -826,8 +872,12 @@ class MeshBackend extends CallBackend { await groupCall.sendMemberStateEvent(); + // ignore: deprecated_member_use_from_same_package groupCall.onGroupCallEvent + // ignore: deprecated_member_use_from_same_package .add(GroupCallStateChange.localMuteStateChanged); + groupCall.matrixRTCEventStream + .add(GroupCallLocalScreenshareStateChanged(false)); return; } } diff --git a/lib/src/voip/group_call_session.dart b/lib/src/voip/group_call_session.dart index 9058f76e6..27dfaa90a 100644 --- a/lib/src/voip/group_call_session.dart +++ b/lib/src/voip/group_call_session.dart @@ -54,9 +54,11 @@ class GroupCallSession { String groupCallId; + @Deprecated('Use matrixRTCEventStream instead') final CachedStreamController onGroupCallState = CachedStreamController(); + @Deprecated('Use matrixRTCEventStream instead') final CachedStreamController onGroupCallEvent = CachedStreamController(); @@ -105,8 +107,11 @@ class GroupCallSession { void setState(GroupCallState newState) { state = newState; + // ignore: deprecated_member_use_from_same_package onGroupCallState.add(newState); + // ignore: deprecated_member_use_from_same_package onGroupCallEvent.add(GroupCallStateChange.groupCallStateChanged); + matrixRTCEventStream.add(GroupCallStateChanged(newState)); } bool hasLocalParticipant() { @@ -313,6 +318,7 @@ class GroupCallSession { .add(ParticipantsLeftEvent(participants: anyLeft.toList())); } + // ignore: deprecated_member_use_from_same_package onGroupCallEvent.add(GroupCallStateChange.participantsChanged); } } diff --git a/lib/src/voip/models/matrixrtc_call_event.dart b/lib/src/voip/models/matrixrtc_call_event.dart index 82086974c..57259f8ea 100644 --- a/lib/src/voip/models/matrixrtc_call_event.dart +++ b/lib/src/voip/models/matrixrtc_call_event.dart @@ -5,15 +5,18 @@ import 'package:matrix/matrix.dart'; /// often. sealed class MatrixRTCCallEvent {} +/// Event type for participants change sealed class ParticipantsChangeEvent implements MatrixRTCCallEvent {} final class ParticipantsJoinEvent implements ParticipantsChangeEvent { + /// The participants who joined the call final List participants; ParticipantsJoinEvent({required this.participants}); } final class ParticipantsLeftEvent implements ParticipantsChangeEvent { + /// The participants who left the call final List participants; ParticipantsLeftEvent({required this.participants}); @@ -46,3 +49,89 @@ final class CallReactionRemovedEvent implements CallReactionEvent { required this.redactedEventId, }); } + +/// Group call active speaker changed event +final class GroupCallActiveSpeakerChanged implements MatrixRTCCallEvent { + final CallParticipant participant; + GroupCallActiveSpeakerChanged(this.participant); +} + +/// Group calls changed event type +sealed class GroupCallChanged implements MatrixRTCCallEvent {} + +/// Group call, call added event +final class CallAddedEvent implements GroupCallChanged { + final CallSession call; + CallAddedEvent(this.call); +} + +/// Group call, call removed event +final class CallRemovedEvent implements GroupCallChanged { + final CallSession call; + CallRemovedEvent(this.call); +} + +/// Group call, call replaced event +final class CallReplacedEvent extends GroupCallChanged { + final CallSession existingCall, replacementCall; + CallReplacedEvent(this.existingCall, this.replacementCall); +} + +enum GroupCallStreamType { + userMedia, + screenshare, +} + +/// Group call stream added event +final class GroupCallStreamAdded implements MatrixRTCCallEvent { + final GroupCallStreamType type; + GroupCallStreamAdded(this.type); +} + +/// Group call stream removed event +final class GroupCallStreamRemoved implements MatrixRTCCallEvent { + final GroupCallStreamType type; + GroupCallStreamRemoved(this.type); +} + +/// Group call stream replaced event +final class GroupCallStreamReplaced implements MatrixRTCCallEvent { + final GroupCallStreamType type; + GroupCallStreamReplaced(this.type); +} + +/// Group call local screenshare state changed event +final class GroupCallLocalScreenshareStateChanged + implements MatrixRTCCallEvent { + final bool screensharing; + GroupCallLocalScreenshareStateChanged(this.screensharing); +} + +/// Group call local muted changed event +final class GroupCallLocalMutedChanged implements MatrixRTCCallEvent { + final bool muted; + final MediaInputKind kind; + GroupCallLocalMutedChanged(this.muted, this.kind); +} + +enum GroupCallState { + localCallFeedUninitialized, + initializingLocalCallFeed, + localCallFeedInitialized, + entering, + entered, + ended +} + +/// Group call state changed event +final class GroupCallStateChanged implements MatrixRTCCallEvent { + final GroupCallState state; + GroupCallStateChanged(this.state); +} + +/// Group call error event +final class GroupCallStateError implements MatrixRTCCallEvent { + final String msg; + final dynamic err; + GroupCallStateError(this.msg, this.err); +} diff --git a/lib/src/voip/utils/types.dart b/lib/src/voip/utils/types.dart index 094557267..29531fdd3 100644 --- a/lib/src/voip/utils/types.dart +++ b/lib/src/voip/utils/types.dart @@ -165,6 +165,7 @@ class GroupCallError extends Error { } } +@Deprecated('Use the events implementing MatrixRTCCallEvent instead') enum GroupCallStateChange { groupCallStateChanged, activeSpeakerChanged, @@ -176,12 +177,3 @@ enum GroupCallStateChange { participantsChanged, error } - -enum GroupCallState { - localCallFeedUninitialized, - initializingLocalCallFeed, - localCallFeedInitialized, - entering, - entered, - ended -} diff --git a/test/matrixrtc_event_stream_test.dart b/test/matrixrtc_event_stream_test.dart new file mode 100644 index 000000000..4b5ae3eb1 --- /dev/null +++ b/test/matrixrtc_event_stream_test.dart @@ -0,0 +1,1061 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/voip/models/call_options.dart'; +import 'fake_client.dart'; +import 'webrtc_stub.dart'; + +void main() { + late Client matrix; + late Room room; + late VoIP voip; + late MeshBackend backend; + late GroupCallSession groupCall; + + group('MatrixRTC Event Stream Tests', () { + Logs().level = Level.info; + + setUp(() async { + matrix = await getClient(); + await matrix.abortSync(); + + voip = VoIP(matrix, MockWebRTCDelegate()); + final id = '!calls:example.com'; + room = matrix.getRoomById(id)!; + backend = MeshBackend(); + }); + + tearDown(() async { + if (voip.groupCalls.isNotEmpty) { + for (final groupCall in voip.groupCalls.values.toList()) { + try { + await groupCall.leave(); + } catch (e) { + // ignore errors during cleanup + } + } + } + }); + + group('GroupCallStateChanged Events', () { + test('emits GroupCallStateChanged when transitioning through all states', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-2', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStateChanged) + .cast() + .listen((event) { + events.add(event); + }); + + // Trigger state changes + groupCall.setState(GroupCallState.initializingLocalCallFeed); + groupCall.setState(GroupCallState.localCallFeedInitialized); + groupCall.setState(GroupCallState.entered); + + await pumpEventQueue(); + + expect(events.length, 3); + expect( + events[0].state, + GroupCallState.initializingLocalCallFeed, + ); + expect( + events[1].state, + GroupCallState.localCallFeedInitialized, + ); + expect(events[2].state, GroupCallState.entered); + }); + }); + + group('ParticipantsJoinEvent and ParticipantsLeftEvent', () { + test('emits ParticipantsJoinEvent when participants join', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-4', + ); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: '123', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is ParticipantsJoinEvent) + .cast() + .listen((event) { + events.add(event); + }); + + room.setState( + Event( + room: room, + eventId: '1234', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: '@remoteuser:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'DEVICE123', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'remote-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: '@remoteuser:example.com', + stateKey: '@remoteuser:example.com', + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].participants.length, 1); + expect(events[0].participants[0].userId, '@remoteuser:example.com'); + expect(events[0].participants[0].deviceId, 'DEVICE123'); + }); + + test('emits ParticipantsLeftEvent when participants leave', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-5', + ); + + // Initialize local stream + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + // Add a participant first + room.setState( + Event( + room: room, + eventId: '1234', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@remoteuser:example.com', + stateKey: '@remoteuser:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@remoteuser:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'DEVICE123', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'remote-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is ParticipantsLeftEvent) + .cast() + .listen((event) { + events.add(event); + }); + + // Remove the participant + room.setState( + Event( + room: room, + eventId: '1234', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@remoteuser:example.com', + stateKey: '@remoteuser:example.com', + content: { + 'memberships': [], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].participants.length, 1); + expect(events[0].participants[0].userId, '@remoteuser:example.com'); + expect(events[0].participants[0].deviceId, 'DEVICE123'); + }); + }); + + group('CallAddedEvent, CallRemovedEvent, and CallReplacedEvent', () { + test('emits CallAddedEvent when a call is added', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-7', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is CallAddedEvent) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: 'local-123', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + room.setState( + Event( + room: room, + eventId: 'remote-call-add-123', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@zane:example.com', + stateKey: '@zane:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@zane:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'ZANEDEVICE', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'zane-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].call.remoteUserId, '@zane:example.com'); + expect(events[0].call.remoteDeviceId, 'ZANEDEVICE'); + expect(events[0].call.groupCallId, groupCall.groupCallId); + }); + + test('emits CallRemovedEvent when a call is removed', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-8', + ); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: 'local-456', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + room.setState( + Event( + room: room, + eventId: 'remote-call-remove-456', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@zoe:example.com', + stateKey: '@zoe:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@zoe:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'ZOEDEVICE', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'zoe-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is CallRemovedEvent) + .cast() + .listen((event) { + events.add(event); + }); + + final call = voip.calls.values.firstWhere( + (c) => + c.remoteUserId == '@zoe:example.com' && + c.groupCallId == groupCall.groupCallId, + ); + + await call.hangup(reason: CallErrorCode.userHangup); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].call.remoteUserId, '@zoe:example.com'); + expect(events[0].call.remoteDeviceId, 'ZOEDEVICE'); + }); + + test('emits CallReplacedEvent when a call is replaced', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-9', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is CallReplacedEvent) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + room.setState( + Event( + room: room, + eventId: 'local-789', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + content: { + 'memberships': [ + CallMembership( + userId: matrix.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: matrix.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: voip.currentSessionId, + feeds: [], + voip: voip, + ).toJson(), + ], + }, + senderId: matrix.userID!, + stateKey: matrix.userID!, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + room.setState( + Event( + room: room, + eventId: 'remote-call-replace-789', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: '@zara:example.com', + stateKey: '@zara:example.com', + content: { + 'memberships': [ + CallMembership( + userId: '@zara:example.com', + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: 'ZARADEVICE', + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'zara-session-id-1', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.onMemberStateChanged(); + await pumpEventQueue(); + + final existingCall = voip.calls.values.firstWhere( + (c) => + c.remoteUserId == '@zara:example.com' && + c.groupCallId == groupCall.groupCallId, + ); + + final replacementCall = voip.createNewCall( + CallOptions( + callId: VoIP.customTxid ?? 'replacement-call-id', + room: room, + voip: voip, + dir: CallDirection.kOutgoing, + localPartyId: voip.currentSessionId, + groupCallId: groupCall.groupCallId, + type: CallType.kVideo, + iceServers: [], + ), + ); + replacementCall.remoteUserId = '@zara:example.com'; + replacementCall.remoteDeviceId = 'ZARADEVICE'; + + existingCall.onCallReplaced.add(replacementCall); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].existingCall.callId, existingCall.callId); + expect(events[0].replacementCall.callId, replacementCall.callId); + expect(events[0].existingCall.remoteUserId, '@zara:example.com'); + expect(events[0].replacementCall.remoteUserId, '@zara:example.com'); + }); + }); + + group('GroupCallStreamAdded, Removed, and Replaced Events', () { + test('emits GroupCallStreamAdded when user media stream is added', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-10', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamAdded) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].type, GroupCallStreamType.userMedia); + }); + + test('emits GroupCallStreamAdded when screenshare stream is added', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-11', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamAdded) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + await backend.setScreensharingEnabled(groupCall, true, ''); + await pumpEventQueue(); + + expect(events.last.type, GroupCallStreamType.screenshare); + }); + + test('emits GroupCallStreamRemoved when stream is removed', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-12', + ); + + final addedEvents = []; + final removedEvents = []; + + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamAdded) + .cast() + .listen((event) { + addedEvents.add(event); + }); + + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallStreamRemoved) + .cast() + .listen((event) { + removedEvents.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + await backend.setScreensharingEnabled(groupCall, true, ''); + await pumpEventQueue(); + + await backend.setScreensharingEnabled(groupCall, false, ''); + await pumpEventQueue(); + + expect(removedEvents.last.type, GroupCallStreamType.screenshare); + }); + }); + + group('GroupCallActiveSpeakerChanged Event', () { + test('emits GroupCallActiveSpeakerChanged when active speaker changes', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-14', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallActiveSpeakerChanged) + .cast() + .listen((event) { + events.add(event); + }); + + room.setState( + Event( + room: room, + eventId: 'local-membership', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: room.client.userID!, + stateKey: room.client.userID!, + content: { + 'memberships': [ + CallMembership( + userId: room.client.userID!, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: room.client.deviceID!, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'local-session-id', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + final remoteUserId = '@zach:example.com'; + final remoteDeviceId = 'ZACHDEVICE'; + + room.setState( + Event( + room: room, + eventId: 'remote-member-1', + originServerTs: DateTime.now(), + type: EventTypes.GroupCallMember, + senderId: remoteUserId, + stateKey: remoteUserId, + content: { + 'memberships': [ + CallMembership( + userId: remoteUserId, + roomId: room.id, + callId: groupCall.groupCallId, + application: groupCall.application, + scope: groupCall.scope, + backend: backend, + deviceId: remoteDeviceId, + expiresTs: DateTime.now() + .add(Duration(hours: 1)) + .millisecondsSinceEpoch, + membershipId: 'remote-session-id-1', + feeds: [], + voip: voip, + ).toJson(), + ], + }, + ), + ); + + await groupCall.enter(); + await pumpEventQueue(); + + final call = voip.calls.values.firstWhere( + (c) => + c.remoteUserId == remoteUserId && + c.groupCallId == groupCall.groupCallId, + ); + + await call.onSDPStreamMetadataReceived( + SDPStreamMetadata({ + 'remote-stream-id': SDPStreamPurpose( + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: false, + video_muted: false, + ), + }), + ); + + final mockRemoteStream = MockMediaStream('remote-stream-id', 'remote'); + final mockPeerConnection = call.pc as MockRTCPeerConnection; + mockPeerConnection.mockAudioLevel = 0.8; + + if (mockPeerConnection.onTrack != null) { + mockPeerConnection.onTrack!( + MockRTCTrackEvent( + track: MockMediaStreamTrack(), + streams: [mockRemoteStream], + ), + ); + } + + await pumpEventQueue(); + // Keep the 6-second delay as it's likely testing timer-based active speaker detection + await Future.delayed(Duration(seconds: 6)); + + expect(events.length, 1); + expect(events[0].participant.userId, remoteUserId); + expect(events[0].participant.deviceId, remoteDeviceId); + }); + }); + + group('GroupCallLocalMutedChanged Event', () { + test('emits GroupCallLocalMutedChanged for audio and video mute/unmute', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-15', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallLocalMutedChanged) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + // Test audio muting + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.audioinput, + ); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].muted, true); + expect(events[0].kind, MediaInputKind.audioinput); + + // Test video muting + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.videoinput, + ); + await pumpEventQueue(); + + expect(events.length, 2); + expect(events[1].muted, true); + expect(events[1].kind, MediaInputKind.videoinput); + + // Test audio unmuting + await backend.setDeviceMuted( + groupCall, + false, + MediaInputKind.audioinput, + ); + await pumpEventQueue(); + + expect(events.length, 3); + expect(events[2].muted, false); + expect(events[2].kind, MediaInputKind.audioinput); + + // Test video unmuting + await backend.setDeviceMuted( + groupCall, + false, + MediaInputKind.videoinput, + ); + await pumpEventQueue(); + + expect(events.length, 4); + expect(events[3].muted, false); + expect(events[3].kind, MediaInputKind.videoinput); + + // Verify all events have correct MediaInputKind + final audioEvents = + events.where((e) => e.kind == MediaInputKind.audioinput).toList(); + final videoEvents = + events.where((e) => e.kind == MediaInputKind.videoinput).toList(); + + expect(audioEvents.length, 2); + expect(videoEvents.length, 2); + expect(audioEvents[0].muted, true); + expect(audioEvents[1].muted, false); + expect(videoEvents[0].muted, true); + expect(videoEvents[1].muted, false); + }); + }); + + group('GroupCallLocalScreenshareStateChanged Event', () { + test( + 'emits GroupCallLocalScreenshareStateChanged when screenshare is enabled and disabled', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-19', + ); + + final events = []; + groupCall.matrixRTCEventStream.stream + .where((event) => event is GroupCallLocalScreenshareStateChanged) + .cast() + .listen((event) { + events.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + + await backend.setScreensharingEnabled(groupCall, true, ''); + await pumpEventQueue(); + + expect(events.length, 1); + expect(events[0].screensharing, true); + + await backend.setScreensharingEnabled(groupCall, false, ''); + await pumpEventQueue(); + + expect(events.length, 2); + expect(events[1].screensharing, false); + }); + }); + + group('Event Stream Integration Tests', () { + test('multiple event types can be emitted in sequence', () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-23', + ); + + final allEvents = []; + groupCall.matrixRTCEventStream.stream.listen((event) { + allEvents.add(event); + }); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.audioinput, + ); + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.videoinput, + ); + await pumpEventQueue(); + + expect(allEvents.length, 6); + + final stateChangedEvents = + allEvents.whereType().toList(); + final streamAddedEvents = + allEvents.whereType().toList(); + final mutedChangedEvents = + allEvents.whereType().toList(); + + expect(stateChangedEvents.length, 3); + expect(streamAddedEvents.length, 1); + expect(mutedChangedEvents.length, 2); + + expect(allEvents[0], isA()); + expect( + (allEvents[0] as GroupCallStateChanged).state, + GroupCallState.initializingLocalCallFeed, + ); + expect(allEvents[1], isA()); + expect( + (allEvents[1] as GroupCallStreamAdded).type, + GroupCallStreamType.userMedia, + ); + expect(allEvents[2], isA()); + expect( + (allEvents[2] as GroupCallStateChanged).state, + GroupCallState.localCallFeedInitialized, + ); + expect(allEvents[3], isA()); + expect( + (allEvents[3] as GroupCallStateChanged).state, + GroupCallState.entered, + ); + expect(allEvents[4], isA()); + expect( + (allEvents[4] as GroupCallLocalMutedChanged).kind, + MediaInputKind.audioinput, + ); + expect((allEvents[4] as GroupCallLocalMutedChanged).muted, true); + expect(allEvents[5], isA()); + expect( + (allEvents[5] as GroupCallLocalMutedChanged).kind, + MediaInputKind.videoinput, + ); + expect((allEvents[5] as GroupCallLocalMutedChanged).muted, true); + }); + + test( + 'event stream supports multiple listeners and filtering by event type', + () async { + groupCall = GroupCallSession.withAutoGenId( + room, + voip, + backend, + 'm.call', + 'm.room', + 'test-group-call-24', + ); + + final allEvents1 = []; + final allEvents2 = []; + final stateChangedEvents = []; + final streamAddedEvents = []; + final mutedChangedEvents = []; + + groupCall.matrixRTCEventStream.stream.listen(allEvents1.add); + groupCall.matrixRTCEventStream.stream.listen(allEvents2.add); + + groupCall.matrixRTCEventStream.stream + .where((e) => e is GroupCallStateChanged) + .cast() + .listen(stateChangedEvents.add); + groupCall.matrixRTCEventStream.stream + .where((e) => e is GroupCallStreamAdded) + .cast() + .listen(streamAddedEvents.add); + groupCall.matrixRTCEventStream.stream + .where((e) => e is GroupCallLocalMutedChanged) + .cast() + .listen(mutedChangedEvents.add); + + await backend.initLocalStream(groupCall); + groupCall.setState(GroupCallState.entered); + await backend.setDeviceMuted( + groupCall, + true, + MediaInputKind.audioinput, + ); + await pumpEventQueue(); + + expect(allEvents1.length, 5); + expect(allEvents2.length, 5); + expect(stateChangedEvents.length, 3); + expect(streamAddedEvents.length, 1); + expect(mutedChangedEvents.length, 1); + + expect( + stateChangedEvents.length + + streamAddedEvents.length + + mutedChangedEvents.length, + allEvents1.length, + ); + + expect( + stateChangedEvents.map((e) => e.state).toList(), + [ + GroupCallState.initializingLocalCallFeed, + GroupCallState.localCallFeedInitialized, + GroupCallState.entered, + ], + ); + expect(streamAddedEvents[0].type, GroupCallStreamType.userMedia); + expect(mutedChangedEvents[0].kind, MediaInputKind.audioinput); + expect(mutedChangedEvents[0].muted, true); + }); + }); + }); +} diff --git a/test/webrtc_stub.dart b/test/webrtc_stub.dart index 70516e922..0744d6147 100644 --- a/test/webrtc_stub.dart +++ b/test/webrtc_stub.dart @@ -86,18 +86,49 @@ class MockEncryptionKeyProvider implements EncryptionKeyProvider { } } +class MockMediaDeviceInfo implements MediaDeviceInfo { + @override + final String deviceId; + @override + final String kind; + @override + final String label; + @override + final String? groupId; + + MockMediaDeviceInfo({ + required this.deviceId, + required this.kind, + required this.label, + this.groupId, + }); +} + class MockMediaDevices implements MediaDevices { @override Function(dynamic event)? ondevicechange; @override - Future> enumerateDevices() { - throw UnimplementedError(); + Future> enumerateDevices() async { + return [ + MockMediaDeviceInfo( + deviceId: 'default_audio_input', + kind: 'audioinput', + label: 'Default Audio Input', + ), + MockMediaDeviceInfo( + deviceId: 'default_video_input', + kind: 'videoinput', + label: 'Default Video Input', + ), + ]; } @override - Future getDisplayMedia(Map mediaConstraints) { - throw UnimplementedError(); + Future getDisplayMedia( + Map mediaConstraints, + ) async { + return MockMediaStream('', ''); } @override @@ -160,6 +191,9 @@ class MockRTCPeerConnection implements RTCPeerConnection { @override Function(RTCTrackEvent event)? onTrack; + // Mock stats to simulate audio levels + double mockAudioLevel = 0.0; + @override RTCSignalingState? get signalingState => throw UnimplementedError(); @@ -276,8 +310,23 @@ class MockRTCPeerConnection implements RTCPeerConnection { @override Future> getStats([MediaStreamTrack? track]) async { // Mock implementation for getting stats - Logs().i('Mock: Getting stats'); - return []; + Logs().i('Mock: Getting stats with audioLevel: $mockAudioLevel'); + return [ + MockStatsReport( + type: 'inbound-rtp', + values: { + 'kind': 'audio', + 'audioLevel': mockAudioLevel, + }, + ), + MockStatsReport( + type: 'media-source', + values: { + 'kind': 'audio', + 'audioLevel': mockAudioLevel, + }, + ), + ]; } @override @@ -850,3 +899,45 @@ class MockVideoRenderer implements VideoRenderer { Logs().i('Mock: Disposing VideoRenderer'); } } + +class MockStatsReport implements StatsReport { + @override + final String type; + + @override + final Map values; + + @override + final String id; + + @override + final double timestamp; + + MockStatsReport({ + required this.type, + required this.values, + this.id = 'mock-stats-id', + this.timestamp = 0.0, + }); +} + +class MockRTCTrackEvent implements RTCTrackEvent { + @override + final MediaStreamTrack track; + + @override + final RTCRtpReceiver? receiver; + + @override + final List streams; + + @override + final RTCRtpTransceiver? transceiver; + + MockRTCTrackEvent({ + required this.track, + this.receiver, + required this.streams, + this.transceiver, + }); +}