diff --git a/lib/main.dart b/lib/main.dart index 557d3263..947a81ec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1031,6 +1031,7 @@ class _SetupScreenState extends State { try { final connRegistry = context.read(); final profileRegistry = context.read(); + final activeProfiles = context.read(); final bootstrap = ConnectionBootstrap( storage: storage, connectionRegistry: connRegistry, @@ -1038,6 +1039,11 @@ class _SetupScreenState extends State { profileRegistry: profileRegistry, ); await bootstrap.run(); + // Provider initialization starts before this screen runs the legacy + // migration. Reload after bootstrap so copied Plex Home users and the + // selected active profile are visible before setup decides binding is + // already settled and navigates to MainScreen. + await activeProfiles.reloadFromStorage(); } catch (e, st) { appLogger.w('Boot-time migration failed', error: e, stackTrace: st); } @@ -1129,9 +1135,15 @@ class _SetupScreenState extends State { // profile to bind. `initialize` is fire-and-forget at provider creation, // so awaiting here pulls control through the same future and triggers // the listener-driven rebind synchronously. - await activeProfile.initialize(); + await activeProfile.reloadFromStorage(); if (!mounted) return; + if (activeProfile.active == null && activeProfile.profiles.isEmpty) { + appLogger.w('Setup: stored connections exist but no profiles resolved after bootstrap; returning to auth'); + unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); + return; + } + // Wire the per-server status listener before either branch so the splash // checkmarks fill in even while the user is choosing a profile. _bindServerStatusListener(activeProfile, _serverManagerFromContext); diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart index edbf47b0..ccec6c6a 100644 --- a/lib/profiles/active_profile_provider.dart +++ b/lib/profiles/active_profile_provider.dart @@ -125,18 +125,19 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier return future; } - Future _initialize() async { - _storage ??= await StorageService.getInstance(); - // Hydrate the Plex Home cache before we read it — `_plexHome.current` - // is only populated after start() finishes its disk-cache load. - await _plexHome.start(); + /// Re-read connections, Plex Home cache, local profiles, and active id. + /// + /// 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(); + } - _localProfiles = await _registry.list(); - final initialConns = await _connections.list(); - _connectionsById = {for (final c in initialConns) c.id: c}; - _plexHomeUsers = _plexHome.current; - _recomputeProfiles(); - _resolveActive(); + Future _initialize() async { + await _reloadSnapshot(); _localSub = _registry.watchProfiles().listen((list) { _localProfiles = list; @@ -161,6 +162,20 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier safeNotifyListeners(); } + Future _reloadSnapshot() async { + _storage ??= await StorageService.getInstance(); + // Hydrate the Plex Home cache before we read it — `_plexHome.current` + // is only populated after start() finishes its disk-cache load. + await _plexHome.start(); + + _localProfiles = await _registry.list(); + final initialConns = await _connections.list(); + _connectionsById = {for (final c in initialConns) c.id: c}; + _plexHomeUsers = _plexHome.current; + _recomputeProfiles(); + _resolveActive(); + } + void _recomputeProfiles() { _profiles = mergeLocalWithPlexHome( locals: _localProfiles, diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index f49edd98..2a778607 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -82,6 +82,36 @@ class PlexHomeService { return future; } + /// Re-read per-connection Plex Home user caches from storage. + /// + /// This is used after boot-time legacy migration. The service is started + /// before [ConnectionBootstrap] runs, so it may have already missed the + /// copied `plex_home_users_{connectionId}` cache and new connection row. + Future reloadFromStorage() async { + await start(); + _storage ??= await StorageService.getInstance(); + + final current = await _connections.list(); + final plexIds = current.whereType().map((c) => c.id).toSet(); + var changed = false; + + for (final id in _byConnection.keys.toList()) { + if (!plexIds.contains(id)) { + _byConnection.remove(id); + changed = true; + } + } + + for (final conn in current.whereType()) { + final cached = _readCache(conn.id); + if (cached == null) continue; + _byConnection[conn.id] = cached; + changed = true; + } + + if (changed) _emit(); + } + Future _start() async { _storage ??= await StorageService.getInstance(); diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index fb698f4e..6fc8c386 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -835,6 +835,7 @@ class _DiscoverScreenState extends State ); if (confirm && mounted) { + final navigator = Navigator.of(context, rootNavigator: true); // Use comprehensive logout through UserProfileProvider final userProfileProvider = Provider.of(context, listen: false); final multiServerProvider = context.read(); @@ -862,11 +863,9 @@ class _DiscoverScreenState extends State await hiddenLibrariesProvider.refresh(); playbackStateProvider.clearShuffle(); - if (mounted) { + if (navigator.mounted) { unawaited( - Navigator.of( - context, - ).pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false), + navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false), ); } } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index ca78041d..5ae597cb 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -28,6 +28,7 @@ import '../../widgets/app_icon.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../libraries/state_messages.dart'; +import '../auth_screen.dart'; import 'add_local_profile_screen.dart'; import 'profile_detail_screen.dart'; @@ -245,9 +246,19 @@ class _ProfileSwitchScreenState extends State { .where((p) => p.id != activeProfile?.id && p.parentConnectionId != parentId) .toList(); final binder = context.read(); + final navigator = Navigator.of(context, rootNavigator: true); try { await connRegistry.remove(parentId); + final noConnectionsLeft = (await connRegistry.list()).isEmpty; + if (noConnectionsLeft) { + await active.clearActiveProfile(); + unawaited(binder.rebindActive()); + if (navigator.mounted) { + unawaited(navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (_) => const AuthScreen()), (_) => false)); + } + return; + } if (!mounted) return; // If the active virtual profile belonged to the removed account, make // the storage state explicit instead of relying on provider fallback. diff --git a/test/profiles/active_profile_provider_test.dart b/test/profiles/active_profile_provider_test.dart index ffc82fee..bbe01c27 100644 --- a/test/profiles/active_profile_provider_test.dart +++ b/test/profiles/active_profile_provider_test.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; @@ -13,6 +15,31 @@ import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; +PlexHomeUser _homeUser(String uuid, {String name = 'Home User'}) { + return PlexHomeUser( + id: 1, + uuid: uuid, + title: name, + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: true, + guest: false, + protected: false, + ); +} + +PlexAccountConnection _account(String id) { + return PlexAccountConnection( + id: id, + accountToken: 'token-$id', + clientIdentifier: 'client-$id', + accountLabel: 'Plex', + createdAt: DateTime(2026, 1, 1), + ); +} + void main() { late AppDatabase db; late ProfileRegistry registry; @@ -20,6 +47,7 @@ void main() { late PlexHomeService plexHome; late ActiveProfileProvider provider; late StorageService storage; + late List fetchedHomeUsers; setUp(() async { resetSharedPreferencesForTest(); @@ -27,12 +55,12 @@ void main() { registry = ProfileRegistry(db); connections = ConnectionRegistry(db); storage = await StorageService.getInstance(); + fetchedHomeUsers = const []; plexHome = PlexHomeService( connections: connections, profileConnections: ProfileConnectionRegistry(db), storage: storage, - // No accounts in tests, so the fetcher is never called. - plexHomeUserFetcher: (_) async => const [], + plexHomeUserFetcher: (_) async => fetchedHomeUsers, ); provider = ActiveProfileProvider( registry: registry, @@ -77,6 +105,25 @@ void main() { expect(provider.activeId, isNull); }); + test('reloadFromStorage resolves Plex Home profile added after early initialize', () async { + await provider.initialize(); + expect(provider.profiles, isEmpty); + + final account = _account('plex.migrated'); + final user = _homeUser('home-user-1', name: 'Migrated User'); + fetchedHomeUsers = [user]; + await connections.upsert(account); + await storage.savePlexHomeUsersCache(account.id, [user.toJson()]); + final profileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: user.uuid); + await storage.setActiveProfileId(profileId); + + await provider.reloadFromStorage(); + + expect(provider.profiles.map((p) => p.id), contains(profileId)); + expect(provider.activeId, profileId); + expect(provider.active?.displayName, 'Migrated User'); + }); + test('initialize clears storage when stored id is stale', () async { // A previously-active profile that was deleted should not keep // storage-scoped settings under the removed profile id. diff --git a/test/profiles/plex_home_service_test.dart b/test/profiles/plex_home_service_test.dart index a63c91d5..cf7097c5 100644 --- a/test/profiles/plex_home_service_test.dart +++ b/test/profiles/plex_home_service_test.dart @@ -110,6 +110,31 @@ void main() { expect(service.current[acct.id]!.first.uuid, 'seeded-uuid'); }); + test('reloadFromStorage picks up caches written after startup', () async { + final refreshBlocker = Completer>(); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) => refreshBlocker.future, + ); + addTearDown(() { + if (!refreshBlocker.isCompleted) refreshBlocker.complete(const []); + }); + + await service.start(); + expect(service.current, isEmpty); + + final acct = _account('plex.migrated'); + await connections.upsert(acct); + await storage.savePlexHomeUsersCache(acct.id, [_user('migrated-home-user').toJson()]); + + await service.reloadFromStorage(); + + expect(service.current[acct.id], hasLength(1)); + expect(service.current[acct.id]!.single.uuid, 'migrated-home-user'); + }); + test('concurrent start calls await the same in-flight startup', () async { service = PlexHomeService( connections: connections,