fix(profiles): clean up orphaned connections on Plex sign-out

close #1423
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent 6e8320f782
commit 2025e1f9fd
10 changed files with 674 additions and 188 deletions
+5 -6
View File
@@ -87,15 +87,15 @@ Package managers:
- Real-time play / pause / seek sync
### <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
- 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
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
- Customizable keyboard shortcuts[^7]
- Metadata and artwork editing[^2]
- Customizable keyboard shortcuts[^6]
- Metadata and artwork editing
- Settings import/export
- Localized in English plus 14 translations
@@ -104,8 +104,7 @@ Package managers:
[^3]: Not available on iOS or tvOS.
[^4]: Android, iOS, and macOS.
[^5]: Windows, Android, and tvOS.
[^6]: Android TV only.
[^7]: Desktop only.
[^6]: Desktop only.
## Building from Source
+10
View File
@@ -518,6 +518,11 @@ class AppDatabase extends _$AppDatabase {
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}) {
final query = select(syncRules);
if (profileId != null) {
@@ -621,6 +626,11 @@ class AppDatabase extends _$AppDatabase {
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)
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
@@ -1,9 +1,13 @@
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../media/ids.dart';
import '../models/plex/plex_home_user.dart';
import '../services/multi_server_manager.dart';
import '../services/storage_service.dart';
import 'profile.dart';
import 'profile_connection_registry.dart';
import 'profile_merge.dart';
import 'profile_registry.dart';
Future<void> removeProfileConnectionAndCleanup({
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({
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
+2 -42
View File
@@ -24,8 +24,6 @@ import '../utils/content_utils.dart';
import '../widgets/optimized_media_image.dart' show blurArtwork;
import '../providers/discover_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 '../widgets/hub_section.dart';
import '../widgets/app_menu.dart';
@@ -33,16 +31,11 @@ import '../widgets/clickable_cursor.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/profile_switching_overlay.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/plex_home_service.dart';
import '../profiles/profile.dart';
import '../profiles/profile_activation.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 '../widgets/settings_builder.dart';
import '../widgets/fitting_title_text.dart';
@@ -61,7 +54,6 @@ import '../utils/video_player_navigation.dart';
import '../utils/layout_constants.dart';
import '../utils/platform_detector.dart';
import '../theme/mono_tokens.dart';
import 'auth_screen.dart';
import 'libraries/content_state_builder.dart';
import 'main_screen.dart';
import 'settings/settings_screen.dart';
@@ -779,39 +771,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
);
if (confirm && mounted) {
final navigator = Navigator.of(context, rootNavigator: true);
// 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),
);
}
await logoutAllProfiles(context);
}
}
@@ -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();
}
}
+36 -5
View File
@@ -34,7 +34,7 @@ import '../settings/add_connection_screen.dart';
import '../settings/edit_jellyfin_connection_screen.dart';
import 'pin_entry_dialog.dart';
import 'pin_status_row.dart';
import 'profile_delete_flow.dart';
import 'profile_teardown.dart';
import 'profile_name_field.dart';
/// 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 {
final deleted = await confirmAndDeleteProfile(
context,
@@ -253,7 +262,12 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
],
),
const SizedBox(height: 8),
_ConnectionsList(profile: _profile, onRemove: _removeConnection, onEdit: _editConnection),
_ConnectionsList(
profile: _profile,
onRemove: _removeConnection,
onEdit: _editConnection,
onSignOutParent: _signOutParentAccount,
),
const SizedBox(height: 24),
if (isLocal)
FocusableButton(
@@ -277,8 +291,14 @@ class _ConnectionsList extends StatelessWidget {
final Profile profile;
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
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
Widget build(BuildContext context) {
@@ -309,8 +329,9 @@ class _ConnectionsList extends StatelessWidget {
final byId = {for (final c in all) c.id: c};
// Plex Home profiles have an implicit parent connection that
// 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
// user of that account) and isn't shown for locals.
// full picture. The profile *is* a home user of that account,
// 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 visiblePcs = visibleProfileConnections(profile, pcs);
if (visiblePcs.isEmpty && parentConn == null) {
@@ -330,6 +351,16 @@ class _ConnectionsList extends StatelessWidget {
leading: BackendBadge(backend: parentConn.backend, size: 24),
title: Text(parentConn.displayLabel),
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)
+6 -67
View File
@@ -9,7 +9,6 @@ import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_backend.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart';
import '../../profiles/profile.dart';
@@ -21,9 +20,6 @@ import '../../profiles/profile_registry.dart';
import '../../profiles/profiles_view.dart';
import '../../services/app_exit_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_menu.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/profile_switching_overlay.dart';
import '../libraries/state_messages.dart';
import '../auth_screen.dart';
import 'add_local_profile_screen.dart';
import 'profile_delete_flow.dart';
import 'profile_teardown.dart';
import 'profile_detail_screen.dart';
/// 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
/// "Forget account" elsewhere in Plex apps. The connection's join rows
/// cascade away (FK on connection_id), [PlexHomeService]'s
/// `_onChange` listener evicts the cached home users + shadow profile
/// rows, and a binder rebind clears the runtime client. Plex doesn't
/// 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.
/// "Forget account" elsewhere in Plex apps. The shared teardown flow
/// removes the account, its virtual Plex Home profiles, and their
/// borrowed connections, then routes to auth when nothing selectable
/// remains (#1423).
Future<void> _signOutPlexAccount(Profile profile) async {
final parentId = profile.parentConnectionId;
if (parentId == null) return;
final connRegistry = context.read<ConnectionRegistry>();
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);
}
}
await confirmAndSignOutPlexAccount(context, accountConnectionId: parentId);
}
Future<void> _deleteProfile(Profile profile) async {
+248
View File
@@ -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));
}
}
+17
View File
@@ -482,6 +482,23 @@ class StorageService extends BaseSharedPreferencesService {
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
/// 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_registry.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_cleanup.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/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(
id: 'plex-account',
id: id,
accountToken: 'account-token',
clientIdentifier: 'client-1',
accountLabel: 'Plex',
servers: [
PlexServer(
name: 'Plex Server',
clientIdentifier: 'plex-machine',
clientIdentifier: serverMachineId,
accessToken: 'server-token',
connections: [
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() {
late AppDatabase db;
late ConnectionRegistry connections;
@@ -195,6 +217,143 @@ void main() {
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 {
final conn = _plex();
await connections.upsert(conn);
@@ -223,4 +382,84 @@ void main() {
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);
});
});
}