From 2025e1f9fd17adb212102a31cb59c7642c0a0f18 Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Thu, 2 Jul 2026 04:07:26 +0200
Subject: [PATCH] fix(profiles): clean up orphaned connections on Plex sign-out
close #1423
---
README.md | 11 +-
lib/database/app_database.dart | 10 +
lib/profiles/profile_connection_cleanup.dart | 108 ++++++++
lib/screens/discover_screen.dart | 44 +---
lib/screens/profile/profile_delete_flow.dart | 65 -----
.../profile/profile_detail_screen.dart | 41 ++-
.../profile/profile_switch_screen.dart | 73 +-----
lib/screens/profile/profile_teardown.dart | 248 ++++++++++++++++++
lib/services/storage_service.dart | 17 ++
.../profile_connection_cleanup_test.dart | 245 ++++++++++++++++-
10 files changed, 674 insertions(+), 188 deletions(-)
delete mode 100644 lib/screens/profile/profile_delete_flow.dart
create mode 100644 lib/screens/profile/profile_teardown.dart
diff --git a/README.md b/README.md
index fd64adbe..9c49fe69 100644
--- a/README.md
+++ b/README.md
@@ -87,15 +87,15 @@ Package managers:
- Real-time play / pause / seek sync
###
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
###
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
diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart
index faf14c49..b0e751f5 100644
--- a/lib/database/app_database.dart
+++ b/lib/database/app_database.dart
@@ -518,6 +518,11 @@ class AppDatabase extends _$AppDatabase {
return delete(offlineWatchProgress).go();
}
+ /// Drop a removed profile's queued watch actions (profile teardown).
+ Future deleteWatchActionsForProfile(String profileId) async {
+ await (delete(offlineWatchProgress)..where((t) => t.profileId.equals(profileId))).go();
+ }
+
Future> 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 deleteSyncRulesForProfile(String profileId) async {
+ await (delete(syncRules)..where((t) => t.profileId.equals(profileId))).go();
+ }
+
/// Get all downloaded media items (for syncing watch states)
Future> getAllDownloadedMetadata() {
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart
index eacfd463..23b5841f 100644
--- a/lib/profiles/profile_connection_cleanup.dart
+++ b/lib/profiles/profile_connection_cleanup.dart
@@ -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 removeProfileConnectionAndCleanup({
required String profileId,
@@ -64,6 +68,110 @@ Future 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 removedVirtualProfileIds,
+
+ /// Profiles that survive but had a join row onto the removed account
+ /// (locals that borrowed a home user).
+ Set 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 removePlexAccountConnectionAndCleanup({
+ required PlexAccountConnection account,
+ required ProfileConnectionRegistry profileConnections,
+ required ConnectionRegistry connections,
+ required StorageService storage,
+ MultiServerManager? serverManager,
+}) async {
+ final rows = await profileConnections.listAll();
+ final removedVirtualProfileIds = {
+ for (final row in rows)
+ if (parsePlexHomeProfileId(row.profileId)?.accountConnectionId == account.id) row.profileId,
+ };
+ final borrowerProfileIds = {
+ 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 profiles})> resolvePostRemovalState({
+ required ProfileRegistry profileRegistry,
+ required ProfileConnectionRegistry profileConnections,
+ required ConnectionRegistry connections,
+ required Map> 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 []);
+
+ 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 []);
+ return (route: PostRemovalRoute.staySignedIn, profiles: merged);
+}
+
Future pruneUnreferencedJellyfinConnections({
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart
index 079e5a60..c6e2fa42 100644
--- a/lib/screens/discover_screen.dart
+++ b/lib/screens/discover_screen.dart
@@ -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
);
if (confirm && mounted) {
- final navigator = Navigator.of(context, rootNavigator: true);
- // Use comprehensive logout through UserProfileProvider
- final userProfileProvider = Provider.of(context, listen: false);
- final multiServerProvider = context.read();
- final hiddenLibrariesProvider = context.read();
- final playbackStateProvider = context.read();
- final connectionRegistry = context.read();
- final profileRegistry = context.read();
- final profileConnReg = context.read();
- final plexHome = context.read();
- final companionRemote = context.read();
-
- // 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);
}
}
diff --git a/lib/screens/profile/profile_delete_flow.dart b/lib/screens/profile/profile_delete_flow.dart
deleted file mode 100644
index ff40bb98..00000000
--- a/lib/screens/profile/profile_delete_flow.dart
+++ /dev/null
@@ -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 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 deleteProfile(BuildContext context, Profile profile) async {
- final pcRegistry = context.read();
- final connRegistry = context.read();
- final profileRegistry = context.read();
- final downloadProvider = context.read();
- final active = context.read();
- final wasActive = active.activeId == profile.id;
-
- await downloadProvider.deleteDownloadsForProfile(profile.id);
- await removeAllProfileConnectionsAndCleanup(
- profileId: profile.id,
- profileConnections: pcRegistry,
- connections: connRegistry,
- storage: context.read(),
- serverManager: context.read().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();
- }
-}
diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart
index d8647c23..2f82937e 100644
--- a/lib/screens/profile/profile_detail_screen.dart
+++ b/lib/screens/profile/profile_detail_screen.dart
@@ -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 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 _signOutParentAccount(Connection parentConn) async {
+ final signedOut = await confirmAndSignOutPlexAccount(context, accountConnectionId: parentConn.id);
+ if (!signedOut || !mounted) return;
+ Navigator.of(context).pop(true);
+ }
+
Future _deleteProfile() async {
final deleted = await confirmAndDeleteProfile(
context,
@@ -253,7 +262,12 @@ class _ProfileDetailScreenState extends State 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 Function(ProfileConnection pc, Connection conn) onRemove;
final Future Function(Connection conn) onEdit;
+ final Future 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(
+ 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)
diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart
index af7b197a..dae29f91 100644
--- a/lib/screens/profile/profile_switch_screen.dart
+++ b/lib/screens/profile/profile_switch_screen.dart
@@ -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 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 _signOutPlexAccount(Profile profile) async {
final parentId = profile.parentConnectionId;
if (parentId == null) return;
- final connRegistry = context.read();
- 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();
- 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();
- 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 _deleteProfile(Profile profile) async {
diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart
new file mode 100644
index 00000000..109d79c4
--- /dev/null
+++ b/lib/screens/profile/profile_teardown.dart
@@ -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(),
+ binder = context.read(),
+ plexHome = context.read(),
+ profileRegistry = context.read(),
+ profileConnections = context.read(),
+ connections = context.read(),
+ multiServer = context.read(),
+ hiddenLibraries = context.read(),
+ downloads = context.read(),
+ database = context.read(),
+ storage = context.read(),
+ 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 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 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 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 confirmAndSignOutPlexAccount(BuildContext context, {required String accountConnectionId}) async {
+ final account = await context.read().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 logoutAllProfiles(BuildContext context) async {
+ final scope = SessionTeardownScope.of(context);
+ final userProfileProvider = context.read();
+ final companionRemote = context.read();
+ final playbackState = context.read();
+
+ 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));
+ }
+}
diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart
index 71fb497a..11761afc 100644
--- a/lib/services/storage_service.dart
+++ b/lib/services/storage_service.dart
@@ -482,6 +482,23 @@ class StorageService extends BaseSharedPreferencesService {
await _clearKeysWithPrefix(_prefixProfileLastUsed);
}
+ Future 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 clearUserScopedPreferencesForProfile(String profileId) async {
+ await _clearKeysWithPrefix(_userPrefixForProfileId(profileId));
+ }
+
+ /// Remove user-scoped prefs for every scope (full logout).
+ Future clearAllUserScopedPreferences() async {
+ await _clearKeysWithPrefix('user_');
+ }
+
// Private helper methods
/// Helper to read and decode JSON `List` from preferences
diff --git a/test/profiles/profile_connection_cleanup_test.dart b/test/profiles/profile_connection_cleanup_test.dart
index 429c2277..11e7b315 100644
--- a/test/profiles/profile_connection_cleanup_test.dart
+++ b/test/profiles/profile_connection_cleanup_test.dart
@@ -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 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 profiles})> resolve({
+ Map> 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);
+ });
+ });
}