diff --git a/lib/models/plex/plex_config.dart b/lib/models/plex/plex_config.dart index 8b511bc8..5193d570 100644 --- a/lib/models/plex/plex_config.dart +++ b/lib/models/plex/plex_config.dart @@ -1,5 +1,7 @@ import 'package:package_info_plus/package_info_plus.dart'; +import '../../utils/device_identity.dart'; + class PlexConfig { final String baseUrl; final String? token; @@ -8,6 +10,10 @@ class PlexConfig { final String version; final String platform; final String? device; + + /// Friendly device name — Plex dashboards and Tautulli show it as the + /// session's "Player". + final String? deviceName; final bool acceptJson; final String? machineIdentifier; final String? languageCode; @@ -20,6 +26,7 @@ class PlexConfig { required this.version, this.platform = 'Flutter', this.device, + this.deviceName, this.acceptJson = true, this.machineIdentifier, this.languageCode, @@ -37,14 +44,16 @@ class PlexConfig { String? languageCode, }) async { final packageInfo = await PackageInfo.fromPlatform(); + final identity = await DeviceIdentityService.resolve(); return PlexConfig( baseUrl: baseUrl, token: token, clientIdentifier: clientIdentifier, product: product ?? 'Plezy', version: packageInfo.version, - platform: platform ?? 'Flutter', - device: device, + platform: platform ?? identity.platform, + device: device ?? sanitizeHeaderValue(identity.deviceModel), + deviceName: sanitizeHeaderValue(identity.deviceName), acceptJson: acceptJson, machineIdentifier: machineIdentifier, languageCode: languageCode, @@ -59,6 +68,7 @@ class PlexConfig { 'X-Plex-Platform': platform, 'X-Plex-Client-Profile-Name': 'Generic', 'X-Plex-Device': ?device, + 'X-Plex-Device-Name': ?deviceName, if (acceptJson) 'Accept': 'application/json', 'Accept-Charset': 'utf-8', 'Accept-Language': ?_normalizedLanguageCode, @@ -85,6 +95,7 @@ class PlexConfig { String? version, String? platform, String? device, + String? deviceName, bool? acceptJson, String? machineIdentifier, String? languageCode, @@ -97,6 +108,7 @@ class PlexConfig { version: version ?? this.version, platform: platform ?? this.platform, device: device ?? this.device, + deviceName: deviceName ?? this.deviceName, acceptJson: acceptJson ?? this.acceptJson, machineIdentifier: machineIdentifier ?? this.machineIdentifier, languageCode: languageCode ?? this.languageCode, diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index b080eb65..7c056727 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import '../connection/connection.dart'; import '../connection/connection_registry.dart'; @@ -20,7 +18,7 @@ import '../services/companion_remote/lan_discovery_service.dart'; import '../services/companion_remote/remote_auth_context.dart'; import '../services/companion_remote/remote_auth_service.dart'; import '../utils/app_logger.dart'; -import '../utils/platform_detector.dart'; +import '../utils/device_identity.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; export '../services/companion_remote/lan_discovery_service.dart' show DiscoveredHost; @@ -91,36 +89,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } Future _initializeDeviceInfo() async { - final deviceInfo = DeviceInfoPlugin(); - - try { - if (Platform.isAndroid) { - final androidInfo = await deviceInfo.androidInfo; - final osName = await TvDetectionService.getAndroidDeviceName(); - _deviceName = osName ?? '${androidInfo.brand} ${androidInfo.model}'; - _platform = 'Android'; - } else if (Platform.isIOS) { - final iosInfo = await deviceInfo.iosInfo; - _deviceName = iosInfo.name; - _platform = 'iOS'; - } else if (Platform.isMacOS) { - final macInfo = await deviceInfo.macOsInfo; - _deviceName = macInfo.computerName; - _platform = 'macOS'; - } else if (Platform.isWindows) { - final windowsInfo = await deviceInfo.windowsInfo; - _deviceName = windowsInfo.computerName; - _platform = 'Windows'; - } else if (Platform.isLinux) { - final host = Platform.localHostname.trim(); - _deviceName = (host.isNotEmpty && host != 'localhost') ? host : (await deviceInfo.linuxInfo).name; - _platform = 'Linux'; - } - } catch (e) { - appLogger.e('CompanionRemote: Failed to get device info', error: e); - _deviceName = t.companionRemote.unknownDevice; - _platform = Platform.operatingSystem; - } + final identity = await DeviceIdentityService.resolve(); + _deviceName = identity.deviceName ?? t.companionRemote.unknownDevice; + _platform = identity.platform; safeNotifyListeners(); } diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 57b8c410..b5721425 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -26,6 +26,7 @@ import '../../services/jellyfin_lan_discovery_service.dart'; import '../../services/storage_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; +import '../../utils/device_identity.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../profile/profile_switch_screen.dart'; @@ -436,10 +437,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } Future _resolveDeviceName() async { - // PackageInfo doesn't expose a device name; fall back to a generic label. - // Jellyfin only shows this in the admin "Devices" list — fine to keep - // simple until we add proper device_info_plus integration. - return 'Plezy'; + final identity = await DeviceIdentityService.resolve(); + return sanitizeHeaderValue(identity.deviceName) ?? 'Plezy'; } @override diff --git a/lib/services/jellyfin_auth_header.dart b/lib/services/jellyfin_auth_header.dart index 74b4f3b0..0a82aae5 100644 --- a/lib/services/jellyfin_auth_header.dart +++ b/lib/services/jellyfin_auth_header.dart @@ -1,6 +1,10 @@ /// Build the `MediaBrowser` Authorization header value the way the Jellyfin /// SDK formats it. Used at auth time and on every authenticated request so /// the server sees a consistent client identity. +/// +/// Values are quote-stripped: the header grammar has no escape for `"`, so a +/// device name like `My "cool" TV` would otherwise corrupt every field after +/// it. String buildJellyfinAuthHeader({ required String clientName, required String clientVersion, @@ -8,12 +12,13 @@ String buildJellyfinAuthHeader({ required String deviceId, String? accessToken, }) { + String quoted(String value) => '"${value.replaceAll('"', '')}"'; final parts = [ - 'Client="$clientName"', - 'Device="$deviceName"', - 'DeviceId="$deviceId"', - 'Version="$clientVersion"', - if (accessToken != null && accessToken.isNotEmpty) 'Token="$accessToken"', + 'Client=${quoted(clientName)}', + 'Device=${quoted(deviceName)}', + 'DeviceId=${quoted(deviceId)}', + 'Version=${quoted(clientVersion)}', + if (accessToken != null && accessToken.isNotEmpty) 'Token=${quoted(accessToken)}', ]; return 'MediaBrowser ${parts.join(', ')}'; } diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 10296102..1b38821f 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -41,6 +41,7 @@ import '../models/media_subscription.dart'; import '../media/media_source_info.dart'; import '../media/media_sort.dart'; import '../utils/app_logger.dart'; +import '../utils/device_identity.dart'; import '../utils/failover_http_client.dart'; import '../utils/media_server_retry.dart'; import '../utils/media_server_timeouts.dart'; @@ -125,10 +126,16 @@ class JellyfinClient } catch (_) { // Tests / non-platform contexts — keep the fallback version. } + String? deviceName; + try { + deviceName = sanitizeHeaderValue((await DeviceIdentityService.resolve()).deviceName); + } catch (_) { + // Tests / non-platform contexts — keep the fallback name. + } final authHeader = buildJellyfinAuthHeader( clientName: 'Plezy', clientVersion: version, - deviceName: 'Plezy', + deviceName: deviceName ?? 'Plezy', deviceId: connection.deviceId, accessToken: connection.accessToken, ); diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index be61fb34..094be8c1 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -9,6 +9,7 @@ import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; +import '../utils/device_identity.dart'; import '../utils/endpoint_race.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/media_server_http_client.dart'; @@ -53,8 +54,17 @@ class PlexAuthService { final String _clientIdentifier; final String _appVersion; final String _platformVersion; + final String _platform; + final String? _deviceName; - PlexAuthService._(this._http, this._clientIdentifier, this._appVersion, this._platformVersion); + PlexAuthService._( + this._http, + this._clientIdentifier, + this._appVersion, + this._platformVersion, + this._platform, + this._deviceName, + ); @visibleForTesting PlexAuthService.forTesting({ @@ -62,7 +72,9 @@ class PlexAuthService { String clientIdentifier = 'test-client', String appVersion = 'test', String platformVersion = 'test', - }) : this._(http, clientIdentifier, appVersion, platformVersion); + String platform = 'Flutter', + String? deviceName, + }) : this._(http, clientIdentifier, appVersion, platformVersion, platform, deviceName); /// Close the underlying HTTP client. Call when the service is short-lived /// (created for a single API call) to avoid leaking sockets. @@ -76,7 +88,15 @@ class PlexAuthService { ); final clientIdentifier = await storage.getOrCreateClientIdentifier(); final packageInfo = await PackageInfo.fromPlatform(); - return PlexAuthService._(http, clientIdentifier, packageInfo.version, Platform.operatingSystemVersion); + final identity = await DeviceIdentityService.resolve(); + return PlexAuthService._( + http, + clientIdentifier, + packageInfo.version, + Platform.operatingSystemVersion, + identity.platform, + sanitizeHeaderValue(identity.deviceName), + ); } String get clientIdentifier => _clientIdentifier; @@ -86,6 +106,8 @@ class PlexAuthService { 'Accept': 'application/json', 'X-Plex-Product': _appName, 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Platform': _platform, + 'X-Plex-Device-Name': ?_deviceName, }; if (authToken != null) { @@ -251,7 +273,7 @@ class PlexAuthService { 'X-Plex-Product': _appName, 'X-Plex-Version': _appVersion, 'X-Plex-Client-Identifier': _clientIdentifier, - 'X-Plex-Platform': 'Flutter', + 'X-Plex-Platform': _platform, 'X-Plex-Platform-Version': _platformVersion, 'X-Plex-Token': currentToken, 'X-Plex-Language': 'en', diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index c139befb..8ddde4ec 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -52,6 +52,7 @@ import '../utils/content_utils.dart'; import '../media/media_sort.dart'; import '../models/plex/plex_video_playback_data.dart'; import '../models/transcode_quality_preset.dart'; +import '../utils/device_identity.dart'; import '../utils/failover_http_client.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_retry.dart'; @@ -551,6 +552,8 @@ class PlexClient Duration timeout = const Duration(seconds: 5), String? clientIdentifier, }) async { + // Memoized after the first call — resolve outside the latency window. + final identity = await DeviceIdentityService.resolve(); final stopwatch = Stopwatch()..start(); MediaServerHttpClient? client; @@ -561,7 +564,7 @@ class PlexClient if (clientIdentifier != null) { headers['X-Plex-Client-Identifier'] = clientIdentifier; headers['X-Plex-Product'] = 'Plezy'; - headers['X-Plex-Device-Name'] = 'Plezy'; + headers['X-Plex-Device-Name'] = sanitizeHeaderValue(identity.deviceName) ?? 'Plezy'; } final response = await client.get('/', headers: headers); @@ -3138,6 +3141,7 @@ class PlexClient // [_transcodePlatformName] for the mapping. 'X-Plex-Platform': _transcodePlatformName(), if (config.device != null) 'X-Plex-Device': config.device!, + if (config.deviceName != null) 'X-Plex-Device-Name': config.deviceName!, if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(), if (config.token != null) 'X-Plex-Token': config.token!, }; diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 9bc79096..35bfded0 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -999,7 +999,10 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { 'X-Plex-Product': config.product, 'X-Plex-Version': config.version, 'X-Plex-Client-Identifier': config.clientIdentifier, - 'X-Plex-Platform': config.platform, + // Pinned rather than config.platform: this decision request is only + // known to work with the 'Plex Desktop' profile + 'Flutter' platform + // pairing, and real OS names map to server-side preset profiles. + 'X-Plex-Platform': 'Flutter', 'X-Plex-Client-Profile-Name': 'Plex Desktop', if (offsetSeconds != null) 'offset': offsetSeconds.toString(), if (config.token != null) 'X-Plex-Token': config.token!, diff --git a/lib/utils/device_identity.dart b/lib/utils/device_identity.dart new file mode 100644 index 00000000..d2fe018a --- /dev/null +++ b/lib/utils/device_identity.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; + +import 'app_logger.dart'; +import 'platform_detector.dart'; + +/// What this install should call itself when talking to media servers and +/// companion peers: a real platform name, the hardware model, and the +/// user-facing device name (Plex dashboards show it as the "Player"; +/// Jellyfin as the "Device"). +class DeviceIdentity { + /// 'Android' | 'iOS' | 'tvOS' | 'macOS' | 'Windows' | 'Linux', falling back + /// to [Platform.operatingSystem] when detection fails. + final String platform; + + /// Hardware model for `X-Plex-Device`, e.g. 'AFTKM' (Fire TV), 'iPhone', + /// 'Apple TV'. Null when unresolvable. + final String? deviceModel; + + /// Friendly, usually user-assigned name (Settings > About > Device name on + /// Android, computer name on desktop). Null when unresolvable — callers + /// pick their own fallback. May contain characters that are not valid in + /// HTTP headers; pass through [sanitizeHeaderValue] before sending. + final String? deviceName; + + final bool isTv; + + const DeviceIdentity({required this.platform, this.deviceModel, this.deviceName, this.isTv = false}); +} + +/// Resolves the device identity once per process and memoizes it. Never +/// throws — platform-channel failures (tests, exotic platforms) degrade to +/// [Platform.operatingSystem] with null name/model. +class DeviceIdentityService { + DeviceIdentityService._(); + + static Future? _cached; + + static Future resolve() => _cached ??= _resolve(); + + @visibleForTesting + static void debugOverride(DeviceIdentity? identity) { + _cached = identity == null ? null : Future.value(identity); + } + + static Future _resolve() async { + final deviceInfo = DeviceInfoPlugin(); + final isTv = TvDetectionService.isTVSync(); + + try { + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + final assignedName = await TvDetectionService.getAndroidDeviceName(); + return DeviceIdentity( + platform: 'Android', + deviceModel: androidInfo.model, + deviceName: assignedName ?? '${androidInfo.brand} ${androidInfo.model}', + isTv: isTv, + ); + } + if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + if (TvDetectionService.isAppleTVSync()) { + return DeviceIdentity(platform: 'tvOS', deviceModel: 'Apple TV', deviceName: iosInfo.name, isTv: true); + } + return DeviceIdentity(platform: 'iOS', deviceModel: iosInfo.model, deviceName: iosInfo.name, isTv: isTv); + } + if (Platform.isMacOS) { + final macInfo = await deviceInfo.macOsInfo; + return DeviceIdentity( + platform: 'macOS', + deviceModel: macInfo.model, + deviceName: macInfo.computerName, + isTv: isTv, + ); + } + if (Platform.isWindows) { + final windowsInfo = await deviceInfo.windowsInfo; + return DeviceIdentity( + platform: 'Windows', + deviceModel: 'Windows', + deviceName: windowsInfo.computerName, + isTv: isTv, + ); + } + if (Platform.isLinux) { + final host = Platform.localHostname.trim(); + final name = (host.isNotEmpty && host != 'localhost') ? host : (await deviceInfo.linuxInfo).name; + return DeviceIdentity(platform: 'Linux', deviceModel: 'Linux', deviceName: name, isTv: isTv); + } + } catch (e) { + appLogger.w('DeviceIdentity: failed to resolve device info', error: e); + } + + return DeviceIdentity(platform: Platform.operatingSystem, isTv: isTv); + } +} + +/// Makes a free-form device name safe to send as an HTTP header value: +/// drops CR/LF and any code unit above 0xFF (dart:io's HttpHeaders throws a +/// FormatException on non-latin-1 — an emoji in an iPhone name would +/// otherwise kill every request), trims, and returns null when nothing +/// usable remains. +String? sanitizeHeaderValue(String? value) { + if (value == null) return null; + final filtered = String.fromCharCodes( + value.codeUnits.where((unit) => unit != 0x0D && unit != 0x0A && unit <= 0xFF), + ); + final trimmed = filtered.trim(); + return trimmed.isEmpty ? null : trimmed; +} diff --git a/test/models/plex_config_test.dart b/test/models/plex_config_test.dart new file mode 100644 index 00000000..452c48df --- /dev/null +++ b/test/models/plex_config_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex/plex_config.dart'; + +void main() { + group('PlexConfig.headers', () { + test('includes X-Plex-Device-Name when deviceName is set', () { + final config = PlexConfig( + baseUrl: 'https://plex.example.com', + clientIdentifier: 'client-1', + product: 'Plezy', + version: '1.0', + platform: 'Windows', + device: 'Windows', + deviceName: 'Living Room PC', + ); + expect(config.headers['X-Plex-Platform'], 'Windows'); + expect(config.headers['X-Plex-Device'], 'Windows'); + expect(config.headers['X-Plex-Device-Name'], 'Living Room PC'); + }); + + test('omits X-Plex-Device-Name and X-Plex-Device when unset', () { + final config = PlexConfig( + baseUrl: 'https://plex.example.com', + clientIdentifier: 'client-1', + product: 'Plezy', + version: '1.0', + ); + expect(config.headers.containsKey('X-Plex-Device-Name'), isFalse); + expect(config.headers.containsKey('X-Plex-Device'), isFalse); + // Raw-constructor default is unchanged for tests that rely on it. + expect(config.headers['X-Plex-Platform'], 'Flutter'); + }); + }); +} diff --git a/test/services/jellyfin_auth_header_test.dart b/test/services/jellyfin_auth_header_test.dart new file mode 100644 index 00000000..3c51bdb7 --- /dev/null +++ b/test/services/jellyfin_auth_header_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/jellyfin_auth_header.dart'; + +void main() { + group('buildJellyfinAuthHeader', () { + test('formats the SDK-style MediaBrowser header', () { + final header = buildJellyfinAuthHeader( + clientName: 'Plezy', + clientVersion: '1.2.3', + deviceName: 'Living Room TV', + deviceId: 'dev-1', + accessToken: 'tok', + ); + expect( + header, + 'MediaBrowser Client="Plezy", Device="Living Room TV", DeviceId="dev-1", Version="1.2.3", Token="tok"', + ); + }); + + test('omits Token when access token is null or empty', () { + for (final token in [null, '']) { + final header = buildJellyfinAuthHeader( + clientName: 'Plezy', + clientVersion: '1.2.3', + deviceName: 'Plezy', + deviceId: 'dev-1', + accessToken: token, + ); + expect(header, isNot(contains('Token='))); + } + }); + + test('strips embedded quotes so a device name cannot corrupt the header', () { + final header = buildJellyfinAuthHeader( + clientName: 'Plezy', + clientVersion: '1.2.3', + deviceName: 'My "cool" TV', + deviceId: 'dev-1', + accessToken: 'tok', + ); + expect(header, contains('Device="My cool TV"')); + }); + }); +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index a3dca817..d115d446 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/playback_initialization_types.dart'; +import 'package:plezy/utils/device_identity.dart'; JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) => JellyfinConnection( @@ -32,6 +33,11 @@ JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'http /// tests pin the contract so the next iteration of the player (Task 8 wiring) /// has something to point at. void main() { + // Pin device identity so JellyfinClient.create's MediaBrowser header falls + // back to Device="Plezy" instead of resolving the host machine's name. + setUpAll(() => DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test'))); + tearDownAll(() => DeviceIdentityService.debugOverride(null)); + group('JellyfinClient URL builders', () { late JellyfinClient client; diff --git a/test/services/jellyfin_trickplay_service_test.dart b/test/services/jellyfin_trickplay_service_test.dart index 56f3fbd4..13575158 100644 --- a/test/services/jellyfin_trickplay_service_test.dart +++ b/test/services/jellyfin_trickplay_service_test.dart @@ -8,6 +8,7 @@ import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/jellyfin_trickplay_service.dart'; import 'package:plezy/services/scrub_preview_source.dart'; +import 'package:plezy/utils/device_identity.dart'; JellyfinConnection _conn() => JellyfinConnection( id: 'srv-1/user-1', @@ -44,6 +45,11 @@ TrickplayInfo _info({ ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0])); void main() { + // Pin device identity so JellyfinClient.create doesn't resolve the host + // machine's name. + setUpAll(() => DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test'))); + tearDownAll(() => DeviceIdentityService.debugOverride(null)); + group('JellyfinTrickplayService.create — width selection', () { late JellyfinClient client; diff --git a/test/utils/device_identity_test.dart b/test/utils/device_identity_test.dart new file mode 100644 index 00000000..507849a5 --- /dev/null +++ b/test/utils/device_identity_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/device_identity.dart'; + +void main() { + group('sanitizeHeaderValue', () { + test('passes plain latin-1 names through trimmed', () { + expect(sanitizeHeaderValue(' Living Room TV '), 'Living Room TV'); + expect(sanitizeHeaderValue("Édouard's Mac"), "Édouard's Mac"); + }); + + test('strips code units above latin-1 (emoji would break dart:io headers)', () { + expect(sanitizeHeaderValue('📱 Bob\'s iPhone'), "Bob's iPhone"); + expect(sanitizeHeaderValue('电视'), isNull); + }); + + test('strips CR/LF', () { + expect(sanitizeHeaderValue('evil\r\nX-Injected: 1'), 'evilX-Injected: 1'); + }); + + test('returns null for null, empty, and whitespace-only input', () { + expect(sanitizeHeaderValue(null), isNull); + expect(sanitizeHeaderValue(''), isNull); + expect(sanitizeHeaderValue(' '), isNull); + }); + }); + + group('DeviceIdentityService.debugOverride', () { + tearDown(() => DeviceIdentityService.debugOverride(null)); + + test('resolve returns the overridden identity', () async { + const identity = DeviceIdentity(platform: 'TestOS', deviceModel: 'Model-X', deviceName: 'Unit Test', isTv: true); + DeviceIdentityService.debugOverride(identity); + final resolved = await DeviceIdentityService.resolve(); + expect(resolved.platform, 'TestOS'); + expect(resolved.deviceModel, 'Model-X'); + expect(resolved.deviceName, 'Unit Test'); + expect(resolved.isTv, isTrue); + }); + + test('a later override replaces the memoized value', () async { + DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'First')); + expect((await DeviceIdentityService.resolve()).platform, 'First'); + DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Second')); + expect((await DeviceIdentityService.resolve()).platform, 'Second'); + }); + }); +}