diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index ae7f13498..5b25984cf 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -4,6 +4,7 @@ - Fixed an issue where ringing a member during an ongoing call could prematurely end the call if they declined. - Fixed an issue where a failed call accept attempt left the CallKit call active on iOS. +- Fixed `X-Stream-Client` header and SFU `ClientDetails` being sent with stale or incomplete device/app info. - Fixed incoming calls being locally rejected after accept when the coordinator WebSocket event arrived before the HTTP response ([#1254](https://github.com/GetStream/stream-video-flutter/issues/1254)). ## 1.4.0 diff --git a/packages/stream_video/lib/globals.dart b/packages/stream_video/lib/globals.dart index 626880181..0bc439b7c 100644 --- a/packages/stream_video/lib/globals.dart +++ b/packages/stream_video/lib/globals.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as webrtc; import 'protobuf/video/sfu/models/models.pb.dart'; +import 'src/video_environment_manager.dart'; const String streamSdkName = 'stream-flutter'; const String streamVideoVersion = '1.4.0'; @@ -12,11 +13,13 @@ const String iosWebRTCVersion = webrtc.iosWebRTCVersion; const String streamDefaultUserAgent = 'stream-video-flutter-v$streamVideoVersion'; -final xStreamClientHeader = '$streamDefaultUserAgent|$clientVersionDetails'; -/// Details regarding app name, version, os and device. Is set during initialization of StreamVideo instance. +/// Manages the current video environment (OS, device, app info). @internal -String? clientVersionDetails; +final videoEnvironmentManager = VideoEnvironmentManager(); -@internal -ClientDetails? clientDetails; +/// The `X-Stream-Client` header value. +String get xStreamClientHeader => videoEnvironmentManager.xStreamClientHeader; + +/// The SFU `ClientDetails` proto for the current environment. +ClientDetails get clientDetails => videoEnvironmentManager.clientDetails; diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index 4bac7ae7d..cb4998137 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -3,16 +3,12 @@ import 'dart:async'; import 'package:async/async.dart' as async; import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; import 'package:meta/meta.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; -import 'package:system_info2/system_info2.dart'; import 'package:uuid/uuid.dart'; -import '../../../protobuf/video/sfu/models/models.pb.dart' as sfu_models; import '../globals.dart'; import '../open_api/video/coordinator/api.dart'; import 'audio_processing/audio_processor.dart'; @@ -235,30 +231,33 @@ class StreamVideo extends Disposable { _setupLogger(options.logPriority, options.logHandlerFunction); unawaited( - _setClientDetails().onError((dynamic error, StackTrace stackTrace) { - _logger.e( - () => - '[StreamVideo] failed to set client details: $error with stackTrace: $stackTrace', - ); - - return null; - }), + videoEnvironmentManager + .collectAndUpdate() + .catchError((Object error, StackTrace stackTrace) { + _logger.e( + () => + '[StreamVideo] failed to collect environment: $error ' + 'with stackTrace: $stackTrace', + ); + }) + .whenComplete(() { + if (options.autoConnect) { + connect( + includeUserDetails: options.includeUserDetailsForAutoConnect, + ).catchError((dynamic error, StackTrace stackTrace) { + _logger.e( + () => + '[StreamVideo] failed to auto connect: $error ' + 'with stackTrace: $stackTrace', + ); + + return Result.error( + 'Failed to auto connect: $error', + ); + }); + } + }), ); - - if (options.autoConnect) { - unawaited( - connect( - includeUserDetails: options.includeUserDetailsForAutoConnect, - ).onError((dynamic error, StackTrace stackTrace) { - _logger.e( - () => - '[StreamVideo] failed to auto connect: $error with stackTrace: $stackTrace', - ); - - return Result.error('Failed to auto connect: $error'); - }), - ); - } } static final InstanceHolder _instanceHolder = InstanceHolder(); @@ -1311,98 +1310,6 @@ void _setupLogger(Priority logPriority, LogHandlerFunction logHandlerFunction) { } } -Future _setClientDetails() async { - try { - final packageInfo = await PackageInfo.fromPlatform(); - - final appName = packageInfo.appName; - final appVersion = packageInfo.version; - - sfu_models.Device? device; - sfu_models.Browser? browser; - String? webrtcVersion; - - var os = sfu_models.OS(name: CurrentPlatform.name); - - if (CurrentPlatform.isAndroid) { - final deviceInfo = await DeviceInfoPlugin().androidInfo; - os = sfu_models.OS( - name: CurrentPlatform.name, - version: deviceInfo.version.release, - architecture: SysInfo.rawKernelArchitecture, - ); - device = sfu_models.Device( - name: '${deviceInfo.manufacturer} : ${deviceInfo.model}', - ); - webrtcVersion = androidWebRTCVersion; - } else if (CurrentPlatform.isIos) { - final deviceInfo = await DeviceInfoPlugin().iosInfo; - os = sfu_models.OS( - name: CurrentPlatform.name, - version: deviceInfo.systemVersion, - ); - device = sfu_models.Device(name: deviceInfo.utsname.machine); - webrtcVersion = iosWebRTCVersion; - } else if (CurrentPlatform.isMacOS) { - final deviceInfo = await DeviceInfoPlugin().macOsInfo; - os = sfu_models.OS( - name: CurrentPlatform.name, - version: - '${deviceInfo.majorVersion}.${deviceInfo.minorVersion}.${deviceInfo.patchVersion}', - architecture: deviceInfo.arch, - ); - device = sfu_models.Device( - name: deviceInfo.model, - version: deviceInfo.osRelease, - ); - } else if (CurrentPlatform.isWindows) { - final deviceInfo = await DeviceInfoPlugin().windowsInfo; - os = sfu_models.OS( - name: CurrentPlatform.name, - version: - '${deviceInfo.majorVersion}.${deviceInfo.minorVersion}.${deviceInfo.buildNumber}', - architecture: deviceInfo.buildLabEx, - ); - } else if (CurrentPlatform.isLinux) { - final deviceInfo = await DeviceInfoPlugin().linuxInfo; - os = sfu_models.OS( - name: CurrentPlatform.name, - version: '${deviceInfo.name} ${deviceInfo.version}', - ); - } else if (CurrentPlatform.isWeb) { - final browserInfo = await DeviceInfoPlugin().webBrowserInfo; - browser = sfu_models.Browser( - name: browserInfo.browserName.name, - version: browserInfo.appVersion, - ); - } - - final versionSplit = streamVideoVersion.split('.'); - clientDetails = sfu_models.ClientDetails( - sdk: sfu_models.Sdk( - type: sfu_models.SdkType.SDK_TYPE_FLUTTER, - major: versionSplit.first, - minor: versionSplit.skip(1).first, - patch: versionSplit.last, - ), - os: os, - device: device, - browser: browser, - webrtcVersion: webrtcVersion, - ); - - final deviceName = (device?.name != null && device!.name.isNotEmpty) - ? device.name - : null; - - return clientVersionDetails ??= - 'app=$appName|app_version=$appVersion|os=${CurrentPlatform.name} ${os.version}${deviceName != null ? '|device_model=$deviceName' : ''}'; - } catch (e) { - streamLog.e(_tag, () => '[_setClientDetails] failed: $e'); - return null; - } -} - /// Default log handler function for the [StreamVideo] logger. void _defaultLogHandler( Priority priority, diff --git a/packages/stream_video/lib/src/video_environment.dart b/packages/stream_video/lib/src/video_environment.dart new file mode 100644 index 000000000..56339f5c2 --- /dev/null +++ b/packages/stream_video/lib/src/video_environment.dart @@ -0,0 +1,80 @@ +/// Immutable snapshot of the environment in which the Stream Video SDK runs. +class VideoEnvironment { + const VideoEnvironment({ + required this.sdkVersion, + required this.osName, + this.appName, + this.appVersion, + this.osVersion, + this.osArchitecture, + this.deviceModel, + this.deviceVersion, + this.browserName, + this.browserVersion, + this.webrtcVersion, + }); + + final String sdkVersion; + final String osName; + + final String? appName; + final String? appVersion; + + final String? osVersion; + final String? osArchitecture; + + /// Device model name (mobile / desktop). + final String? deviceModel; + + /// OS release string — populated on macOS only. + final String? deviceVersion; + + /// Browser name / version — populated on web only. + final String? browserName; + final String? browserVersion; + + final String? webrtcVersion; + + VideoEnvironment copyWith({ + String? sdkVersion, + String? osName, + String? appName, + String? appVersion, + String? osVersion, + String? osArchitecture, + String? deviceModel, + String? deviceVersion, + String? browserName, + String? browserVersion, + String? webrtcVersion, + }) { + return VideoEnvironment( + sdkVersion: sdkVersion ?? this.sdkVersion, + osName: osName ?? this.osName, + appName: appName ?? this.appName, + appVersion: appVersion ?? this.appVersion, + osVersion: osVersion ?? this.osVersion, + osArchitecture: osArchitecture ?? this.osArchitecture, + deviceModel: deviceModel ?? this.deviceModel, + deviceVersion: deviceVersion ?? this.deviceVersion, + browserName: browserName ?? this.browserName, + browserVersion: browserVersion ?? this.browserVersion, + webrtcVersion: webrtcVersion ?? this.webrtcVersion, + ); + } +} + +extension VideoEnvironmentHeader on VideoEnvironment { + /// Builds the `X-Stream-Client` header value. + String get xStreamClientHeader => [ + 'stream-video-flutter-v$sdkVersion', + if (appName case final name?) 'app=$name', + if (appVersion case final version?) 'app_version=$version', + switch ((osName, osVersion)) { + (final name, final version?) => 'os=$name $version', + (final name, null) => 'os=$name', + }, + if (deviceModel case final model?) 'device_model=$model', + if (browserName case final name?) 'browser=$name', + ].join('|'); +} diff --git a/packages/stream_video/lib/src/video_environment_collector.dart b/packages/stream_video/lib/src/video_environment_collector.dart new file mode 100644 index 000000000..74ac2a13f --- /dev/null +++ b/packages/stream_video/lib/src/video_environment_collector.dart @@ -0,0 +1,113 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:meta/meta.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:system_info2/system_info2.dart'; + +import '../globals.dart' + show androidWebRTCVersion, iosWebRTCVersion, streamVideoVersion; +import 'logger/stream_log.dart'; +import 'platform_detector/platform_detector.dart'; +import 'video_environment.dart'; + +const _tag = 'SV:EnvCollector'; + +/// Collects platform and app info for [VideoEnvironment]. +// ignore: avoid_classes_with_only_static_members +class VideoEnvironmentCollector { + /// Override for testing. When set, replaces [collect] implementation. + @visibleForTesting + static Future Function()? override; + + /// Collects platform and app info and returns a fully-populated + /// [VideoEnvironment]. + static Future collect() { + return override?.call() ?? _collect(); + } + + static Future _collect() async { + try { + String? appName; + String? appVersion; + try { + final packageInfo = await PackageInfo.fromPlatform(); + appName = packageInfo.appName; + appVersion = packageInfo.version; + } catch (e, stk) { + streamLog.e( + _tag, + () => '[collect] package info failed: $e\n$stk', + ); + } + + String? osVersion; + String? osArchitecture; + String? deviceModel; + String? deviceVersion; + String? browserName; + String? browserVersion; + String? webrtcVersion; + + try { + if (CurrentPlatform.isAndroid) { + final info = await DeviceInfoPlugin().androidInfo; + osVersion = info.version.release; + osArchitecture = SysInfo.rawKernelArchitecture; + deviceModel = '${info.manufacturer} ${info.model}'; + webrtcVersion = androidWebRTCVersion; + } else if (CurrentPlatform.isIos) { + final info = await DeviceInfoPlugin().iosInfo; + osVersion = info.systemVersion; + deviceModel = info.utsname.machine; + webrtcVersion = iosWebRTCVersion; + } else if (CurrentPlatform.isMacOS) { + final info = await DeviceInfoPlugin().macOsInfo; + osVersion = + '${info.majorVersion}.${info.minorVersion}.${info.patchVersion}'; + osArchitecture = info.arch; + deviceModel = info.model; + deviceVersion = info.osRelease; + } else if (CurrentPlatform.isWindows) { + final info = await DeviceInfoPlugin().windowsInfo; + osVersion = + '${info.majorVersion}.${info.minorVersion}.${info.buildNumber}'; + osArchitecture = info.buildLabEx; + } else if (CurrentPlatform.isLinux) { + final info = await DeviceInfoPlugin().linuxInfo; + osVersion = '${info.name} ${info.version}'; + } else if (CurrentPlatform.isWeb) { + final info = await DeviceInfoPlugin().webBrowserInfo; + browserName = info.browserName.name; + browserVersion = info.appVersion; + } + } catch (e, stk) { + streamLog.e( + _tag, + () => '[collect] platform info failed: $e\n$stk', + ); + } + + return VideoEnvironment( + sdkVersion: streamVideoVersion, + osName: CurrentPlatform.name, + appName: appName, + appVersion: appVersion, + osVersion: osVersion, + osArchitecture: osArchitecture, + deviceModel: deviceModel, + deviceVersion: deviceVersion, + browserName: browserName, + browserVersion: browserVersion, + webrtcVersion: webrtcVersion, + ); + } catch (e, stk) { + streamLog.e( + _tag, + () => '[collect] failed: $e\n$stk', + ); + return VideoEnvironment( + sdkVersion: streamVideoVersion, + osName: CurrentPlatform.name, + ); + } + } +} diff --git a/packages/stream_video/lib/src/video_environment_manager.dart b/packages/stream_video/lib/src/video_environment_manager.dart new file mode 100644 index 000000000..bc7c8181f --- /dev/null +++ b/packages/stream_video/lib/src/video_environment_manager.dart @@ -0,0 +1,73 @@ +import '../globals.dart' show streamVideoVersion; +import '../protobuf/video/sfu/models/models.pb.dart' as sfu_models; +import 'platform_detector/platform_detector.dart'; +import 'video_environment.dart'; +import 'video_environment_collector.dart'; + +/// Manages the current [VideoEnvironment] and derives the header / proto objects from it. +class VideoEnvironmentManager { + VideoEnvironmentManager({VideoEnvironment? environment}) + : _environment = + environment ?? + VideoEnvironment( + sdkVersion: streamVideoVersion, + osName: CurrentPlatform.name, + ); + + VideoEnvironment _environment; + + VideoEnvironment get environment => _environment; + + // ignore: use_setters_to_change_properties + void updateEnvironment(VideoEnvironment environment) { + _environment = environment; + } + + /// Collects platform info and updates the managed [VideoEnvironment]. + Future collectAndUpdate() async { + final environment = await VideoEnvironmentCollector.collect(); + updateEnvironment(environment); + } + + /// The `X-Stream-Client` header value for the current environment. + String get xStreamClientHeader => _environment.xStreamClientHeader; + + /// The SFU `ClientDetails` proto for the current environment. + sfu_models.ClientDetails get clientDetails { + final env = _environment; + final versionParts = env.sdkVersion.split('.'); + + sfu_models.Device? device; + if (env.deviceModel case final model?) { + device = sfu_models.Device( + name: model, + version: env.deviceVersion ?? '', + ); + } + + sfu_models.Browser? browser; + if (env.browserName case final name?) { + browser = sfu_models.Browser( + name: name, + version: env.browserVersion, + ); + } + + return sfu_models.ClientDetails( + sdk: sfu_models.Sdk( + type: sfu_models.SdkType.SDK_TYPE_FLUTTER, + major: versionParts.isNotEmpty ? versionParts[0] : '0', + minor: versionParts.length > 1 ? versionParts[1] : '0', + patch: versionParts.length > 2 ? versionParts[2] : '0', + ), + os: sfu_models.OS( + name: env.osName, + version: env.osVersion ?? '', + architecture: env.osArchitecture ?? '', + ), + device: device, + browser: browser, + webrtcVersion: env.webrtcVersion ?? '', + ); + } +} diff --git a/packages/stream_video/test/src/stream_video_auto_connect_test.dart b/packages/stream_video/test/src/stream_video_auto_connect_test.dart new file mode 100644 index 000000000..b1cc858d9 --- /dev/null +++ b/packages/stream_video/test/src/stream_video_auto_connect_test.dart @@ -0,0 +1,111 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/globals.dart'; +import 'package:stream_video/src/core/connection_state.dart'; +import 'package:stream_video/src/video_environment.dart'; +import 'package:stream_video/src/video_environment_collector.dart'; +import 'package:stream_video/stream_video.dart'; + +void main() { + group('StreamVideo autoConnect after environment collection', () { + late User user; + late String userToken; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + + user = User.regular( + userId: 'test-user', + name: 'Test User', + ); + + userToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiSm9obiBEb2UifQ.hrrtiYCtfs2cowE2sx2dypxoXhsEE8pQl-V6Nq4i8qU'; + }); + + tearDown(() async { + VideoEnvironmentCollector.override = null; + await StreamVideo.reset(); + }); + + Future waitForInit() async { + await pumpEventQueue(times: 20); + } + + bool connectWasAttempted(ConnectionState connectionState) { + return connectionState.isConnecting || + connectionState.isFailed || + connectionState.isConnected; + } + + test('autoConnect runs when environment collection throws', () async { + VideoEnvironmentCollector.override = () async { + throw Exception('collect failed'); + }; + + final streamVideo = StreamVideo.create( + 'test-api-key', + user: user, + userToken: userToken, + options: StreamVideoOptions(autoConnect: true), + ); + + await waitForInit(); + + expect( + connectWasAttempted(streamVideo.state.connection.value), + isTrue, + reason: 'connect should be attempted after env collection fails', + ); + }); + + test( + 'autoConnect does not run when disabled despite env collection failure', + () async { + VideoEnvironmentCollector.override = () async { + throw Exception('collect failed'); + }; + + final streamVideo = StreamVideo.create( + 'test-api-key', + user: user, + userToken: userToken, + options: StreamVideoOptions(autoConnect: false), + ); + + await waitForInit(); + + expect(streamVideo.state.connection.value.isDisconnected, isTrue); + }, + ); + + test( + 'updates environment and autoConnects when collection succeeds', + () async { + VideoEnvironmentCollector.override = () async => const VideoEnvironment( + sdkVersion: 'test-sdk', + osName: 'test-os', + appName: 'test-app', + ); + + final streamVideo = StreamVideo.create( + 'test-api-key', + user: user, + userToken: userToken, + options: StreamVideoOptions(autoConnect: true), + ); + + await waitForInit(); + + expect(videoEnvironmentManager.environment.sdkVersion, 'test-sdk'); + expect(videoEnvironmentManager.environment.osName, 'test-os'); + expect(videoEnvironmentManager.environment.appName, 'test-app'); + expect( + connectWasAttempted(streamVideo.state.connection.value), + isTrue, + ); + }, + ); + }); +} diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 49da9befb..faf51a172 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## Upcoming + +- Fixed `X-Stream-Client` header and SFU `ClientDetails` being sent with stale or incomplete device/app info. + ## 1.4.0 Each call now owns an isolated native `PeerConnectionFactory`. This fixes cross-call audio interference, sibling-call microphone capture loss, and noise cancellation failing to engage during lobby preview.