chore: restore product scaffold to rollback baseline

This commit is contained in:
Edison Jwa
2026-05-29 14:02:04 +09:00
parent 2896f14ec9
commit fe6e07353e
434 changed files with 27278 additions and 63230 deletions
@@ -18,19 +18,17 @@ Future<void>? _storageInitFuture;
Future<void>? _vadBootstrapFuture;
StorageDirectoryProvider _storageDirectoryProvider =
getApplicationSupportDirectory;
StorageDirectoryProvider _vadModelDirectoryProvider =
getApplicationSupportDirectory;
StorageInitializer _storageInitializer = _defaultStorageInitializer;
Future<void> _defaultStorageInitializer(String dir) {
return rust.initStorage(dir: dir);
}
Future<File> _copyBundledAssetToManagedDirectory({
Future<File> _copyBundledAssetToDocuments({
required String assetPath,
required String fileName,
}) async {
final dir = await _vadModelDirectoryProvider();
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
@@ -61,7 +59,7 @@ Future<void> configureBundledVadModels() async {
}
Future<void> _configureBundledVadModelsImpl() async {
final silero = await _copyBundledAssetToManagedDirectory(
final silero = await _copyBundledAssetToDocuments(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
@@ -126,15 +124,12 @@ Future<void> _wireStorageImpl() async {
@visibleForTesting
void debugResetStorageBootstrap({
StorageDirectoryProvider? storageDirectoryProvider,
StorageDirectoryProvider? vadModelDirectoryProvider,
StorageInitializer? storageInitializer,
}) {
_storageInitFuture = null;
_vadBootstrapFuture = null;
_storageDirectoryProvider =
storageDirectoryProvider ?? getApplicationSupportDirectory;
_vadModelDirectoryProvider =
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
}
@@ -35,6 +35,12 @@ extension ConnectionPhaseState on ConnectionPhase {
bool get canDisconnect => canOpenChat;
bool shouldShowSnapshotLoading({required bool hasSnapshot}) =>
isServerReachable && !hasSnapshot;
bool canOpenChatWithSnapshot({required bool hasSnapshot}) =>
canOpenChat && hasSnapshot;
ConnectionTokens tokens(ColorScheme colorScheme) {
switch (this) {
case ConnectionPhase.idle:
@@ -52,6 +58,15 @@ extension ConnectionPhaseState on ConnectionPhase {
}
}
ConnectionPhase phaseAfterConnectedEvent(
ConnectionPhase phase, {
required bool hasSnapshot,
}) {
return phase == ConnectionPhase.connected && hasSnapshot
? ConnectionPhase.connected
: ConnectionPhase.synchronizing;
}
String connectionStatusText({
required ConnectionPhase phase,
required AppL10n l10n,
@@ -77,3 +92,9 @@ String connectionStatusText({
return l10n.statusIdle;
}
}
ConnectionPhase phaseAfterSnapshotApplied(ConnectionPhase phase) {
return phase == ConnectionPhase.synchronizing
? ConnectionPhase.connected
: phase;
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LinkTrustService extends ChangeNotifier {
@@ -54,42 +56,45 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Open external link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You are about to open a link to:\n\n$domain'),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
builder: (ctx, setDialogState) {
final l10n = AppL10n.of(ctx);
return AlertDialog(
title: Text(l10n.linkTrustTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.linkTrustBody(domain)),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
),
),
),
const SizedBox(width: 8),
const Flexible(child: Text('Trust all links from this domain')),
],
const SizedBox(width: 8),
Flexible(child: Text(l10n.linkTrustRememberDomain)),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: Text(l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: Text(l10n.openAction),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: const Text('Open'),
),
],
),
);
},
),
);
}
@@ -0,0 +1,31 @@
import 'dart:async';
class PrefetchDebouncer {
PrefetchDebouncer({
required this.onPrefetch,
this.delay = const Duration(milliseconds: 700),
});
final Future<void> Function(String host) onPrefetch;
final Duration delay;
Timer? _timer;
bool _disposed = false;
void schedule(String rawHost) {
if (_disposed) return;
_timer?.cancel();
final host = rawHost.trim();
if (host.isEmpty) return;
_timer = Timer(delay, () {
if (_disposed) return;
unawaited(onPrefetch(host));
});
}
void dispose() {
_disposed = true;
_timer?.cancel();
_timer = null;
}
}
@@ -1,444 +0,0 @@
import 'dart:ffi';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import '../src/rust/api.dart' as rust;
class StartupDependencyCheckResult {
const StartupDependencyCheckResult({
required this.issues,
required this.platformLabel,
});
final List<StartupDependencyIssue> issues;
final String platformLabel;
bool get hasIssues => issues.isNotEmpty;
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
}
class StartupDependencyIssue {
const StartupDependencyIssue({
required this.id,
required this.title,
required this.summary,
required this.details,
required this.severity,
this.installHints = const [],
});
final String id;
final String title;
final String summary;
final List<String> details;
final StartupDependencySeverity severity;
final List<StartupInstallHint> installHints;
bool get isRequired => severity == StartupDependencySeverity.required;
}
class StartupInstallHint {
const StartupInstallHint({required this.label, required this.command});
final String label;
final String command;
}
enum StartupDependencySeverity { required, recommended }
enum _LinuxDistro { debian, fedora, arch, other }
enum _LinuxArch { x64, arm64, other }
typedef _LibraryProbe = bool Function(String candidate);
typedef _FileExists = Future<bool> Function(String path);
typedef _ResolvedExecutableProvider = String Function();
typedef _CurrentDirectoryProvider = String Function();
typedef _OsReleaseProvider = Future<String?> Function();
typedef _PlatformIsLinux = bool Function();
typedef _LogFilePathProvider = String Function();
typedef _CurrentAbiProvider = Abi Function();
_LibraryProbe _libraryProbe = _defaultLibraryProbe;
_FileExists _fileExists = _defaultFileExists;
_ResolvedExecutableProvider _resolvedExecutableProvider = () =>
Platform.resolvedExecutable;
_CurrentDirectoryProvider _currentDirectoryProvider = () =>
Directory.current.path;
_OsReleaseProvider _osReleaseProvider = _defaultOsReleaseProvider;
_PlatformIsLinux _platformIsLinux = () => Platform.isLinux;
_LogFilePathProvider _logFilePathProvider = rust.logFilePathStr;
_CurrentAbiProvider _currentAbiProvider = Abi.current;
Future<void> logStartupDependencyIssues(
StartupDependencyCheckResult result,
) async {
if (!result.hasIssues) return;
final path = _logFilePathProvider();
if (path.isEmpty) return;
final issues = result.issues
.map(
(issue) =>
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}:'
'${_sanitizeLogValue(issue.title)}',
)
.join(', ');
final line =
'${DateTime.now().toUtc().toIso8601String()} '
'[startup_dependency_check] '
'platform="${result.platformLabel}" '
'blocking=${result.hasBlockingIssues} '
'issues=[$issues]';
try {
await File(
path,
).writeAsString('$line\n', mode: FileMode.append, flush: true);
} catch (_) {
// Best-effort only: a missing/unwritable log file must not block app startup.
}
}
String _sanitizeLogValue(String value) => value.replaceAll('"', "'");
Future<StartupDependencyCheckResult> checkStartupDependencies() async {
if (!_platformIsLinux()) {
return const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
);
}
final distro = await _detectLinuxDistro();
final arch = _detectLinuxArch();
final executableDir = File(_resolvedExecutableProvider()).parent.path;
final issues = <StartupDependencyIssue>[];
if (!_hasAnyLoadableLibrary(const [
'libpipewire-0.3.so.0',
'libpipewire-0.3.so',
])) {
issues.add(_buildPipeWireIssue(distro));
}
if (!_hasAnyLoadableLibrary(const [
'libpulse.so.0',
'libpulse.so',
'libpulse-simple.so.0',
'libpulse-simple.so',
])) {
issues.add(_buildPulseAudioIssue(distro));
}
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
issues.add(_buildOnnxIssue(executableDir: executableDir, arch: arch));
}
return StartupDependencyCheckResult(
issues: issues,
platformLabel: switch (distro) {
_LinuxDistro.debian => 'Debian / Ubuntu',
_LinuxDistro.fedora => 'Fedora',
_LinuxDistro.arch => 'Arch Linux',
_LinuxDistro.other => 'Linux',
},
);
}
bool _defaultLibraryProbe(String candidate) {
try {
DynamicLibrary.open(candidate);
return true;
} catch (_) {
return false;
}
}
Future<bool> _defaultFileExists(String path) => File(path).exists();
Future<String?> _defaultOsReleaseProvider() async {
const path = '/etc/os-release';
final file = File(path);
if (!await file.exists()) {
return null;
}
return file.readAsString();
}
bool _hasAnyLoadableLibrary(List<String> candidates) {
for (final candidate in candidates) {
if (_libraryProbe(candidate)) {
return true;
}
}
return false;
}
Future<bool> _hasOnnxRuntime({required String executableDir}) async {
final envPath = Platform.environment['ORT_DYLIB_PATH'];
if (envPath != null && envPath.isNotEmpty && await _fileExists(envPath)) {
return true;
}
final candidates = <String>{
'$executableDir/lib/libonnxruntime.so',
'$executableDir/libonnxruntime.so',
'${_currentDirectoryProvider()}/libonnxruntime.so',
'/usr/lib/libonnxruntime.so',
'/usr/lib64/libonnxruntime.so',
'/usr/local/lib/libonnxruntime.so',
'/lib/x86_64-linux-gnu/libonnxruntime.so',
'/usr/lib/x86_64-linux-gnu/libonnxruntime.so',
'/lib/aarch64-linux-gnu/libonnxruntime.so',
'/usr/lib/aarch64-linux-gnu/libonnxruntime.so',
};
for (final candidate in candidates) {
if (await _fileExists(candidate)) {
return true;
}
}
for (final dir in const [
'/usr/lib',
'/usr/lib64',
'/usr/local/lib',
'/usr/lib/x86_64-linux-gnu',
'/usr/lib/aarch64-linux-gnu',
]) {
final match = await _firstMatchingDirEntry(dir, 'libonnxruntime.so');
if (match != null) {
return true;
}
}
return _hasAnyLoadableLibrary(const ['libonnxruntime.so']);
}
Future<String?> _firstMatchingDirEntry(String dir, String prefix) async {
final directory = Directory(dir);
if (!await directory.exists()) {
return null;
}
await for (final entity in directory.list(followLinks: false)) {
if (entity is! File) {
continue;
}
final name = entity.uri.pathSegments.isEmpty
? ''
: entity.uri.pathSegments.last;
if (name == prefix || name.startsWith('$prefix.')) {
return entity.path;
}
}
return null;
}
Future<_LinuxDistro> _detectLinuxDistro() async {
final content = await _osReleaseProvider();
if (content == null || content.isEmpty) {
return _LinuxDistro.other;
}
final fields = _parseOsRelease(content);
final id = fields['id'] ?? '';
final idLike = (fields['id_like'] ?? '').split(RegExp(r'\s+'));
final ids = {id, ...idLike};
if (ids.contains('fedora')) {
return _LinuxDistro.fedora;
}
if (ids.contains('ubuntu') || ids.contains('debian')) {
return _LinuxDistro.debian;
}
if (ids.contains('arch')) {
return _LinuxDistro.arch;
}
return _LinuxDistro.other;
}
Map<String, String> _parseOsRelease(String content) {
final fields = <String, String>{};
for (final line in content.split('\n')) {
final separator = line.indexOf('=');
if (separator <= 0) continue;
final key = line.substring(0, separator).trim().toLowerCase();
var value = line.substring(separator + 1).trim().toLowerCase();
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
value = value.substring(1, value.length - 1);
}
fields[key] = value;
}
return fields;
}
_LinuxArch _detectLinuxArch() {
return switch (_currentAbiProvider()) {
Abi.linuxX64 => _LinuxArch.x64,
Abi.linuxArm64 => _LinuxArch.arm64,
_ => _LinuxArch.other,
};
}
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpipewire-0.3-0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pipewire-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S pipewire',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary:
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
details: const [
'Install the PipeWire runtime package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpulse0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pulseaudio-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S libpulse',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pulseaudio-runtime',
title: 'PulseAudio runtime is missing',
summary:
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
details: const [
'Install the PulseAudio client library package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildOnnxIssue({
required String executableDir,
required _LinuxArch arch,
}) {
final bundlePath = '$executableDir/lib/libonnxruntime.so';
final archivePrefix = switch (arch) {
_LinuxArch.x64 => 'onnxruntime-linux-x64-<version>.tgz',
_LinuxArch.arm64 => 'onnxruntime-linux-aarch64-<version>.tgz',
_LinuxArch.other => 'onnxruntime-linux-<arch>-<version>.tgz',
};
final archiveDescription = switch (arch) {
_LinuxArch.x64 =>
'This machine needs the Linux x64 CPU archive ($archivePrefix).',
_LinuxArch.arm64 =>
'This machine needs the Linux ARM64 / aarch64 CPU archive ($archivePrefix).',
_LinuxArch.other =>
'Download the Linux CPU archive that matches this machine ($archivePrefix).',
};
return StartupDependencyIssue(
id: 'linux-onnxruntime',
title: 'ONNX Runtime is not available',
summary:
'Silero voice activity detection needs libonnxruntime.so. Chanora can continue, but it will fall back to simpler voice detection.',
details: [
archiveDescription,
'Open the official ONNX Runtime releases page, download the matching Linux CPU archive, then extract it.',
'Inside the extracted archive, use the file in lib/ named libonnxruntime.so (or the versioned libonnxruntime.so.* file it points to).',
'Use a Chanora build that already bundles ONNX Runtime, or place libonnxruntime.so in the app bundle lib/ directory.',
'You can also point Chanora at an existing ONNX Runtime shared library with ORT_DYLIB_PATH.',
],
severity: StartupDependencySeverity.recommended,
installHints: [
StartupInstallHint(
label: switch (arch) {
_LinuxArch.x64 => 'Download Linux x64 archive',
_LinuxArch.arm64 => 'Download Linux ARM64 archive',
_LinuxArch.other => 'Open release downloads',
},
command: 'https://github.com/microsoft/onnxruntime/releases',
),
StartupInstallHint(
label: 'Archive name to look for',
command: archivePrefix,
),
const StartupInstallHint(
label: 'Official install reference',
command: 'https://onnxruntime.ai/docs/install/',
),
const StartupInstallHint(
label: 'Temporary shell setup',
command: 'export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so',
),
StartupInstallHint(
label: 'Bundle into this app',
command: 'cp /absolute/path/to/libonnxruntime.so $bundlePath',
),
StartupInstallHint(label: 'Bundled file location', command: bundlePath),
],
);
}
@visibleForTesting
void debugResetStartupDependencyCheck({
bool Function(String)? libraryProbe,
Future<bool> Function(String path)? fileExists,
String Function()? resolvedExecutableProvider,
String Function()? currentDirectoryProvider,
Future<String?> Function()? osReleaseProvider,
bool Function()? platformIsLinux,
String Function()? logFilePathProvider,
Abi Function()? currentAbiProvider,
}) {
_libraryProbe = libraryProbe ?? _defaultLibraryProbe;
_fileExists = fileExists ?? _defaultFileExists;
_resolvedExecutableProvider =
resolvedExecutableProvider ?? (() => Platform.resolvedExecutable);
_currentDirectoryProvider =
currentDirectoryProvider ?? (() => Directory.current.path);
_osReleaseProvider = osReleaseProvider ?? _defaultOsReleaseProvider;
_platformIsLinux = platformIsLinux ?? (() => Platform.isLinux);
_logFilePathProvider = logFilePathProvider ?? rust.logFilePathStr;
_currentAbiProvider = currentAbiProvider ?? Abi.current;
}
@@ -1,53 +1,35 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
enum UiThemeMode {
system,
light,
dark;
static UiThemeMode fromStorage(String? value) {
return UiThemeMode.values.firstWhere(
(mode) => mode.name == value,
orElse: () => UiThemeMode.system,
);
}
}
class UiSettings {
const UiSettings({this.host = '', this.nickname = ''});
const UiSettings({
this.host = '',
this.nickname = '',
this.themeMode = UiThemeMode.system,
});
final String host;
final String nickname;
}
class ClientPlaybackPreference {
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
final double volume;
final bool muted;
double get appliedVolume => muted ? 0.0 : volume;
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
return ClientPlaybackPreference(
volume: _clampVolume(volume ?? this.volume),
muted: muted ?? this.muted,
);
}
Map<String, Object> toJson() => {
'volume': _clampVolume(volume),
'muted': muted,
};
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
final volume = json['volume'];
return ClientPlaybackPreference(
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
muted: json['muted'] as bool? ?? false,
);
}
static double _clampVolume(double volume) {
if (!volume.isFinite) return 1.0;
return volume.clamp(0.0, 4.0);
}
final UiThemeMode themeMode;
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _themeModeKey = 'ui.theme_mode';
static const _permissionsExplainedKey = 'perms_explained';
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
const UiPreferencesService();
@@ -56,6 +38,7 @@ class UiPreferencesService {
return UiSettings(
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
);
}
@@ -65,6 +48,11 @@ class UiPreferencesService {
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
}
Future<void> saveThemeMode(UiThemeMode themeMode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeModeKey, themeMode.name);
}
Future<bool> hasExplainedPermissions() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_permissionsExplainedKey) ?? false;
@@ -74,80 +62,4 @@ class UiPreferencesService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_permissionsExplainedKey, true);
}
Future<Map<String, ClientPlaybackPreference>>
loadClientPlaybackPreferencesForServer(String serverHost) async {
final normalizedHost = _normalizeServerHost(serverHost);
if (normalizedHost.isEmpty) return const {};
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_clientPlaybackPrefsKey);
if (raw == null || raw.isEmpty) return const {};
final decoded = _decodeClientPlaybackPreferences(raw);
final result = <String, ClientPlaybackPreference>{};
for (final entry in decoded.entries) {
final key = entry.key;
final value = entry.value;
final (host, uid) = _splitCompositeKey(key);
if (host != normalizedHost ||
uid.isEmpty ||
value is! Map<String, dynamic>) {
continue;
}
result[uid] = ClientPlaybackPreference.fromJson(value);
}
return result;
}
Future<void> saveClientPlaybackPreference({
required String serverHost,
required String userUid,
double? volume,
bool? muted,
}) async {
final normalizedHost = _normalizeServerHost(serverHost);
final normalizedUid = userUid.trim();
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final current = await loadClientPlaybackPreferencesForServer(serverHost);
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
final updated = previous.copyWith(volume: volume, muted: muted);
final raw = prefs.getString(_clientPlaybackPrefsKey);
final decoded = _decodeClientPlaybackPreferences(raw);
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
if (updated.volume == 1.0 && !updated.muted) {
decoded.remove(compositeKey);
} else {
decoded[compositeKey] = updated.toJson();
}
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
}
String _compositeKey(String normalizedHost, String normalizedUid) {
return '$normalizedHost|$normalizedUid';
}
(String, String) _splitCompositeKey(String key) {
final index = key.indexOf('|');
if (index == -1) return ('', '');
return (key.substring(0, index), key.substring(index + 1));
}
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
if (raw == null || raw.isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
}