fix: defer profile binding before selection

This commit is contained in:
edde746
2026-05-01 03:16:32 +02:00
parent 6cde5b6bd1
commit 7057ee93ac
6 changed files with 206 additions and 21 deletions
+16 -8
View File
@@ -687,14 +687,22 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// mounts (and discover/libraries query) before any client exists.
Provider<ActiveProfileBinder>(
lazy: false,
create: (context) => ActiveProfileBinder(
activeProfile: context.read<ActiveProfileProvider>(),
connections: context.read<ConnectionRegistry>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
serverManager: _serverManager,
multiServerProvider: context.read<MultiServerProvider>(),
pinPrompt: _rootPinPrompt,
)..start(),
create: (context) {
final activeProfile = context.read<ActiveProfileProvider>();
return ActiveProfileBinder(
activeProfile: activeProfile,
connections: context.read<ConnectionRegistry>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
serverManager: _serverManager,
multiServerProvider: context.read<MultiServerProvider>(),
pinPrompt: _rootPinPrompt,
shouldDeferInitialBind: (_) async {
final settings = await SettingsService.getInstance();
return settings.read(SettingsService.requireProfileSelectionOnOpen) &&
activeProfile.hasMultipleProfiles;
},
)..start();
},
dispose: (_, binder) => binder.dispose(),
),
// Offline mode provider - depends on MultiServerProvider
+44 -4
View File
@@ -20,6 +20,8 @@ import 'profile_connection_registry.dart';
/// (typically in `main_screen.dart`) should call `showPinEntryDialog`.
typedef PlexHomePinPrompt = Future<String?> Function(Profile profile, {String? errorMessage});
typedef ShouldDeferInitialBind = FutureOr<bool> Function(Profile profile);
@visibleForTesting
bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce, required bool plexProtected}) {
return preVerified || (!hasBoundOnce && !plexProtected);
@@ -52,6 +54,7 @@ class ActiveProfileBinder {
required this.serverManager,
required this.multiServerProvider,
required this.pinPrompt,
this.shouldDeferInitialBind,
PlexAuthService? plexAuth,
}) : _plexAuth = plexAuth;
@@ -61,6 +64,7 @@ class ActiveProfileBinder {
final MultiServerManager serverManager;
final MultiServerProvider multiServerProvider;
final PlexHomePinPrompt pinPrompt;
final ShouldDeferInitialBind? shouldDeferInitialBind;
PlexAuthService? _plexAuth;
@@ -87,6 +91,7 @@ class ActiveProfileBinder {
/// once by [_bindPlexHome] to permit the freshly cached user-token for
/// that single rebind and avoid a duplicate PIN prompt.
final Set<String> _plexHomePreVerified = {};
final Set<String> _userInitiatedActivations = {};
bool get isSwitching => _isSwitching;
@@ -97,11 +102,20 @@ class ActiveProfileBinder {
_plexHomePreVerified.add(profileId);
}
void markUserInitiatedActivation(String profileId) {
_userInitiatedActivations.add(profileId);
}
@visibleForTesting
bool consumePlexHomePreVerified(String profileId) {
return _plexHomePreVerified.remove(profileId);
}
@visibleForTesting
bool consumeUserInitiatedActivation(String profileId) {
return _userInitiatedActivations.remove(profileId);
}
void start() {
if (_started) return;
_started = true;
@@ -194,14 +208,21 @@ class ActiveProfileBinder {
// waiting, doesn't surface a spurious "switch failed" error. Also
// clear the runtime filter so stale clients from the previous
// profile cannot leak into the no-selection state.
for (final serverId in serverManager.serverIds.toList()) {
serverManager.removeServer(serverId);
}
multiServerProvider.setVisibleServerIds(<String>{});
_clearBoundServers();
success = true;
return;
}
attemptedProfileId = profile.id;
final userInitiated = consumeUserInitiatedActivation(profile.id);
if (!userInitiated && !_hasBoundOnce && await _shouldDeferInitialBind(profile)) {
appLogger.i('ActiveProfileBinder: deferring initial bind for ${profile.displayName} until profile selection');
_clearBoundServers();
attemptedProfileId = null;
success = true;
return;
}
appLogger.i('ActiveProfileBinder: rebinding for ${profile.displayName} (${profile.id})');
final visibleServerIds = <String>{};
@@ -507,10 +528,29 @@ class ActiveProfileBinder {
return _plexAuth ??= await PlexAuthService.create();
}
Future<bool> _shouldDeferInitialBind(Profile profile) async {
final shouldDefer = shouldDeferInitialBind;
if (shouldDefer == null) return false;
try {
return await shouldDefer(profile);
} catch (e, st) {
appLogger.w('ActiveProfileBinder: defer check failed; continuing with bind', error: e, stackTrace: st);
return false;
}
}
void _clearBoundServers() {
for (final serverId in serverManager.serverIds.toList()) {
serverManager.removeServer(serverId);
}
multiServerProvider.setVisibleServerIds(<String>{});
}
void dispose() {
if (!_started) return;
activeProfile.removeListener(_onActiveProfileChanged);
_plexHomePreVerified.clear();
_userInitiatedActivations.clear();
_plexAuth?.dispose();
_plexAuth = null;
_started = false;
+8 -2
View File
@@ -27,16 +27,19 @@ import 'profile_connection_registry.dart';
/// token instead of re-prompting for the same PIN.
Future<bool> activateProfileWithPin(BuildContext context, Profile profile) async {
final active = context.read<ActiveProfileProvider>();
final binder = context.read<ActiveProfileBinder>();
if (profile.isPlexHome) {
if (profile.plexProtected) {
final ok = await _preVerifyPlexHomePin(context, profile);
if (!ok) return false;
}
binder.markUserInitiatedActivation(profile.id);
return active.activate(profile);
}
if (!profile.isPinProtected) {
binder.markUserInitiatedActivation(profile.id);
return active.activate(profile);
}
@@ -45,8 +48,11 @@ Future<bool> activateProfileWithPin(BuildContext context, Profile profile) async
if (!context.mounted) return false;
final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
if (pin == null) return false; // user cancelled
final ok = await active.activate(profile, pin: pin);
if (ok) return true;
final hash = profile.pinHash;
if (hash != null && verifyPin(pin, hash)) {
binder.markUserInitiatedActivation(profile.id);
return active.activate(profile, pin: pin);
}
errorMessage = 'Incorrect PIN. Please try again.';
}
}
+40 -7
View File
@@ -8,6 +8,7 @@ import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart';
import '../services/plex_auth_service.dart';
import '../services/settings_service.dart';
import '../services/storage_service.dart';
import '../providers/user_profile_provider.dart';
import '../i18n/strings.g.dart';
@@ -20,6 +21,7 @@ import '../widgets/backend_badge.dart';
import '../widgets/dialog_action_button.dart';
import 'auth/plex_pin_auth_flow.dart';
import 'main_screen.dart';
import 'profile/profile_switch_screen.dart';
import 'settings/add_jellyfin_screen.dart';
class AuthScreen extends StatefulWidget {
@@ -77,11 +79,10 @@ class _AuthScreenState extends State<AuthScreen> {
await activeProfiles.activate(profile);
}
/// Persist the new Plex account into the connection pipeline and
/// navigate to the main screen. The [ActiveProfileBinder] (mounted by
/// [MainScreen]) takes over from there: it picks up the active profile
/// id we set below and connects servers via
/// [MultiServerManager.refreshTokensForProfile].
/// Persist the new Plex account into the connection pipeline, resolve the
/// initial active profile when possible, and navigate to the main screen.
/// The top-level [ActiveProfileBinder] picks up the active profile id and
/// connects servers via [MultiServerManager.refreshTokensForProfile].
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
if (!mounted) return;
@@ -130,14 +131,36 @@ class _AuthScreenState extends State<AuthScreen> {
await connectionRegistry.upsert(accountConnection);
await plexHome.refresh(accountConnection);
if (!mounted) return;
await _selectInitialProfile(plexHome, context.read<ActiveProfileProvider>(), accountConnection);
final activeProfiles = context.read<ActiveProfileProvider>();
await _selectInitialProfile(plexHome, activeProfiles, accountConnection);
if (!mounted) return;
final settings = await SettingsService.getInstance();
if (!mounted) return;
final promptHandled = shouldPromptForInitialProfileSelection(
activeProfile: activeProfiles.active,
hasProfiles: activeProfiles.profiles.isNotEmpty,
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
requireProfileSelectionOnOpen:
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
);
if (promptHandled) {
final selected = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
if (!mounted) return;
if (selected != true || activeProfiles.active == null) {
setState(() => _isAuthenticating = false);
return;
}
}
await context.read<UserProfileProvider>().initialize();
if (!mounted) return;
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen())));
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: promptHandled))));
} catch (e) {
appLogger.e('Failed to connect to servers', error: e);
if (!mounted) return;
@@ -363,6 +386,16 @@ Profile? initialPlexHomeProfileFromCache(PlexHomeService plexHome, PlexAccountCo
return Profile.virtualPlexHome(connectionId: accountConn.id, homeUser: users.single);
}
@visibleForTesting
bool shouldPromptForInitialProfileSelection({
required Profile? activeProfile,
required bool hasProfiles,
required bool accountHasHomeUsers,
required bool requireProfileSelectionOnOpen,
}) {
return requireProfileSelectionOnOpen || (activeProfile == null && (hasProfiles || accountHasHomeUsers));
}
/// Stateful so the [TextEditingController] is disposed when the dialog
/// closes — the previous inline `showDialog` builder created the
/// controller in a closure and leaked it on every dismissal.
@@ -26,6 +26,7 @@ void main() {
late MultiServerProvider multiServerProvider;
late ActiveProfileBinder binder;
late StorageService storage;
late bool shouldDeferInitialBind;
setUp(() async {
resetSharedPreferencesForTest();
@@ -48,6 +49,7 @@ void main() {
);
manager = MultiServerManager();
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
shouldDeferInitialBind = false;
binder = ActiveProfileBinder(
activeProfile: activeProfile,
connections: connections,
@@ -55,6 +57,7 @@ void main() {
serverManager: manager,
multiServerProvider: multiServerProvider,
pinPrompt: (_, {String? errorMessage}) async => null,
shouldDeferInitialBind: (_) async => shouldDeferInitialBind,
);
});
@@ -67,6 +70,14 @@ void main() {
await db.close();
});
Future<Profile> createActiveLocalProfile(String id) async {
final profile = Profile(id: id, kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
await profiles.upsert(profile);
await storage.setActiveProfileId(profile.id);
await activeProfile.initialize();
return profile;
}
test('local profile with no connections binds successfully with empty visibility', () async {
final profile = Profile(
id: 'local-owner',
@@ -108,6 +119,31 @@ void main() {
expect(notifications, lessThan(8));
});
test('initial bind can be deferred until profile selection', () async {
final profile = await createActiveLocalProfile('local-deferred');
shouldDeferInitialBind = true;
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isTrue);
expect(activeProfile.isBinding, isFalse);
expect(binder.debugLastBoundProfileId, isNull);
expect(binder.consumeUserInitiatedActivation(profile.id), isFalse);
expect(multiServerProvider.serverIds, isEmpty);
});
test('user initiated activation bypasses initial bind defer', () async {
final profile = await createActiveLocalProfile('local-user-initiated');
shouldDeferInitialBind = true;
binder.markUserInitiatedActivation(profile.id);
await binder.rebindActive();
expect(activeProfile.lastBindingSucceeded, isTrue);
expect(binder.debugLastBoundProfileId, profile.id);
expect(binder.consumeUserInitiatedActivation(profile.id), isFalse);
});
group('Plex Home token cache policy', () {
test('protected cold start revalidates PIN even when profile selection is not required', () {
expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: true), isFalse);
+62
View File
@@ -61,4 +61,66 @@ void main() {
expect(profile.parentConnectionId, account.id);
expect(profile.displayName, 'Home User');
});
test('initial profile selection is required when home users exist but no profile is active', () {
expect(
shouldPromptForInitialProfileSelection(
activeProfile: null,
hasProfiles: false,
accountHasHomeUsers: true,
requireProfileSelectionOnOpen: false,
),
isTrue,
);
});
test('initial profile selection is skipped when a profile was auto-selected', () {
final profile = Profile(
id: 'local-owner',
kind: ProfileKind.local,
displayName: 'Owner',
createdAt: DateTime(2026, 1, 1),
);
expect(
shouldPromptForInitialProfileSelection(
activeProfile: profile,
hasProfiles: true,
accountHasHomeUsers: true,
requireProfileSelectionOnOpen: false,
),
isFalse,
);
});
test('initial profile selection is required when the launch setting is enabled', () {
final profile = Profile(
id: 'local-owner',
kind: ProfileKind.local,
displayName: 'Owner',
createdAt: DateTime(2026, 1, 1),
);
expect(
shouldPromptForInitialProfileSelection(
activeProfile: profile,
hasProfiles: true,
accountHasHomeUsers: true,
requireProfileSelectionOnOpen: true,
),
isTrue,
);
});
test('initial profile selection is skipped when no profiles are available', () {
expect(
shouldPromptForInitialProfileSelection(
activeProfile: null,
hasProfiles: false,
accountHasHomeUsers: false,
requireProfileSelectionOnOpen: false,
),
isFalse,
);
});
}