From ecd9b1c32fde04d6473c822e81f636d86a1e1f48 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 8 Oct 2026 21:13:59 +0200 Subject: [PATCH 1/3] refactor(llc): move app lifecycle handling into AppLifecycleController The client keeps only the connection keep-alive: it closes the connection in the background with no active call, and reopens it when it closed it or the connection dropped. Each active call mutes and restores its own camera and microphone, so the per-cid mute maps in StreamVideo are gone. Co-Authored-By: Claude Opus 5.5 --- V2_REFACTOR_PLAN.md | 2 +- packages/stream_video/CHANGELOG.md | 1 + packages/stream_video/lib/src/call/call.dart | 31 ++- .../call/media/local_media_controller.dart | 61 ++++++ .../lifecycle/app_lifecycle_controller.dart | 63 ++++++ .../stream_video/lib/src/stream_video.dart | 114 ++--------- .../src/call/call_background_mute_test.dart | 192 ++++++++++++++++++ .../src/call/fixtures/call_test_helpers.dart | 8 + .../app_lifecycle_controller_test.dart | 107 ++++++++++ 9 files changed, 483 insertions(+), 96 deletions(-) create mode 100644 packages/stream_video/lib/src/lifecycle/app_lifecycle_controller.dart create mode 100644 packages/stream_video/test/src/call/call_background_mute_test.dart create mode 100644 packages/stream_video/test/src/lifecycle/app_lifecycle_controller_test.dart diff --git a/V2_REFACTOR_PLAN.md b/V2_REFACTOR_PLAN.md index 71f6146da..80c95b7ca 100644 --- a/V2_REFACTOR_PLAN.md +++ b/V2_REFACTOR_PLAN.md @@ -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/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); + }); +} From 26fa56be4328f03898b34bc10f345d10f600a96a Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 8 Oct 2026 21:14:26 +0200 Subject: [PATCH 2/3] chore(repo): add the C4 PR to the refactor plan Co-Authored-By: Claude Opus 5.5 --- V2_REFACTOR_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/V2_REFACTOR_PLAN.md b/V2_REFACTOR_PLAN.md index 80c95b7ca..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` | | From 539a29d4582a92131398c0bfb2559f5fae6dbbfa Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 9 Oct 2026 15:31:43 +0200 Subject: [PATCH 3/3] test(llc): stub the client's connection state in the StreamVideo fixture Co-Authored-By: Claude Opus 5.5 --- .../stream_video/test/src/fixtures/stream_video_fixture.dart | 2 ++ 1 file changed, 2 insertions(+) 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',