import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../connection/connection.dart'; import '../connection/connection_registry.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; import '../models/plex/plex_home_user.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import 'plex_home_service.dart'; import 'profile.dart'; import 'profile_avatar_source.dart'; import 'profile_connection.dart'; import 'profile_connection_registry.dart'; import 'profile_merge.dart'; import 'profile_registry.dart'; /// Holds the currently active [Profile] and a merged list of all available /// profiles — local rows from [ProfileRegistry] plus virtual Plex Home /// profiles built from [PlexHomeService]'s live cache. /// /// The active id (in storage) can reference either a local row or a Plex /// Home virtual id like `plex-home-{connId}-{uuid}`. Resolution checks /// local profiles first, then live home users; if neither matches we fall /// back to the first profile in the merged list. class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { ActiveProfileProvider({ required this._registry, required this._plexHome, required this._connections, required this._profileConnections, this._storage, this._activeProfileIdWriter, }); final ProfileRegistry _registry; final PlexHomeService _plexHome; final ConnectionRegistry _connections; final ProfileConnectionRegistry _profileConnections; StorageService? _storage; final Future Function(String profileId)? _activeProfileIdWriter; Profile? _active; List _profiles = const []; List _localProfiles = const []; Map> _plexHomeUsers = const {}; Map _connectionsById = const {}; Map> _connectionsByProfile = const {}; Map _avatarUrls = const {}; StreamSubscription>? _localSub; StreamSubscription>? _connSub; StreamSubscription>? _pcSub; StreamSubscription>>? _plexHomeSub; Future? _initializeFuture; bool _initialized = false; bool _isBinding = false; bool _lastBindingSucceeded = true; final List> _bindingSettleWaiters = []; Future? _identityMutationQueue; int _identityMutationGeneration = 0; int _committedIdentityGeneration = 0; int _pendingIdentityMutations = 0; final Map> _identityMutationReservations = {}; Profile? get active => _active; String? get activeId => _active?.id; List get profiles => _profiles; /// Derived picture URL for [profileId], or null when initials should render. String? avatarUrlFor(String profileId) => _avatarUrls[profileId]; bool get hasMultipleProfiles => _profiles.length > 1; bool get isInitialized => _initialized; /// Monotonically identifies the last active-profile identity that /// successfully committed. Failed and cancelled activation attempts do not /// advance it. int get committedIdentityGeneration => _committedIdentityGeneration; int get identityMutationGeneration => _identityMutationGeneration; /// True while [ActiveProfileBinder] is wiring servers/tokens for the /// active profile. The picker reads this so it can stay open (and stay /// behind any PIN dialog the binder pops) until binding settles. bool get isBinding => _isBinding; /// Outcome of the most recent settled bind. False after a PIN cancel, /// failed `/home/users/{uuid}/switch`, or any error inside the binder. bool get lastBindingSucceeded => _lastBindingSucceeded; /// Called by [ActiveProfileBinder] at the start of a rebind cycle. /// Re-entrant — no-ops if already in [isBinding]. void markBindingStarted() { if (_isBinding) return; _isBinding = true; _lastBindingSucceeded = true; safeNotifyListeners(); } /// Called by [ActiveProfileBinder] when the rebind settles. [success] /// means the active profile was applied successfully. A local profile with /// no connections is successful and intentionally exposes no servers; false /// covers the "user cancelled the PIN" path and real binding errors. void markBindingFinished({required bool success}) { if (!_isBinding && _lastBindingSucceeded == success) return; _isBinding = false; _lastBindingSucceeded = success; safeNotifyListeners(); } /// Resolves once the binder reports done. Returns immediately when no /// rebind is in flight. The boolean reflects [lastBindingSucceeded] at /// the moment binding settles, so the picker can decide whether to /// dismiss or surface an error. /// /// Pending completers are tracked so [dispose] can settle them — without /// that, awaiters (picker, user-profile refresh, post-bind hooks) hang /// indefinitely when the provider is torn down mid-rebind. Future awaitBindingSettle() { if (!_isBinding) return Future.value(_lastBindingSucceeded); final completer = Completer(); _bindingSettleWaiters.add(completer); void listener() { if (_isBinding) return; removeListener(listener); if (_bindingSettleWaiters.remove(completer) && !completer.isCompleted) { completer.complete(_lastBindingSucceeded); } } addListener(listener); return completer.future; } Future initialize() { if (_initialized) return Future.value(); final pending = _initializeFuture; if (pending != null) return pending; final future = _initialize().catchError((Object error, StackTrace stackTrace) { _initializeFuture = null; Error.throwWithStackTrace(error, stackTrace); }); _initializeFuture = future; return future; } /// 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(); } Future _initialize() async { await _reloadSnapshot(); // Every listener diffs its snapshot first: drift re-emits on any table // write (including no-op upserts like the binder's server refresh), and // each unchecked pass rebuilds every Profile and notifies the whole // listener tree (binder, MainScreen, pickers). _localSub = _registry.watchProfiles().listen((list) { if (listEquals(list, _localProfiles)) return; _localProfiles = list; _recomputeProfiles(); _resolveActive(); safeNotifyListeners(); }); _connSub = _connections.watchConnections().listen((list) { final byId = {for (final c in list) c.id: c}; if (_sameConnections(byId, _connectionsById)) return; _connectionsById = byId; _recomputeProfiles(); _resolveActive(); safeNotifyListeners(); }); _pcSub = _profileConnections.watchAll().listen((list) { final byProfile = groupConnectionsByProfile(list); if (_sameProfileConnections(byProfile, _connectionsByProfile)) return; _connectionsByProfile = byProfile; _recomputeProfiles(); _resolveActive(); safeNotifyListeners(); }); _plexHomeSub = _plexHome.stream.listen((cache) { if (_samePlexHomeUsers(cache, _plexHomeUsers)) return; _plexHomeUsers = cache; _recomputeProfiles(); _resolveActive(); safeNotifyListeners(); }); _initialized = true; 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}; _connectionsByProfile = groupConnectionsByProfile(await _profileConnections.listAll()); _plexHomeUsers = _plexHome.current; _recomputeProfiles(); _resolveActive(); } /// [Connection] has no value equality; its persisted config is the cheapest /// faithful comparison key for the handful of rows involved. /// /// `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 /// 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) { 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.createdAt != other.createdAt) return false; if (jsonEncode(entry.value.toConfigJson()) != jsonEncode(other.toConfigJson())) return false; } return true; } static bool _sameProfileConnections(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 || entry.value.length != other.length) return false; for (final row in entry.value) { var hasSameFingerprint = false; for (final candidate in other) { if (row.profileId == candidate.profileId && row.connectionId == candidate.connectionId && row.userIdentifier == candidate.userIdentifier) { hasSameFingerprint = true; break; } } // Only these identity fields affect avatar selection. Deliberately // ignore tokens, default status, and binder-maintained timestamps. if (!hasSameFingerprint) return false; } } return true; } static bool _samePlexHomeUsers(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 || !listEquals(entry.value, other)) return false; } return true; } void _recomputeProfiles() { _profiles = mergeLocalWithPlexHome( locals: _localProfiles, plexHomeByConnectionId: _plexHomeUsers, connectionsById: _connectionsById, storage: _storage, ); _avatarUrls = resolveProfileAvatarUrls( profiles: _profiles, connectionsByProfile: _connectionsByProfile, connectionsById: _connectionsById, plexHomeByConnectionId: _plexHomeUsers, ); } void _resolveActive() { if (_pendingIdentityMutations > 0) return; if (_profiles.isEmpty) { _active = null; return; } final id = _storage?.getActiveProfileId(); // No saved id means "fresh state" — leave the active profile null so // MainScreen prompts the user via the picker. Auto-falling back to // `_profiles.first` would let the binder bind (and possibly PIN-prompt) // a profile the user never picked. if (id == null) { _active = null; return; } for (final p in _profiles) { if (p.id == id) { _active = p; return; } } // Saved id no longer matches anything. Keep the persisted id: this // resolver runs on every stream emission, and early/partial snapshots // (boot before migration, a Plex Home cache that hasn't hydrated yet) // must not irreversibly wipe the user's selection. Genuinely // unresolvable states clear the id at their decision points — the boot // guard and the post-removal settle flow. _active = null; } /// Claims ownership for an identity change that must perform asynchronous /// preparation before it can enter the serialized mutation queue. /// /// The claim is synchronous so a newer user request can invalidate older /// preparation immediately. Callers must always pair this with /// [finishIdentityMutationRequest]. int beginIdentityMutationRequest() { final generation = ++_identityMutationGeneration; _identityMutationReservations[generation] = Completer(); return generation; } bool isIdentityMutationRequestCurrent(int generation) => generation == _identityMutationGeneration; void finishIdentityMutationRequest(int generation) { final reservation = _identityMutationReservations.remove(generation); if (reservation != null && !reservation.isCompleted) reservation.complete(); } /// Activate [profile]. PIN-protected local profiles must supply a matching /// PIN; for Plex Home profiles the binder enforces the PIN via /// `/home/users/{uuid}/switch` after activation. Future activate(Profile profile, {String? pin}) async { if (profile.isLocal && profile.isPinProtected) { final hash = profile.pinHash; if (pin == null || hash == null || !verifyPin(pin, hash)) { return false; } } final storage = _storage; if (storage == null) return false; return _serializeIdentityMutation((generation) async { if (generation != _identityMutationGeneration) return false; final now = DateTime.now(); await storage.markProfileUsed(profile.id, now); if (generation != _identityMutationGeneration) return false; final previousActiveProfileId = storage.getActiveProfileId(); try { await _writeActiveProfileId(storage, profile.id); } catch (_) { await _restoreActiveProfileId(storage, previousActiveProfileId); rethrow; } if (generation != _identityMutationGeneration) { await _restoreActiveProfileId(storage, previousActiveProfileId); return false; } final activated = profile.copyWith(lastUsedAt: now); _active = activated; _committedIdentityGeneration = generation; _profiles = sortProfilesByLastUsed([for (final p in _profiles) p.id == profile.id ? activated : p]); safeNotifyListeners(); appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})'); if (profile.isLocal) { // Local rows also bump the DB's lastUsedAt so the in-DB sortable column // stays accurate — the in-memory mark above keeps the picker snappy. unawaited( _registry.markUsed(profile.id, now).catchError((Object e, StackTrace s) { appLogger.w('markUsed failed for ${profile.id}', error: e, stackTrace: s); }), ); } return true; }); } /// Restore the profile that owned the current authenticated session when a /// later activation fails. The caller must supply the profile captured /// before that activation; no PIN prompt is repeated for the session that /// was already unlocked. Future restoreAfterFailedActivation( Profile profile, { required String expectedActiveId, required int expectedCommittedGeneration, int? requestGeneration, }) async { final storage = _storage; if (storage == null) { throw StateError('ActiveProfileProvider is not initialized'); } var generation = requestGeneration; while (_active?.id == expectedActiveId && _committedIdentityGeneration == expectedCommittedGeneration) { if (generation != null && generation != _identityMutationGeneration) { // A newer request may still be preparing before it enters the queue // (for example, clearing its former shelf owner). Do not reclaim // ownership until that request has finished. If it fails without // committing, the genuinely current failed identity can still be // restored on the next pass. await _awaitIdentityWorkAfter(generation); if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) { return null; } generation = null; } late int attemptedGeneration; Future restore(int ownedGeneration) { attemptedGeneration = ownedGeneration; return _restoreFailedActivation( storage, profile, expectedActiveId, expectedCommittedGeneration, ownedGeneration, ); } final restoredGeneration = generation == null ? await _serializeIdentityMutation(restore) : await _queueIdentityMutation(generation, restore); if (restoredGeneration != null) return restoredGeneration; if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) { return null; } await _awaitIdentityWorkAfter(attemptedGeneration); generation = null; } return null; } Future _restoreFailedActivation( StorageService storage, Profile profile, String expectedActiveId, int expectedCommittedGeneration, int generation, ) async { if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration || generation != _identityMutationGeneration) { return null; } final previousActiveProfileId = storage.getActiveProfileId(); try { await _writeActiveProfileId(storage, profile.id); } catch (_) { await _restoreActiveProfileId(storage, previousActiveProfileId); rethrow; } if (generation != _identityMutationGeneration) { await _restoreActiveProfileId(storage, previousActiveProfileId); return null; } _committedIdentityGeneration = generation; _active = profile; _profiles = sortProfilesByLastUsed([ for (final candidate in _profiles) candidate.id == profile.id ? profile : candidate, ]); safeNotifyListeners(); appLogger.i('ActiveProfileProvider: restored ${profile.displayName} (${profile.id}) after failed activation'); return generation; } /// Clear the selected profile in both storage and memory so the picker /// can force an explicit choice on the next screen. Future clearActiveProfile() async { final generation = beginIdentityMutationRequest(); try { final storage = _storage ??= await StorageService.getInstance(); if (!isIdentityMutationRequestCurrent(generation)) return; await _queueIdentityMutation(generation, (ownedGeneration) async { if (ownedGeneration != _identityMutationGeneration) return; final previousActiveProfileId = storage.getActiveProfileId(); await storage.clearActiveProfileId(); if (ownedGeneration != _identityMutationGeneration) { await _restoreActiveProfileId(storage, previousActiveProfileId); return; } _committedIdentityGeneration = ownedGeneration; _active = null; safeNotifyListeners(); }); } finally { finishIdentityMutationRequest(generation); } } Future _writeActiveProfileId(StorageService storage, String profileId) { return _activeProfileIdWriter?.call(profileId) ?? storage.setActiveProfileId(profileId); } Future _restoreActiveProfileId(StorageService storage, String? profileId) { if (profileId == null) return storage.clearActiveProfileId(); return _writeActiveProfileId(storage, profileId); } Future _serializeIdentityMutation(Future Function(int generation) mutation) { final generation = ++_identityMutationGeneration; return _queueIdentityMutation(generation, mutation); } Future _queueIdentityMutation(int generation, Future Function(int generation) mutation) { final previous = _identityMutationQueue; _pendingIdentityMutations++; final operation = () async { if (previous != null) await previous; try { return await mutation(generation); } finally { _pendingIdentityMutations--; } }(); _identityMutationQueue = operation.then((_) {}).catchError((Object _, StackTrace _) {}); return operation; } Future _awaitIdentityWorkAfter(int generation) async { while (true) { final reservations = [ for (final entry in _identityMutationReservations.entries) if (entry.key > generation) entry.value.future, ]; final queue = _identityMutationQueue; if (reservations.isNotEmpty) await Future.wait(reservations); if (queue != null) await queue; final hasNewerReservation = _identityMutationReservations.keys.any((candidate) => candidate > generation); if (!hasNewerReservation && identical(queue, _identityMutationQueue)) return; } } @visibleForTesting Future resetForTesting() async { await _localSub?.cancel(); await _connSub?.cancel(); await _plexHomeSub?.cancel(); await _pcSub?.cancel(); _localSub = null; _connSub = null; _plexHomeSub = null; _pcSub = null; _profiles = const []; _localProfiles = const []; _plexHomeUsers = const {}; _connectionsById = const {}; _connectionsByProfile = const {}; _avatarUrls = const {}; _active = null; _initializeFuture = null; _initialized = false; _isBinding = false; _lastBindingSucceeded = true; for (final c in _bindingSettleWaiters) { if (!c.isCompleted) c.complete(_lastBindingSucceeded); } _bindingSettleWaiters.clear(); for (final reservation in _identityMutationReservations.values) { if (!reservation.isCompleted) reservation.complete(); } _identityMutationReservations.clear(); } @override void dispose() { // Settle anyone awaiting binding before the listeners go away — leaving // them pending traps callers in a forever-await on app teardown. for (final c in _bindingSettleWaiters) { if (!c.isCompleted) c.complete(_lastBindingSucceeded); } _bindingSettleWaiters.clear(); for (final reservation in _identityMutationReservations.values) { if (!reservation.isCompleted) reservation.complete(); } _identityMutationReservations.clear(); _initializeFuture = null; _localSub?.cancel(); _connSub?.cancel(); _plexHomeSub?.cancel(); _pcSub?.cancel(); super.dispose(); } }