import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; import '../api/marianumcloud/app_password/delete_app_password.dart'; import '../api/marianumconnect/auth/auth_interceptor.dart'; import '../api/marianumconnect/auth/token_storage.dart'; import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../api/marianumconnect/queries/auth_me/auth_me.dart'; import '../auth_link/guardian_link_listener.dart'; import '../background/widget_background_task.dart'; import '../push/chat_thread_store.dart'; import '../push/nid_store.dart'; import '../push/push_registration.dart'; import '../storage/account_storage.dart'; import '../widget_data/widget_sync.dart'; import 'session.dart'; import 'session_manager.dart'; abstract final class SessionLifecycle { /// Why the last sign-out happened, when the user did not ask for it. The /// login screen (or, when another account takes over, the app) shows it /// once and clears it. static final ValueNotifier signOutNotice = ValueNotifier(null); /// Account to return to while "add account" runs; null otherwise. static String? _addReturnId; /// Ordered teardown: unregister push and revoke the Nextcloud app passwords /// (while those credentials still exist), then revoke the MC bearer token, /// finally wipe the local session and its data. Each step is best-effort so /// an offline sign-out still reaches a clean local state. Another signed-in /// account takes over when there is one; returns its id. static Future signOut({String? notice}) async { signOutNotice.value = notice; try { await PushRegistration().logoutCleanup(); } on Object catch (e) { log('Sign-out: push cleanup failed: $e'); } await AuthLogout().run(); final removed = await SessionManager().signOut(); // A login link that arrived while signed in must not be replayed on the // login screen that follows. GuardianLinkListener.clear(); await _clearPushStores(); if (removed != null) await AccountStorage.delete(removed.namespace); for ( var next = SessionManager().accounts.value.mostRecentExcept(null); next != null; next = SessionManager().accounts.value.mostRecentExcept(null) ) { try { await SessionManager().activate(next.id); break; } on Object catch (e) { log('Sign-out: taking over ${next.id} failed: $e'); await SessionManager().forget(next.id); } } await AccountStorage.activate(SessionManager().activeAccount); if (SessionManager().isSignedIn) await _resetWidget(); return SessionManager().activeAccount?.id; } /// Makes the stored account [id] the active one. Push moves along: the /// previous account is unregistered here, the new one registers once the /// app has remounted for it. static Future switchTo(String id) async { final previous = SessionManager().activeAccount; if (previous?.id == id) return; await MarianumConnectAuthInterceptor.idle(); try { await PushRegistration().deactivate(); } on Object catch (e) { log('Switch: push deactivation failed: $e'); } await SessionManager().stashActive(); try { await SessionManager().activate(id); } on Object { if (previous != null) await SessionManager().activate(previous.id); rethrow; } finally { await _clearPushStores(); await AccountStorage.activate(SessionManager().activeAccount); } await _resetWidget(); } /// Parks the active account before the login screen signs in another one. static Future beginAddAccount() async { await SessionManager().stashActive(); _addReturnId = SessionManager().activeAccount?.id; } static bool get isAddingAccount => _addReturnId != null; /// Leaves "add account" without a new account: a half-finished sign-in is /// dropped and the previous account restored. static Future cancelAddAccount() async { final returnId = _addReturnId; _addReturnId = null; if (returnId == null) return; if (SessionManager().activeAccount?.id != returnId && SessionManager().isSignedIn) { await SessionManager().signOut(); } await SessionManager().activate(returnId); } /// Called once a login finished. After "add account" the previous account's /// push is unregistered with its own credentials before the new one takes /// over. Returns the id of the now active account. static Future finishLogin() async { final returnId = _addReturnId; _addReturnId = null; final added = SessionManager().activeAccount!; if (returnId != null && returnId != added.id) { await SessionManager().stashActive(); await SessionManager().activate(returnId); try { await PushRegistration().deactivate(); } on Object catch (e) { log('Add account: push deactivation failed: $e'); } await SessionManager().activate(added.id); await _clearPushStores(); } await AccountStorage.activate(SessionManager().activeAccount); return added.id; } /// Refreshes the stored real name of the active account (best-effort). static Future refreshDisplayName() async { if (SessionManager().current case final session? when !session.isDemo) { try { final user = await AuthMe().user(); final name = '${user.firstName} ${user.lastName}'.trim(); if (name.isNotEmpty) await SessionManager().setDisplayName(name); } on Object catch (e) { log('Display name refresh failed: $e'); } } } /// Signs out an account that is not active: revokes its tokens with the /// stored credentials (best-effort) and deletes its local data. Its push /// was already unregistered when it stopped being active. static Future removeInactive(String id) async { final entry = SessionManager().accounts.value.byId(id); if (entry == null || entry.id == SessionManager().activeAccount?.id) return; final (session, fields) = await SessionManager().readVault(id); if (session != null && !session.isDemo) { await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]); } await SessionManager().forget(id); await AccountStorage.delete(entry.namespace); } static Future _revoke(Session session, String? token) async { if (token != null && token.isNotEmpty) await AuthLogout.revoke(token); final nextcloud = session.nextcloud; if (nextcloud == null) return; try { if (nextcloud.hasAppPassword) { await DeleteAppPassword().run( authorizationHeader: nextcloud.basicAuthHeader, ); } if (nextcloud.hasAppPasswordTalk) { await DeleteAppPassword().run( authorizationHeader: nextcloud.talkBasicAuthHeader, ); } } on Object catch (e) { log('Remove account: app password revoke failed: $e'); } } /// The widget still shows the previous account's plan; the app republishes /// once it mounted, the refresh covers a backgrounded app. static Future _resetWidget() async { try { await WidgetSync.clear(); await WidgetSync.triggerUpdate(); unawaited(WidgetBackgroundTask.requestImmediateRefresh()); } on Object catch (e) { log('Widget reset failed: $e'); } } // Tray bookkeeping of the previous account's pushes. static Future _clearPushStores() async { try { await NidStore().clear(); await ChatThreadStore().clearAll(); } on Object catch (e) { log('Push store cleanup failed: $e'); } } }