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); + }); + }); }