fix: defer profile binding before selection
This commit is contained in:
+16
-8
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user