diff --git a/lib/connection/connection_registry.dart b/lib/connection/connection_registry.dart index 0d8adec36..79ff5bee2 100644 --- a/lib/connection/connection_registry.dart +++ b/lib/connection/connection_registry.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import '../database/app_database.dart'; @@ -16,6 +17,19 @@ class ConnectionRegistry { ConnectionRegistry(this._db); final AppDatabase _db; + static const DeepCollectionEquality _configEquality = DeepCollectionEquality(); + + final Expando> _decryptedConfigs = Expando>( + 'ConnectionRegistry.decryptedConfigs', + ); + + /// Compares the decrypted persisted config projections retained while rows + /// are decoded. The fallback covers models supplied outside this registry. + bool hasSameConfig(Connection a, Connection b) { + final aConfig = _decryptedConfigs[a] ??= a.toConfigJson(); + final bConfig = _decryptedConfigs[b] ??= b.toConfigJson(); + return _configEquality.equals(aConfig, bConfig); + } /// Emits the current set of connections after every mutation. Drift's /// `watch()` provides this for free. @@ -121,6 +135,7 @@ class ConnectionRegistry { dialect: kind.dialect!, ), }; + _decryptedConfigs[connection] = revealed.config; if (revealed.migrated) { await upsert(connection); } diff --git a/lib/main.dart b/lib/main.dart index 573c82ed8..b0706b899 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -57,6 +57,7 @@ import 'utils/snackbar_helper.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; import 'services/data_aggregation_service.dart'; +import 'services/credential_vault.dart'; import 'services/server_registry.dart'; import 'services/download_manager_service.dart'; import 'services/pip_service.dart'; @@ -474,6 +475,11 @@ Future repairStartupStorage( final outcome = unreadableKey != null ? await BaseSharedPreferencesService.dropUnreadableCredential(unreadableKey) : await BaseSharedPreferencesService.repairCorruptStore(reopenSafe: reopenSafe); + // The repair replaced or dropped entries in the backing store, so the vault's + // memoized key and its ciphertext -> plaintext cache now describe a store + // that no longer exists. Drop both before anything reads a credential again, + // or a repaired install could keep serving pre-repair plaintext. + CredentialVault.invalidateCache(); final result = outcome.requiresRestart ? StartupRepairResult.restart : StartupRepairResult.retry; if (!context.mounted) return result; @@ -888,7 +894,6 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async await _optionalGatePhase(StartupPhase.locale, () async { final savedLocale = settings.read(SettingsService.appLocale); await LocaleSettings.setLocale(savedLocale); - await initializeDateFormatting(savedLocale.intlLocaleName, null); }); markStartupPhase('locale'); @@ -930,12 +935,7 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async await _optionalGatePhase(StartupPhase.imageCache, () async => DevicePerformance.applyImageCacheBudget()); - // DownloadManagerService reads this singleton synchronously in MainApp's - // initState, but `getArtworkPathSync` already models "not ready" by - // returning null and the path re-resolves lazily, so offline artwork is - // not a launch requirement. - await _optionalGatePhase(StartupPhase.downloadStorage, () => DownloadStorageService.instance.initialize(settings)); - markStartupPhase('download-storage'); + markStartupPhase('image-cache'); return _StartupDependencies( settings: settings, @@ -950,14 +950,25 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async } void _startNonessentialInitialization(SettingsService settings) { + // `onCommitted` runs before the rebuild that creates MainApp. Share one + // end-of-frame hop so synchronous tasks cannot delay its first frame. + final afterFirstAppFrame = WidgetsBinding.instance.endOfFrame; + void bestEffort(String name, FutureOr Function() action) { unawaited( - Future.sync(action).catchError((Object error, StackTrace stackTrace) { + afterFirstAppFrame.then((_) => action()).catchError((Object error, StackTrace stackTrace) { appLogger.e('$name startup task failed (${error.runtimeType})', stackTrace: stackTrace); }), ); } + bestEffort( + 'Date formatting', + () => initializeDateFormatting(settings.read(SettingsService.appLocale).intlLocaleName, null), + ); + bestEffort('Download storage', () => DownloadStorageService.instance.initialize(settings)); + bestEffort('Trackers', TrackerCoordinator.instance.initialize); + bestEffort('Legacy image cache cleanup', () async { if (settings.read(SettingsService.cleanedOldImageCache)) return; try { @@ -1244,7 +1255,11 @@ class _MainAppState extends State with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); - _startRssWatchdog(); + // The watchdog's first useful sample is at least 15 seconds away; install + // its timer only after the first app frame has completed. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _startRssWatchdog(); + }); _serverManager = MultiServerManager(); _aggregationService = DataAggregationService(_serverManager); @@ -1258,14 +1273,14 @@ class _MainAppState extends State with WidgetsBindingObserver { storageService: DownloadStorageService.instance, clientResolver: _serverManager.resolveDownloadClient, ); - _downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads(); + // Keep the awaitable assigned synchronously, but do not let recovery's + // Drift/native work compete with SetupScreen's first frame. + _downloadManager.recoveryFuture = WidgetsBinding.instance.endOfFrame.then( + (_) => _downloadManager.recoverInterruptedDownloads(), + ); _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); - // Tracker singletons init once per app; per-profile hydration happens in - // the profile-scoped provider subtree's create callbacks. - unawaited(TrackerCoordinator.instance.initialize()); - _appLifecycleListener = AppLifecycleListener( onExitRequested: () async { await _shutdownForExit(); @@ -2220,7 +2235,6 @@ class _SetupScreenState extends State with MountedSetStateMixin { if (_serverStatus.isEmpty) return const SizedBox.shrink(); final textTheme = Theme.of(context).textTheme; final dimColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5); - const coralColor = Color(0xFFE5A00D); const successColor = Color(0xFF4CAF50); const failColor = Color(0xFFEF5350); @@ -2230,11 +2244,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { final (name, connected) = entry.value; final Widget statusIcon; if (connected == null) { - statusIcon = const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor), - ); + statusIcon = AppIcon(Symbols.circle_rounded, size: 10, color: dimColor); } else if (connected) { statusIcon = const AppIcon(Symbols.check_circle_rounded, size: 14, color: successColor); } else { diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart index 5bcbed83a..146b97db6 100644 --- a/lib/profiles/active_profile_provider.dart +++ b/lib/profiles/active_profile_provider.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -55,6 +54,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier StreamSubscription>? _pcSub; StreamSubscription>>? _plexHomeSub; Future? _initializeFuture; + Future? _reloadFromStorageFuture; bool _initialized = false; bool _isBinding = false; @@ -166,11 +166,24 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier /// /// Provider initialization starts before boot-time migration, so the first /// snapshot can legitimately miss the migrated connection/profile state. - Future reloadFromStorage() async { - await initialize(); - await _plexHome.reloadFromStorage(); - await _reloadSnapshot(); - safeNotifyListeners(); + Future reloadFromStorage() { + final pending = _reloadFromStorageFuture; + if (pending != null) return pending; + + final future = _reloadFromStorage(); + _reloadFromStorageFuture = future; + return future; + } + + Future _reloadFromStorage() async { + try { + await initialize(); + await _plexHome.reloadFromStorage(); + await _reloadSnapshot(); + safeNotifyListeners(); + } finally { + _reloadFromStorageFuture = null; + } } Future _initialize() async { @@ -230,23 +243,26 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier _resolveActive(); } - /// [Connection] has no value equality; its persisted config is the cheapest - /// faithful comparison key for the handful of rows involved. + /// The decrypted persisted config is a faithful comparison projection for + /// the handful of rows involved. [ConnectionRegistry] retains that + /// projection alongside each decoded model, avoiding rebuilt config maps + /// and JSON strings on every Drift emit. /// /// `createdAt` is compared separately because it is a real column rather - /// than part of `toConfigJson`, and avatar selection reads it: a profile - /// shows the picture of its oldest linked connection. `ConnectionRegistry` - /// pins creation order across re-authentication, so this costs no extra + /// than part of the config, and avatar selection reads it: a profile shows + /// the picture of its oldest linked connection. `ConnectionRegistry` pins + /// creation order across re-authentication, so this costs no extra /// notifications — it only stops a genuine correction (a restore, a /// backfill) from being swallowed until the next launch. - static bool _sameConnections(Map a, Map b) { + bool _sameConnections(Map a, Map b) { if (a.length != b.length) return false; for (final entry in a.entries) { final other = b[entry.key]; if (other == null) return false; if (identical(entry.value, other)) continue; + if (entry.value.kind != other.kind) return false; if (entry.value.createdAt != other.createdAt) return false; - if (jsonEncode(entry.value.toConfigJson()) != jsonEncode(other.toConfigJson())) return false; + if (!_connections.hasSameConfig(entry.value, other)) return false; } return true; } diff --git a/lib/services/credential_vault.dart b/lib/services/credential_vault.dart index c6146d946..09dc59c07 100644 --- a/lib/services/credential_vault.dart +++ b/lib/services/credential_vault.dart @@ -22,21 +22,43 @@ class CredentialVault { static const String _keyPref = credentialVaultKeyPref; static const String _prefix = 'enc:v1:'; static final AesGcm _algorithm = AesGcm.with256bits(); + static final Map> _decryptionCache = {}; static Future? _secretKey; + static int _cacheGeneration = 0; - /// Drops the memoized key so tests can simulate key loss/divergence. + /// Invalidates the memoized key and decrypted credentials after the + /// underlying preference store is repaired or replaced. + static void invalidateCache() { + _cacheGeneration++; + _secretKey = null; + _decryptionCache.clear(); + } + + /// Drops memoized vault state so tests can simulate key loss/divergence. @visibleForTesting static void resetKeyForTesting() { - _secretKey = null; + invalidateCache(); } static bool isProtected(String? value) => value != null && value.startsWith(_prefix); static Future protect(String value) async { if (value.isEmpty || isProtected(value)) return value; - final key = await _getSecretKey(); - final box = await _algorithm.encrypt(utf8.encode(value), secretKey: key); - return '$_prefix${jsonEncode({'n': base64Encode(box.nonce), 'c': base64Encode(box.cipherText), 'm': base64Encode(box.mac.bytes)})}'; + while (true) { + final generation = _cacheGeneration; + final key = await _getSecretKey(); + final box = await _algorithm.encrypt(utf8.encode(value), secretKey: key); + if (generation != _cacheGeneration) continue; + + final payload = { + 'n': base64Encode(box.nonce), + 'c': base64Encode(box.cipherText), + 'm': base64Encode(box.mac.bytes), + }; + final ciphertext = '$_prefix${jsonEncode(payload)}'; + _decryptionCache[ciphertext] = Future.value(value); + return ciphertext; + } } /// Decrypts a protected value, or returns it unchanged when it isn't @@ -46,6 +68,14 @@ class CredentialVault { /// never a reason to crash; callers treat null as "re-acquire the token". static Future reveal(String value) async { if (!isProtected(value)) return value; + while (true) { + final generation = _cacheGeneration; + final clear = await _decryptionCache.putIfAbsent(value, () => _decrypt(value, generation)); + if (generation == _cacheGeneration) return clear; + } + } + + static Future _decrypt(String value, int generation) async { try { final payload = jsonDecode(value.substring(_prefix.length)) as Map; final box = SecretBox( @@ -56,6 +86,7 @@ class CredentialVault { final clear = await _algorithm.decrypt(box, secretKey: await _getSecretKey()); return utf8.decode(clear); } catch (e) { + if (generation != _cacheGeneration) return null; appLogger.w('CredentialVault: failed to decrypt stored credential, treating as lost', error: e); return null; } diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 04c78d884..5b87737c9 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -1,8 +1,22 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'gapped_track_shape.dart'; import 'mono_tokens.dart'; +final Map<({bool dark, bool oled, TargetPlatform platform}), ThemeData> _monoThemeCache = {}; + ThemeData monoTheme({required bool dark, bool oled = false}) { + // ThemeData derives several defaults from defaultTargetPlatform. + final key = (dark: dark || oled, oled: oled, platform: defaultTargetPlatform); + final cached = _monoThemeCache[key]; + if (cached != null) return cached; + + final theme = _buildMonoTheme(dark: key.dark, oled: key.oled, platform: key.platform); + _monoThemeCache[key] = theme; + return theme; +} + +ThemeData _buildMonoTheme({required bool dark, required bool oled, required TargetPlatform platform}) { // neutral greys tuned for crisp contrast final ({Color bg, Color surface, Color outline, Color text, Color textMuted}) c; if (oled) { @@ -46,6 +60,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { ); final base = ThemeData( + platform: platform, useMaterial3: true, brightness: isDark ? Brightness.dark : Brightness.light, colorScheme: ColorScheme( diff --git a/test/theme/mono_theme_test.dart b/test/theme/mono_theme_test.dart new file mode 100644 index 000000000..588752f6b --- /dev/null +++ b/test/theme/mono_theme_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/theme/mono_theme.dart'; + +/// `monoTheme` builds a full `ColorScheme`, an applied+copyWith'd Typography +/// `TextTheme`, ~14 sub-theme objects, and then clones the whole `ThemeData` +/// again via `copyWith(extensions:)`. It used to run five times per cold start — +/// two of them before `runApp`, on the critical path to first frame — and twice +/// more on every app-shell rebuild. It is memoized now, so these tests pin the +/// two properties that make that safe: stable identity per palette, and a cache +/// key that includes every input the builder actually reads. +void main() { + test('repeated calls return the identical instance per palette', () { + expect(identical(monoTheme(dark: false), monoTheme(dark: false)), isTrue); + expect(identical(monoTheme(dark: true), monoTheme(dark: true)), isTrue); + expect(identical(monoTheme(dark: true, oled: true), monoTheme(dark: true, oled: true)), isTrue); + }); + + test('the three palettes are distinct instances', () { + final light = monoTheme(dark: false); + final dark = monoTheme(dark: true); + final oled = monoTheme(dark: true, oled: true); + + expect(identical(light, dark), isFalse); + expect(identical(dark, oled), isFalse); + expect(light.brightness, Brightness.light); + expect(dark.brightness, Brightness.dark); + // OLED is the pure-black variant, which is the whole reason it exists. + expect(oled.scaffoldBackgroundColor, const Color(0xFF000000)); + expect(dark.scaffoldBackgroundColor, isNot(const Color(0xFF000000))); + }); + + test('oled normalizes dark, so it cannot produce a light OLED theme', () { + // ThemeProvider.darkThemeFor only ever asks for oled alongside dark, but the + // signature allows dark:false — collapsing it keeps the cache at three + // palette states instead of four, and a light OLED theme is meaningless. + expect(identical(monoTheme(dark: false, oled: true), monoTheme(dark: true, oled: true)), isTrue); + expect(monoTheme(dark: false, oled: true).brightness, Brightness.dark); + }); + + test('palette colours are unchanged by memoization', () { + // Guards against a cache key that accidentally collapses two palettes. + final light = monoTheme(dark: false); + final dark = monoTheme(dark: true); + + expect(light.colorScheme.brightness, Brightness.light); + expect(dark.colorScheme.brightness, Brightness.dark); + expect(light.scaffoldBackgroundColor, isNot(dark.scaffoldBackgroundColor)); + expect(light.colorScheme.onSurface, isNot(dark.colorScheme.onSurface)); + }); + + group('target platform is part of the cache key', () { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + test('each platform gets a theme carrying that platform', () { + // ThemeData() derives platform defaults (tap target size, visual density, + // typography) from defaultTargetPlatform. Keying only on the palette would + // hand an Android-derived theme to a test or debug run overriding to iOS. + for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) { + debugDefaultTargetPlatformOverride = platform; + expect(monoTheme(dark: true).platform, platform); + } + }); + + test('two platforms do not share an instance, and returning restores identity', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final android = monoTheme(dark: true); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final ios = monoTheme(dark: true); + expect(identical(android, ios), isFalse); + + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(identical(monoTheme(dark: true), android), isTrue); + }); + }); +}