diff --git a/V2_REFACTOR_PLAN.md b/V2_REFACTOR_PLAN.md index 71f6146da..0cab2149b 100644 --- a/V2_REFACTOR_PLAN.md +++ b/V2_REFACTOR_PLAN.md @@ -59,7 +59,7 @@ stacks. | 21 | C3 | `fix/flu-857-coordinator-connection` | #1436 | | 22 | C1 | `feat/flu-856-ringing-call-coordinator` | #1437 | | 23 | B3 | `feat/flu-863-call-ringing-controller` | #1438 | -| 24 | C4 | `feat/flu-858-app-lifecycle-controller` | | +| 24 | C4 | `feat/flu-858-app-lifecycle-controller` | #1439 | | 25 | B6 | `feat/flu-852-call-host` | | | 26 | C5 | `feat/flu-854-stream-video-constructor` | | @@ -86,7 +86,7 @@ stacks. - [x] 21. **C3** [FLU-857](https://linear.app/stream/issue/FLU-857): `CoordinatorConnection` with a real single-flight guard (M). Fixes bug 4. - [x] 22. **C1** [FLU-856](https://linear.app/stream/issue/FLU-856): `RingingFlowCoordinator` as `streamVideo.ringing` (L, v2 breaking). Its `ensureConnected` callback comes from C3. - [x] 23. **B3** [FLU-863](https://linear.app/stream/issue/FLU-863): `CallRingingController` (M). After C1, which takes the cross-call orchestration from `Call.accept`. The accept waits stop on leave, so a cancelled ring no longer logs a timeout error 25 s later. -- [ ] 24. **C4** [FLU-858](https://linear.app/stream/issue/FLU-858): `AppLifecycleController`; background mute and restore moves into Call (S). +- [x] 24. **C4** [FLU-858](https://linear.app/stream/issue/FLU-858): `AppLifecycleController`; background mute and restore moves into Call (S). - [ ] 25. **B6** [FLU-852](https://linear.app/stream/issue/FLU-852): `CallHost` interface (M). Late on purpose: after C1, B3 and C4 the interface no longer needs the ringing hooks or the mute maps. - [ ] 26. **C5** [FLU-854](https://linear.app/stream/issue/FLU-854): StreamVideo constructor and options cleanup (M, v2 breaking). Last, because every earlier C ticket wires into the constructor. Also covers a client built for background push handling, which must do no media setup. diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 7bd5401f9..053283423 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -70,6 +70,7 @@ ### 🔄 Changed +- When the app comes back to the foreground, the client reopens the coordinator connection only when it closed it in the background or the connection dropped. - `StreamVideo.dispose()` leaves and disposes the calls it tracks, and disconnects the user like `disconnect()` but keeps the push device registered. - A `StreamVideo.connect()` that registers the push device now does so even when the connect that opened the connection skipped it. - `StreamVideo.disconnect()` leaves the user disconnected even when unregistering the push device fails, or when the connection had dropped and was reconnecting. diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index dae39f859..834790501 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -6,7 +6,7 @@ import 'dart:math'; import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_core/stream_core.dart'; +import 'package:stream_core/stream_core.dart' hide LifecycleState; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; import 'package:synchronized/synchronized.dart'; @@ -19,6 +19,7 @@ import '../coordinator/coordinator_client.dart'; import '../coordinator/models/coordinator_events.dart'; import '../coordinator/models/coordinator_models.dart'; import '../errors/stream_video_exception.dart'; +import '../lifecycle/lifecycle_state.dart'; import '../logger/impl/tagged_logger.dart'; import '../logger/stream_log.dart'; import '../models/models.dart'; @@ -105,6 +106,7 @@ const _idConnect = 6; const _idReconnect = 9; const _idNativeWebRtc = 10; const _idAudioPlayback = 11; +const _idAppLifecycle = 12; const _tag = 'SV:Call'; int _callSeq = 1; @@ -311,6 +313,8 @@ class Call { _streamVideo.options.audioConfigurationPolicy, muteVideoWhenInBackground: () => _streamVideo.options.muteVideoWhenInBackground, + muteAudioWhenInBackground: () => + _streamVideo.options.muteAudioWhenInBackground, onMicrophoneMuted: (muted) async => _streamVideo.pushNotificationManager ?.setCallMutedByCid(callCid.value, muted), logger: _logger, @@ -658,6 +662,7 @@ class Call { _observeUserId(); _observeNativeWebRtcEventStream(); _observeWebAudioPlaybackBlocked(); + _observeAppLifecycle(); _logger.v(() => '[_init] initialized'); _initialized = true; @@ -685,6 +690,30 @@ class Call { ); } + /// Mutes and restores this call's media as the app goes to the background + /// and comes back, while the call is active. + void _observeAppLifecycle() { + _subscriptions.add( + _idAppLifecycle, + _streamVideo.state.appLifecycleState.listen(_onAppLifecycleState), + ); + } + + Future _onAppLifecycleState(LifecycleState? state) async { + if (state == null || !isActiveCall) return; + try { + if (state.isPaused) { + traceSessionLog('device.stateChange', 'paused'); + await _media.onAppPaused(); + } else if (state.isResumed) { + traceSessionLog('device.stateChange', 'resumed'); + await _media.onAppResumed(); + } + } catch (e, stk) { + _logger.e(() => '[onAppLifecycleState] $state failed: $e\n$stk'); + } + } + void _observeEvents() { _subscriptions.cancel(_idCoordEvents); _subscriptions.add( diff --git a/packages/stream_video/lib/src/call/media/local_media_controller.dart b/packages/stream_video/lib/src/call/media/local_media_controller.dart index 1c628b3e1..c65b3028d 100644 --- a/packages/stream_video/lib/src/call/media/local_media_controller.dart +++ b/packages/stream_video/lib/src/call/media/local_media_controller.dart @@ -27,6 +27,7 @@ import '../../webrtc/rtc_media_device/device_enumeration_trigger.dart'; import '../../webrtc/rtc_media_device/rtc_media_device.dart'; import '../../webrtc/rtc_media_device/rtc_media_device_notifier.dart'; import '../../webrtc/rtc_track/rtc_track.dart'; +import '../../internal/_background_mute_policy.dart'; import '../call_connect_options.dart'; import '../session/call_session.dart'; import '../state/call_state_notifier.dart'; @@ -44,6 +45,7 @@ class LocalMediaController { required this._rtcMediaDeviceNotifier, required this._audioConfigurationPolicy, required this._muteVideoWhenInBackground, + required this._muteAudioWhenInBackground, required this._onMicrophoneMuted, required this._logger, }); @@ -55,12 +57,18 @@ class LocalMediaController { final RtcMediaDeviceNotifier _rtcMediaDeviceNotifier; final AudioConfigurationPolicy Function() _audioConfigurationPolicy; final bool Function() _muteVideoWhenInBackground; + final bool Function() _muteAudioWhenInBackground; final Future Function(bool muted) _onMicrophoneMuted; final TaggedLogger _logger; final _multitaskingCameraLock = Lock(); final _sfuStatsTimers = >{}; + /// Whether going to the background turned the camera or microphone off, + /// so coming back turns it on again. + bool _cameraMutedInBackground = false; + bool _microphoneMutedInBackground = false; + CallConnectOptions _connectOptions = const CallConnectOptions(); CallConnectOptions? _connectOptionsOverride; @@ -1033,4 +1041,57 @@ class LocalMediaController { return result; } + + /// Whether the capture session in use supports camera access while + /// multitasking, or `null` when it could not be read. + Future _multitaskingCameraAccessSupported() async { + if (!CurrentPlatform.isIos) return null; + + try { + return await rtc.Helper.isIOSMultitaskingCameraAccessSupported(); + } catch (e) { + _logger.w(() => '[multitaskingCameraAccessSupported] failed: $e'); + return null; + } + } + + /// Turns the camera and microphone off as the app goes to the background, + /// as the options and the platform ask. + Future onAppPaused() async { + final localParticipant = _state.localParticipant; + final isVideoEnabled = localParticipant?.isVideoEnabled ?? false; + final isAudioEnabled = localParticipant?.isAudioEnabled ?? false; + + if (shouldMuteCameraInBackground( + isVideoEnabled: isVideoEnabled, + muteVideoWhenInBackground: _muteVideoWhenInBackground(), + multitaskingCameraAccessSupported: isVideoEnabled + ? await _multitaskingCameraAccessSupported() + : null, + platform: CurrentPlatform.type, + )) { + await setCameraEnabled(enabled: false); + _cameraMutedInBackground = true; + _logger.v(() => 'Muted camera track since app was paused.'); + } + if (_muteAudioWhenInBackground() && isAudioEnabled) { + await setMicrophoneEnabled(enabled: false); + _microphoneMutedInBackground = true; + _logger.v(() => 'Muted audio track since app was paused.'); + } + } + + /// Turns on again what [onAppPaused] turned off. + Future onAppResumed() async { + if (_cameraMutedInBackground) { + await setCameraEnabled(enabled: true); + _cameraMutedInBackground = false; + _logger.v(() => 'Unmuted camera track since app was unpaused.'); + } + if (_microphoneMutedInBackground) { + await setMicrophoneEnabled(enabled: true); + _microphoneMutedInBackground = false; + _logger.v(() => 'Unmuted audio track since app was unpaused.'); + } + } } diff --git a/packages/stream_video/lib/src/lifecycle/app_lifecycle_controller.dart b/packages/stream_video/lib/src/lifecycle/app_lifecycle_controller.dart new file mode 100644 index 000000000..9d3189983 --- /dev/null +++ b/packages/stream_video/lib/src/lifecycle/app_lifecycle_controller.dart @@ -0,0 +1,63 @@ +import 'package:meta/meta.dart'; + +import '../core/client_state.dart'; +import '../logger/impl/tagged_logger.dart'; +import 'lifecycle_state.dart'; + +/// Follows the app's lifecycle for the client: records the state, and closes +/// the coordinator connection while the app is in the background with no +/// active call. +/// +/// Each call mutes and restores its own media in the background. +@internal +class AppLifecycleController { + AppLifecycleController({ + required this._state, + required this._keepConnectionAliveInBackground, + required this._isConnected, + required this._closeConnection, + required this._openConnection, + }); + + final MutableClientState _state; + final bool Function() _keepConnectionAliveInBackground; + final bool Function() _isConnected; + final Future Function() _closeConnection; + final Future Function() _openConnection; + + final _logger = taggedLogger(tag: 'SV:AppLifecycle'); + + /// Whether this closed the connection when the app went to the background. + bool _closedInBackground = false; + + Future onAppState(LifecycleState state) async { + _logger.d(() => '[onAppState] state: $state'); + _state.appLifecycleState.value = state; + + if (state.isPaused) { + if (_state.activeCalls.value.isNotEmpty || + _keepConnectionAliveInBackground()) { + return; + } + + _logger.i(() => '[onAppState] close connection'); + _closedInBackground = true; + try { + await _closeConnection(); + } catch (e, stk) { + _logger.e(() => '[onAppState] closing the connection failed: $e\n$stk'); + } + } else if (state.isResumed) { + // A connection kept open can still have dropped in the background. + if (!_closedInBackground && _isConnected()) return; + + _logger.i(() => '[onAppState] open connection'); + _closedInBackground = false; + try { + await _openConnection(); + } catch (e, stk) { + _logger.e(() => '[onAppState] opening the connection failed: $e\n$stk'); + } + } + } +} diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index 41ea220af..b85d2b3b3 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -22,10 +22,10 @@ import 'core/client_state.dart'; import 'core/coordinator_connection.dart'; import 'core/internet_connection_network_state_provider.dart'; import 'errors/stream_video_exception.dart'; -import 'internal/_background_mute_policy.dart'; import 'internal/_instance_holder.dart'; import 'latency/latency_service.dart'; import 'latency/latency_settings.dart'; +import 'lifecycle/app_lifecycle_controller.dart'; import 'lifecycle/lifecycle_state.dart'; import 'lifecycle/lifecycle_utils.dart' if (dart.library.io) 'lifecycle/lifecycle_utils_io.dart' @@ -388,8 +388,22 @@ class StreamVideo extends Disposable { return tokenResult.data; } - final Map _mutedCameraByStateChange = {}; - final Map _mutedAudioByStateChange = {}; + /// Records the app's lifecycle, and closes the connection in the + /// background while no call is active. + late final _lifecycle = AppLifecycleController( + state: _state, + keepConnectionAliveInBackground: () => + _options.keepConnectionsAliveWhenInBackground, + isConnected: () => _client.isConnected, + closeConnection: () async { + _subscriptions.cancel(_idEvents); + await _client.closeConnection(); + }, + openConnection: () async { + await _client.openConnection(); + _subscriptions.add(_idEvents, _client.events.listen(_onEvent)); + }, + ); /// Handles ringing calls: the incoming ring, the native call screen's /// actions, the ringing pushes, and the auto-reject of an unanswered call. @@ -447,7 +461,9 @@ class StreamVideo extends Disposable { _subscriptions.add(_idEvents, _client.events.listen(_onEvent)); _subscriptions.add( _idAppState, - (_appStateOverride?.call() ?? lifecycle.appState).listen(_onAppState), + (_appStateOverride?.call() ?? lifecycle.appState).listen( + _lifecycle.onAppState, + ), ); }, onDisconnected: () async { @@ -560,96 +576,6 @@ class StreamVideo extends Disposable { ); } - /// Whether the capture session in use supports camera access while - /// multitasking, or `null` when it could not be read. - Future _multitaskingCameraAccessSupported() async { - if (!CurrentPlatform.isIos) return null; - - try { - return await rtc.Helper.isIOSMultitaskingCameraAccessSupported(); - } catch (e) { - _logger.w(() => '[multitaskingCameraAccessSupported] failed: $e'); - return null; - } - } - - Future _onAppState(LifecycleState state) async { - _logger.d(() => '[onAppState] state: $state'); - try { - final activeCalls = _state.activeCalls.value; - _state.appLifecycleState.value = state; - - if (state.isPaused) { - for (final activeCall in activeCalls) { - activeCall.traceSessionLog('device.stateChange', 'paused'); - } - - // Handle app paused state - if (activeCalls.isEmpty && - !_options.keepConnectionsAliveWhenInBackground) { - _logger.i(() => '[onAppState] close connection'); - _subscriptions.cancel(_idEvents); - await _client.closeConnection(); - } else if (activeCalls.isNotEmpty) { - final multitaskingCameraAccessSupported = - await _multitaskingCameraAccessSupported(); - - for (final activeCall in activeCalls) { - final callState = activeCall.state.value; - final isVideoEnabled = - callState.localParticipant?.isVideoEnabled ?? false; - final isAudioEnabled = - callState.localParticipant?.isAudioEnabled ?? false; - - if (shouldMuteCameraInBackground( - isVideoEnabled: isVideoEnabled, - muteVideoWhenInBackground: _options.muteVideoWhenInBackground, - multitaskingCameraAccessSupported: - multitaskingCameraAccessSupported, - platform: CurrentPlatform.type, - )) { - await activeCall.setCameraEnabled(enabled: false); - _mutedCameraByStateChange[activeCall.callCid.value] = true; - _logger.v(() => 'Muted camera track since app was paused.'); - } - if (_options.muteAudioWhenInBackground && isAudioEnabled) { - await activeCall.setMicrophoneEnabled(enabled: false); - _mutedAudioByStateChange[activeCall.callCid.value] = true; - _logger.v(() => 'Muted audio track since app was paused.'); - } - } - } - } else if (state.isResumed) { - // Handle app resumed state - _logger.i(() => '[onAppState] open connection'); - await _client.openConnection(); - _subscriptions.add(_idEvents, _client.events.listen(_onEvent)); - - for (final activeCall in activeCalls) { - activeCall.traceSessionLog('device.stateChange', 'resumed'); - - final wasCameraMuted = - _mutedCameraByStateChange[activeCall.callCid.value] ?? false; - if (wasCameraMuted) { - await activeCall.setCameraEnabled(enabled: true); - _mutedCameraByStateChange[activeCall.callCid.value] = false; - _logger.v(() => 'Unmuted camera track since app was unpaused.'); - } - - final wasAudioMuted = - _mutedAudioByStateChange[activeCall.callCid.value] ?? false; - if (wasAudioMuted) { - await activeCall.setMicrophoneEnabled(enabled: true); - _mutedAudioByStateChange[activeCall.callCid.value] = false; - _logger.v(() => 'Unmuted audio track since app was unpaused.'); - } - } - } - } catch (e) { - _logger.e(() => '[onAppState] failed: $e'); - } - } - StreamSubscription listenActiveCall( void Function(Call? value)? onActiveCall, ) { diff --git a/packages/stream_video/test/src/call/call_background_mute_test.dart b/packages/stream_video/test/src/call/call_background_mute_test.dart new file mode 100644 index 000000000..c8b843b2d --- /dev/null +++ b/packages/stream_video/test/src/call/call_background_mute_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/src/lifecycle/lifecycle_state.dart'; +import 'package:stream_video/stream_video.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; + +import '../../test_helpers.dart'; +import 'fixtures/call_test_helpers.dart'; +import 'fixtures/connection_harness.dart'; + +class _FakeMediaStreamTrack extends Fake implements rtc.MediaStreamTrack { + @override + bool enabled = true; +} + +/// Pins that an active call turns its camera and microphone off as the app +/// goes to the background, and on again as it comes back. +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + registerMockFallbackValues(); + }); + + late ConnectionHarness harness; + + MutableStateEmitter appState() => + harness.streamVideo.state.appLifecycleState + as MutableStateEmitter; + + Future goTo(LifecycleState state) async { + appState().value = state; + await pumpEventQueue(); + } + + setUp(() { + harness = ConnectionHarness(); + for (final permission in CallPermission.values) { + when( + () => harness.permissionsManager.hasPermission(permission), + ).thenReturn(true); + } + when(() => harness.streamVideo.options).thenReturn( + StreamVideoOptions( + muteVideoWhenInBackground: true, + muteAudioWhenInBackground: true, + networkMonitorSettings: const NetworkMonitorSettings( + offlineCheckInterval: testReconnectSettleDelay, + ), + ), + ); + final published = MockRtcLocalTrack(); + when(() => published.mediaTrack).thenReturn(_FakeMediaStreamTrack()); + when( + () => harness.session.setCameraEnabled( + any(), + constraints: any(named: 'constraints'), + ), + ).thenAnswer((_) async => Result.success(published)); + when( + () => harness.session.setMicrophoneEnabled( + any(), + constraints: any(named: 'constraints'), + stopTrackOnMute: any(named: 'stopTrackOnMute'), + ), + ).thenAnswer((_) async => Result.success(published)); + }); + + tearDown(() => harness.dispose()); + + /// Joins a call with the camera and microphone on, and makes it the active + /// call. + Future activeCall({bool active = true}) async { + final call = harness.buildCall(); + await call.join(); + harness.stateManager.state = harness.stateManager.callState.copyWith( + callParticipants: [ + CallParticipantState( + userId: harness.stateManager.callState.currentUserId, + roles: const [], + name: 'me', + custom: const {}, + sessionId: 'session-0', + trackIdPrefix: 'me', + isLocal: true, + ), + ], + ); + await call.setCameraEnabled(enabled: true); + await call.setMicrophoneEnabled(enabled: true); + if (active) { + (harness.streamVideo.state.activeCalls as MutableStateEmitter>) + .value = [ + call, + ]; + } + clearInteractions(harness.session); + return call; + } + + void verifyCamera(bool enabled, int times) => verify( + () => harness.session.setCameraEnabled( + enabled, + constraints: any(named: 'constraints'), + ), + ).called(times); + + void verifyMicrophone(bool enabled, int times) => verify( + () => harness.session.setMicrophoneEnabled( + enabled, + constraints: any(named: 'constraints'), + stopTrackOnMute: any(named: 'stopTrackOnMute'), + ), + ).called(times); + + test('turns the camera and microphone off in the background, and on ' + 'again after', () async { + final call = await activeCall(); + expect(call.state.value.localParticipant?.isVideoEnabled, isTrue); + + await goTo(LifecycleState.paused); + verifyCamera(false, 1); + verifyMicrophone(false, 1); + + await goTo(LifecycleState.resumed); + verifyCamera(true, 1); + verifyMicrophone(true, 1); + }); + + test('turns on again only what it turned off', () async { + final call = await activeCall(); + await call.setMicrophoneEnabled(enabled: false); + clearInteractions(harness.session); + + await goTo(LifecycleState.paused); + await goTo(LifecycleState.resumed); + + verifyCamera(false, 1); + verifyCamera(true, 1); + verifyNever( + () => harness.session.setMicrophoneEnabled( + any(), + constraints: any(named: 'constraints'), + stopTrackOnMute: any(named: 'stopTrackOnMute'), + ), + ); + }); + + test('leaves a call that is not active alone', () async { + await activeCall(active: false); + + await goTo(LifecycleState.paused); + + verifyNever( + () => harness.session.setCameraEnabled( + any(), + constraints: any(named: 'constraints'), + ), + ); + }); + + test('a call joined in the background is muted only when the app goes to ' + 'the background again', () async { + appState().value = LifecycleState.paused; + await activeCall(); + + await goTo(LifecycleState.resumed); + verifyNever( + () => harness.session.setCameraEnabled( + any(), + constraints: any(named: 'constraints'), + ), + ); + + await goTo(LifecycleState.paused); + verifyCamera(false, 1); + }); + + test('stops following the app once the call is left', () async { + final call = await activeCall(); + await call.leave(); + clearInteractions(harness.session); + + await goTo(LifecycleState.paused); + + verifyNever( + () => harness.session.setCameraEnabled( + any(), + constraints: any(named: 'constraints'), + ), + ); + }); +} diff --git a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart index 79992de60..724920b45 100644 --- a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart +++ b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart @@ -13,6 +13,7 @@ import 'package:stream_video/src/call/session/call_session_factory.dart'; import 'package:stream_video/src/call/state/call_state_notifier.dart'; import 'package:stream_video/src/call/stats/tracer.dart'; import 'package:stream_video/src/core/client_state.dart'; +import 'package:stream_video/src/lifecycle/lifecycle_state.dart'; import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; import 'package:stream_video/src/sfu/data/models/sfu_call_state.dart'; import 'package:stream_video/src/sfu/data/models/sfu_participant.dart'; @@ -182,6 +183,13 @@ MockClientState setupMockClientState() { when( () => clientState.setIncomingCall(any()), ).thenAnswer((_) => Future.value()); + final appLifecycleStateEmitter = MutableStateEmitter( + null, + sync: true, + ); + when( + () => clientState.appLifecycleState, + ).thenAnswer((_) => appLifecycleStateEmitter); return clientState; } diff --git a/packages/stream_video/test/src/fixtures/stream_video_fixture.dart b/packages/stream_video/test/src/fixtures/stream_video_fixture.dart index bf9d0e813..517801b15 100644 --- a/packages/stream_video/test/src/fixtures/stream_video_fixture.dart +++ b/packages/stream_video/test/src/fixtures/stream_video_fixture.dart @@ -49,6 +49,8 @@ class StreamVideoFixture { when(client.disconnectUser).thenAnswer( (_) async => const Result.success(none), ); + // Connected unless a test says otherwise; a resume reads it. + when(() => client.isConnected).thenReturn(true); streamVideo = StreamVideo.forTesting( 'test-api-key', diff --git a/packages/stream_video/test/src/lifecycle/app_lifecycle_controller_test.dart b/packages/stream_video/test/src/lifecycle/app_lifecycle_controller_test.dart new file mode 100644 index 000000000..6e7845935 --- /dev/null +++ b/packages/stream_video/test/src/lifecycle/app_lifecycle_controller_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/src/lifecycle/lifecycle_state.dart'; +import 'package:stream_video/stream_video.dart'; + +import '../fixtures/stream_video_fixture.dart'; + +class _MockCall extends Mock implements Call {} + +/// Pins how the client follows the app into the background and back: the +/// connection is closed only with no active call, and reopened only when it +/// was closed or dropped. +void main() { + setUpAll(() { + registerFallbackValue(const UserInfo(id: 'fallback')); + }); + + late StreamVideoFixture fixture; + + Future goTo(LifecycleState state) async { + fixture.appState.add(state); + await pumpEventQueue(); + } + + Future connect({StreamVideoOptions? options}) async { + fixture = StreamVideoFixture(options: options); + when(() => fixture.client.isConnected).thenReturn(true); + await fixture.streamVideo.connect(); + } + + tearDown(() => fixture.dispose()); + + test('closes the connection in the background and reopens it', () async { + await connect(); + + await goTo(LifecycleState.paused); + verify(fixture.client.closeConnection).called(1); + + when(() => fixture.client.isConnected).thenReturn(false); + await goTo(LifecycleState.resumed); + verify(fixture.client.openConnection).called(1); + }); + + test('keeps the connection with an active call', () async { + await connect(); + final call = _MockCall(); + when(() => call.callCid).thenReturn(StreamCallCid(cid: 'default:active')); + await fixture.streamVideo.state.setActiveCall(call); + + await goTo(LifecycleState.paused); + await goTo(LifecycleState.resumed); + + verifyNever(fixture.client.closeConnection); + verifyNever(fixture.client.openConnection); + }); + + test('keeps the connection when asked to keep it alive', () async { + await connect( + options: StreamVideoOptions( + autoConnect: false, + keepConnectionsAliveWhenInBackground: true, + ), + ); + + await goTo(LifecycleState.paused); + await goTo(LifecycleState.resumed); + + verifyNever(fixture.client.closeConnection); + verifyNever(fixture.client.openConnection); + }); + + test('reopens a connection kept open that dropped meanwhile', () async { + await connect( + options: StreamVideoOptions( + autoConnect: false, + keepConnectionsAliveWhenInBackground: true, + ), + ); + + await goTo(LifecycleState.paused); + when(() => fixture.client.isConnected).thenReturn(false); + await goTo(LifecycleState.resumed); + + verify(fixture.client.openConnection).called(1); + }); + + test('records the app state', () async { + await connect(); + + await goTo(LifecycleState.paused); + + expect( + fixture.streamVideo.state.appLifecycleState.value, + LifecycleState.paused, + ); + }); + + test('a close that throws does not stop the reopen', () async { + await connect(); + when(fixture.client.closeConnection).thenThrow(StateError('socket')); + + await goTo(LifecycleState.paused); + await goTo(LifecycleState.resumed); + + verify(fixture.client.openConnection).called(1); + }); +}