Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions V2_REFACTOR_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |

Expand All @@ -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): `RingingCallCoordinator` 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.

Expand Down
1 change: 1 addition & 0 deletions packages/stream_video/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` disconnects the user like `disconnect()`, but keeps the push device registered.
- `StreamVideo.connect(registerPushDevice: true)` registers the push device when the connect that opened the connection skipped it.
- `StreamVideo.dispose()` leaves and disposes the calls it tracks.
Expand Down
31 changes: 30 additions & 1 deletion packages/stream_video/lib/src/call/call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -656,6 +660,7 @@ class Call {
_observeUserId();
_observeNativeWebRtcEventStream();
_observeWebAudioPlaybackBlocked();
_observeAppLifecycle();

_logger.v(() => '[_init] initialized');
_initialized = true;
Expand Down Expand Up @@ -683,6 +688,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<void> _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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -44,6 +45,7 @@ class LocalMediaController {
required this._rtcMediaDeviceNotifier,
required this._audioConfigurationPolicy,
required this._muteVideoWhenInBackground,
required this._muteAudioWhenInBackground,
required this._onMicrophoneMuted,
required this._logger,
});
Expand All @@ -55,12 +57,18 @@ class LocalMediaController {
final RtcMediaDeviceNotifier _rtcMediaDeviceNotifier;
final AudioConfigurationPolicy Function() _audioConfigurationPolicy;
final bool Function() _muteVideoWhenInBackground;
final bool Function() _muteAudioWhenInBackground;
final Future<void> Function(bool muted) _onMicrophoneMuted;
final TaggedLogger _logger;

final _multitaskingCameraLock = Lock();
final _sfuStatsTimers = <CancelableOperation<void>>{};

/// 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;

Expand Down Expand Up @@ -1019,4 +1027,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<bool?> _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<void> 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<void> 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.');
}
}
}
Original file line number Diff line number Diff line change
@@ -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<void> Function() _closeConnection;
final Future<void> Function() _openConnection;

final _logger = taggedLogger(tag: 'SV:AppLifecycle');

/// Whether this closed the connection when the app went to the background.
bool _closedInBackground = false;

Future<void> 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');
}
}
}
}
Loading
Loading