fix(profiles): clean up orphaned connections on Plex sign-out
close #1423
This commit is contained in:
@@ -87,15 +87,15 @@ Package managers:
|
|||||||
- Real-time play / pause / seek sync
|
- Real-time play / pause / seek sync
|
||||||
|
|
||||||
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
|
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
|
||||||
- Discord Rich Presence[^7]
|
- Discord Rich Presence[^6]
|
||||||
- Trakt, MyAnimeList, AniList, and Simkl tracking & rating
|
- Trakt, MyAnimeList, AniList, and Simkl tracking & rating
|
||||||
- Plezy Remote — control desktop and TV from mobile
|
- Plezy Remote — control desktop and TV from mobile
|
||||||
- Watch Next row[^6]
|
- Watch Next row
|
||||||
|
|
||||||
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
|
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
|
||||||
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
|
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
|
||||||
- Customizable keyboard shortcuts[^7]
|
- Customizable keyboard shortcuts[^6]
|
||||||
- Metadata and artwork editing[^2]
|
- Metadata and artwork editing
|
||||||
- Settings import/export
|
- Settings import/export
|
||||||
- Localized in English plus 14 translations
|
- Localized in English plus 14 translations
|
||||||
|
|
||||||
@@ -104,8 +104,7 @@ Package managers:
|
|||||||
[^3]: Not available on iOS or tvOS.
|
[^3]: Not available on iOS or tvOS.
|
||||||
[^4]: Android, iOS, and macOS.
|
[^4]: Android, iOS, and macOS.
|
||||||
[^5]: Windows, Android, and tvOS.
|
[^5]: Windows, Android, and tvOS.
|
||||||
[^6]: Android TV only.
|
[^6]: Desktop only.
|
||||||
[^7]: Desktop only.
|
|
||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,11 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return delete(offlineWatchProgress).go();
|
return delete(offlineWatchProgress).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a removed profile's queued watch actions (profile teardown).
|
||||||
|
Future<void> deleteWatchActionsForProfile(String profileId) async {
|
||||||
|
await (delete(offlineWatchProgress)..where((t) => t.profileId.equals(profileId))).go();
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||||
final query = select(syncRules);
|
final query = select(syncRules);
|
||||||
if (profileId != null) {
|
if (profileId != null) {
|
||||||
@@ -621,6 +626,11 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a removed profile's sync rules (profile teardown).
|
||||||
|
Future<void> deleteSyncRulesForProfile(String profileId) async {
|
||||||
|
await (delete(syncRules)..where((t) => t.profileId.equals(profileId))).go();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all downloaded media items (for syncing watch states)
|
/// Get all downloaded media items (for syncing watch states)
|
||||||
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
|
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
|
||||||
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
|
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import '../connection/connection.dart';
|
import '../connection/connection.dart';
|
||||||
import '../connection/connection_registry.dart';
|
import '../connection/connection_registry.dart';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
|
import '../models/plex/plex_home_user.dart';
|
||||||
import '../services/multi_server_manager.dart';
|
import '../services/multi_server_manager.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
|
import 'profile.dart';
|
||||||
import 'profile_connection_registry.dart';
|
import 'profile_connection_registry.dart';
|
||||||
|
import 'profile_merge.dart';
|
||||||
|
import 'profile_registry.dart';
|
||||||
|
|
||||||
Future<void> removeProfileConnectionAndCleanup({
|
Future<void> removeProfileConnectionAndCleanup({
|
||||||
required String profileId,
|
required String profileId,
|
||||||
@@ -64,6 +68,110 @@ Future<void> removeAllProfileConnectionsAndCleanup({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Profile ids affected by a Plex account removal, so the caller can sweep
|
||||||
|
/// per-profile data (downloads, sync rules, queued watch actions) that this
|
||||||
|
/// layer doesn't own.
|
||||||
|
typedef PlexAccountRemoval = ({
|
||||||
|
/// The account's virtual Plex Home profiles — they cease to exist.
|
||||||
|
Set<String> removedVirtualProfileIds,
|
||||||
|
|
||||||
|
/// Profiles that survive but had a join row onto the removed account
|
||||||
|
/// (locals that borrowed a home user).
|
||||||
|
Set<String> borrowerProfileIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Sign out of a Plex account: remove the account [Connection], every join
|
||||||
|
/// row referencing it, and everything owned by its virtual Plex Home
|
||||||
|
/// profiles — including borrowed Jellyfin connections left unreferenced,
|
||||||
|
/// which previously survived as orphans and wedged the session (#1423).
|
||||||
|
///
|
||||||
|
/// All cleanup is explicit and completes before this returns; correctness
|
||||||
|
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
|
||||||
|
/// runs later and no-ops.
|
||||||
|
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
|
||||||
|
required PlexAccountConnection account,
|
||||||
|
required ProfileConnectionRegistry profileConnections,
|
||||||
|
required ConnectionRegistry connections,
|
||||||
|
required StorageService storage,
|
||||||
|
MultiServerManager? serverManager,
|
||||||
|
}) async {
|
||||||
|
final rows = await profileConnections.listAll();
|
||||||
|
final removedVirtualProfileIds = <String>{
|
||||||
|
for (final row in rows)
|
||||||
|
if (parsePlexHomeProfileId(row.profileId)?.accountConnectionId == account.id) row.profileId,
|
||||||
|
};
|
||||||
|
final borrowerProfileIds = <String>{
|
||||||
|
for (final row in rows)
|
||||||
|
if (row.connectionId == account.id && !removedVirtualProfileIds.contains(row.profileId)) row.profileId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove direct join rows first so per-profile pref cleanup observes each
|
||||||
|
// row going away; the FK cascade from the connection delete is then a no-op.
|
||||||
|
for (final row in rows.where((r) => r.connectionId == account.id)) {
|
||||||
|
await removeProfileConnectionAndCleanup(
|
||||||
|
profileId: row.profileId,
|
||||||
|
connection: account,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
serverManager: serverManager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await connections.remove(account.id);
|
||||||
|
await storage.clearPlexHomeUsersCache(account.id);
|
||||||
|
|
||||||
|
// The account's virtual profiles die with the connection; their borrowed
|
||||||
|
// connections and per-profile prefs must go too.
|
||||||
|
for (final profileId in removedVirtualProfileIds) {
|
||||||
|
await removeAllProfileConnectionsAndCleanup(
|
||||||
|
profileId: profileId,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
serverManager: serverManager,
|
||||||
|
);
|
||||||
|
await storage.clearProfileLastUsed(profileId);
|
||||||
|
await storage.clearUserScopedPreferencesForProfile(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the session should land after a profile or connection removal.
|
||||||
|
enum PostRemovalRoute { signedOut, staySignedIn }
|
||||||
|
|
||||||
|
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
||||||
|
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
||||||
|
/// Jellyfin connections, then decide whether any selectable profile remains.
|
||||||
|
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
|
||||||
|
/// accounts are harmless because the connection map is re-read here.
|
||||||
|
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
||||||
|
required ProfileRegistry profileRegistry,
|
||||||
|
required ProfileConnectionRegistry profileConnections,
|
||||||
|
required ConnectionRegistry connections,
|
||||||
|
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
||||||
|
required StorageService storage,
|
||||||
|
MultiServerManager? serverManager,
|
||||||
|
}) async {
|
||||||
|
await pruneUnreferencedJellyfinConnections(
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
serverManager: serverManager,
|
||||||
|
);
|
||||||
|
final conns = await connections.list();
|
||||||
|
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||||
|
|
||||||
|
final merged = mergeLocalWithPlexHome(
|
||||||
|
locals: await profileRegistry.list(),
|
||||||
|
plexHomeByConnectionId: plexHomeUsers,
|
||||||
|
connectionsById: {for (final c in conns) c.id: c},
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||||
|
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> pruneUnreferencedJellyfinConnections({
|
Future<int> pruneUnreferencedJellyfinConnections({
|
||||||
required ProfileConnectionRegistry profileConnections,
|
required ProfileConnectionRegistry profileConnections,
|
||||||
required ConnectionRegistry connections,
|
required ConnectionRegistry connections,
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import '../utils/content_utils.dart';
|
|||||||
import '../widgets/optimized_media_image.dart' show blurArtwork;
|
import '../widgets/optimized_media_image.dart' show blurArtwork;
|
||||||
import '../providers/discover_provider.dart';
|
import '../providers/discover_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
import '../providers/hidden_libraries_provider.dart';
|
|
||||||
import '../providers/playback_state_provider.dart';
|
|
||||||
import '../providers/watch_state_store.dart';
|
import '../providers/watch_state_store.dart';
|
||||||
import '../widgets/hub_section.dart';
|
import '../widgets/hub_section.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
@@ -33,16 +31,11 @@ import '../widgets/clickable_cursor.dart';
|
|||||||
import '../widgets/loading_indicator_box.dart';
|
import '../widgets/loading_indicator_box.dart';
|
||||||
import '../widgets/profile_switching_overlay.dart';
|
import '../widgets/profile_switching_overlay.dart';
|
||||||
import 'profile/profile_switch_screen.dart';
|
import 'profile/profile_switch_screen.dart';
|
||||||
import '../connection/connection_registry.dart';
|
import 'profile/profile_teardown.dart';
|
||||||
import '../profiles/active_profile_provider.dart';
|
import '../profiles/active_profile_provider.dart';
|
||||||
import '../profiles/plex_home_service.dart';
|
|
||||||
import '../profiles/profile.dart';
|
import '../profiles/profile.dart';
|
||||||
import '../profiles/profile_activation.dart';
|
import '../profiles/profile_activation.dart';
|
||||||
import '../profiles/profile_avatar.dart';
|
import '../profiles/profile_avatar.dart';
|
||||||
import '../profiles/profile_connection_registry.dart';
|
|
||||||
import '../profiles/profile_registry.dart';
|
|
||||||
import '../providers/user_profile_provider.dart';
|
|
||||||
import '../services/storage_service.dart';
|
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
import '../widgets/fitting_title_text.dart';
|
import '../widgets/fitting_title_text.dart';
|
||||||
@@ -61,7 +54,6 @@ import '../utils/video_player_navigation.dart';
|
|||||||
import '../utils/layout_constants.dart';
|
import '../utils/layout_constants.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
import '../theme/mono_tokens.dart';
|
import '../theme/mono_tokens.dart';
|
||||||
import 'auth_screen.dart';
|
|
||||||
import 'libraries/content_state_builder.dart';
|
import 'libraries/content_state_builder.dart';
|
||||||
import 'main_screen.dart';
|
import 'main_screen.dart';
|
||||||
import 'settings/settings_screen.dart';
|
import 'settings/settings_screen.dart';
|
||||||
@@ -779,39 +771,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirm && mounted) {
|
if (confirm && mounted) {
|
||||||
final navigator = Navigator.of(context, rootNavigator: true);
|
await logoutAllProfiles(context);
|
||||||
// Use comprehensive logout through UserProfileProvider
|
|
||||||
final userProfileProvider = Provider.of<UserProfileProvider>(context, listen: false);
|
|
||||||
final multiServerProvider = context.read<MultiServerProvider>();
|
|
||||||
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
|
||||||
final playbackStateProvider = context.read<PlaybackStateProvider>();
|
|
||||||
final connectionRegistry = context.read<ConnectionRegistry>();
|
|
||||||
final profileRegistry = context.read<ProfileRegistry>();
|
|
||||||
final profileConnReg = context.read<ProfileConnectionRegistry>();
|
|
||||||
final plexHome = context.read<PlexHomeService>();
|
|
||||||
final companionRemote = context.read<CompanionRemoteProvider>();
|
|
||||||
|
|
||||||
// Clear all user data and provider states
|
|
||||||
await companionRemote.resetForLogout();
|
|
||||||
await userProfileProvider.logout();
|
|
||||||
multiServerProvider.clearAllConnections();
|
|
||||||
// Drop the profile/connection rows so the next sign-in starts clean
|
|
||||||
// and doesn't bind to stale tokens or orphaned profile rows.
|
|
||||||
await profileConnReg.clear();
|
|
||||||
await profileRegistry.clear();
|
|
||||||
await connectionRegistry.clear();
|
|
||||||
await plexHome.clearAll();
|
|
||||||
final storage = await StorageService.getInstance();
|
|
||||||
await storage.clearActiveProfileId();
|
|
||||||
await storage.clearAllProfileLastUsed();
|
|
||||||
await hiddenLibrariesProvider.refresh();
|
|
||||||
playbackStateProvider.clearShuffle();
|
|
||||||
|
|
||||||
if (navigator.mounted) {
|
|
||||||
unawaited(
|
|
||||||
navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../../connection/connection_registry.dart';
|
|
||||||
import '../../i18n/strings.g.dart';
|
|
||||||
import '../../profiles/active_profile_provider.dart';
|
|
||||||
import '../../profiles/profile.dart';
|
|
||||||
import '../../profiles/profile_connection_cleanup.dart';
|
|
||||||
import '../../profiles/profile_connection_registry.dart';
|
|
||||||
import '../../profiles/profile_registry.dart';
|
|
||||||
import '../../providers/download_provider.dart';
|
|
||||||
import '../../providers/multi_server_provider.dart';
|
|
||||||
import '../../services/storage_service.dart';
|
|
||||||
import '../../utils/app_logger.dart';
|
|
||||||
import '../../utils/dialogs.dart';
|
|
||||||
import '../../utils/snackbar_helper.dart';
|
|
||||||
|
|
||||||
Future<bool> confirmAndDeleteProfile(
|
|
||||||
BuildContext context, {
|
|
||||||
required Profile profile,
|
|
||||||
required String title,
|
|
||||||
required String message,
|
|
||||||
String? confirmText,
|
|
||||||
}) async {
|
|
||||||
final confirmed = await showDeleteConfirmation(context, title: title, message: message, confirmText: confirmText);
|
|
||||||
if (!confirmed || !context.mounted) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteProfile(context, profile);
|
|
||||||
return true;
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
appLogger.w('Failed to delete profile ${profile.id}', error: error, stackTrace: stackTrace);
|
|
||||||
if (context.mounted) {
|
|
||||||
showErrorSnackBar(context, t.errors.failedToDeleteProfile(displayName: profile.displayName));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteProfile(BuildContext context, Profile profile) async {
|
|
||||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
|
||||||
final connRegistry = context.read<ConnectionRegistry>();
|
|
||||||
final profileRegistry = context.read<ProfileRegistry>();
|
|
||||||
final downloadProvider = context.read<DownloadProvider>();
|
|
||||||
final active = context.read<ActiveProfileProvider>();
|
|
||||||
final wasActive = active.activeId == profile.id;
|
|
||||||
|
|
||||||
await downloadProvider.deleteDownloadsForProfile(profile.id);
|
|
||||||
await removeAllProfileConnectionsAndCleanup(
|
|
||||||
profileId: profile.id,
|
|
||||||
profileConnections: pcRegistry,
|
|
||||||
connections: connRegistry,
|
|
||||||
storage: context.read<StorageService>(),
|
|
||||||
serverManager: context.read<MultiServerProvider>().serverManager,
|
|
||||||
);
|
|
||||||
await profileRegistry.remove(profile.id);
|
|
||||||
|
|
||||||
if (!wasActive) return;
|
|
||||||
final remaining = active.profiles.where((p) => p.id != profile.id).toList();
|
|
||||||
if (remaining.isNotEmpty) {
|
|
||||||
await active.activate(remaining.first);
|
|
||||||
} else {
|
|
||||||
await active.clearActiveProfile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,7 @@ import '../settings/add_connection_screen.dart';
|
|||||||
import '../settings/edit_jellyfin_connection_screen.dart';
|
import '../settings/edit_jellyfin_connection_screen.dart';
|
||||||
import 'pin_entry_dialog.dart';
|
import 'pin_entry_dialog.dart';
|
||||||
import 'pin_status_row.dart';
|
import 'pin_status_row.dart';
|
||||||
import 'profile_delete_flow.dart';
|
import 'profile_teardown.dart';
|
||||||
import 'profile_name_field.dart';
|
import 'profile_name_field.dart';
|
||||||
|
|
||||||
/// Manage one [Profile] — rename, change PIN, list/add/remove
|
/// Manage one [Profile] — rename, change PIN, list/add/remove
|
||||||
@@ -162,6 +162,15 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sign out of this virtual profile's parent Plex account. The profile
|
||||||
|
/// ceases to exist with the account, so pop the detail screen — unless
|
||||||
|
/// the teardown already reset the stack to AuthScreen (unmounted here).
|
||||||
|
Future<void> _signOutParentAccount(Connection parentConn) async {
|
||||||
|
final signedOut = await confirmAndSignOutPlexAccount(context, accountConnectionId: parentConn.id);
|
||||||
|
if (!signedOut || !mounted) return;
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _deleteProfile() async {
|
Future<void> _deleteProfile() async {
|
||||||
final deleted = await confirmAndDeleteProfile(
|
final deleted = await confirmAndDeleteProfile(
|
||||||
context,
|
context,
|
||||||
@@ -253,7 +262,12 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ConnectionsList(profile: _profile, onRemove: _removeConnection, onEdit: _editConnection),
|
_ConnectionsList(
|
||||||
|
profile: _profile,
|
||||||
|
onRemove: _removeConnection,
|
||||||
|
onEdit: _editConnection,
|
||||||
|
onSignOutParent: _signOutParentAccount,
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (isLocal)
|
if (isLocal)
|
||||||
FocusableButton(
|
FocusableButton(
|
||||||
@@ -277,8 +291,14 @@ class _ConnectionsList extends StatelessWidget {
|
|||||||
final Profile profile;
|
final Profile profile;
|
||||||
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
|
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
|
||||||
final Future<void> Function(Connection conn) onEdit;
|
final Future<void> Function(Connection conn) onEdit;
|
||||||
|
final Future<void> Function(Connection conn) onSignOutParent;
|
||||||
|
|
||||||
const _ConnectionsList({required this.profile, required this.onRemove, required this.onEdit});
|
const _ConnectionsList({
|
||||||
|
required this.profile,
|
||||||
|
required this.onRemove,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onSignOutParent,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -309,8 +329,9 @@ class _ConnectionsList extends StatelessWidget {
|
|||||||
final byId = {for (final c in all) c.id: c};
|
final byId = {for (final c in all) c.id: c};
|
||||||
// Plex Home profiles have an implicit parent connection that
|
// Plex Home profiles have an implicit parent connection that
|
||||||
// isn't in the join table — list it first so the user sees the
|
// isn't in the join table — list it first so the user sees the
|
||||||
// full picture. It can't be removed (the profile *is* a home
|
// full picture. The profile *is* a home user of that account,
|
||||||
// user of that account) and isn't shown for locals.
|
// so the only removal is signing out of the whole account;
|
||||||
|
// it isn't shown for locals.
|
||||||
final parentConn = profile.isPlexHome ? byId[profile.parentConnectionId] : null;
|
final parentConn = profile.isPlexHome ? byId[profile.parentConnectionId] : null;
|
||||||
final visiblePcs = visibleProfileConnections(profile, pcs);
|
final visiblePcs = visibleProfileConnections(profile, pcs);
|
||||||
if (visiblePcs.isEmpty && parentConn == null) {
|
if (visiblePcs.isEmpty && parentConn == null) {
|
||||||
@@ -330,6 +351,16 @@ class _ConnectionsList extends StatelessWidget {
|
|||||||
leading: BackendBadge(backend: parentConn.backend, size: 24),
|
leading: BackendBadge(backend: parentConn.backend, size: 24),
|
||||||
title: Text(parentConn.displayLabel),
|
title: Text(parentConn.displayLabel),
|
||||||
subtitle: Text(t.profiles.plexHomeAccount),
|
subtitle: Text(t.profiles.plexHomeAccount),
|
||||||
|
trailing: FocusablePopupMenuButton<String>(
|
||||||
|
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||||
|
tooltip: t.profiles.manage,
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'sign_out') {
|
||||||
|
unawaited(onSignOutParent(parentConn));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final pc in visiblePcs)
|
for (final pc in visiblePcs)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import '../../focus/focusable_wrapper.dart';
|
|||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../media/media_backend.dart';
|
import '../../media/media_backend.dart';
|
||||||
import '../../mixins/mounted_set_state_mixin.dart';
|
import '../../mixins/mounted_set_state_mixin.dart';
|
||||||
import '../../profiles/active_profile_binder.dart';
|
|
||||||
import '../../profiles/active_profile_provider.dart';
|
import '../../profiles/active_profile_provider.dart';
|
||||||
import '../../profiles/plex_home_service.dart';
|
import '../../profiles/plex_home_service.dart';
|
||||||
import '../../profiles/profile.dart';
|
import '../../profiles/profile.dart';
|
||||||
@@ -21,9 +20,6 @@ import '../../profiles/profile_registry.dart';
|
|||||||
import '../../profiles/profiles_view.dart';
|
import '../../profiles/profiles_view.dart';
|
||||||
import '../../services/app_exit_service.dart';
|
import '../../services/app_exit_service.dart';
|
||||||
import '../../services/storage_service.dart';
|
import '../../services/storage_service.dart';
|
||||||
import '../../utils/app_logger.dart';
|
|
||||||
import '../../utils/dialogs.dart';
|
|
||||||
import '../../utils/snackbar_helper.dart';
|
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../../widgets/backend_badge.dart';
|
import '../../widgets/backend_badge.dart';
|
||||||
@@ -31,9 +27,8 @@ import '../../widgets/focusable_popup_menu_button.dart';
|
|||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
import '../../widgets/profile_switching_overlay.dart';
|
import '../../widgets/profile_switching_overlay.dart';
|
||||||
import '../libraries/state_messages.dart';
|
import '../libraries/state_messages.dart';
|
||||||
import '../auth_screen.dart';
|
|
||||||
import 'add_local_profile_screen.dart';
|
import 'add_local_profile_screen.dart';
|
||||||
import 'profile_delete_flow.dart';
|
import 'profile_teardown.dart';
|
||||||
import 'profile_detail_screen.dart';
|
import 'profile_detail_screen.dart';
|
||||||
|
|
||||||
/// Flat picker showing every [Profile] in the system — Plex Home users
|
/// Flat picker showing every [Profile] in the system — Plex Home users
|
||||||
@@ -263,70 +258,14 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the parent Plex account [profile] hangs off — same effect as
|
/// Drop the parent Plex account [profile] hangs off — same effect as
|
||||||
/// "Forget account" elsewhere in Plex apps. The connection's join rows
|
/// "Forget account" elsewhere in Plex apps. The shared teardown flow
|
||||||
/// cascade away (FK on connection_id), [PlexHomeService]'s
|
/// removes the account, its virtual Plex Home profiles, and their
|
||||||
/// `_onChange` listener evicts the cached home users + shadow profile
|
/// borrowed connections, then routes to auth when nothing selectable
|
||||||
/// rows, and a binder rebind clears the runtime client. Plex doesn't
|
/// remains (#1423).
|
||||||
/// expose a single-session revoke endpoint we can rely on, so we don't
|
|
||||||
/// touch the server side — the user can revoke via plex.tv if they want.
|
|
||||||
Future<void> _signOutPlexAccount(Profile profile) async {
|
Future<void> _signOutPlexAccount(Profile profile) async {
|
||||||
final parentId = profile.parentConnectionId;
|
final parentId = profile.parentConnectionId;
|
||||||
if (parentId == null) return;
|
if (parentId == null) return;
|
||||||
final connRegistry = context.read<ConnectionRegistry>();
|
await confirmAndSignOutPlexAccount(context, accountConnectionId: parentId);
|
||||||
final parent = await connRegistry.getPlexAccount(parentId);
|
|
||||||
if (parent == null || !mounted) return;
|
|
||||||
|
|
||||||
final confirmed = await showDeleteConfirmation(
|
|
||||||
context,
|
|
||||||
title: t.profiles.signOutPlexTitle,
|
|
||||||
message: t.profiles.signOutPlexMessage(displayName: parent.displayLabel),
|
|
||||||
confirmText: t.profiles.signOut,
|
|
||||||
);
|
|
||||||
if (!confirmed || !mounted) return;
|
|
||||||
|
|
||||||
final active = context.read<ActiveProfileProvider>();
|
|
||||||
final activeProfile = active.active;
|
|
||||||
final wasActiveAccount = activeProfile?.parentConnectionId == parentId;
|
|
||||||
final remainingProfiles = active.profiles
|
|
||||||
.where((p) => p.id != activeProfile?.id && p.parentConnectionId != parentId)
|
|
||||||
.toList();
|
|
||||||
final binder = context.read<ActiveProfileBinder>();
|
|
||||||
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.
|
|
||||||
if (wasActiveAccount) {
|
|
||||||
if (remainingProfiles.isNotEmpty) {
|
|
||||||
await active.activate(remainingProfiles.first);
|
|
||||||
} else {
|
|
||||||
await active.clearActiveProfile();
|
|
||||||
unawaited(binder.rebindActive());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Active profile stayed the same, but borrowed rows for this account
|
|
||||||
// may have cascaded away.
|
|
||||||
unawaited(binder.rebindActive());
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
showSuccessSnackBar(context, t.profiles.signedOutPlex);
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w('Plex sign-out failed for $parentId', error: e, stackTrace: st);
|
|
||||||
if (mounted) {
|
|
||||||
showErrorSnackBar(context, t.profiles.signOutFailed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteProfile(Profile profile) async {
|
Future<void> _deleteProfile(Profile profile) async {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../connection/connection_registry.dart';
|
||||||
|
import '../../database/app_database.dart';
|
||||||
|
import '../../i18n/strings.g.dart';
|
||||||
|
import '../../profiles/active_profile_binder.dart';
|
||||||
|
import '../../profiles/active_profile_provider.dart';
|
||||||
|
import '../../profiles/plex_home_service.dart';
|
||||||
|
import '../../profiles/profile.dart';
|
||||||
|
import '../../profiles/profile_connection_cleanup.dart';
|
||||||
|
import '../../profiles/profile_connection_registry.dart';
|
||||||
|
import '../../profiles/profile_registry.dart';
|
||||||
|
import '../../providers/companion_remote_provider.dart';
|
||||||
|
import '../../providers/download_provider.dart';
|
||||||
|
import '../../providers/hidden_libraries_provider.dart';
|
||||||
|
import '../../providers/multi_server_provider.dart';
|
||||||
|
import '../../providers/playback_state_provider.dart';
|
||||||
|
import '../../providers/user_profile_provider.dart';
|
||||||
|
import '../../services/api_cache.dart';
|
||||||
|
import '../../services/multi_server_manager.dart';
|
||||||
|
import '../../services/storage_service.dart';
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../../utils/dialogs.dart';
|
||||||
|
import '../../utils/snackbar_helper.dart';
|
||||||
|
import '../auth_screen.dart';
|
||||||
|
|
||||||
|
/// The collaborators every teardown flow needs, snapshotted from [context]
|
||||||
|
/// BEFORE the first await so no flow touches `context.read` mid-teardown.
|
||||||
|
class SessionTeardownScope {
|
||||||
|
final ActiveProfileProvider active;
|
||||||
|
final ActiveProfileBinder binder;
|
||||||
|
final PlexHomeService plexHome;
|
||||||
|
final ProfileRegistry profileRegistry;
|
||||||
|
final ProfileConnectionRegistry profileConnections;
|
||||||
|
final ConnectionRegistry connections;
|
||||||
|
final MultiServerProvider multiServer;
|
||||||
|
final HiddenLibrariesProvider? hiddenLibraries;
|
||||||
|
final DownloadProvider downloads;
|
||||||
|
final AppDatabase database;
|
||||||
|
final StorageService storage;
|
||||||
|
final NavigatorState navigator;
|
||||||
|
|
||||||
|
MultiServerManager get serverManager => multiServer.serverManager;
|
||||||
|
|
||||||
|
SessionTeardownScope.of(BuildContext context)
|
||||||
|
: active = context.read<ActiveProfileProvider>(),
|
||||||
|
binder = context.read<ActiveProfileBinder>(),
|
||||||
|
plexHome = context.read<PlexHomeService>(),
|
||||||
|
profileRegistry = context.read<ProfileRegistry>(),
|
||||||
|
profileConnections = context.read<ProfileConnectionRegistry>(),
|
||||||
|
connections = context.read<ConnectionRegistry>(),
|
||||||
|
multiServer = context.read<MultiServerProvider>(),
|
||||||
|
hiddenLibraries = context.read<HiddenLibrariesProvider?>(),
|
||||||
|
downloads = context.read<DownloadProvider>(),
|
||||||
|
database = context.read<AppDatabase>(),
|
||||||
|
storage = context.read<StorageService>(),
|
||||||
|
navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide where the session lands after any profile/connection removal and
|
||||||
|
/// apply it. Mirrors the boot guard: no connections, or connections but no
|
||||||
|
/// resolvable profile, routes to [AuthScreen]; otherwise the active profile
|
||||||
|
/// is kept (optionally rebound) or the next non-PIN-protected profile is
|
||||||
|
/// activated so the picker can force an explicit, PIN-checked choice when
|
||||||
|
/// only protected profiles remain.
|
||||||
|
///
|
||||||
|
/// Returns true when it navigated to [AuthScreen] — the caller must stop
|
||||||
|
/// touching its own UI in that case.
|
||||||
|
Future<bool> settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindIfActiveKept = false}) async {
|
||||||
|
final result = await resolvePostRemovalState(
|
||||||
|
profileRegistry: scope.profileRegistry,
|
||||||
|
profileConnections: scope.profileConnections,
|
||||||
|
connections: scope.connections,
|
||||||
|
plexHomeUsers: scope.plexHome.current,
|
||||||
|
storage: scope.storage,
|
||||||
|
serverManager: scope.serverManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.route == PostRemovalRoute.signedOut) {
|
||||||
|
await scope.active.clearActiveProfile();
|
||||||
|
unawaited(scope.binder.rebindActive());
|
||||||
|
if (scope.navigator.mounted) {
|
||||||
|
unawaited(
|
||||||
|
scope.navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (_) => const AuthScreen()), (_) => false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeId = scope.storage.getActiveProfileId();
|
||||||
|
final activeStillExists = activeId != null && result.profiles.any((p) => p.id == activeId);
|
||||||
|
if (activeStillExists) {
|
||||||
|
if (rebindIfActiveKept) unawaited(scope.binder.rebindActive());
|
||||||
|
} else {
|
||||||
|
// Auto-activation must not bypass PIN gates ([activate] rejects local
|
||||||
|
// PIN profiles without a pin; protected Plex Home profiles would PIN
|
||||||
|
// prompt at bind time) — skip protected profiles instead.
|
||||||
|
Profile? next;
|
||||||
|
for (final profile in result.profiles) {
|
||||||
|
if (!profile.isPinProtected) {
|
||||||
|
next = profile;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final activated = next != null && await scope.active.activate(next);
|
||||||
|
if (!activated) {
|
||||||
|
await scope.active.clearActiveProfile();
|
||||||
|
unawaited(scope.binder.rebindActive());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await scope.hiddenLibraries?.refresh();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> confirmAndDeleteProfile(
|
||||||
|
BuildContext context, {
|
||||||
|
required Profile profile,
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
String? confirmText,
|
||||||
|
}) async {
|
||||||
|
final confirmed = await showDeleteConfirmation(context, title: title, message: message, confirmText: confirmText);
|
||||||
|
if (!confirmed || !context.mounted) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteProfile(context, profile);
|
||||||
|
return true;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
appLogger.w('Failed to delete profile ${profile.id}', error: error, stackTrace: stackTrace);
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorSnackBar(context, t.errors.failedToDeleteProfile(displayName: profile.displayName));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a local profile and everything it owns: downloads, sync rules,
|
||||||
|
/// queued watch actions, join rows (pruning now-unreferenced Jellyfin
|
||||||
|
/// connections), last-used marker, and user-scoped prefs.
|
||||||
|
Future<void> deleteProfile(BuildContext context, Profile profile) async {
|
||||||
|
final scope = SessionTeardownScope.of(context);
|
||||||
|
|
||||||
|
await scope.downloads.deleteDownloadsForProfile(profile.id);
|
||||||
|
await scope.database.deleteSyncRulesForProfile(profile.id);
|
||||||
|
await scope.database.deleteWatchActionsForProfile(profile.id);
|
||||||
|
await removeAllProfileConnectionsAndCleanup(
|
||||||
|
profileId: profile.id,
|
||||||
|
profileConnections: scope.profileConnections,
|
||||||
|
connections: scope.connections,
|
||||||
|
storage: scope.storage,
|
||||||
|
serverManager: scope.serverManager,
|
||||||
|
);
|
||||||
|
await scope.profileRegistry.remove(profile.id);
|
||||||
|
await scope.storage.clearProfileLastUsed(profile.id);
|
||||||
|
await scope.storage.clearUserScopedPreferencesForProfile(profile.id);
|
||||||
|
|
||||||
|
await settleSessionAfterRemoval(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out of a Plex account after confirmation: the account connection,
|
||||||
|
/// its virtual Plex Home profiles, and their borrowed connections are all
|
||||||
|
/// removed (#1423); surviving borrower profiles release the account's
|
||||||
|
/// server downloads. Plex exposes no reliable single-session revoke
|
||||||
|
/// endpoint, so the server side is untouched — the user can revoke the
|
||||||
|
/// device via plex.tv.
|
||||||
|
///
|
||||||
|
/// Returns true when the sign-out ran, false when cancelled or the account
|
||||||
|
/// no longer exists.
|
||||||
|
Future<bool> confirmAndSignOutPlexAccount(BuildContext context, {required String accountConnectionId}) async {
|
||||||
|
final account = await context.read<ConnectionRegistry>().getPlexAccount(accountConnectionId);
|
||||||
|
if (account == null || !context.mounted) return false;
|
||||||
|
|
||||||
|
final confirmed = await showDeleteConfirmation(
|
||||||
|
context,
|
||||||
|
title: t.profiles.signOutPlexTitle,
|
||||||
|
message: t.profiles.signOutPlexMessage(displayName: account.displayLabel),
|
||||||
|
confirmText: t.profiles.signOut,
|
||||||
|
);
|
||||||
|
if (!confirmed || !context.mounted) return false;
|
||||||
|
|
||||||
|
final scope = SessionTeardownScope.of(context);
|
||||||
|
try {
|
||||||
|
final removal = await removePlexAccountConnectionAndCleanup(
|
||||||
|
account: account,
|
||||||
|
profileConnections: scope.profileConnections,
|
||||||
|
connections: scope.connections,
|
||||||
|
storage: scope.storage,
|
||||||
|
serverManager: scope.serverManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final profileId in removal.removedVirtualProfileIds) {
|
||||||
|
await scope.downloads.deleteDownloadsForProfile(profileId);
|
||||||
|
await scope.database.deleteSyncRulesForProfile(profileId);
|
||||||
|
await scope.database.deleteWatchActionsForProfile(profileId);
|
||||||
|
}
|
||||||
|
final accountServerIds = {for (final server in account.servers) server.clientIdentifier};
|
||||||
|
for (final profileId in removal.borrowerProfileIds) {
|
||||||
|
await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
final navigatedAway = await settleSessionAfterRemoval(scope, rebindIfActiveKept: true);
|
||||||
|
if (!navigatedAway && context.mounted) {
|
||||||
|
showSuccessSnackBar(context, t.profiles.signedOutPlex);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e, st) {
|
||||||
|
appLogger.w('Plex sign-out failed for $accountConnectionId', error: e, stackTrace: st);
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorSnackBar(context, t.profiles.signOutFailed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full logout: clear every profile, connection, credential, cached API
|
||||||
|
/// row, and user-scoped pref, then reset to [AuthScreen]. The caller
|
||||||
|
/// confirms first.
|
||||||
|
Future<void> logoutAllProfiles(BuildContext context) async {
|
||||||
|
final scope = SessionTeardownScope.of(context);
|
||||||
|
final userProfileProvider = context.read<UserProfileProvider>();
|
||||||
|
final companionRemote = context.read<CompanionRemoteProvider>();
|
||||||
|
final playbackState = context.read<PlaybackStateProvider>();
|
||||||
|
|
||||||
|
await companionRemote.resetForLogout();
|
||||||
|
await userProfileProvider.logout();
|
||||||
|
scope.multiServer.clearAllConnections();
|
||||||
|
// Drop the profile/connection rows so the next sign-in starts clean and
|
||||||
|
// doesn't bind to stale tokens or orphaned profile rows.
|
||||||
|
await scope.profileConnections.clear();
|
||||||
|
await scope.profileRegistry.clear();
|
||||||
|
await scope.connections.clear();
|
||||||
|
await scope.plexHome.clearAll();
|
||||||
|
await scope.storage.clearActiveProfileId();
|
||||||
|
await scope.storage.clearAllProfileLastUsed();
|
||||||
|
await scope.storage.clearAllUserScopedPreferences();
|
||||||
|
// The API cache is app-global and Plex rows are keyed by server only, so
|
||||||
|
// a later sign-in as a different user must not inherit them.
|
||||||
|
await ApiCache.instance.clearVolatile();
|
||||||
|
await scope.hiddenLibraries?.refresh();
|
||||||
|
playbackState.clearShuffle();
|
||||||
|
|
||||||
|
if (scope.navigator.mounted) {
|
||||||
|
unawaited(scope.navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (_) => const AuthScreen()), (_) => false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -482,6 +482,23 @@ class StorageService extends BaseSharedPreferencesService {
|
|||||||
await _clearKeysWithPrefix(_prefixProfileLastUsed);
|
await _clearKeysWithPrefix(_prefixProfileLastUsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearProfileLastUsed(String profileId) async {
|
||||||
|
await prefs.remove('$_prefixProfileLastUsed$profileId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every user-scoped pref under [profileId]'s scope. For Plex Home
|
||||||
|
/// profiles the scope is the home-user uuid, which is shared by any borrow
|
||||||
|
/// of the same home user — only call when that user's access is being torn
|
||||||
|
/// down entirely (profile delete / account sign-out).
|
||||||
|
Future<void> clearUserScopedPreferencesForProfile(String profileId) async {
|
||||||
|
await _clearKeysWithPrefix(_userPrefixForProfileId(profileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove user-scoped prefs for every scope (full logout).
|
||||||
|
Future<void> clearAllUserScopedPreferences() async {
|
||||||
|
await _clearKeysWithPrefix('user_');
|
||||||
|
}
|
||||||
|
|
||||||
// Private helper methods
|
// Private helper methods
|
||||||
|
|
||||||
/// Helper to read and decode JSON `List<String>` from preferences
|
/// Helper to read and decode JSON `List<String>` from preferences
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:plezy/connection/connection.dart';
|
import 'package:plezy/connection/connection.dart';
|
||||||
import 'package:plezy/connection/connection_registry.dart';
|
import 'package:plezy/connection/connection_registry.dart';
|
||||||
import 'package:plezy/database/app_database.dart';
|
import 'package:plezy/database/app_database.dart';
|
||||||
|
import 'package:plezy/models/plex/plex_home_user.dart';
|
||||||
|
import 'package:plezy/profiles/profile.dart';
|
||||||
import 'package:plezy/profiles/profile_connection.dart';
|
import 'package:plezy/profiles/profile_connection.dart';
|
||||||
import 'package:plezy/profiles/profile_connection_cleanup.dart';
|
import 'package:plezy/profiles/profile_connection_cleanup.dart';
|
||||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||||
|
import 'package:plezy/profiles/profile_registry.dart';
|
||||||
import 'package:plezy/services/plex_auth_service.dart';
|
import 'package:plezy/services/plex_auth_service.dart';
|
||||||
import 'package:plezy/services/storage_service.dart';
|
import 'package:plezy/services/storage_service.dart';
|
||||||
|
|
||||||
@@ -26,16 +29,16 @@ JellyfinConnection _jellyfin({String machineId = 'jf-machine', String userId = '
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PlexAccountConnection _plex() {
|
PlexAccountConnection _plex({String id = 'plex-account', String serverMachineId = 'plex-machine'}) {
|
||||||
return PlexAccountConnection(
|
return PlexAccountConnection(
|
||||||
id: 'plex-account',
|
id: id,
|
||||||
accountToken: 'account-token',
|
accountToken: 'account-token',
|
||||||
clientIdentifier: 'client-1',
|
clientIdentifier: 'client-1',
|
||||||
accountLabel: 'Plex',
|
accountLabel: 'Plex',
|
||||||
servers: [
|
servers: [
|
||||||
PlexServer(
|
PlexServer(
|
||||||
name: 'Plex Server',
|
name: 'Plex Server',
|
||||||
clientIdentifier: 'plex-machine',
|
clientIdentifier: serverMachineId,
|
||||||
accessToken: 'server-token',
|
accessToken: 'server-token',
|
||||||
connections: [
|
connections: [
|
||||||
PlexConnection(
|
PlexConnection(
|
||||||
@@ -56,6 +59,25 @@ PlexAccountConnection _plex() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlexHomeUser _homeUser(String uuid) {
|
||||||
|
return PlexHomeUser(
|
||||||
|
id: 1,
|
||||||
|
uuid: uuid,
|
||||||
|
title: 'User $uuid',
|
||||||
|
thumb: '',
|
||||||
|
hasPassword: false,
|
||||||
|
restricted: false,
|
||||||
|
updatedAt: null,
|
||||||
|
admin: false,
|
||||||
|
guest: false,
|
||||||
|
protected: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileConnection _row(String profileId, Connection conn, {String userIdentifier = 'user'}) {
|
||||||
|
return ProfileConnection(profileId: profileId, connectionId: conn.id, userToken: 't', userIdentifier: userIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
late ConnectionRegistry connections;
|
late ConnectionRegistry connections;
|
||||||
@@ -195,6 +217,143 @@ void main() {
|
|||||||
expect(storage.getHiddenLibraries(), {'jf-machine:movies'});
|
expect(storage.getHiddenLibraries(), {'jf-machine:movies'});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'sign-out removes the account, its virtual profiles, and their borrowed Jellyfin connection (#1423)',
|
||||||
|
() async {
|
||||||
|
const uuid = 'aaaaaaaaaaaaaaaa';
|
||||||
|
final acct = _plex();
|
||||||
|
final jf = _jellyfin();
|
||||||
|
final vProfile = plexHomeProfileId(accountConnectionId: acct.id, homeUserUuid: uuid);
|
||||||
|
await connections.upsert(acct);
|
||||||
|
await connections.upsert(jf);
|
||||||
|
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
|
||||||
|
await profileConnections.upsert(_row(vProfile, jf));
|
||||||
|
await storage.savePlexHomeUsersCache(acct.id, [_homeUser(uuid).toJson()]);
|
||||||
|
await storage.markProfileUsed(vProfile, DateTime.fromMillisecondsSinceEpoch(2_000_000));
|
||||||
|
await storage.setActiveProfileId(vProfile);
|
||||||
|
await storage.saveHiddenLibraries({'jf-machine:movies'});
|
||||||
|
|
||||||
|
final removal = await removePlexAccountConnectionAndCleanup(
|
||||||
|
account: acct,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(removal.removedVirtualProfileIds, {vProfile});
|
||||||
|
expect(removal.borrowerProfileIds, isEmpty);
|
||||||
|
expect(await connections.list(), isEmpty);
|
||||||
|
expect(await profileConnections.listAll(), isEmpty);
|
||||||
|
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
|
||||||
|
expect(storage.getProfileLastUsed(vProfile), isNull);
|
||||||
|
expect(storage.getHiddenLibraries(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('sign-out keeps a borrowed Jellyfin connection another profile still references', () async {
|
||||||
|
const uuid = 'aaaaaaaaaaaaaaaa';
|
||||||
|
final acct = _plex();
|
||||||
|
final jf = _jellyfin();
|
||||||
|
final vProfile = plexHomeProfileId(accountConnectionId: acct.id, homeUserUuid: uuid);
|
||||||
|
await connections.upsert(acct);
|
||||||
|
await connections.upsert(jf);
|
||||||
|
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
|
||||||
|
await profileConnections.upsert(_row(vProfile, jf));
|
||||||
|
await profileConnections.upsert(_row('local-1', jf));
|
||||||
|
|
||||||
|
await removePlexAccountConnectionAndCleanup(
|
||||||
|
account: acct,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await connections.get(jf.id), isNotNull);
|
||||||
|
final remaining = await profileConnections.listAll();
|
||||||
|
expect(remaining, hasLength(1));
|
||||||
|
expect(remaining.single.profileId, 'local-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'sign-out from a local borrower keeps the local profile and its Jellyfin connection (repro 2 shape)',
|
||||||
|
() async {
|
||||||
|
final acct = _plex();
|
||||||
|
final jf = _jellyfin();
|
||||||
|
await connections.upsert(acct);
|
||||||
|
await connections.upsert(jf);
|
||||||
|
await profileConnections.upsert(_row('local-1', acct));
|
||||||
|
await profileConnections.upsert(_row('local-1', jf));
|
||||||
|
|
||||||
|
final removal = await removePlexAccountConnectionAndCleanup(
|
||||||
|
account: acct,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(removal.removedVirtualProfileIds, isEmpty);
|
||||||
|
expect(removal.borrowerProfileIds, {'local-1'});
|
||||||
|
expect(await connections.get(acct.id), isNull);
|
||||||
|
expect(await connections.get(jf.id), isNotNull);
|
||||||
|
final remaining = await profileConnections.listAll();
|
||||||
|
expect(remaining, hasLength(1));
|
||||||
|
expect(remaining.single.connectionId, jf.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('sign-out leaves another account and its virtual profiles untouched (hyphen-bearing ids)', () async {
|
||||||
|
const uuid1 = 'aaaaaaaaaaaaaaaa';
|
||||||
|
const uuid2 = 'bbbbbbbbbbbbbbbb';
|
||||||
|
final acct1 = _plex();
|
||||||
|
final acct2 = _plex(id: 'plex-account-2', serverMachineId: 'plex-machine-2');
|
||||||
|
final v1 = plexHomeProfileId(accountConnectionId: acct1.id, homeUserUuid: uuid1);
|
||||||
|
final v2 = plexHomeProfileId(accountConnectionId: acct2.id, homeUserUuid: uuid2);
|
||||||
|
await connections.upsert(acct1);
|
||||||
|
await connections.upsert(acct2);
|
||||||
|
await profileConnections.upsert(_row(v1, acct1, userIdentifier: uuid1));
|
||||||
|
await profileConnections.upsert(_row(v2, acct2, userIdentifier: uuid2));
|
||||||
|
await storage.savePlexHomeUsersCache(acct2.id, [_homeUser(uuid2).toJson()]);
|
||||||
|
|
||||||
|
final removal = await removePlexAccountConnectionAndCleanup(
|
||||||
|
account: acct1,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(removal.removedVirtualProfileIds, {v1});
|
||||||
|
expect(await connections.get(acct2.id), isNotNull);
|
||||||
|
final remaining = await profileConnections.listAll();
|
||||||
|
expect(remaining, hasLength(1));
|
||||||
|
expect(remaining.single.profileId, v2);
|
||||||
|
expect(storage.getPlexHomeUsersCacheJson(acct2.id), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign-out is idempotent', () async {
|
||||||
|
const uuid = 'aaaaaaaaaaaaaaaa';
|
||||||
|
final acct = _plex();
|
||||||
|
final jf = _jellyfin();
|
||||||
|
final vProfile = plexHomeProfileId(accountConnectionId: acct.id, homeUserUuid: uuid);
|
||||||
|
await connections.upsert(acct);
|
||||||
|
await connections.upsert(jf);
|
||||||
|
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
|
||||||
|
await profileConnections.upsert(_row(vProfile, jf));
|
||||||
|
|
||||||
|
Future<PlexAccountRemoval> run() => removePlexAccountConnectionAndCleanup(
|
||||||
|
account: acct,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
final second = await run();
|
||||||
|
|
||||||
|
expect(second.removedVirtualProfileIds, isEmpty);
|
||||||
|
expect(await connections.list(), isEmpty);
|
||||||
|
expect(await profileConnections.listAll(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('Plex profile unlink clears only that profile because Plex Home access can be implicit', () async {
|
test('Plex profile unlink clears only that profile because Plex Home access can be implicit', () async {
|
||||||
final conn = _plex();
|
final conn = _plex();
|
||||||
await connections.upsert(conn);
|
await connections.upsert(conn);
|
||||||
@@ -223,4 +382,84 @@ void main() {
|
|||||||
expect(storage.getHiddenLibraries(), {'plex-machine:movies'});
|
expect(storage.getHiddenLibraries(), {'plex-machine:movies'});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('resolvePostRemovalState', () {
|
||||||
|
late ProfileRegistry profileRegistry;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
profileRegistry = ProfileRegistry(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<({PostRemovalRoute route, List<Profile> profiles})> resolve({
|
||||||
|
Map<String, List<PlexHomeUser>> plexHomeUsers = const {},
|
||||||
|
}) {
|
||||||
|
return resolvePostRemovalState(
|
||||||
|
profileRegistry: profileRegistry,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
plexHomeUsers: plexHomeUsers,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile local(String id) =>
|
||||||
|
Profile.local(id: id, displayName: id, createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000));
|
||||||
|
|
||||||
|
test('no connections → signed out', () async {
|
||||||
|
final result = await resolve();
|
||||||
|
expect(result.route, PostRemovalRoute.signedOut);
|
||||||
|
expect(result.profiles, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only an orphaned Jellyfin connection → pruned, signed out (the #1423 wedge)', () async {
|
||||||
|
final jf = _jellyfin();
|
||||||
|
await connections.upsert(jf);
|
||||||
|
|
||||||
|
final result = await resolve();
|
||||||
|
|
||||||
|
expect(result.route, PostRemovalRoute.signedOut);
|
||||||
|
expect(await connections.list(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('account with cached home users → stay signed in with the virtual profiles', () async {
|
||||||
|
final acct = _plex();
|
||||||
|
await connections.upsert(acct);
|
||||||
|
|
||||||
|
final result = await resolve(
|
||||||
|
plexHomeUsers: {
|
||||||
|
acct.id: [_homeUser('aaaaaaaaaaaaaaaa')],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.route, PostRemovalRoute.staySignedIn);
|
||||||
|
expect(result.profiles.single.isPlexHome, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('account but no resolvable home users and no locals → signed out (boot-guard mirror)', () async {
|
||||||
|
final acct = _plex();
|
||||||
|
await connections.upsert(acct);
|
||||||
|
// Referenced so the prune keeps nothing to do; still unresolvable.
|
||||||
|
await profileConnections.upsert(_row('plex-home-${acct.id}-aaaaaaaaaaaaaaaa', acct));
|
||||||
|
|
||||||
|
final result = await resolve();
|
||||||
|
|
||||||
|
expect(result.route, PostRemovalRoute.signedOut);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local profile survives alongside an orphaned Jellyfin connection → stay signed in, orphan pruned', () async {
|
||||||
|
final jf = _jellyfin();
|
||||||
|
final referencedJf = _jellyfin(userId: 'user-b');
|
||||||
|
await connections.upsert(jf);
|
||||||
|
await connections.upsert(referencedJf);
|
||||||
|
await profileRegistry.upsert(local('local-1'));
|
||||||
|
await profileConnections.upsert(_row('local-1', referencedJf));
|
||||||
|
|
||||||
|
final result = await resolve();
|
||||||
|
|
||||||
|
expect(result.route, PostRemovalRoute.staySignedIn);
|
||||||
|
expect(result.profiles.single.id, 'local-1');
|
||||||
|
expect(await connections.get(jf.id), isNull);
|
||||||
|
expect(await connections.get(referencedJf.id), isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user