2026-07-05 01:22:20 +02:00
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
2026-07-28 05:07:00 +02:00
|
|
|
|
import 'package:unorm_dart/unorm_dart.dart';
|
2026-07-05 01:22:20 +02:00
|
|
|
|
|
|
|
|
|
|
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<DeviceIdentity>? _cached;
|
|
|
|
|
|
|
|
|
|
|
|
static Future<DeviceIdentity> resolve() => _cached ??= _resolve();
|
|
|
|
|
|
|
|
|
|
|
|
@visibleForTesting
|
|
|
|
|
|
static void debugOverride(DeviceIdentity? identity) {
|
|
|
|
|
|
_cached = identity == null ? null : Future.value(identity);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static Future<DeviceIdentity> _resolve() async {
|
|
|
|
|
|
final deviceInfo = DeviceInfoPlugin();
|
2026-08-17 01:50:15 +02:00
|
|
|
|
final isTv = PlatformDetector.isTV();
|
2026-07-05 01:22:20 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-17 01:50:15 +02:00
|
|
|
|
if (PlatformDetector.isAppleTV()) {
|
2026-07-05 01:22:20 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 05:07:00 +02:00
|
|
|
|
/// Makes a free-form device name safe to send as an HTTP header value on
|
|
|
|
|
|
/// every transport Plezy uses: folds Latin letters to their base form
|
|
|
|
|
|
/// (`Bjørn PC` → `Bjorn PC`), drops whatever is still outside printable
|
|
|
|
|
|
/// ASCII, trims, and returns null when nothing usable remains.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The ASCII restriction is not cosmetic. `dart:io` rejects header values
|
|
|
|
|
|
/// containing anything above 0x7F with a `FormatException`, and CFNetwork
|
|
|
|
|
|
/// puts the raw code unit on the wire as a Latin-1 byte, which HTTP servers
|
|
|
|
|
|
/// decoding headers as UTF-8 (Kestrel, hosting Jellyfin) reject as a
|
|
|
|
|
|
/// malformed request. Headers with a documented percent-encoded wire format
|
|
|
|
|
|
/// carry the name intact instead — see `buildJellyfinAuthHeader`.
|
2026-07-05 01:22:20 +02:00
|
|
|
|
String? sanitizeHeaderValue(String? value) {
|
|
|
|
|
|
if (value == null) return null;
|
2026-07-28 05:07:00 +02:00
|
|
|
|
final buffer = StringBuffer();
|
|
|
|
|
|
for (final unit in nfd(_foldNonDecomposableLatin(value)).codeUnits) {
|
|
|
|
|
|
if (unit >= 0x20 && unit < 0x7F) buffer.writeCharCode(unit);
|
|
|
|
|
|
}
|
|
|
|
|
|
final trimmed = buffer.toString().trim();
|
2026-07-05 01:22:20 +02:00
|
|
|
|
return trimmed.isEmpty ? null : trimmed;
|
|
|
|
|
|
}
|
2026-07-28 05:07:00 +02:00
|
|
|
|
|
|
|
|
|
|
/// Latin letters NFD leaves alone because they are single code points rather
|
|
|
|
|
|
/// than base + combining mark. Without this, Nordic and Central European
|
|
|
|
|
|
/// device names lose whole letters instead of being transliterated.
|
|
|
|
|
|
const Map<String, String> _nonDecomposableLatin = {
|
|
|
|
|
|
'æ': 'ae',
|
|
|
|
|
|
'Æ': 'AE',
|
|
|
|
|
|
'œ': 'oe',
|
|
|
|
|
|
'Œ': 'OE',
|
|
|
|
|
|
'ø': 'o',
|
|
|
|
|
|
'Ø': 'O',
|
|
|
|
|
|
'ß': 'ss',
|
|
|
|
|
|
'đ': 'd',
|
|
|
|
|
|
'Đ': 'D',
|
|
|
|
|
|
'ð': 'd',
|
|
|
|
|
|
'Ð': 'D',
|
|
|
|
|
|
'þ': 'th',
|
|
|
|
|
|
'Þ': 'Th',
|
|
|
|
|
|
'ł': 'l',
|
|
|
|
|
|
'Ł': 'L',
|
|
|
|
|
|
'ħ': 'h',
|
|
|
|
|
|
'Ħ': 'H',
|
|
|
|
|
|
'ı': 'i',
|
|
|
|
|
|
'ŧ': 't',
|
|
|
|
|
|
'Ŧ': 'T',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
final RegExp _nonDecomposableLatinPattern = RegExp('[${_nonDecomposableLatin.keys.join()}]');
|
|
|
|
|
|
|
|
|
|
|
|
String _foldNonDecomposableLatin(String value) =>
|
|
|
|
|
|
value.replaceAllMapped(_nonDecomposableLatinPattern, (match) => _nonDecomposableLatin[match[0]]!);
|