forked from eatvio/eatvio_app
feat(vide_calls): gms calls handling
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart';
|
||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'video_call_manager_audio_interruption_test.mocks.dart';
|
||||
|
||||
class _FakeAudioSessionCoordinator extends VideoCallAudioSessionCoordinator {
|
||||
_FakeAudioSessionCoordinator(this.onStart);
|
||||
|
||||
final void Function(void Function(AudioInterruptionEvent event) onInterruption) onStart;
|
||||
bool reactivateCalled = false;
|
||||
|
||||
@override
|
||||
Future<void> start({required void Function(AudioInterruptionEvent event) onInterruption}) async {
|
||||
onStart(onInterruption);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> reactivate() async {
|
||||
reactivateCalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallRepository>(), MockSpec<lk.Room>(), MockSpec<lk.LocalParticipant>()])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('VideoCallManager audio interruption', () {
|
||||
late MockVideoCallRepository mockRepository;
|
||||
late MockRoom mockRoom;
|
||||
late MockLocalParticipant mockLocalParticipant;
|
||||
late _FakeAudioSessionCoordinator audioCoordinator;
|
||||
late VideoCallManager manager;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockVideoCallRepository();
|
||||
mockRoom = MockRoom();
|
||||
mockLocalParticipant = MockLocalParticipant();
|
||||
audioCoordinator = _FakeAudioSessionCoordinator((_) {});
|
||||
manager = VideoCallManager(
|
||||
videoCallRepository: mockRepository,
|
||||
foregroundCoordinator: VideoCallForegroundCoordinator(),
|
||||
lifecycleBinding: VideoCallLifecycleBinding(),
|
||||
audioSessionCoordinator: audioCoordinator,
|
||||
);
|
||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||
when(mockRoom.localParticipant).thenReturn(mockLocalParticipant);
|
||||
when(mockLocalParticipant.setMicrophoneEnabled(any, audioCaptureOptions: anyNamed('audioCaptureOptions'))).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
when(mockLocalParticipant.getTrackPublicationBySource(lk.TrackSource.microphone)).thenReturn(null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('duck interruption mutes mic and sets interrupted UI state', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
manager.handleAudioInterruptionForTesting(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.duck),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(manager.snapshot.systemAudioInterrupted, isTrue);
|
||||
verify(
|
||||
mockLocalParticipant.setMicrophoneEnabled(
|
||||
false,
|
||||
audioCaptureOptions: anyNamed('audioCaptureOptions'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('interruption begin mutes mic and sets interrupted state', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(manager.snapshot.systemAudioInterrupted, isTrue);
|
||||
expect(manager.snapshot.micEnabled, isTrue);
|
||||
verify(mockLocalParticipant.setMicrophoneEnabled(false, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1);
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
});
|
||||
|
||||
test('interruption end reactivates session and restores mic', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(false, AudioInterruptionType.pause));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(manager.snapshot.systemAudioInterrupted, isFalse);
|
||||
expect(audioCoordinator.reactivateCalled, isTrue);
|
||||
verify(mockLocalParticipant.setMicrophoneEnabled(true, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1);
|
||||
});
|
||||
|
||||
test('toggleMic before call stage updates intent without livekit', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
manager.setCallStageEnteredForTesting(false);
|
||||
|
||||
await manager.toggleMic(false);
|
||||
|
||||
expect(manager.snapshot.micEnabled, isTrue);
|
||||
verifyNever(
|
||||
mockLocalParticipant.setMicrophoneEnabled(
|
||||
any,
|
||||
audioCaptureOptions: anyNamed('audioCaptureOptions'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'toggleMic from UI off while interrupted keeps interrupted and does not unmute',
|
||||
() async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
manager.handleAudioInterruptionForTesting(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
clearInteractions(mockLocalParticipant);
|
||||
|
||||
await manager.toggleMic(false);
|
||||
|
||||
expect(manager.snapshot.systemAudioInterrupted, isTrue);
|
||||
expect(manager.snapshot.micEnabled, isTrue);
|
||||
verifyNever(
|
||||
mockLocalParticipant.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions: anyNamed('audioCaptureOptions'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('toggleMic during interruption disables intent and keeps mic paused', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
clearInteractions(mockLocalParticipant);
|
||||
|
||||
await manager.toggleMic(true);
|
||||
|
||||
expect(manager.snapshot.micEnabled, isFalse);
|
||||
expect(manager.snapshot.systemAudioInterrupted, isTrue);
|
||||
verify(
|
||||
mockLocalParticipant.setMicrophoneEnabled(
|
||||
false,
|
||||
audioCaptureOptions: anyNamed('audioCaptureOptions'),
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
mockLocalParticipant.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions: anyNamed('audioCaptureOptions'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart';
|
||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'video_call_manager_lifecycle_test.mocks.dart';
|
||||
|
||||
class _RecordingForeground extends VideoCallForegroundCoordinator {
|
||||
int startCount = 0;
|
||||
int stopCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> startOngoingCall({
|
||||
required String conversationUuid,
|
||||
required String notificationTitle,
|
||||
required String notificationBody,
|
||||
required String endCallButtonLabel,
|
||||
}) async {
|
||||
startCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopOngoingCall() async {
|
||||
stopCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallRepository>(), MockSpec<lk.Room>()])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('VideoCallManager lifecycle', () {
|
||||
late MockVideoCallRepository mockRepository;
|
||||
late _RecordingForeground foreground;
|
||||
late VideoCallLifecycleBinding lifecycleBinding;
|
||||
late VideoCallManager manager;
|
||||
late MockRoom mockRoom;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockVideoCallRepository();
|
||||
foreground = _RecordingForeground();
|
||||
lifecycleBinding = VideoCallLifecycleBinding();
|
||||
manager = VideoCallManager(
|
||||
videoCallRepository: mockRepository,
|
||||
foregroundCoordinator: foreground,
|
||||
lifecycleBinding: lifecycleBinding,
|
||||
);
|
||||
mockRoom = MockRoom();
|
||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||
when(mockRoom.localParticipant).thenReturn(null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('paused starts foreground and does not disconnect room', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
lifecycleBinding.simulateLifecycle(AppLifecycleState.paused);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(foreground.startCount, 1);
|
||||
expect(manager.liveKitDisposedForTesting, isFalse);
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
});
|
||||
|
||||
test('paused without active call does not start foreground', () async {
|
||||
lifecycleBinding.simulateLifecycle(AppLifecycleState.paused);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(foreground.startCount, 0);
|
||||
});
|
||||
|
||||
test('endCall stops foreground service', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
await manager.endCall();
|
||||
|
||||
expect(foreground.stopCount, 1);
|
||||
expect(manager.liveKitDisposedForTesting, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'video_call_session_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallManager>()])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('VideoCallSessionCubit', () {
|
||||
late MockVideoCallManager mockManager;
|
||||
late VideoCallSessionCubit cubit;
|
||||
|
||||
late VideoCallManagerListener managerListener;
|
||||
|
||||
setUp(() {
|
||||
mockManager = MockVideoCallManager();
|
||||
when(mockManager.snapshot).thenReturn(VideoCallManagerSnapshot.initial);
|
||||
when(mockManager.room).thenReturn(null);
|
||||
when(mockManager.addListener(any)).thenAnswer((invocation) {
|
||||
managerListener =
|
||||
invocation.positionalArguments[0] as VideoCallManagerListener;
|
||||
});
|
||||
cubit = VideoCallSessionCubit(videoCallManager: mockManager);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
when(mockManager.endCall()).thenAnswer((_) async {});
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('onCallScreenDisposed minimizes to pip when in call stage', () async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
cubit.onCallScreenDisposed();
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.pip);
|
||||
verifyNever(mockManager.endCall());
|
||||
});
|
||||
|
||||
test('onCallScreenDisposed ends call when not in call stage', () async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: false,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
when(mockManager.endCall()).thenAnswer((_) async {});
|
||||
|
||||
cubit.onCallScreenDisposed();
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
verify(mockManager.endCall()).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'manager idle snapshot resets callStageEntered and uiMode',
|
||||
() async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.pip,
|
||||
),
|
||||
);
|
||||
|
||||
managerListener(VideoCallManagerSnapshot.initial);
|
||||
|
||||
expect(cubit.state.callStageEntered, isFalse);
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.hidden);
|
||||
expect(cubit.state.connectPhase, VideoCallConnectPhase.idle);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'manager connecting snapshot resets callStageEntered for new call',
|
||||
() async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'old',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
managerListener(
|
||||
const VideoCallManagerSnapshot(
|
||||
conversationUuid: 'new-uuid',
|
||||
micEnabled: true,
|
||||
videoEnabled: true,
|
||||
connectPhase: VideoCallConnectPhase.connecting,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
),
|
||||
);
|
||||
|
||||
expect(cubit.state.conversationUuid, 'new-uuid');
|
||||
expect(cubit.state.callStageEntered, isFalse);
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.fullscreen);
|
||||
expect(cubit.state.connectPhase, VideoCallConnectPhase.connecting);
|
||||
},
|
||||
);
|
||||
|
||||
test('isMicActiveForUi is false when system audio interrupted', () {
|
||||
const state = VideoCallSessionState(
|
||||
micEnabled: true,
|
||||
systemAudioInterrupted: true,
|
||||
);
|
||||
expect(state.isMicActiveForUi, isFalse);
|
||||
});
|
||||
|
||||
test('onCallEndedExternally resets session state', () async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.pip,
|
||||
),
|
||||
);
|
||||
|
||||
await cubit.onCallEndedExternally();
|
||||
|
||||
expect(cubit.state, const VideoCallSessionState());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows error text when connectError is provided', (tester) async {
|
||||
testWidgets('hides connectError text when room is connected without remote video', (
|
||||
tester,
|
||||
) async {
|
||||
final room = buildRoom(state: lk.ConnectionState.connected);
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -37,10 +39,27 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Connection failed'), findsOneWidget);
|
||||
expect(find.text('Connection failed'), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows error text when connectError and room is not connected', (
|
||||
tester,
|
||||
) async {
|
||||
final room = buildRoom(state: lk.ConnectionState.disconnected);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
VideoCallRemoteVideoStage(
|
||||
room: room,
|
||||
connectError: 'Connection failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Connection failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows progress indicator when not connected and no error', (tester) async {
|
||||
final room = buildRoom(state: lk.ConnectionState.disconnected);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ void main() {
|
||||
expect(shrinkPressed, greaterThanOrEqualTo(0));
|
||||
});
|
||||
|
||||
testWidgets('split layout shows connectError on remote pane and uses bottomInset', (tester) async {
|
||||
testWidgets('split layout hides connectError on remote pane when connected', (tester) async {
|
||||
final room = buildRoom();
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -145,7 +145,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('No connection'), findsOneWidget);
|
||||
expect(find.text('No connection'), findsNothing);
|
||||
expect(find.text('Doctor'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user