Files
plezy/lib/profiles/active_profile_provider.dart
edde746 c4059d0ead fix(startup): stop Cronet and Plex Home from blocking time-to-interactive
Two cold-start findings from the same pass. They share a call site in
`MainScreen`'s post-frame block, so they land together.

## Cronet was 33% of time-to-interactive

`createPlatformClient()` built the shared `CronetEngine` inline, so whichever
consumer happened to create the first HTTP client paid for it — and that landed
between `database_ready` and `credentials_loaded`, i.e. squarely on the path to
the first usable screen.

Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by
temporarily forcing the existing `_cronetBroken` fallback so no engine is ever
built:

| phase | engine built inline | engine never built |
|---|---|---|
| database_ready | +455 | +456 |
| credentials_loaded | +1171 | +703 |
| binding_settled | +1331 | +827 |
| main_screen | +1394 | +932 |

So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule
loadModule2NoCrashUtils` then `HttpFlagsLoader` reading
`com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider
*enumeration*, not selection — `CronetEngine.Builder(Context)` calls
`isEnabled()` on every registered provider, and `PlayServicesCronetProvider`
answers that by installing the Play services Dynamite module. `play-services-cronet`
arrives transitively through `media3-datasource-cronet`, and `package:cronet_http`
offers no way to choose a provider, so the only lever available in Dart is *when*
the cost is paid.

Android's `createPlatformClient()` now returns a client that resolves its
delegate per request: the tuned IOClient that already backstops a broken Cronet
until the shared engine exists, Cronet afterwards. Per-request matters — a
`MediaServerHttpClient` builds its client in a constructor initializer and lives
for the process, so deciding once at construction would have pinned primary
media-server traffic to HTTP/1.1 forever, which is a silent steady-state
regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine
from `MainScreen`'s post-frame block.

Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines
(`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build
now runs from +1023 to +1419, entirely after the first screen, and produces no
Choreographer or Davey report — the UI is static waiting on hub content there, so
there are no frames to drop.

## Plex Home refresh raced the offline decision

`PlexHomeService.start()` conflated disk hydration with going live: it decoded
the cached `plex_home_users_*` entries *and* subscribed to connection changes,
installed a refresh timer and fired `_refreshAll()`. It was invoked straight from
a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi
these devices typically have — it started requests that would time out during the
exact window the startup gate needs. Its immediate neighbour
`ActiveProfileBinder` is explicitly not auto-started for this reason and says so
in a comment; the same argument applied here and had simply not been followed.

`start()` is now the live/network entry point and `hydrate()` is the disk-only
half, coalesced and lifecycle-guarded like `start()` already was. The provider
`create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow
picker hydrates, because it reads `current` immediately and is reachable while
offline. Only `MainScreen` goes live, gated on `!_isOffline`, with
`_handleOfflineStatusChanged` picking it up if the session later regains network —
otherwise an airplane-mode launch would never refresh Plex Home again.

Hydration still `_emit()`s, so `stream`'s replay contract holds even when the
network side never starts, which is what keeps a late listener behind a
`combineLatest` off a permanent spinner.
2026-08-23 18:13:39 +02:00

623 lines
23 KiB
Dart

import 'dart:async';
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<void> Function(String profileId)? _activeProfileIdWriter;
Profile? _active;
List<Profile> _profiles = const [];
List<Profile> _localProfiles = const [];
Map<String, List<PlexHomeUser>> _plexHomeUsers = const {};
Map<String, Connection> _connectionsById = const {};
Map<String, List<ProfileConnection>> _connectionsByProfile = const {};
Map<String, String?> _avatarUrls = const {};
StreamSubscription<List<Profile>>? _localSub;
StreamSubscription<List<Connection>>? _connSub;
StreamSubscription<List<ProfileConnection>>? _pcSub;
StreamSubscription<Map<String, List<PlexHomeUser>>>? _plexHomeSub;
Future<void>? _initializeFuture;
Future<void>? _reloadFromStorageFuture;
bool _initialized = false;
bool _isBinding = false;
bool _lastBindingSucceeded = true;
final List<Completer<bool>> _bindingSettleWaiters = [];
Future<void>? _identityMutationQueue;
int _identityMutationGeneration = 0;
int _committedIdentityGeneration = 0;
int _pendingIdentityMutations = 0;
final Map<int, Completer<void>> _identityMutationReservations = {};
Profile? get active => _active;
String? get activeId => _active?.id;
List<Profile> get profiles => _profiles;
/// Derived picture URL for [profileId], or null when initials should render.
String? avatarUrlFor(String profileId) => _avatarUrls[profileId];
/// Per-profile borrowed connections. Does **not** include the Plex Home
/// parent — that's implicit via [Profile.parentConnectionId]. Plex Home
/// profiles can have entries here too (e.g. borrowed Jellyfin servers).
Map<String, List<ProfileConnection>> get connectionsByProfile => _connectionsByProfile;
Map<String, Connection> get connectionsById => _connectionsById;
/// Live Plex Home users per account connection id. Chip/label UI needs it
/// to turn a [ProfileConnection.userIdentifier] uuid into the Home user's
/// name; without it a borrowed Plex connection can only name the account
/// owner, which reads as the wrong identity.
Map<String, List<PlexHomeUser>> get plexHomeByConnectionId => _plexHomeUsers;
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<bool> awaitBindingSettle() {
if (!_isBinding) return Future.value(_lastBindingSucceeded);
final completer = Completer<bool>();
_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<void> 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<void> reloadFromStorage() {
final pending = _reloadFromStorageFuture;
if (pending != null) return pending;
final future = _reloadFromStorage();
_reloadFromStorageFuture = future;
return future;
}
Future<void> _reloadFromStorage() async {
try {
await initialize();
await _plexHome.reloadFromStorage();
await _reloadSnapshot();
safeNotifyListeners();
} finally {
_reloadFromStorageFuture = null;
}
}
Future<void> _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<void> _reloadSnapshot() async {
_storage ??= await StorageService.getInstance();
// Hydrate the Plex Home cache before we read it — `_plexHome.current`
// is only populated after the disk-cache load finishes.
await _plexHome.hydrate();
_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();
}
/// 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 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.
bool _sameConnections(Map<String, Connection> a, Map<String, Connection> 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 (!_connections.hasSameConfig(entry.value, other)) return false;
}
return true;
}
static bool _sameProfileConnections(Map<String, List<ProfileConnection>> a, Map<String, List<ProfileConnection>> 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<String, List<PlexHomeUser>> a, Map<String, List<PlexHomeUser>> 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<void>();
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<bool> 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<bool>((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<int?> 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<int?> restore(int ownedGeneration) {
attemptedGeneration = ownedGeneration;
return _restoreFailedActivation(
storage,
profile,
expectedActiveId,
expectedCommittedGeneration,
ownedGeneration,
);
}
final restoredGeneration = generation == null
? await _serializeIdentityMutation<int?>(restore)
: await _queueIdentityMutation<int?>(generation, restore);
if (restoredGeneration != null) return restoredGeneration;
if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) {
return null;
}
await _awaitIdentityWorkAfter(attemptedGeneration);
generation = null;
}
return null;
}
Future<int?> _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<void> clearActiveProfile() async {
final generation = beginIdentityMutationRequest();
try {
final storage = _storage ??= await StorageService.getInstance();
if (!isIdentityMutationRequestCurrent(generation)) return;
await _queueIdentityMutation<void>(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<void> _writeActiveProfileId(StorageService storage, String profileId) {
return _activeProfileIdWriter?.call(profileId) ?? storage.setActiveProfileId(profileId);
}
Future<void> _restoreActiveProfileId(StorageService storage, String? profileId) {
if (profileId == null) return storage.clearActiveProfileId();
return _writeActiveProfileId(storage, profileId);
}
Future<T> _serializeIdentityMutation<T>(Future<T> Function(int generation) mutation) {
final generation = ++_identityMutationGeneration;
return _queueIdentityMutation(generation, mutation);
}
Future<T> _queueIdentityMutation<T>(int generation, Future<T> 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<void>((_) {}).catchError((Object _, StackTrace _) {});
return operation;
}
Future<void> _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<void> 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();
}
}