From 27563a6dc10e5bdc4c7fd0e00178ee7f367e779f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:50:35 +0200 Subject: [PATCH] added support for multiple accounts with an account switcher in settings --- CLAUDE.md | 4 +- lib/api/cache_store.dart | 99 +++-- .../app_password/delete_app_password.dart | 13 +- .../auth/auth_interceptor.dart | 11 + .../auth/session_validator.dart | 11 +- .../marianumconnect/auth/token_storage.dart | 20 + .../queries/auth_logout/auth_logout.dart | 15 + .../queries/auth_me/auth_me.dart | 40 ++ lib/main.dart | 417 +++++++++--------- lib/model/session_wipe.dart | 111 ++--- lib/push/push_registration.dart | 7 + lib/routing/app_routes.dart | 4 +- lib/session/account_codec.dart | 218 +++++++++ lib/session/session_lifecycle.dart | 186 +++++++- lib/session/session_manager.dart | 156 ++++++- .../loadable_hydrated_bloc.dart | 10 + .../app/modules/account/account_scope.dart | 66 +++ .../modules/account/bloc/account_bloc.dart | 32 +- .../modules/account/bloc/account_event.dart | 6 + .../modules/account/bloc/account_state.dart | 17 +- .../chat_list/bloc/chat_list_bloc.dart | 6 +- .../modules/settings/bloc/settings_cubit.dart | 9 +- lib/storage/account_storage.dart | 78 ++++ lib/storage/hydrated_storage_bootstrap.dart | 28 +- lib/view/login/login.dart | 137 ++++-- lib/view/login/widgets/login_branding.dart | 10 +- lib/view/login/widgets/login_card.dart | 13 +- lib/view/pages/settings/diagnostics_page.dart | 9 +- .../settings/sections/account_section.dart | 123 +++--- lib/widget/account_switcher_sheet.dart | 189 ++++++++ lib/widget/debug/bloc_storage_view.dart | 36 +- test/api/cache_store_test.dart | 16 + test/session/account_codec_test.dart | 166 +++++++ 33 files changed, 1801 insertions(+), 462 deletions(-) create mode 100644 lib/api/marianumconnect/queries/auth_me/auth_me.dart create mode 100644 lib/session/account_codec.dart create mode 100644 lib/state/app/modules/account/account_scope.dart create mode 100644 lib/storage/account_storage.dart create mode 100644 lib/widget/account_switcher_sheet.dart create mode 100644 test/session/account_codec_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 1777378..9e4ba53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil ``` lib/ ├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) -├── session/ Session-Modell, SessionManager, SessionLifecycle +├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine ├── view/ Screens @@ -54,6 +54,8 @@ lib/ **Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). +**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert. + ## Build / Run ```bash diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart index 81412f3..169540c 100644 --- a/lib/api/cache_store.dart +++ b/lib/api/cache_store.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; +import '../session/session_manager.dart'; import '../utils/directory_size.dart'; /// One cached response: the raw JSON payload and when it was stored. @@ -22,6 +23,9 @@ class CacheEntry { /// in memory for the whole session and JSON-encodes payloads a second time. /// Here every access is async and touches exactly one file, and the payload is /// stored verbatim behind a one-line header (`\n`). +/// +/// Every account has its own directory; all access goes to the one of the +/// active account, in every isolate. class CacheStore { CacheStore._(); @@ -30,15 +34,30 @@ class CacheStore { /// Payloads above this size are decoded on a background isolate. static const int isolateDecodeThreshold = 64 * 1024; - Future? _dir; + static const _directoryPrefix = 'request_cache'; + + final Map> _dirs = {}; final Map> _writes = {}; - Future _directory() => _dir ??= () async { - final base = await getApplicationCacheDirectory(); - final dir = Directory('${base.path}/request_cache'); - await dir.create(recursive: true); - return dir; - }(); + /// Directory name for an account's storage [namespace]; null while no + /// account is active. The account from before multi-account support + /// (namespace '') keeps the original directory. + @visibleForTesting + static String directoryName(String? namespace) => switch (namespace) { + null => '$_directoryPrefix-signed-out', + '' => _directoryPrefix, + _ => '$_directoryPrefix-$namespace', + }; + + Future _directory() { + final name = directoryName(SessionManager().activeAccount?.namespace); + return _dirs[name] ??= () async { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$name'); + await dir.create(recursive: true); + return dir; + }(); + } Future _file(String key) async => File('${(await _directory()).path}/${_safeName(key)}'); @@ -49,8 +68,9 @@ class CacheStore { Future read(String key) async { try { - await _writes[key]; - return parse(await (await _file(key)).readAsString()); + final file = await _file(key); + await _writes[file.path]; + return parse(await file.readAsString()); } on Object { // Missing (PathNotFoundException) or unreadable: a cache miss. return null; @@ -64,18 +84,21 @@ class CacheStore { return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]}; } - Future write(String key, String json) { - final previous = _writes[key] ?? Future.value(); + Future write(String key, String json) async { + // Resolved up front: a queued write must not follow an account switch + // into the next account's directory. + final file = await _file(key); + final path = file.path; + final previous = _writes[path] ?? Future.value(); late final Future current; - current = previous.then((_) => _write(key, json)).whenComplete(() { - if (identical(_writes[key], current)) _writes.remove(key); + current = previous.then((_) => _write(file, json)).whenComplete(() { + if (identical(_writes[path], current)) _writes.remove(path); }); - return _writes[key] = current; + return _writes[path] = current; } - Future _write(String key, String json) async { + Future _write(File file, String json) async { try { - final file = await _file(key); // Write-then-rename so a reader (or the widget background isolate) // never sees a half-written file. final tmp = File('${file.path}.tmp'); @@ -85,19 +108,21 @@ class CacheStore { ); await tmp.rename(file.path); } on Object catch (e) { - debugPrint('CacheStore.write($key) failed: $e'); + debugPrint('CacheStore.write(${file.path}) failed: $e'); } } Future delete(String key) async { try { - await _writes[key]; - await (await _file(key)).delete(); + final file = await _file(key); + await _writes[file.path]; + await file.delete(); } on Object { // A missing or locked file is as good as deleted for a cache. } } + /// Empties the active account's cache. Future clear() async { try { final dir = await _directory(); @@ -105,7 +130,20 @@ class CacheStore { } on Object catch (e) { debugPrint('CacheStore.clear failed: $e'); } - _dir = null; + _dirs.clear(); + } + + /// Removes the cache of the account with [namespace], active or not. + Future deleteNamespace(String namespace) async { + final name = directoryName(namespace); + try { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$name'); + if (dir.existsSync()) await dir.delete(recursive: true); + } on Object catch (e) { + debugPrint('CacheStore.deleteNamespace failed: $e'); + } + _dirs.removeWhere((key, _) => key == name); } /// All stored keys, optionally filtered by [prefix]. @@ -124,17 +162,22 @@ class CacheStore { } } - /// Removes entries not written for [maxAge], judged by file mtime so nothing - /// has to be read or decoded. + /// Removes entries not written for [maxAge] from every account's cache, + /// judged by file mtime so nothing has to be read or decoded. Future deleteOlderThan(Duration maxAge) async { try { - final dir = await _directory(); + final base = await getApplicationCacheDirectory(); final cutoff = DateTime.now().subtract(maxAge); - await for (final entity in dir.list()) { - if (entity is! File) continue; - // ignore: avoid_slow_async_io - final modified = (await entity.stat()).modified; - if (modified.isBefore(cutoff)) await entity.delete(); + await for (final dir in base.list()) { + if (dir is! Directory) continue; + final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty); + if (!name.startsWith(_directoryPrefix)) continue; + await for (final entity in dir.list()) { + if (entity is! File) continue; + // ignore: avoid_slow_async_io + final modified = (await entity.stat()).modified; + if (modified.isBefore(cutoff)) await entity.delete(); + } } } on Object catch (e) { debugPrint('CacheStore.deleteOlderThan failed: $e'); diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart index 12cbcd9..3726c86 100644 --- a/lib/api/marianumcloud/app_password/delete_app_password.dart +++ b/lib/api/marianumcloud/app_password/delete_app_password.dart @@ -16,10 +16,15 @@ class DeleteAppPassword { Future run({String? authorizationHeader}) async { await _client.delete( NextcloudOcs.uri('core/apppassword'), - headers: { - ...NextcloudOcs.headers(), - 'Authorization': ?authorizationHeader, - }, + // An explicit header may belong to an inactive account, so the active + // session's headers must not be required then. + headers: authorizationHeader == null + ? NextcloudOcs.headers() + : { + 'Accept': 'application/json', + 'OCS-APIRequest': 'true', + 'Authorization': authorizationHeader, + }, ); } } diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index 3118911..ed0a1d8 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -19,6 +19,15 @@ class MarianumConnectAuthInterceptor extends Interceptor { // each spawning a fresh row in api_tokens. Future? _pendingReLogin; + static Future? _anyPendingReLogin; + + /// Resolves once no silent re-login is running. An account switch waits for + /// it — the renewed token would otherwise land in the next account's slot. + static Future idle() async { + final pending = _anyPendingReLogin; + if (pending != null) await pending; + } + MarianumConnectAuthInterceptor({ MarianumConnectTokenStorage tokenStorage = const MarianumConnectTokenStorage(), @@ -81,8 +90,10 @@ class MarianumConnectAuthInterceptor extends Interceptor { if (inFlight != null) return inFlight; final fresh = _performReLogin(); _pendingReLogin = fresh; + _anyPendingReLogin = fresh; fresh.whenComplete(() { if (identical(_pendingReLogin, fresh)) _pendingReLogin = null; + if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null; }); return fresh; } diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 311db69..c5f67ee 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -8,9 +8,10 @@ import '../queries/auth_verify/auth_verify.dart'; /// Credential probe. A server-side password rotation forces a re-login on the /// next cold start even when the bearer token would still be accepted. +/// Another stored account then takes over. class SessionValidator { static Future probeStored({ - required Future Function() onInvalidated, + required Future Function(String? nextAccountId) onInvalidated, }) async { final session = SessionManager().current; // The probes use their own dio (bypassing the demo interceptor), so a demo @@ -24,18 +25,18 @@ class SessionValidator { } } on AuthException catch (e) { if (e.statusCode != 401) return; - // The probed account already signed out; the 401 must not sign out - // whoever logged in meanwhile. + // The probed account is no longer the active one; the 401 must not + // sign out whoever took over meanwhile. if (!SessionManager().isCurrentSession(epoch)) return; log('MC: stored session rejected — forcing re-login'); - await SessionLifecycle.signOut( + final next = await SessionLifecycle.signOut( notice: switch (session) { CredentialSession() => 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich ' 'wurde dein Passwort geändert. Bitte melde dich erneut an.', }, ); - await onInvalidated(); + await onInvalidated(next); } catch (e) { log('MC: background session check failed (transient): $e'); } diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index 24e85c8..4692dae 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -60,6 +60,26 @@ class MarianumConnectTokenStorage { ); } + static const bearerKey = _tokenKey; + static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey]; + + /// Raw stored fields, for parking the token of an inactive account. + Future> readAll() async => { + for (final key in fieldKeys) key: ?await _storage.read(key: key), + }; + + /// Restores fields from [readAll]; missing ones are deleted. + Future writeAll(Map fields) async { + for (final key in fieldKeys) { + final value = fields[key]; + if (value == null) { + await _storage.delete(key: key); + } else { + await _storage.write(key: key, value: value); + } + } + } + Future clear() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _tokenIdKey); diff --git a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart index e96d581..dad824a 100644 --- a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart +++ b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart @@ -1,6 +1,8 @@ import 'package:dio/dio.dart'; import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; import '../../marianumconnect_query.dart'; /// Revokes the stored MC bearer token both server-side and locally. Best-effort @@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery { await _tokenStorage.clear(); } } + + /// Revokes the token of an inactive account; the stored (active) token is + /// left alone. Best-effort. + static Future revoke(String token) async { + try { + await MarianumConnectApi.plainDio().post( + MarianumConnectEndpoint.resolve('auth/logout'), + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + } on DioException catch (_) { + // ignore + } + } } diff --git a/lib/api/marianumconnect/queries/auth_me/auth_me.dart b/lib/api/marianumconnect/queries/auth_me/auth_me.dart new file mode 100644 index 0000000..76b597e --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_me/auth_me.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; + +import '../../../errors/auth_exception.dart'; +import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_query.dart'; +import '../auth_login/auth_login_response.dart'; + +/// Reads the user behind the stored bearer token, which also probes that the +/// token is still accepted. +/// +/// Bypasses the shared dio singleton so the auth interceptor does not react +/// to the 401 this probe is meant to observe. +class AuthMe extends MarianumConnectQuery { + final MarianumConnectTokenStorage _tokenStorage; + + AuthMe({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? dio, + }) : _tokenStorage = tokenStorage, + super(dio: dio ?? MarianumConnectApi.plainDio()); + + /// Throws [AuthException] when the token is missing or rejected. + Future run() async { + await user(); + } + + /// The signed-in user (names, type). Throws like [run]. + Future user() async { + final options = await _tokenStorage.requireBearerOptions('AuthMe'); + return guard(() async { + final response = await dio.get>( + endpoint('auth/me'), + options: options, + ); + return AuthLoginUser.fromJson(response.data!); + }); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4d457c7..842c8d8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,7 +11,6 @@ import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; @@ -33,19 +32,20 @@ import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; +import 'session/account_codec.dart'; +import 'session/session_lifecycle.dart'; import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; +import 'state/app/modules/account/account_scope.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/app_modules.dart'; -import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; -import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; -import 'storage/hydrated_storage_bootstrap.dart'; +import 'storage/account_storage.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; @@ -59,6 +59,7 @@ import 'widget/breaker/breaker.dart'; import 'widget/debug/debug_stats_overlay.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; +import 'widget/info_dialog.dart'; import 'widget_data/widget_sync.dart'; /// Runs one startup step with a time limit. Anything that throws or hangs @@ -146,8 +147,7 @@ Future main() async { ]), ), _startupStep('hydrated storage', () async { - final path = (await getTemporaryDirectory()).path; - HydratedBloc.storage = await buildHydratedStorageWithFallback(path); + await AccountStorage.init((await getTemporaryDirectory()).path); }, timeout: null), _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; @@ -167,6 +167,16 @@ Future main() async { log('starting app initialisation...'); await Future.wait(initialisationTasks); + // Snapshot: the session may still finish loading later (see _MainState), + // the bloc and the storage must agree on the account they start with. + final initialAccount = SessionManager().isLoaded + ? SessionManager().activeAccount + : null; + await _startupStep( + 'account storage', + () => AccountStorage.activate(initialAccount), + timeout: null, + ); log('app initialisation done!'); // Independent of the notification setup below, so it runs alongside it. @@ -237,34 +247,48 @@ Future main() async { // placeholder flash. AvatarDiskCache.instance.warmUp(); + // Created eagerly so the endpoint is configured before anything below can + // issue a request (the timetable bloc loads on creation). + final settingsCubit = SettingsCubit(storage: AccountStorage.global); + _syncMarianumConnectEndpoint(settingsCubit.state); + settingsCubit.stream.listen(_syncMarianumConnectEndpoint); + log('running app...'); runApp( MultiBlocProvider( providers: [ - BlocProvider(create: (_) => SettingsCubit()), + BlocProvider.value(value: settingsCubit), BlocProvider( - create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), + create: (_) => AccountBloc( + initialStatus: _initialAccountStatus(initialAccount), + accountId: initialAccount?.id, + ), ), - BlocProvider(create: (_) => BreakerBloc()), - BlocProvider(create: (_) => CapabilitiesCubit()), - BlocProvider( - create: (_) => NextcloudCapabilitiesCubit(), - ), - BlocProvider(create: (_) => ChatListBloc()), - BlocProvider( - create: (ctx) => ChatBloc(chatListBloc: ctx.read()), - ), - BlocProvider(create: (_) => TimetableBloc()), ], child: const Main(), ), ); } -AccountStatus _initialAccountStatus() { - final session = SessionManager(); - if (session.isSignedIn) return AccountStatus.loggedIn; - return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; +String? _syncedMcBaseUrl; + +/// Keeps the MC dio singleton aligned with the selected endpoint (live / +/// beta / custom), mirrored into WidgetSync so the background isolate +/// refreshes against the same endpoint. Settings emit on every toggle; only +/// an actual URL change is applied. +void _syncMarianumConnectEndpoint(Settings settings) { + final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl(); + if (url == _syncedMcBaseUrl) return; + _syncedMcBaseUrl = url; + MarianumConnectEndpoint.update(url); + unawaited(WidgetSync.setMarianumConnectBaseUrl(url)); +} + +AccountStatus _initialAccountStatus(AccountEntry? account) { + if (account != null) return AccountStatus.loggedIn; + return SessionManager().isLoaded + ? AccountStatus.loggedOut + : AccountStatus.undefined; } class Main extends StatefulWidget { @@ -293,31 +317,52 @@ class _MainState extends State
{ Jiffy.setLocale('de'); _lastStatus = context.read().state.status; - SessionManager().waitForLoad().then((session) { + SessionManager().waitForLoad().then((session) async { if (!mounted) return; final accountBloc = context.read(); - accountBloc.setStatus( - session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut, - ); - if (session != null) { - _scheduleSessionValidation(accountBloc); - // Cold start while already logged in: the account status doesn't - // change, so the loggedIn listener below never fires — refresh - // capabilities here, then self-heal the push registration. - final settingsCubit = context.read(); - unawaited( - context.read().load().then((_) { - if (!mounted) return; - final capabilities = context.read(); - _syncPush(settingsCubit, capabilities); - _applyRoleDefaults(settingsCubit, capabilities); - }), - ); - unawaited(context.read().load()); + if (session == null) { + accountBloc.setStatus(AccountStatus.loggedOut); + return; } + // Covers a session that finished loading after the startup snapshot; + // otherwise the id is unchanged and nothing remounts. + final account = SessionManager().activeAccount; + await AccountStorage.activate(account); + if (!mounted) return; + accountBloc.activated(account?.id); + _scheduleSessionValidation(accountBloc); }); } + /// Runs whenever the account-scoped tree mounts for a signed-in account: + /// pulls the capability flags, then registers push for this account. + void _onSessionActive(BuildContext scopeContext) { + final settingsCubit = scopeContext.read(); + final capabilitiesCubit = scopeContext.read(); + unawaited( + capabilitiesCubit.load().then((_) { + // Closed: the account was switched away from while loading. + if (!mounted || capabilitiesCubit.isClosed) return; + _syncPush(settingsCubit, capabilitiesCubit); + _applyRoleDefaults(settingsCubit, capabilitiesCubit); + }), + ); + unawaited(scopeContext.read().load()); + unawaited(SessionLifecycle.refreshDisplayName()); + _prefetchBaseData(scopeContext); + WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice()); + } + + /// A forced sign-out that handed over to another account never reaches the + /// login screen, which normally explains it. + void _showTakeoverNotice() { + final notice = SessionLifecycle.signOutNotice.value; + final overlayContext = AppRoutes.overlayContext; + if (notice == null || overlayContext == null) return; + SessionLifecycle.signOutNotice.value = null; + InfoDialog.show(overlayContext, notice, title: 'Abgemeldet'); + } + /// Warms the core caches (timetable, chat list, files root) in the /// background so the first screen render hits populated data. void _prefetchBaseData(BuildContext context) { @@ -362,19 +407,48 @@ class _MainState extends State
{ } /// Background credential check: a 401 means the password was rotated - /// server-side, so the validator wipes the local session and flips the - /// account bloc to `loggedOut` (sending the user to the login screen). + /// server-side, so the validator signs the account out and the app moves + /// on to another stored account or the login screen. void _scheduleSessionValidation(AccountBloc accountBloc) { unawaited( SessionValidator.probeStored( - onInvalidated: () async { + onInvalidated: (nextAccountId) async { if (!mounted) return; - accountBloc.setStatus(AccountStatus.loggedOut); + accountBloc.activated(nextAccountId); }, ), ); } + void _onAccountChanged(BuildContext context, AccountState accountState) { + if (accountState.status == AccountStatus.loggedIn && + accountState.freshLogin) { + _showPostLoginSplash = true; + _appMounted = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _appMounted = true); + }); + } + final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; + _lastStatus = accountState.status; + // A cold start that merely resolves as signed out has nothing to wipe, + // and a share waiting for the login must survive it. + if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return; + // Deferred until the account-scoped tree is torn down. + WidgetsBinding.instance.addPostFrameCallback( + (_) => unawaited(SessionWipe.deviceLeft()), + ); + } + + /// The account-scoped tree only lives while its account is the one in use: + /// adding another account parks it, so its blocs go as well. + static Object _scopeOf(AccountState state) => ( + state.accountId, + state.status == AccountStatus.addingAccount, + ); + + Object? _scope; + @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, @@ -384,7 +458,6 @@ class _MainState extends State
{ child: BlocSelector( selector: (settings) => ( appTheme: settings.appTheme, - mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(), notificationsEnabled: settings.notificationSettings.enabled, showPerformanceOverlay: settings.devToolsSettings.showPerformanceOverlay, @@ -395,12 +468,6 @@ class _MainState extends State
{ textScaleOverride: settings.devToolsSettings.textScaleOverride, ), builder: (context, root) { - // Keep the MC dio singleton aligned with the currently selected - // endpoint (live / beta / custom). Idempotent when the URL is - // unchanged. Mirrored into WidgetSync so the background isolate - // refreshes against the same endpoint. - MarianumConnectEndpoint.update(root.mcBaseUrl); - unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl)); // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( @@ -409,164 +476,116 @@ class _MainState extends State
{ ), ); timeDilation = root.slowAnimations ? 5.0 : 1.0; - return MaterialApp( - showPerformanceOverlay: root.showPerformanceOverlay, - showSemanticsDebugger: root.showSemanticsDebugger, - debugShowCheckedModeBanner: false, - navigatorKey: AppRoutes.rootNavigatorKey, - // Used by ChatView.didPopNext to reclaim the global ChatBloc. - // DownloadRouteObserver tracks full-page navigations so the downloads - // chip only surfaces once the user leaves the screen they started on. - navigatorObservers: _navigatorObservers, - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [Locale('de'), Locale('en')], - locale: const Locale('de'), - title: 'Marianum Fulda', - themeMode: root.appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - // Brand-colored backdrop behind every route. During the logout - // home-swap and route pop animations the framework can briefly - // expose the layer below the topmost Scaffold; without this - // the dark Material default shows through and the user sees a - // black flash. - builder: (context, child) { - Widget app = ColoredBox( - color: LightAppTheme.marianumRed, - // Downloads tray mounted ABOVE the navigator so its chip floats - // over every route (folder views, chat, viewer are full-page - // pushes that would otherwise cover it). - child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), - ); - final scale = root.textScaleOverride; - if (scale > 0) { - app = MediaQuery.withClampedTextScaling( - minScaleFactor: scale, - maxScaleFactor: scale, - child: app, - ); + return BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status || + previous.accountId != current.accountId, + listener: _onAccountChanged, + buildWhen: (previous, current) => + _scopeOf(previous) != _scopeOf(current), + builder: (context, account) { + final scope = _scopeOf(account); + if (scope != _scope) { + _scope = scope; + // The old navigator (and every route on it) goes with the old + // account; a shared GlobalKey would carry it over instead. + AppRoutes.rootNavigatorKey = GlobalKey(); } - return DebugStatsOverlay( - showFrameStats: root.showFrameStats, - showMemoryStats: root.showMemoryStats, - child: app, + return AccountScope( + key: ValueKey(scope), + onActive: _onSessionActive, + child: _buildApp(root), ); }, - home: EmergencyNoticeGate( - child: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, - child: BlocConsumer( - listenWhen: (previous, current) => - previous.status != current.status, - listener: (context, accountState) { - final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; - _lastStatus = accountState.status; - // Fresh login (loggedOut -> loggedIn): pull capability flags - // for the newly authenticated user, then register push right - // away instead of deferring it to the next app start. - if (accountState.status == AccountStatus.loggedIn) { - final settingsCubit = context.read(); - final capabilitiesCubit = context - .read(); - unawaited( - capabilitiesCubit.load().then((_) { - if (!mounted) return; - _syncPush(settingsCubit, capabilitiesCubit); - _applyRoleDefaults(settingsCubit, capabilitiesCubit); - }), - ); - unawaited( - context.read().load(), - ); - _showPostLoginSplash = true; - _appMounted = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _appMounted = true); - }); - _prefetchBaseData(context); - } - if (accountState.status != AccountStatus.loggedOut) return; - // A pending share would otherwise survive logout and be - // re-applied to the next account. A cold-start share that - // is merely waiting for the stored session to resolve as - // signed out stays, to be sent after login. - if (wasLoggedIn) SessionWipe.clearImmediate(); - // Routes pushed via AppRoutes (e.g. Settings) live on the - // root navigator and survive the home swap below, so they - // would still cover the Login screen after logout. Pop - // them here so the user immediately sees Login. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst); - } - // Capture bloc references before the post-frame callback - // — by the time it runs the dialog/Settings context is - // gone but this listener context is still valid. - final timetableBloc = context.read(); - final chatListBloc = context.read(); - final chatBloc = context.read(); - final breakerBloc = context.read(); - final capabilitiesCubit = context.read(); - final nextcloudCapabilitiesCubit = context - .read(); - // Defer the actual wipe until after this frame so the - // App tree (TimetableBloc/ChatListBloc watchers etc.) - // is already torn down. Resetting blocs while App is - // still in front caused a black-frame race. - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited( - SessionWipe.run( - timetableBloc: timetableBloc, - chatListBloc: chatListBloc, - chatBloc: chatBloc, - breakerBloc: breakerBloc, - capabilitiesCubit: capabilitiesCubit, - nextcloudCapabilitiesCubit: - nextcloudCapabilitiesCubit, - ), - ); - }); - }, - builder: (context, accountState) { - switch (accountState.status) { - case AccountStatus.loggedIn: - return Stack( - fit: StackFit.expand, - children: [ - if (_appMounted) - const App(key: ValueKey('app-shell')), - if (_showPostLoginSplash) - PostLoginSplash( - key: const ValueKey('post-login-splash'), - onComplete: () => setState( - () => _showPostLoginSplash = false, - ), - ), - ], - ); - case AccountStatus.loggedOut: - return const Login(); - case AccountStatus.undefined: - return const AccountLoadingScreen(); - } - }, - ), - ), - ), - ), ); }, ), ); + + Widget _buildApp(_RootSettings root) => MaterialApp( + showPerformanceOverlay: root.showPerformanceOverlay, + showSemanticsDebugger: root.showSemanticsDebugger, + debugShowCheckedModeBanner: false, + navigatorKey: AppRoutes.rootNavigatorKey, + // Used by ChatView.didPopNext to reclaim the global ChatBloc. + // DownloadRouteObserver tracks full-page navigations so the downloads + // chip only surfaces once the user leaves the screen they started on. + navigatorObservers: _navigatorObservers, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: root.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + // Brand-colored backdrop behind every route. During the logout + // home-swap and route pop animations the framework can briefly + // expose the layer below the topmost Scaffold; without this + // the dark Material default shows through and the user sees a + // black flash. + builder: (context, child) { + Widget app = ColoredBox( + color: LightAppTheme.marianumRed, + // Downloads tray mounted ABOVE the navigator so its chip floats + // over every route (folder views, chat, viewer are full-page + // pushes that would otherwise cover it). + child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), + ); + final scale = root.textScaleOverride; + if (scale > 0) { + app = MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: app, + ); + } + return DebugStatsOverlay( + showFrameStats: root.showFrameStats, + showMemoryStats: root.showMemoryStats, + child: app, + ); + }, + home: EmergencyNoticeGate( + child: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, + child: BlocBuilder( + buildWhen: (previous, current) => previous.status != current.status, + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return Stack( + fit: StackFit.expand, + children: [ + if (_appMounted) const App(key: ValueKey('app-shell')), + if (_showPostLoginSplash) + PostLoginSplash( + key: const ValueKey('post-login-splash'), + onComplete: () => + setState(() => _showPostLoginSplash = false), + ), + ], + ); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.addingAccount: + return const Login(addingAccount: true); + case AccountStatus.undefined: + return const AccountLoadingScreen(); + } + }, + ), + ), + ), + ), + ); } typedef _RootSettings = ({ ThemeMode appTheme, - String mcBaseUrl, bool notificationsEnabled, bool showPerformanceOverlay, bool showSemanticsDebugger, diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index 5efa03e..3bcbefc 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -4,11 +4,9 @@ import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../api/cache_store.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../background/widget_background_task.dart'; import '../notification/notification_service.dart'; @@ -18,20 +16,14 @@ import '../push/push_keypair.dart'; import '../push/push_tap_router.dart'; import '../routing/app_routes.dart'; import '../share_intent/share_intent_listener.dart'; -import '../state/app/modules/breaker/bloc/breaker_bloc.dart'; -import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; -import '../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; -import '../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; -import '../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../utils/app_paths.dart'; import '../utils/downloads/download_manager.dart'; import '../utils/file_clipboard.dart'; import '../widget_data/widget_sync.dart'; -/// Removes everything the signed-out account left on the device. Every step -/// is isolated: one failing step used to skip all later ones and leave the -/// previous account's data behind. +/// Removes what an account leaves on the device outside its own storage (see +/// `AccountStorage` for that). Every step is isolated: one failing step used +/// to skip all later ones and leave the previous account's data behind. abstract final class SessionWipe { static Future? _running; @@ -39,8 +31,9 @@ abstract final class SessionWipe { /// wipe could clear what the next account just stored (widget job, caches). static Future get done => _running ?? Future.value(); - /// State that must be gone before the next frame (a login screen can be - /// reached right away): pending navigation and in-memory singletons. + /// State that must be gone before the next frame (a login screen or another + /// account can be reached right away): pending navigation and in-memory + /// singletons. static void clearImmediate() { _step('share intents', ShareIntentListener.instance.clearAll); _step('share folder cache', resetTalkShareFolderCache); @@ -52,66 +45,17 @@ abstract final class SessionWipe { _step('downloads', DownloadManager.instance.clearAll); } - static Future run({ - required TimetableBloc timetableBloc, - required ChatListBloc chatListBloc, - required ChatBloc chatBloc, - required BreakerBloc breakerBloc, - required CapabilitiesCubit capabilitiesCubit, - required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, - }) { - final wipe = _run( - timetableBloc: timetableBloc, - chatListBloc: chatListBloc, - chatBloc: chatBloc, - breakerBloc: breakerBloc, - capabilitiesCubit: capabilitiesCubit, - nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit, - ); - _running = wipe; - return wipe.whenComplete(() { - if (identical(_running, wipe)) _running = null; - }); - } - - static Future _run({ - required TimetableBloc timetableBloc, - required ChatListBloc chatListBloc, - required ChatBloc chatBloc, - required BreakerBloc breakerBloc, - required CapabilitiesCubit capabilitiesCubit, - required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, - }) async { - // SettingsCubit is deliberately left alone: its BlocBuilder wraps - // MaterialApp, and emitting a fresh state here tore down the freshly - // mounted Login tree (blank screen until the next interaction). - await Future.wait([ - _stepAsync('timetable', timetableBloc.reset), - _stepAsync('chat list', chatListBloc.reset), - _stepAsync('chat', chatBloc.reset), - _stepAsync('breakers', breakerBloc.reset), - _stepAsync('capabilities', capabilitiesCubit.reset), - _stepAsync('nc capabilities', nextcloudCapabilitiesCubit.reset), - ]); - await _stepAsync('shared preferences', () async { - await (await SharedPreferences.getInstance()).clear(); - }); - await _stepAsync('hydrated storage', HydratedBloc.storage.clear); - await _stepAsync('request cache', CacheStore.instance.clear); - await _stepAsync('chat background', () async { - final image = File(AppPaths.chatBackgroundImage); - if (image.existsSync()) await image.delete(); - }); + /// Runs whenever the active account stops being active (sign-out or + /// switch). The tray and its bookkeeping belong to that account — a tap or + /// inline reply there would otherwise act as the next one. A new keypair + /// and FCM token make pushes still addressed to the previous registration + /// undecryptable and undeliverable (an offline sign-out or switch could not + /// unregister it). + static Future accountLeft() async { await _stepAsync('download files', () async { final dir = Directory('${(await getTemporaryDirectory()).path}/downloads'); if (dir.existsSync()) await dir.delete(recursive: true); }); - - // Push: the tray and its bookkeeping belong to the previous account — a - // tap or inline reply there would otherwise act as the next one. A new - // keypair and FCM token make pushes still addressed to the previous - // registration undecryptable and undeliverable (an offline sign-out - // could not unregister it). await _stepAsync( 'notification tray', NotificationService().flutterLocalNotificationsPlugin.cancelAll, @@ -120,8 +64,35 @@ abstract final class SessionWipe { await _stepAsync('push nid store', NidStore().clear); await _stepAsync('push thread store', ChatThreadStore().clearAll); await _stepAsync('push keypair', const PushKeypair().clear); - await _stepAsync('fcm token', FirebaseMessaging.instance.deleteToken); + // Bounded: the next account waits for this, also without a network. + await _stepAsync( + 'fcm token', + () => FirebaseMessaging.instance.deleteToken().timeout( + const Duration(seconds: 5), + ), + ); + } + /// Wipes what outlives accounts, once the last one signed out. + static Future deviceLeft() { + final wipe = _deviceLeft(); + _running = wipe; + return wipe.whenComplete(() { + if (identical(_running, wipe)) _running = null; + }); + } + + static Future _deviceLeft() async { + // SettingsCubit is deliberately left alone: its selector wraps + // MaterialApp, and emitting a fresh state here tore down the freshly + // mounted Login tree (blank screen until the next interaction). + await _stepAsync('shared preferences', () async { + await (await SharedPreferences.getInstance()).clear(); + }); + await _stepAsync('chat background', () async { + final image = File(AppPaths.chatBackgroundImage); + if (image.existsSync()) await image.delete(); + }); // Stop the periodic widget refresh job so the background isolate doesn't // wake up every 30 minutes only to write `loggedIn=false`. Re-registers // on the next successful login. diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index eaabb46..f8a75d4 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -427,6 +427,13 @@ class PushRegistration { /// first, then the proxy) with the new token. Future onTokenRefresh() => register(); + /// Stops pushes for the active account before another one takes over. The + /// app passwords stay valid so switching back can re-register silently. + Future deactivate() async { + if (DemoMode.active || _nextcloudOrNull == null) return; + await unregister(); + } + /// Full teardown for logout: unregister push, revoke BOTH app passwords /// (each authenticated with itself — the endpoint revokes the credential it /// is called with), then clear them locally. Ordered so the proxy stops diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index bfb8298..428eb55 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -64,7 +64,9 @@ class AppRoutes { /// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g. /// the downloads tray, which lives above the navigator in `MaterialApp.builder` /// and is therefore never covered by a pushed route) open full-page routes. - static final GlobalKey rootNavigatorKey = + /// Replaced per account (see `Main`), so a switch starts on a fresh + /// navigator. + static GlobalKey rootNavigatorKey = GlobalKey(); /// A context that is a descendant of the root navigator (its overlay), safe to diff --git a/lib/session/account_codec.dart b/lib/session/account_codec.dart new file mode 100644 index 0000000..7270af3 --- /dev/null +++ b/lib/session/account_codec.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; + +import 'session.dart'; +import 'session_codec.dart'; + +/// One signed-in account on this device. The session itself lives in the +/// keychain (active slots or the account's vault); this is the index entry. +class AccountEntry { + final String id; + final String kind; + + /// Login name — the identity of the account. + final String label; + + /// Real name as known to the server; null until it was loaded once. + final String? displayName; + final bool isDemo; + + /// Storage namespace of the account's local data. Empty for the account that + /// existed before multi-account support, so its data stays where it was. + final String namespace; + final int lastUsed; + + const AccountEntry({ + required this.id, + required this.kind, + required this.label, + required this.namespace, + this.displayName, + this.isDemo = false, + this.lastUsed = 0, + }); + + AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry( + id: id, + kind: kind, + label: label, + namespace: namespace, + displayName: displayName ?? this.displayName, + isDemo: isDemo, + lastUsed: lastUsed ?? this.lastUsed, + ); + + Map toJson() => { + 'id': id, + 'kind': kind, + 'label': label, + 'namespace': namespace, + 'displayName': displayName, + 'isDemo': isDemo, + 'lastUsed': lastUsed, + }; + + static AccountEntry? fromJson(Object? json) { + if (json is! Map) return null; + final id = json['id']; + final kind = json['kind']; + final label = json['label']; + if (id is! String || kind is! String || label is! String) return null; + return AccountEntry( + id: id, + kind: kind, + label: label, + namespace: json['namespace'] is String ? json['namespace'] as String : id, + displayName: json['displayName'] is String + ? json['displayName'] as String + : null, + isDemo: json['isDemo'] == true, + lastUsed: json['lastUsed'] is int ? json['lastUsed'] as int : 0, + ); + } +} + +class AccountIndex { + final List accounts; + final String? activeId; + + const AccountIndex({this.accounts = const [], this.activeId}); + + static const empty = AccountIndex(); + + AccountEntry? get active => byId(activeId); + + AccountEntry? byId(String? id) { + if (id == null) return null; + for (final entry in accounts) { + if (entry.id == id) return entry; + } + return null; + } + + /// The entry [session] belongs to — same kind, identity and demo flag. + AccountEntry? matching(Session session) { + final (kind, label) = identityOf(session); + for (final entry in accounts) { + if (entry.kind == kind && + entry.label == label && + entry.isDemo == session.isDemo) { + return entry; + } + } + return null; + } + + /// Makes [session] the active account, reusing its entry when it is already + /// known and otherwise adding one with [newId] (also its namespace). + AccountIndex activate( + Session session, { + required String newId, + String? namespace, + int now = 0, + }) { + final existing = matching(session); + if (existing != null) return select(existing.id, now: now); + final (kind, label) = identityOf(session); + return AccountIndex( + accounts: [ + ...accounts, + AccountEntry( + id: newId, + kind: kind, + label: label, + namespace: namespace ?? newId, + isDemo: session.isDemo, + lastUsed: now, + ), + ], + activeId: newId, + ); + } + + AccountIndex select(String id, {int now = 0}) => AccountIndex( + accounts: accounts + .map((e) => e.id == id ? e.copyWith(lastUsed: now) : e) + .toList(), + activeId: id, + ); + + AccountIndex rename(String id, String displayName) => AccountIndex( + accounts: accounts + .map((e) => e.id == id ? e.copyWith(displayName: displayName) : e) + .toList(), + activeId: activeId, + ); + + /// Drops [id]; the active account is left unset when it was the one removed. + AccountIndex remove(String id) => AccountIndex( + accounts: [ + for (final e in accounts) + if (e.id != id) e, + ], + activeId: activeId == id ? null : activeId, + ); + + /// Most recently used account other than [excludedId], if any. + AccountEntry? mostRecentExcept(String? excludedId) { + AccountEntry? best; + for (final entry in accounts) { + if (entry.id == excludedId) continue; + if (best == null || entry.lastUsed > best.lastUsed) best = entry; + } + return best; + } + + String encode() => jsonEncode({ + 'activeId': activeId, + 'accounts': [for (final e in accounts) e.toJson()], + }); + + static AccountIndex decode(String? raw) { + if (raw == null || raw.isEmpty) return empty; + try { + final json = jsonDecode(raw); + if (json is! Map) return empty; + final list = json['accounts']; + return AccountIndex( + accounts: [ + if (list is List) + for (final item in list) ?AccountEntry.fromJson(item), + ], + activeId: json['activeId'] is String + ? json['activeId'] as String + : null, + ); + } on FormatException { + return empty; + } + } +} + +(String kind, String label) identityOf(Session session) => switch (session) { + CredentialSession(:final username) => (SessionKeys.kindCredential, username), +}; + +/// Keychain fields of [session] including the app passwords, as stored in an +/// inactive account's vault. Unset fields are omitted. +Map sessionVaultFields(Session session) => { + for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) + key: ?value, + SessionKeys.appPassword: ?session.nextcloud?.appPassword, + SessionKeys.appPasswordTalk: ?session.nextcloud?.appPasswordTalk, +}; + +String encodeVault(Map fields) => jsonEncode(fields); + +Map decodeVault(String? raw) { + if (raw == null || raw.isEmpty) return const {}; + try { + final json = jsonDecode(raw); + if (json is! Map) return const {}; + return { + for (final MapEntry(:key, :value) in json.entries) + if (key is String && value is String) key: value, + }; + } on FormatException { + return const {}; + } +} diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart index 0526f0d..2392692 100644 --- a/lib/session/session_lifecycle.dart +++ b/lib/session/session_lifecycle.dart @@ -1,22 +1,36 @@ +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 '../background/widget_background_task.dart'; +import '../model/session_wipe.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 shows it once and clears it — without it an expired session - /// just drops the user on the login screen with no explanation. + /// 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. Each step is best-effort so an offline - /// sign-out still reaches a clean local state. - static Future signOut({String? notice}) async { + /// 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(); @@ -24,6 +38,166 @@ abstract final class SessionLifecycle { log('Sign-out: push cleanup failed: $e'); } await AuthLogout().run(); - await SessionManager().signOut(); + final removed = await SessionManager().signOut(); + SessionWipe.clearImmediate(); + await SessionWipe.accountLeft(); + 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(); + SessionWipe.clearImmediate(); + var restored = false; + try { + await SessionManager().activate(id); + } on Object { + if (previous != null) { + await SessionManager().activate(previous.id); + restored = true; + } + rethrow; + } finally { + await SessionWipe.accountLeft(); + await AccountStorage.activate(SessionManager().activeAccount); + // Nothing remounts for the account that stays, so its push would + // remain unregistered until the next start. + if (restored) unawaited(PushRegistration().register()); + } + 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; + SessionWipe.clearImmediate(); + // Whatever runs behind the login screen must not write into the parked + // account's data. + await AccountStorage.activate(null); + } + + 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); + await AccountStorage.activate(SessionManager().activeAccount); + } + + /// 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 SessionWipe.accountLeft(); + } + 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'); + } } } diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart index 44c17e8..58b25af 100644 --- a/lib/session/session_manager.dart +++ b/lib/session/session_manager.dart @@ -2,17 +2,26 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../api/marianumconnect/auth/token_storage.dart'; import '../push/push_secure_storage.dart'; import '../utils/exponential_backoff.dart'; +import '../utils/random_id.dart'; +import 'account_codec.dart'; import 'nextcloud_credentials.dart'; import 'session.dart'; import 'session_codec.dart'; /// Owns the active [Session] and its persistence. One instance per isolate; /// the widget background isolate reads the same keychain. +/// +/// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC +/// token and the group-keychain app passwords always hold the *active* one +/// (native code and the background isolate read only those). Inactive +/// accounts are parked in a per-account vault entry. class SessionManager { // `first_unlock` so a background launch on a locked device (silent push, // BGAppRefresh) can still read the session. Items written by older versions @@ -32,6 +41,10 @@ class SessionManager { SessionKeys.loginFlow, ]; + static const _indexKey = 'accounts_index'; + static String _vaultKey(String id) => 'account_vault_$id'; + static const _tokenStorage = MarianumConnectTokenStorage(); + static final SessionManager _instance = SessionManager._(); factory SessionManager() => _instance; @@ -46,7 +59,8 @@ class SessionManager { int _sessionEpoch = 0; - /// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its + /// Bumped whenever the active account changes (sign-out, switch, another + /// account signing in). Async work captures it when it starts and drops its /// result when it changed meanwhile, so a request of the previous account /// cannot land in the next account's state or cache. int get sessionEpoch => _sessionEpoch; @@ -58,6 +72,13 @@ class SessionManager { /// Called from `main()`; background entry points never run it. void markUiEngine() => _isUiEngine = true; + /// Every signed-in account and which one is active. + final ValueNotifier accounts = ValueNotifier( + AccountIndex.empty, + ); + + AccountEntry? get activeAccount => accounts.value.active; + bool get isSignedIn => _current != null; bool get isDemo => _current?.isDemo ?? false; @@ -85,8 +106,22 @@ class SessionManager { NextcloudCredentials requireNextcloud() => _current?.nextcloud ?? (throw const NextcloudUnavailableException()); - /// Replaces any stored session completely; no prior [signOut] needed. + /// Makes [session] the active account, replacing the active slots + /// completely. An account signed in before keeps its entry; call + /// [stashActive] first so the previously active one stays switchable. Future signIn(Session session) async { + await _writeActive(session); + await _saveIndex( + accounts.value.activate( + session, + newId: randomHexId(bytes: 8), + now: _now(), + ), + ); + if (!_loaded.isCompleted) _loaded.complete(); + } + + Future _writeActive(Session session) async { if (!_isSameAccount(_current, session)) _sessionEpoch++; await Future.wait([ for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) @@ -101,10 +136,12 @@ class SessionManager { ), ]); _current = session; - if (!_loaded.isCompleted) _loaded.complete(); } - Future signOut() async { + /// Wipes the active slots and forgets the active account. Other accounts + /// stay in their vaults; see [activate]. + Future signOut() async { + final removed = activeAccount; _sessionEpoch++; _loaded = Completer(); _current = null; @@ -112,14 +149,70 @@ class SessionManager { for (final field in _sessionFields) _secureStorage.delete(key: field), _writeGroupSecret(SessionKeys.appPassword, null), _writeGroupSecret(SessionKeys.appPasswordTalk, null), + if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)), ]); + if (removed != null) await _saveIndex(accounts.value.remove(removed.id)); + return removed; } - static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) { - (final CredentialSession a, final CredentialSession b) => - a.username == b.username && a.isDemo == b.isDemo, - _ => a == b, - }; + /// Parks the active account (session, app passwords, MC token) in its vault + /// so the slots can be taken over by another account. + Future stashActive() async { + final session = _current; + final entry = activeAccount; + if (session == null || entry == null) return; + final fields = { + ...sessionVaultFields(session), + ...await _tokenStorage.readAll(), + }; + await _secureStorage.write( + key: _vaultKey(entry.id), + value: encodeVault(fields), + ); + } + + /// Loads the vault of [id] into the active slots. The active account must + /// have been stashed before, or its session is lost. + Future activate(String id) async { + final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); + final session = decodeSession(fields); + if (session == null) throw StateError('No stored session for account $id'); + await _writeActive(session); + await _tokenStorage.writeAll(fields); + await _saveIndex(accounts.value.select(id, now: _now())); + if (!_loaded.isCompleted) _loaded.complete(); + } + + /// Remembers the real name of the active account for the account list. + Future setDisplayName(String displayName) async { + final entry = activeAccount; + if (entry == null || entry.displayName == displayName) return; + await _saveIndex(accounts.value.rename(entry.id, displayName)); + } + + /// Session and MC token of an inactive account, e.g. to revoke them. + Future<(Session?, Map)> readVault(String id) async { + final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); + return (decodeSession(fields), fields); + } + + /// Drops an inactive account without touching the active slots. + Future forget(String id) async { + await _secureStorage.delete(key: _vaultKey(id)); + await _saveIndex(accounts.value.remove(id)); + } + + static bool _isSameAccount(Session? a, Session? b) { + if (a == null || b == null) return a == b; + return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo; + } + + Future _saveIndex(AccountIndex index) async { + accounts.value = index; + await _secureStorage.write(key: _indexKey, value: index.encode()); + } + + static int _now() => DateTime.now().millisecondsSinceEpoch; /// Persists a freshly minted Nextcloud app password; from then on every /// Nextcloud call authenticates with it instead of the real password. @@ -202,6 +295,7 @@ class SessionManager { await _migrateFromLegacyStorage(); await _migrateKeychainAccessibility(); _current = await _readActive(); + await _loadIndex(); if (!_loaded.isCompleted) _loaded.complete(); } @@ -226,28 +320,60 @@ class SessionManager { return decodeSession(raw); } - /// Username currently in the keystore. Other engines (widget task, push - /// isolates) sign out or in without this instance noticing. + /// Username currently in the active slots. Other engines (widget task, push + /// isolates) sign out, in or switch without this instance noticing. Future readStoredUsername() => _secureStorage.read(key: SessionKeys.username); /// Re-reads the session for long-lived background engines: they load once - /// and would otherwise keep acting with an account that signed out in the - /// app meanwhile. Keeps the known state when the keystore is unreadable. + /// and would otherwise keep acting with an account that signed out or was + /// switched away from in the app meanwhile. Keeps the known state when the + /// keystore is unreadable. Future reloadFromStorage() async { - // The UI engine performs sign-in and sign-out itself, so its state is - // current; re-reading mid sign-out could resurrect the removed account. + // The UI engine performs sign-in, sign-out and switches itself, so its + // state is current; re-reading in between could resurrect the removed + // account. if (_isUiEngine) return; try { final session = await _readActive(); + final index = AccountIndex.decode( + await _secureStorage.read(key: _indexKey), + ); if (!_isSameAccount(_current, session)) _sessionEpoch++; _current = session; + accounts.value = index; if (!_loaded.isCompleted) _loaded.complete(); } on Object catch (e) { log('Session reload failed, keeping loaded state: $e'); } } + Future _loadIndex() async { + var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey)); + final session = _current; + if (session != null) { + if (index.active == null || + index.matching(session)?.id != index.activeId) { + // First start after the update: the existing account keeps its data + // in the un-namespaced storage. + index = index.activate( + session, + newId: randomHexId(bytes: 8), + namespace: index.accounts.isEmpty ? '' : null, + now: _now(), + ); + await _secureStorage.write(key: _indexKey, value: index.encode()); + } + accounts.value = index; + return; + } + accounts.value = index.remove(index.activeId ?? ''); + // Interrupted switch or sign-out: fall back to another stored account + // instead of showing the login screen. + final fallback = accounts.value.mostRecentExcept(null); + if (fallback != null) await activate(fallback.id); + } + // Move credentials from the old SharedPreferences plain-text storage into the // platform's secure keystore. Run once per install and clear the legacy keys. Future _migrateFromLegacyStorage() async { diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index c09ea2a..71aaec0 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -126,6 +126,14 @@ abstract class LoadableHydratedBloc< add(Reset()); } + // Blocs are rebuilt per account (see AccountScope). One that briefly + // outlives its account would otherwise load with the next account's + // credentials and persist the result into its own storage. + final int _ownSession = SessionManager().sessionEpoch; + + /// Whether the account this bloc was created for is still the active one. + bool get ownsSession => SessionManager().isCurrentSession(_ownSession); + static const _sessionKey = #loadableSessionEpoch; /// Runs [body] tagged with the current session: events it adds, also from @@ -136,6 +144,7 @@ abstract class LoadableHydratedBloc< @override void add(LoadableHydratedBlocEvent event) { + if (!ownsSession) return; final epoch = Zone.current[_sessionKey]; if (epoch is int && !SessionManager().isCurrentSession(epoch)) return; super.add(event); @@ -173,6 +182,7 @@ abstract class LoadableHydratedBloc< final SessionSingleFlight _fetchFlight = SessionSingleFlight(); void fetch() { + if (!ownsSession) return; unawaited( _fetchFlight.run(() { log('Fetching data for ${TState.toString()}'); diff --git a/lib/state/app/modules/account/account_scope.dart b/lib/state/app/modules/account/account_scope.dart new file mode 100644 index 0000000..e0d5136 --- /dev/null +++ b/lib/state/app/modules/account/account_scope.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../breaker/bloc/breaker_bloc.dart'; +import '../capabilities/bloc/capabilities_cubit.dart'; +import '../chat/bloc/chat_bloc.dart'; +import '../chat_list/bloc/chat_list_bloc.dart'; +import '../nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; +import '../timetable/bloc/timetable_bloc.dart'; +import 'bloc/account_bloc.dart'; +import 'bloc/account_state.dart'; + +/// The blocs of one account. Keyed by the account id above, so a switch +/// recreates all of them from the new account's storage instead of resetting +/// them one by one. +class AccountScope extends StatelessWidget { + /// Called with a context below the account's blocs once the scope mounted + /// for a signed-in account. + final void Function(BuildContext scopeContext) onActive; + final Widget child; + + const AccountScope({super.key, required this.onActive, required this.child}); + + @override + Widget build(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => CapabilitiesCubit()), + BlocProvider( + create: (_) => NextcloudCapabilitiesCubit(), + ), + BlocProvider(create: (_) => ChatListBloc()), + BlocProvider( + create: (ctx) => ChatBloc(chatListBloc: ctx.read()), + ), + BlocProvider(create: (_) => TimetableBloc()), + ], + child: _Activation(onActive: onActive, child: child), + ); +} + +class _Activation extends StatefulWidget { + final void Function(BuildContext scopeContext) onActive; + final Widget child; + + const _Activation({required this.onActive, required this.child}); + + @override + State<_Activation> createState() => _ActivationState(); +} + +class _ActivationState extends State<_Activation> { + @override + void initState() { + super.initState(); + if (context.read().state.status != AccountStatus.loggedIn) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onActive(context); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart index f1d2b6a..c02a24a 100644 --- a/lib/state/app/modules/account/bloc/account_bloc.dart +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -4,12 +4,38 @@ import 'account_event.dart'; import 'account_state.dart'; class AccountBloc extends Bloc { - AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) - : super(AccountState(status: initialStatus)) { + AccountBloc({ + AccountStatus initialStatus = AccountStatus.undefined, + String? accountId, + }) : super(AccountState(status: initialStatus, accountId: accountId)) { on( - (event, emit) => emit(state.copyWith(status: event.status)), + (event, emit) => emit( + AccountState( + status: event.status, + accountId: event.status == AccountStatus.loggedOut + ? null + : state.accountId, + ), + ), + ); + on( + (event, emit) => emit( + AccountState( + status: AccountStatus.loggedIn, + accountId: event.accountId, + freshLogin: event.freshLogin, + ), + ), ); } void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); + + /// [accountId] became the active account (login, switch or takeover after a + /// sign-out); null means no account is left. + void activated(String? accountId, {bool freshLogin = false}) => add( + accountId == null + ? const AccountStatusChanged(AccountStatus.loggedOut) + : AccountActivated(accountId, freshLogin: freshLogin), + ); } diff --git a/lib/state/app/modules/account/bloc/account_event.dart b/lib/state/app/modules/account/bloc/account_event.dart index a3a0f8a..b740e8a 100644 --- a/lib/state/app/modules/account/bloc/account_event.dart +++ b/lib/state/app/modules/account/bloc/account_event.dart @@ -8,3 +8,9 @@ class AccountStatusChanged extends AccountEvent { final AccountStatus status; const AccountStatusChanged(this.status); } + +class AccountActivated extends AccountEvent { + final String accountId; + final bool freshLogin; + const AccountActivated(this.accountId, {required this.freshLogin}); +} diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart index 878407d..aa4f2c2 100644 --- a/lib/state/app/modules/account/bloc/account_state.dart +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -1,9 +1,18 @@ -enum AccountStatus { undefined, loggedIn, loggedOut } +enum AccountStatus { undefined, loggedIn, loggedOut, addingAccount } class AccountState { final AccountStatus status; - const AccountState({this.status = AccountStatus.undefined}); - AccountState copyWith({AccountStatus? status}) => - AccountState(status: status ?? this.status); + /// Active account; the account-scoped part of the app is keyed by it. + final String? accountId; + + /// Set when [accountId] came from a login (not a switch), to show the + /// post-login splash. + final bool freshLogin; + + const AccountState({ + this.status = AccountStatus.undefined, + this.accountId, + this.freshLogin = false, + }); } diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index 24128f8..54681e2 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -87,8 +87,10 @@ class ChatListBloc /// Concurrent callers (tab switch, poll, push, resume) join the running /// refresh instead of each fetching and re-emitting the full room list. - Future refresh({bool renew = true, bool silent = false}) => - _refreshFlight.run(() => _refresh(renew: renew, silent: silent)); + Future refresh({bool renew = true, bool silent = false}) async { + if (!ownsSession) return; + await _refreshFlight.run(() => _refresh(renew: renew, silent: silent)); + } /// Skips the refresh when the list was fetched within [maxAge]. Future refreshIfOlderThan(Duration maxAge) { diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 4b420a4..8c2aa4a 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -14,7 +14,12 @@ class SettingsCubit extends HydratedCubit { Map? _lastEmittedJson; Timer? _silentSave; - SettingsCubit() : super(DefaultSettings.get()); + final Storage? _storage; + + // ignore: use_super_parameters + SettingsCubit({Storage? storage}) + : _storage = storage, + super(DefaultSettings.get(), storage: storage); Settings val({bool write = false}) { if (write) { @@ -50,7 +55,7 @@ class SettingsCubit extends HydratedCubit { if (_silentSave == null) return; _silentSave!.cancel(); _silentSave = null; - HydratedBloc.storage.write(storageToken, state.toJson()); + (_storage ?? HydratedBloc.storage).write(storageToken, state.toJson()); } void _emitFreshInstance() { diff --git a/lib/storage/account_storage.dart b/lib/storage/account_storage.dart new file mode 100644 index 0000000..8726c9e --- /dev/null +++ b/lib/storage/account_storage.dart @@ -0,0 +1,78 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../api/cache_store.dart'; +import '../session/account_codec.dart'; +import 'hydrated_storage_bootstrap.dart'; + +/// Per-account local data: every account gets its own HydratedBloc box and +/// request cache directory (see [CacheStore]), so switching back is instant +/// and offline. Only the app settings live in the shared [global] box. +abstract final class AccountStorage { + static const _settingsKey = 'SettingsCubit'; + + static late String _baseDir; + static final Map _open = {}; + static Storage _global = InMemoryStorage(); + + static Storage get global => _global; + + /// Opens the global box. Settings of installs from before multi-account + /// support are copied over from the legacy box once. + static Future init(String baseDir) async { + _baseDir = baseDir; + _global = await buildHydratedStorageWithFallback( + _ensureDir('$baseDir/hydrated_global'), + ); + HydratedBloc.storage = InMemoryStorage(); + if (_global.read(_settingsKey) != null) return; + final legacySettings = (await _storageFor('')).read(_settingsKey); + if (legacySettings != null) { + await _global.write(_settingsKey, legacySettings); + } + } + + /// Points HydratedBloc at [account]'s data; the request cache follows the + /// active account on its own. Blocs keep the storage they were created + /// with, so the per-account blocs have to be recreated afterwards. + static Future activate(AccountEntry? account) async { + HydratedBloc.storage = account == null + ? InMemoryStorage() + : await _storageFor(account.namespace); + } + + /// Removes all local data of the account with [namespace]. + static Future delete(String namespace) async { + try { + final storage = await _storageFor(namespace); + await storage.clear(); + // Closed boxes ignore writes, so blocs of the account that are still + // mounted until the remount cannot leave data behind. + await storage.close(); + _open.remove(namespace); + if (namespace.isNotEmpty) { + final dir = Directory(_dirFor(namespace)); + if (dir.existsSync()) await dir.delete(recursive: true); + } + } on Object catch (e, s) { + log('Account storage delete failed: $e', stackTrace: s); + } + await CacheStore.instance.deleteNamespace(namespace); + } + + static Future _storageFor(String namespace) async => + _open[namespace] ??= await buildHydratedStorageWithFallback( + _ensureDir(_dirFor(namespace)), + ); + + // The legacy account keeps the box at the root of the base directory. + static String _dirFor(String namespace) => + namespace.isEmpty ? _baseDir : '$_baseDir/accounts/$namespace'; + + static String _ensureDir(String path) { + Directory(path).createSync(recursive: true); + return path; + } +} diff --git a/lib/storage/hydrated_storage_bootstrap.dart b/lib/storage/hydrated_storage_bootstrap.dart index 08369b3..20b9bfc 100644 --- a/lib/storage/hydrated_storage_bootstrap.dart +++ b/lib/storage/hydrated_storage_bootstrap.dart @@ -9,19 +9,43 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; Future buildHydratedStorageWithFallback(String directoryPath) async { final directory = HydratedStorageDirectory(directoryPath); try { - return await HydratedStorage.build(storageDirectory: directory); + return await _build(directory); } catch (e, s) { log('HydratedStorage open failed, rebuilding: $e', stackTrace: s); } try { await _deleteHydratedBox(directoryPath); - return await HydratedStorage.build(storageDirectory: directory); + return await _build(directory); } catch (e, s) { log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s); return InMemoryStorage(); } } +final Expando Function()> _keyReaders = Expando(); + +Future _build(HydratedStorageDirectory directory) async { + final storage = await HydratedStorage.build(storageDirectory: directory); + // hydrated_bloc exposes no key listing; the Hive instance is the only way + // in. Every build replaces the static one, so it is captured per storage. + // ignore: invalid_use_of_visible_for_testing_member + final hive = HydratedStorage.hive; + _keyReaders[storage] = () => + hive.box('hydrated_box').keys.map((k) => '$k').toList(); + return storage; +} + +/// All keys persisted in [storage], sorted; `null` when they can't be +/// enumerated. +List? hydratedStorageKeys(Storage storage) { + if (storage is InMemoryStorage) return storage.keys.toList()..sort(); + try { + return _keyReaders[storage]?.call()?..sort(); + } on Object { + return null; + } +} + Future _deleteHydratedBox(String directoryPath) async { final directory = Directory(directoryPath); if (!directory.existsSync()) return; diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index f559768..ea804b7 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../background/widget_background_task.dart'; import '../../session/session_lifecycle.dart'; +import '../../session/session_manager.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; -import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../storage/dev_tools_settings.dart'; import '../../storage/settings.dart' as model; @@ -20,7 +21,10 @@ import 'widgets/login_branding.dart'; import 'widgets/login_card.dart'; class Login extends StatefulWidget { - const Login({super.key}); + /// Signs in another account while one is active; offers to go back. + final bool addingAccount; + + const Login({super.key, this.addingAccount = false}); @override State createState() => _LoginState(); @@ -65,59 +69,100 @@ class _LoginState extends State with SingleTickerProviderStateMixin { super.dispose(); } - void _onLoginSuccess() { + Future _onLoginSuccess() async { Haptics.heavyAccent(); + final accountBloc = context.read(); + // Fade the login content out before handing over to the post-login splash. + // Both share the red backdrop, so this reads as one continuous transition + // instead of an abrupt swap. + final finished = SessionLifecycle.finishLogin(); + await _fade.reverse().orCancel.onError((_, _) {}); + String? accountId; + try { + accountId = await finished; + } on Object catch (e) { + log('Login: finishing failed: $e'); + accountId = SessionManager().activeAccount?.id; + } // Re-register the periodic refresh (cancelAll runs on logout) and kick // off an immediate one-off so the widget populates within seconds // instead of waiting up to 30 minutes for the next periodic slot. unawaited(WidgetBackgroundTask.initialize()); unawaited(WidgetBackgroundTask.requestImmediateRefresh()); - // Fade the login content out before handing over to the post-login splash. - // Both share the red backdrop, so this reads as one continuous transition - // instead of an abrupt swap. - _fade.reverse().whenComplete(() { - if (!mounted) return; - context.read().setStatus(AccountStatus.loggedIn); - }); + accountBloc.activated(accountId, freshLogin: true); + } + + Future _cancelAddAccount() async { + final accountBloc = context.read(); + await SessionLifecycle.cancelAddAccount(); + accountBloc.activated(SessionManager().activeAccount?.id); } @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: _marianumRed, - body: FadeTransition( - opacity: _fade, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - maxWidth: 420, + Widget build(BuildContext context) => PopScope( + canPop: !widget.addingAccount, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && !_busy) unawaited(_cancelAddAccount()); + }, + child: Scaffold( + backgroundColor: _marianumRed, + appBar: widget.addingAccount + ? AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: ListenableBuilder( + listenable: _controller, + builder: (context, _) => IconButton( + icon: const Icon(Icons.close), + tooltip: 'Abbrechen', + onPressed: _busy ? null : _cancelAddAccount, ), - // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde - // die Column bei Inhaltsänderungen im unteren Block auf die - // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - const LoginHeader(), - const SizedBox(height: 28), - LoginCard( - controller: _controller, - onSuccess: _onLoginSuccess, - ), - const SizedBox(height: 12), - ], - ), - const Column( - mainAxisSize: MainAxisSize.min, - children: [_EndpointLink(), LoginFooter()], - ), - ], + ), + title: const Text('Konto hinzufügen'), + ) + : null, + body: FadeTransition( + opacity: _fade, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde + // die Column bei Inhaltsänderungen im unteren Block auf die + // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + LoginHeader(addingAccount: widget.addingAccount), + const SizedBox(height: 28), + LoginCard( + controller: _controller, + onSuccess: _onLoginSuccess, + addingAccount: widget.addingAccount, + ), + const SizedBox(height: 12), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The new account must live on the server the app + // already talks to. + if (!widget.addingAccount) const _EndpointLink(), + const LoginFooter(), + ], + ), + ], + ), ), ), ), @@ -126,6 +171,8 @@ class _LoginState extends State with SingleTickerProviderStateMixin { ), ), ); + + bool get _busy => _controller.loading; } /// Subtle text link above the footer that surfaces the currently selected diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 9d6bf2a..d78e949 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; class LoginHeader extends StatelessWidget { - const LoginHeader({super.key}); + /// Signs in an additional account instead of the first one. + final bool addingAccount; + + const LoginHeader({this.addingAccount = false, super.key}); @override Widget build(BuildContext context) => Column( @@ -29,7 +32,10 @@ class LoginHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', + addingAccount + ? 'Melde ein weiteres Konto an. In den Einstellungen kannst du ' + 'danach jederzeit zwischen deinen Konten wechseln.' + : 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.85), diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index a38c152..23703cc 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -12,9 +12,13 @@ class LoginCard extends StatefulWidget { final LoginController controller; final VoidCallback onSuccess; + /// Signs in an additional account instead of the first one. + final bool addingAccount; + const LoginCard({ required this.controller, required this.onSuccess, + this.addingAccount = false, super.key, }); @@ -83,8 +87,11 @@ class _LoginCardState extends State { return Form( key: _formKey, child: LoginCardFrame( - title: 'Anmelden', - hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.', + title: widget.addingAccount ? 'Konto hinzufügen' : 'Anmelden', + hint: widget.addingAccount + ? 'Melde dich mit den Marianum-Zugangsdaten des Kontos an, das ' + 'du hinzufügen möchtest.' + : 'Melde dich mit deinen Marianum-Zugangsdaten an.', children: [ TextFormField( key: const Key('login-username-field'), @@ -127,7 +134,7 @@ class _LoginCardState extends State { const SizedBox(height: 20), LoginSubmitButton( key: const Key('login-submit-button'), - label: 'Anmelden', + label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden', loading: loading, onPressed: _submit, ), diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart index f67959f..695ed88 100644 --- a/lib/view/pages/settings/diagnostics_page.dart +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -16,7 +16,7 @@ import '../../../extensions/date_time.dart'; import '../../../model/endpoint_data.dart'; import '../../../push/push_registration_store.dart'; import '../../../push/push_registration_type.dart'; -import '../../../session/session.dart'; +import '../../../session/account_codec.dart'; import '../../../session/session_manager.dart'; import '../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -103,10 +103,9 @@ class _DiagnosticsPageState extends State { title: 'Sitzung', rows: await rows({ 'Status': () async => accountStatus.name, - 'Benutzer': () async => switch (session) { - CredentialSession(:final username) => username, - null => null, - }, + 'Benutzer': () async => + session == null ? null : identityOf(session).$2, + 'Konten': () async => SessionManager().accounts.value.accounts.length, 'Demo-Modus': () async => session?.isDemo ?? false, 'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false, 'App-Passwort (Dateien)': () async => diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index cfb9d8b..9857480 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -9,7 +9,7 @@ import '../../../../routing/app_routes.dart'; import '../../../../session/session_lifecycle.dart'; import '../../../../session/session_manager.dart'; import '../../../../state/app/modules/account/bloc/account_bloc.dart'; -import '../../../../state/app/modules/account/bloc/account_state.dart'; +import '../../../../widget/account_switcher_sheet.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/avatar_actions_sheet.dart'; @@ -17,34 +17,43 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/user_avatar.dart'; -// Display-name is process-wide stable until the user logs out; cache it so -// every Settings rebuild doesn't re-issue the OCS request. +// Display-name is stable per account; cache it so every Settings rebuild +// doesn't re-issue the OCS request. String? _cachedDisplayName; +String? _cachedDisplayNameFor; -class AccountSection extends StatefulWidget { +class AccountSection extends StatelessWidget { const AccountSection({super.key}); @override - State createState() => _AccountSectionState(); + Widget build(BuildContext context) => const _SchoolAccount(); } -class _AccountSectionState extends State { +class _SchoolAccount extends StatefulWidget { + const _SchoolAccount(); + + @override + State<_SchoolAccount> createState() => _SchoolAccountState(); +} + +class _SchoolAccountState extends State<_SchoolAccount> { int _avatarVersion = 0; bool _avatarBusy = false; - String? _displayName = _cachedDisplayName; + String? _displayName; @override void initState() { super.initState(); - if (_displayName == null) _loadDisplayName(); + final username = SessionManager().requireNextcloud().username; + if (_cachedDisplayNameFor == username) _displayName = _cachedDisplayName; + if (_displayName == null) _loadDisplayName(username); } - Future _loadDisplayName() async { + Future _loadDisplayName(String username) async { try { final info = await GetUserInfo().run(); - _cachedDisplayName = info.displayName.isEmpty - ? null - : info.displayName; + _cachedDisplayNameFor = username; + _cachedDisplayName = info.displayName.isEmpty ? null : info.displayName; if (!mounted) return; setState(() => _displayName = _cachedDisplayName); } catch (_) { @@ -102,7 +111,7 @@ class _AccountSectionState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + padding: const EdgeInsets.fromLTRB(16, 20, 8, 16), child: Row( children: [ SizedBox( @@ -113,8 +122,10 @@ class _AccountSectionState extends State { children: [ Center( child: GestureDetector( - onTap: () => - AppRoutes.openLargeProfilePicture(context, username), + onTap: () => AppRoutes.openLargeProfilePicture( + context, + username, + ), child: UserAvatar( key: ValueKey(_avatarVersion), id: username, @@ -134,7 +145,7 @@ class _AccountSectionState extends State { ], ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -167,12 +178,8 @@ class _AccountSectionState extends State { ], ), ), - const SizedBox(width: 8), - TextButton.icon( - icon: const Icon(Icons.logout_outlined, size: 18), - label: const Text('Abmelden'), - onPressed: () => _showLogoutDialog(context), - ), + const SizedBox(width: 4), + const _AccountActions(), ], ), ), @@ -183,9 +190,7 @@ class _AccountSectionState extends State { AsyncListTile( leading: const Icon(Icons.cloud_sync_outlined), title: const Text('Nextcloud neu verbinden'), - subtitle: const Text( - 'Bei Anmeldeproblemen in Talk oder Dateien', - ), + subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'), closeOnSuccess: false, onPressed: _reconnectNextcloud, ), @@ -204,29 +209,49 @@ class _AccountSectionState extends State { const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')), ); } +} - Future _showLogoutDialog(BuildContext context) async { - // Flip AccountBloc state only after the dialog fully closes: doing it from - // inside the sign-out (the previous approach) raced AsyncDialogAction's - // pop(true) against the listener's popUntil(isFirst) and could leave the - // navigator in an inconsistent state. - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => ConfirmDialog( - title: 'Abmelden?', - content: 'Möchtest du dich wirklich abmelden?', - confirmButton: 'Abmelden', - onConfirmAsync: _performLogout, +Future _confirmLogout(BuildContext context) async { + final accountBloc = context.read(); + final others = SessionManager().accounts.value.accounts.length - 1; + String? nextAccountId; + // Flip AccountBloc state only after the dialog fully closes: doing it from + // inside the sign-out (the previous approach) raced AsyncDialogAction's + // pop(true) against the navigator teardown of the account switch. + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => ConfirmDialog( + title: 'Abmelden?', + content: others > 0 + ? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu ' + 'einem deiner anderen Konten.' + : 'Möchtest du dich wirklich abmelden?', + confirmButton: 'Abmelden', + onConfirmAsync: () async => + nextAccountId = await SessionLifecycle.signOut(), + ), + ); + if (confirmed != true) return; + accountBloc.activated(nextAccountId); +} + +class _AccountActions extends StatelessWidget { + const _AccountActions(); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.logout_outlined, size: 18), + label: const Text('Abmelden'), + onPressed: () => _confirmLogout(context), ), - ); - if (confirmed != true || !context.mounted) return; - context.read().setStatus(AccountStatus.loggedOut); - } - - Future _performLogout() async { - await SessionLifecycle.signOut(); - _cachedDisplayName = null; - } + const AccountSwitchButton(), + ], + ); } class _AvatarEditBadge extends StatelessWidget { @@ -254,11 +279,7 @@ class _AvatarEditBadge extends StatelessWidget { color: theme.colorScheme.onPrimary, ), ) - : Icon( - Icons.edit, - size: 14, - color: theme.colorScheme.onPrimary, - ), + : Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary), ), ), ); diff --git a/lib/widget/account_switcher_sheet.dart b/lib/widget/account_switcher_sheet.dart new file mode 100644 index 0000000..21d8d8d --- /dev/null +++ b/lib/widget/account_switcher_sheet.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../session/account_codec.dart'; +import '../session/session_lifecycle.dart'; +import '../session/session_manager.dart'; +import '../state/app/modules/account/bloc/account_bloc.dart'; +import '../state/app/modules/account/bloc/account_state.dart'; +import '../utils/haptics.dart'; +import 'app_progress_indicator.dart'; +import 'async_action_button.dart'; +import 'centered_leading.dart'; +import 'confirm_dialog.dart'; +import 'details_bottom_sheet.dart'; +import 'user_avatar.dart'; + +/// Lists the signed-in accounts: tap one to switch, add another, or sign out +/// of an inactive one. +Future showAccountSwitcherSheet(BuildContext context) { + final accountBloc = context.read(); + return showDetailsBottomSheet( + context, + header: Builder( + builder: (headerContext) => ListTile( + title: const Text('Konten'), + trailing: TextButton.icon( + icon: const Icon(Icons.person_add_alt_outlined, size: 18), + label: const Text('Hinzufügen'), + onPressed: () { + Navigator.pop(headerContext); + unawaited(startAddAccount(accountBloc)); + }, + ), + ), + ), + children: (sheetContext) => [ + ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final account in index.accounts) + _AccountTile( + account: account, + active: account.id == index.activeId, + accountBloc: accountBloc, + ), + ], + ), + ), + ], + ); +} + +/// Opens the login for another account; the active one is parked meanwhile. +Future startAddAccount(AccountBloc accountBloc) async { + await SessionLifecycle.beginAddAccount(); + accountBloc.setStatus(AccountStatus.addingAccount); +} + +/// Stacked next to the account name, which needs the width more. +/// Only the horizontal padding is trimmed; height stays at a full tap target. +final ButtonStyle compactAccountButtonStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), +); + +/// Below the sign-out button: adds an account, or opens the switcher once +/// there is more than one. +class AccountSwitchButton extends StatelessWidget { + const AccountSwitchButton({super.key}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => index.accounts.length > 1 + ? TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.switch_account_outlined, size: 18), + label: const Text('Wechseln'), + onPressed: () => showAccountSwitcherSheet(context), + ) + : TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.person_add_alt_outlined, size: 18), + label: const Text('Hinzufügen'), + onPressed: () => startAddAccount(context.read()), + ), + ); +} + +class _AccountTile extends StatefulWidget { + final AccountEntry account; + final bool active; + final AccountBloc accountBloc; + + const _AccountTile({ + required this.account, + required this.active, + required this.accountBloc, + }); + + @override + State<_AccountTile> createState() => _AccountTileState(); +} + +class _AccountTileState extends State<_AccountTile> { + bool _busy = false; + + Future _switch() async { + Haptics.selection(); + setState(() => _busy = true); + final ok = await runWithErrorDialog( + context, + () => SessionLifecycle.switchTo(widget.account.id), + ); + if (!mounted) return; + setState(() => _busy = false); + if (!ok) return; + Navigator.pop(context); + widget.accountBloc.activated(SessionManager().activeAccount?.id); + } + + void _confirmRemove() => ConfirmDialog( + title: 'Abmelden?', + content: + '${widget.account.displayName ?? widget.account.label} wird von diesem Gerät abgemeldet und seine ' + 'lokal gespeicherten Daten werden gelöscht.', + confirmButton: 'Abmelden', + onConfirmAsync: () => SessionLifecycle.removeInactive(widget.account.id), + ).asDialog(context); + + @override + Widget build(BuildContext context) { + final account = widget.account; + return ListTile( + leading: CenteredLeading( + _busy + ? const SizedBox.square( + dimension: 24, + child: AppProgressIndicator.small(), + ) + : _AccountAvatar(account: account), + ), + title: Text( + account.displayName ?? account.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + account.isDemo ? '${account.label} (Demo)' : account.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Sized like the IconButton so both line up. + trailing: widget.active + ? const SizedBox.square( + dimension: kMinInteractiveDimension, + child: Icon(Icons.check), + ) + : IconButton( + icon: const Icon(Icons.logout_outlined), + tooltip: 'Abmelden', + onPressed: _busy ? null : _confirmRemove, + ), + onTap: widget.active || _busy ? null : _switch, + ); + } +} + +class _AccountAvatar extends StatelessWidget { + final AccountEntry account; + + const _AccountAvatar({required this.account}); + + @override + Widget build(BuildContext context) { + // Demo accounts have no real Nextcloud user behind them. + if (!account.isDemo) return UserAvatar(id: account.label, size: 16); + final colors = Theme.of(context).colorScheme; + return CircleAvatar( + radius: 16, + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + child: Text(account.label.characters.first.toUpperCase()), + ); + } +} diff --git a/lib/widget/debug/bloc_storage_view.dart b/lib/widget/debug/bloc_storage_view.dart index e1a302f..e3502b1 100644 --- a/lib/widget/debug/bloc_storage_view.dart +++ b/lib/widget/debug/bloc_storage_view.dart @@ -15,30 +15,38 @@ import '../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../storage/account_storage.dart'; import '../../storage/hydrated_storage_bootstrap.dart'; import '../confirm_dialog.dart'; import '../placeholder_view.dart'; import 'json_viewer.dart'; -/// Lists every key persisted through [HydratedBloc.storage]. +/// Lists every key persisted by the active account's blocs and the app-wide +/// settings. class BlocStorageView extends StatefulWidget { const BlocStorageView({super.key}); - /// All persisted keys; `null` when the storage can't be enumerated. + static List get _storages => [ + HydratedBloc.storage, + AccountStorage.global, + ]; + + /// All persisted keys; `null` when a storage can't be enumerated. static List? keys() { - final storage = HydratedBloc.storage; - if (storage is InMemoryStorage) return storage.keys.toList()..sort(); - try { - // hydrated_bloc exposes no key listing; the Hive instance is the only - // way in. - // ignore: invalid_use_of_visible_for_testing_member - final box = HydratedStorage.hive.box('hydrated_box'); - return box.keys.map((k) => '$k').toList()..sort(); - } on Object { - return null; + final keys = {}; + for (final storage in _storages) { + final own = hydratedStorageKeys(storage); + if (own == null) return null; + keys.addAll(own); } + return keys.toList()..sort(); } + static Storage _storageOf(String key) => _storages.firstWhere( + (storage) => hydratedStorageKeys(storage)?.contains(key) ?? false, + orElse: () => HydratedBloc.storage, + ); + @override State createState() => _BlocStorageViewState(); } @@ -75,7 +83,7 @@ class _BlocStorageViewState extends State { final keys = BlocStorageView.keys(); setState( () => _entries = keys - ?.map((k) => _Entry(k, HydratedBloc.storage.read(k))) + ?.map((k) => _Entry(k, BlocStorageView._storageOf(k).read(k))) .toList(), ); } @@ -114,7 +122,7 @@ class _BlocStorageViewState extends State { if (reset != null) { await reset(); } else { - await HydratedBloc.storage.delete(key); + await BlocStorageView._storageOf(key).delete(key); } } diff --git a/test/api/cache_store_test.dart b/test/api/cache_store_test.dart index ae1d676..bad0123 100644 --- a/test/api/cache_store_test.dart +++ b/test/api/cache_store_test.dart @@ -18,4 +18,20 @@ void main() { expect(CacheStore.parse('abc\n{}'), isNull); }); }); + + group('CacheStore.directoryName', () { + test('keeps the original directory for the pre-multi-account account', () { + expect(CacheStore.directoryName(''), 'request_cache'); + }); + + test('separates accounts and the signed-out state', () { + final names = { + CacheStore.directoryName(''), + CacheStore.directoryName('a1b2'), + CacheStore.directoryName('c3d4'), + CacheStore.directoryName(null), + }; + expect(names, hasLength(4)); + }); + }); } diff --git a/test/session/account_codec_test.dart b/test/session/account_codec_test.dart new file mode 100644 index 0000000..ad57f54 --- /dev/null +++ b/test/session/account_codec_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/session/account_codec.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/session/session_codec.dart'; + +void main() { + final school = CredentialSession(username: 'max', password: 'pw'); + final second = CredentialSession(username: 'erika', password: 'pw'); + + group('AccountIndex.activate', () { + test('adds an unknown account with its own namespace', () { + final index = AccountIndex.empty.activate(school, newId: 'a', now: 5); + expect(index.activeId, 'a'); + final entry = index.active!; + expect(entry.kind, SessionKeys.kindCredential); + expect(entry.label, 'max'); + expect(entry.namespace, 'a'); + expect(entry.lastUsed, 5); + }); + + test('legacy account keeps the un-namespaced storage', () { + final index = AccountIndex.empty.activate( + school, + newId: 'a', + namespace: '', + ); + expect(index.active!.namespace, ''); + }); + + test('a known account is reused instead of duplicated', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate(second, newId: 'b') + .activate( + CredentialSession(username: 'max', password: 'new'), + newId: 'c', + now: 9, + ); + expect(index.accounts.map((e) => e.id), ['a', 'b']); + expect(index.activeId, 'a'); + expect(index.active!.lastUsed, 9); + }); + + test('demo and real account with the same name stay apart', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate( + CredentialSession(username: 'max', password: 'demo', isDemo: true), + newId: 'b', + ); + expect(index.accounts, hasLength(2)); + }); + }); + + group('AccountIndex removal', () { + final index = AccountIndex.empty + .activate(school, newId: 'a', now: 1) + .activate(second, newId: 'b', now: 3) + .select('a', now: 2); + + test('removing the active account leaves none active', () { + final removed = index.remove('a'); + expect(removed.activeId, isNull); + expect(removed.accounts.map((e) => e.id), ['b']); + }); + + test('removing an inactive account keeps the active one', () { + expect(index.remove('b').activeId, 'a'); + }); + + test('takeover picks the most recently used remaining account', () { + final third = index.activate( + CredentialSession(username: 'zoe', password: 'pw'), + newId: 'c', + now: 1, + ); + expect(third.remove('a').mostRecentExcept(null)?.id, 'b'); + expect(index.mostRecentExcept('b')?.id, 'a'); + expect(AccountIndex.empty.mostRecentExcept(null), isNull); + }); + }); + + group('AccountIndex codec', () { + test('round trip', () { + final index = AccountIndex.empty + .activate(school, newId: 'a', namespace: '', now: 1) + .activate(second, newId: 'b', now: 2); + final decoded = AccountIndex.decode(index.encode()); + expect(decoded.activeId, 'b'); + expect(decoded.accounts, hasLength(2)); + expect(decoded.byId('a')!.namespace, ''); + expect(decoded.byId('b')!.label, 'erika'); + expect(decoded.byId('b')!.lastUsed, 2); + }); + + test('display name survives rename and round trip', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate(second, newId: 'b') + .rename('b', 'Erika Muster'); + final decoded = AccountIndex.decode(index.encode()); + expect(decoded.byId('b')!.displayName, 'Erika Muster'); + expect(decoded.byId('a')!.displayName, isNull); + expect(decoded.activeId, 'b'); + }); + + test('garbage decodes to an empty index', () { + expect(AccountIndex.decode(null).accounts, isEmpty); + expect(AccountIndex.decode('{nope').accounts, isEmpty); + expect(AccountIndex.decode('[]').accounts, isEmpty); + }); + + test('malformed entries are skipped', () { + final decoded = AccountIndex.decode( + '{"activeId":"a","accounts":[{"id":"a"},' + '{"id":"b","kind":"credential","label":"x"}]}', + ); + expect(decoded.accounts.map((e) => e.id), ['b']); + expect(decoded.active, isNull); + }); + }); + + group('vault', () { + test('credential session survives the vault with app passwords', () { + final session = CredentialSession( + username: 'max', + password: 'pw', + appPassword: 'app', + appPasswordTalk: 'talk', + usesLoginFlow: true, + ); + final fields = decodeVault( + encodeVault({...sessionVaultFields(session), 'mc_bearer_token': 't'}), + ); + final restored = decodeSession(fields)! as CredentialSession; + expect(restored.username, 'max'); + expect(restored.password, 'pw'); + expect(restored.nextcloud.appPassword, 'app'); + expect(restored.nextcloud.appPasswordTalk, 'talk'); + expect(restored.nextcloud.usesLoginFlow, isTrue); + expect(fields['mc_bearer_token'], 't'); + }); + + test('demo session survives the vault without app passwords', () { + final fields = decodeVault( + encodeVault( + sessionVaultFields( + CredentialSession( + username: 'demo@x', + password: 'demo', + isDemo: true, + ), + ), + ), + ); + final restored = decodeSession(fields)! as CredentialSession; + expect(restored.username, 'demo@x'); + expect(restored.isDemo, isTrue); + expect(fields.containsKey(SessionKeys.appPassword), isFalse); + }); + + test('broken vault yields no session', () { + expect(decodeSession(decodeVault('garbage')), isNull); + }); + }); +}