From 5e65c4671aeddf52f40c837bcb60274b03a5f81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:49:38 +0200 Subject: [PATCH] replaced account data singleton with a session model --- CLAUDE.md | 3 + ios/PUSH_NSE_SETUP.md | 4 +- lib/api/demo/data/demo_talk.dart | 7 +- lib/api/demo/demo_mode.dart | 6 +- lib/api/errors/error_mapper.dart | 9 +- .../app_password/get_app_password.dart | 8 +- .../cloud_users/cloud_users_actions.dart | 17 +- lib/api/marianumcloud/nextcloud_ocs.dart | 4 +- lib/api/marianumcloud/webdav/webdav_api.dart | 16 +- .../auth/auth_interceptor.dart | 18 +- .../auth/session_validator.dart | 44 +- .../marianumconnect/auth/token_storage.dart | 17 +- .../marianumconnect_query.dart | 19 +- .../queries/auth_verify/auth_verify.dart | 9 +- .../get_newsletter_file.dart | 11 +- .../get_ticker_page_file.dart | 11 +- .../telemetry_device_id.dart | 12 +- .../custom_events_migration.dart | 20 +- lib/api/request_cache.dart | 6 +- lib/app.dart | 6 +- lib/background/widget_background_task.dart | 28 +- lib/main.dart | 26 +- lib/model/account_data.dart | 398 ------------------ lib/push/push_actions.dart | 20 +- lib/push/push_device_info.dart | 15 + lib/push/push_message_handler.dart | 4 +- lib/push/push_registration.dart | 67 +-- lib/push/push_registration_store.dart | 2 +- lib/push/push_secure_storage.dart | 2 +- lib/push/push_status.dart | 7 +- lib/routing/app_routes.dart | 12 +- lib/session/nextcloud_credentials.dart | 77 ++++ lib/session/session.dart | 64 +++ lib/session/session_codec.dart | 54 +++ lib/session/session_lifecycle.dart | 29 ++ lib/session/session_manager.dart | 288 +++++++++++++ .../loadable_hydrated_bloc.dart | 6 +- .../capabilities/bloc/capabilities_cubit.dart | 6 +- .../bloc/nextcloud_capabilities_cubit.dart | 7 +- lib/utils/downloads/download_manager.dart | 4 +- lib/utils/random_id.dart | 19 + lib/utils/session_single_flight.dart | 4 +- lib/view/login/account_loading_screen.dart | 4 +- lib/view/login/login.dart | 17 + lib/view/login/login_controller.dart | 46 +- lib/view/login/nextcloud_login_flow_page.dart | 10 +- .../pages/files/widgets/file_leading.dart | 4 +- .../element_picker_page.dart | 4 +- lib/view/pages/settings/diagnostics_page.dart | 24 +- .../settings/sections/account_section.dart | 24 +- lib/view/pages/talk/data/chat_message.dart | 4 +- .../pages/talk/details/message_reactions.dart | 8 +- .../talk/details/participants_list_view.dart | 4 +- .../pages/talk/details/shared_items_view.dart | 20 +- lib/view/pages/talk/widgets/chat_tile.dart | 10 +- .../pages/talk/widgets/poll_options_list.dart | 4 +- .../file_viewer/unknown_preview_block.dart | 4 +- lib/widget/user_avatar.dart | 7 +- lib/widget_data/widget_publisher.dart | 8 +- test/session/session_codec_test.dart | 110 +++++ 60 files changed, 1019 insertions(+), 679 deletions(-) delete mode 100644 lib/model/account_data.dart create mode 100644 lib/push/push_device_info.dart create mode 100644 lib/session/nextcloud_credentials.dart create mode 100644 lib/session/session.dart create mode 100644 lib/session/session_codec.dart create mode 100644 lib/session/session_lifecycle.dart create mode 100644 lib/session/session_manager.dart create mode 100644 lib/utils/random_id.dart create mode 100644 test/session/session_codec_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 5202b16..1777378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +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 ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine ├── view/ Screens @@ -51,6 +52,8 @@ lib/ **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. +**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). + ## Build / Run ```bash diff --git a/ios/PUSH_NSE_SETUP.md b/ios/PUSH_NSE_SETUP.md index 6bddbd4..b9b4d7d 100644 --- a/ios/PUSH_NSE_SETUP.md +++ b/ios/PUSH_NSE_SETUP.md @@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = ` + | `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) | | `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart | | `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart | -| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) | +| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) | | `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) | -> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage +> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers > **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE > unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei > jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das diff --git a/lib/api/demo/data/demo_talk.dart b/lib/api/demo/data/demo_talk.dart index eb9d317..bff897e 100644 --- a/lib/api/demo/data/demo_talk.dart +++ b/lib/api/demo/data/demo_talk.dart @@ -1,4 +1,4 @@ -import '../../../model/account_data.dart'; +import '../../../session/session_manager.dart'; import '../../marianumcloud/talk/chat/get_chat_response.dart'; import '../../marianumcloud/talk/room/get_room_response.dart'; import '../demo_persona.dart'; @@ -204,7 +204,8 @@ class DemoTalk { id: base + 2, token: token, ago: const Duration(days: 1, hours: 6), - message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', + message: + 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', ), _msg( id: base + 3, @@ -366,7 +367,7 @@ class DemoTalk { }) => _msg( id: id, token: token, - actor: AccountData().getUsername(), + actor: SessionManager().requireNextcloud().username, display: DemoPersona.studentName, ago: ago, message: message, diff --git a/lib/api/demo/demo_mode.dart b/lib/api/demo/demo_mode.dart index 4da95d8..a0ebd8f 100644 --- a/lib/api/demo/demo_mode.dart +++ b/lib/api/demo/demo_mode.dart @@ -1,4 +1,4 @@ -import '../../model/account_data.dart'; +import '../../session/session_manager.dart'; /// Central switch for the client-side demo mode. /// @@ -8,7 +8,7 @@ import '../../model/account_data.dart'; /// Play reviewers and automated screenshot runs see a fully populated app /// without a real account and without any network dependency. /// -/// The flag is persisted through [AccountData.isDemo], so a demo session +/// The flag is persisted through [Session.isDemo], so a demo session /// survives cold starts exactly like a normal login. It works in release builds /// too — reviewers run the shipped release build. class DemoMode { @@ -23,5 +23,5 @@ class DemoMode { username.trim().toLowerCase().startsWith(usernamePrefix); /// True while the active session is a demo session. - static bool get active => AccountData().isDemo; + static bool get active => SessionManager().isDemo; } diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 9cafa3a..0722b1a 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; import 'package:nextcloud/nextcloud.dart'; +import '../../session/session.dart'; import '../api_error.dart'; import '../http_errors.dart'; import '../marianumcloud/talk/talk_error.dart'; @@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) { AppException _dynamiteToAppException(DynamiteApiException error) { final status = error.statusCode; final preview = previewBody(error.body); - final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; + final detail = preview.isEmpty + ? 'HTTP $status' + : 'HTTP $status body=$preview'; switch (status) { case 401: return AuthException.unauthorized(technicalDetails: detail); @@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (error is AppException) return error.userMessage; if (error is TalkError) return TalkException(error).userMessage; + if (error is NextcloudUnavailableException) { + return 'Diese Funktion ist mit deinem Konto nicht verfügbar.'; + } if (error is DioException) { final mapped = _dioToAppException(error); @@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) { bool errorAllowsRetry(Object? error) { if (error == null) return true; if (error is AppException) return error.allowRetry; + if (error is NextcloudUnavailableException) return false; if (error is DioException) { final mapped = _dioToAppException(error); if (mapped != null) return mapped.allowRetry; diff --git a/lib/api/marianumcloud/app_password/get_app_password.dart b/lib/api/marianumcloud/app_password/get_app_password.dart index 30c920c..eaa4005 100644 --- a/lib/api/marianumcloud/app_password/get_app_password.dart +++ b/lib/api/marianumcloud/app_password/get_app_password.dart @@ -2,13 +2,13 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; +import '../../../session/session_manager.dart'; import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; /// Exchanges the user's real Nextcloud password for a scoped app password via /// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then -/// authenticate with the app password (see [AccountData.getBasicAuthHeader]), +/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]), /// which is what the push-v2 registration binds to. /// /// Must authenticate with the *real* password — an app password cannot mint @@ -33,7 +33,9 @@ class GetAppPassword { // Deliberately NOT the shared Authorization value: that one prefers // the app password, but an app password cannot mint another one — // this endpoint requires the real password. - 'Authorization': AccountData().getRealPasswordBasicAuthHeader(), + 'Authorization': SessionManager() + .requireNextcloud() + .realPasswordBasicAuthHeader, }, ), ))!; diff --git a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart index abd85c4..847b6f3 100644 --- a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart +++ b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart @@ -4,8 +4,8 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; +import '../../../session/session_manager.dart'; import '../../errors/parse_exception.dart'; import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; @@ -27,12 +27,12 @@ Uri _coreAvatarUri() { return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); } -Uri _userInfoUri() => - NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); +Uri _userInfoUri() => NextcloudOcs.uri( + 'cloud/users/${SessionManager().requireNextcloud().username}', +); Future _send( - Future Function(Uri uri, Map headers) - perform, + Future Function(Uri uri, Map headers) perform, Uri uri, ) async { final headers = NextcloudOcs.headers(); @@ -98,16 +98,13 @@ class GetUserInfo { try { final root = jsonDecode(response.body) as Map; final data = - (root['ocs'] as Map)['data'] - as Map; + (root['ocs'] as Map)['data'] as Map; return CloudUserInfo( userId: data['id'] as String, displayName: (data['displayname'] as String?) ?? '', ); } catch (e) { - throw ParseException( - technicalDetails: 'Cloud $uri user info parse: $e', - ); + throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e'); } } } diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index c48121f..b127465 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; +import '../../session/session_manager.dart'; /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by /// TalkApi, AutocompleteApi, FileSharingApi. @@ -16,7 +16,7 @@ class NextcloudOcs { static Map headers() => { 'Accept': 'application/json', 'OCS-APIRequest': 'true', - 'Authorization': AccountData().getBasicAuthHeader(), + 'Authorization': SessionManager().requireNextcloud().basicAuthHeader, }; static Uri uri(String pathSuffix, {Map? queryParameters}) { diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 5395706..34fe90b 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -1,7 +1,7 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; +import '../../../session/session_manager.dart'; import '../../api_response.dart'; abstract class WebdavApi { @@ -20,8 +20,8 @@ abstract class WebdavApi { static Future get webdav { // Keyed by user too: two accounts may share a password (no app password // minted), and the client would keep the previous login name. - final secret = - '${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}'; + final nextcloud = SessionManager().requireNextcloud(); + final secret = '${nextcloud.username}:${nextcloud.secret}'; if (_webdav == null || _webdavSecret != secret) { _webdavSecret = secret; _webdav = establishWebdavConnection(); @@ -33,13 +33,13 @@ abstract class WebdavApi { NextcloudClient( Uri.parse('https://${EndpointData().nextcloud().full()}'), // App password preferred — with 2FA the real password is not accepted - // by Nextcloud at all (see AccountData.usesLoginFlow). - password: AccountData().getNextcloudSecret(), - loginName: AccountData().getUsername(), + // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow). + password: SessionManager().requireNextcloud().secret, + loginName: SessionManager().requireNextcloud().username, ).webdav; /// Builds the WebDAV download URL without embedded credentials. Callers must - /// authenticate via the [AccountData.authHeaders] header instead. + /// authenticate via the [NextcloudCredentials.authHeaders] header instead. static String buildWebdavUrl() => - 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; + 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/'; } diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index 7dc4e2a..3118911 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; -import '../../../model/account_data.dart'; +import '../../../session/session.dart'; +import '../../../session/session_manager.dart'; import '../queries/auth_login/auth_login.dart'; import 'device_token_name.dart'; import 'token_storage.dart'; /// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, -/// re-logs in once with the credentials in [AccountData] before retrying. +/// renews the token once before retrying. class MarianumConnectAuthInterceptor extends Interceptor { static const _retriedKey = 'mc_auth_retried'; @@ -87,25 +88,26 @@ class MarianumConnectAuthInterceptor extends Interceptor { } Future _performReLogin() async { - if (!AccountData().isPopulated()) return false; - final username = AccountData().getUsername(); + final session = SessionManager().current; + if (session is! CredentialSession) return false; + final username = session.username; // A background engine (widget task) keeps the account it loaded. When // the app signed that account out, its revoked token answers 401 — a // re-login would mint a fresh token for it into the shared keystore. - if (await AccountData().readStoredUsername() != username) return false; + if (await SessionManager().readStoredUsername() != username) return false; try { await _loginClient.run( username: username, - password: AccountData().getPassword(), + password: session.password, tokenName: await DeviceTokenName.resolve(), ); } catch (_) { - if (await AccountData().readStoredUsername() == username) { + if (await SessionManager().readStoredUsername() == username) { await _tokenStorage.clear(); } return false; } - final stored = await AccountData().readStoredUsername(); + final stored = await SessionManager().readStoredUsername(); if (stored == username) return true; // Signed out during the login: drop the orphaned token, unless another // account already stored its own. diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 355b0bb..311db69 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -1,39 +1,43 @@ import 'dart:developer'; -import '../../../model/account_data.dart'; +import '../../../session/session.dart'; +import '../../../session/session_lifecycle.dart'; +import '../../../session/session_manager.dart'; import '../../errors/auth_exception.dart'; -import '../queries/auth_logout/auth_logout.dart'; import '../queries/auth_verify/auth_verify.dart'; -import 'token_storage.dart'; -/// Background 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. +/// 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. class SessionValidator { static Future probeStored({ required Future Function() onInvalidated, }) async { - if (!AccountData().isPopulated()) return; - // AuthVerify uses its own dio (bypassing the demo interceptor), so a demo - // session must be skipped here or its missing token would 401 into a logout. - if (AccountData().isDemo) return; - final username = AccountData().getUsername(); - final password = AccountData().getPassword(); - final epoch = AccountData().sessionEpoch; + final session = SessionManager().current; + // The probes use their own dio (bypassing the demo interceptor), so a demo + // session must be skipped or its missing token would 401 into a logout. + if (session == null || session.isDemo) return; + final epoch = SessionManager().sessionEpoch; try { - await AuthVerify().run(username: username, password: password); + switch (session) { + case CredentialSession(:final username, :final password): + await AuthVerify().run(username: username, password: password); + } } 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. - if (!AccountData().isCurrentSession(epoch)) return; - log('MC: stored credentials rejected — forcing re-login'); - await AuthLogout().run(); - await const MarianumConnectTokenStorage().clear(); - await AccountData().removeData(); + if (!SessionManager().isCurrentSession(epoch)) return; + log('MC: stored session rejected — forcing re-login'); + 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(); } catch (e) { - log('MC: background credential check failed (transient): $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 1e19f64..24e85c8 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -1,5 +1,8 @@ +import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../errors/auth_exception.dart'; + /// `first_unlock` accessibility so the token can be read during background /// requests (telemetry heartbeat, push-triggered syncs) after the first device /// unlock following a reboot. The keychain default (`whenUnlocked`) throws @@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions( ); /// Persists the Marianum-Connect bearer token in the platform keystore. Kept -/// separate from `AccountData` because the username/password live on (Nextcloud +/// separate from `SessionManager` because the username/password live on (Nextcloud /// + MHSL still need them) while the MC token is short-lived and per-endpoint. class MarianumConnectTokenStorage { static const _tokenKey = 'mc_bearer_token'; @@ -24,6 +27,18 @@ class MarianumConnectTokenStorage { Future readToken() => _storage.read(key: _tokenKey); + /// Request options carrying the stored token, for probes that bypass the + /// auth interceptor. Throws [AuthException] when no token is stored. + Future requireBearerOptions(String caller) async { + final token = await readToken(); + if (token == null || token.isEmpty) { + throw AuthException.unauthorized( + technicalDetails: '$caller: no bearer token in storage', + ); + } + return Options(headers: {'Authorization': 'Bearer $token'}); + } + Future readTokenId() => _storage.read(key: _tokenIdKey); Future readExpiresAt() async { diff --git a/lib/api/marianumconnect/marianumconnect_query.dart b/lib/api/marianumconnect/marianumconnect_query.dart index a69e373..8800dd6 100644 --- a/lib/api/marianumconnect/marianumconnect_query.dart +++ b/lib/api/marianumconnect/marianumconnect_query.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data'; + import 'package:dio/dio.dart'; +import '../errors/app_exception.dart'; import 'errors/marianumconnect_error.dart'; import 'marianumconnect_api.dart'; import 'marianumconnect_endpoint.dart'; @@ -23,10 +26,14 @@ abstract class MarianumConnectQuery { try { return await body(); } on DioException catch (e) { - throw mapMarianumConnectError(e); + throw mapError(e); } } + /// The AppException a failed call surfaces as. Query families with domain + /// error codes override this instead of re-implementing [guard]. + AppException mapError(DioException error) => mapMarianumConnectError(error); + /// GETs [path] and parses the JSON object body with [fromJson]. Future getObject( String path, @@ -55,6 +62,16 @@ abstract class MarianumConnectQuery { .toList(); }); + /// GETs the raw bytes of [path] (files that need the bearer token). + Future getBytes(String path) => guard(() async { + final response = await dio.get>( + endpoint(path), + options: Options(responseType: ResponseType.bytes), + ); + final bytes = response.data!; + return bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + }); + /// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params. String isoDate(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; diff --git a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart index 561bc1e..e09e022 100644 --- a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart +++ b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart @@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery { required String username, required String password, }) async { - final token = await _tokenStorage.readToken(); - if (token == null || token.isEmpty) { - throw AuthException.unauthorized( - technicalDetails: 'AuthVerify: no bearer token in storage', - ); - } + final options = await _tokenStorage.requireBearerOptions('AuthVerify'); return guard(() async { await dio.post( endpoint('auth/verify'), data: {'username': username, 'password': password}, - options: Options(headers: {'Authorization': 'Bearer $token'}), + options: options, ); }); } diff --git a/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart b/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart index ba530ff..0095053 100644 --- a/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart +++ b/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart @@ -1,7 +1,5 @@ import 'dart:typed_data'; -import 'package:dio/dio.dart'; - import '../../marianumconnect_query.dart'; /// Downloads the raw PDF bytes of a Marianum Message from @@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery { GetNewsletterFile(this.id, {super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('newsletter/${Uri.encodeComponent(id)}/file'), - options: Options(responseType: ResponseType.bytes), - ); - return Uint8List.fromList(response.data!); - }); + Future run() => + getBytes('newsletter/${Uri.encodeComponent(id)}/file'); } diff --git a/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart b/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart index 011db55..f95b67f 100644 --- a/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart +++ b/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart @@ -1,7 +1,5 @@ import 'dart:typed_data'; -import 'package:dio/dio.dart'; - import '../../marianumconnect_query.dart'; /// Downloads the raw bytes of a PROXIED_FILE ticker page from @@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery { GetTickerPageFile(this.slug, {super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'), - options: Options(responseType: ResponseType.bytes), - ); - return Uint8List.fromList(response.data!); - }); + Future run() => + getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file'); } diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart index f4504d3..ad89dee 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart @@ -1,7 +1,7 @@ -import 'dart:math'; - import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../../../utils/random_id.dart'; + /// A stable, anonymous per-install identifier for telemetry. Generated once on /// first use (128 bits from a cryptographic RNG) and persisted in the secure /// keystore, so a device stays a single row across password rotations and FCM @@ -21,15 +21,9 @@ class TelemetryDeviceId { _cached = existing; return existing; } - final generated = _generate(); + final generated = randomHexId(); await _storage.write(key: _key, value: generated); _cached = generated; return generated; } - - static String _generate() { - final random = Random.secure(); - final bytes = List.generate(16, (_) => random.nextInt(256)); - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - } } diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index d26e008..8deebb7 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -2,8 +2,8 @@ import 'dart:developer'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../../../model/account_data.dart'; -import '../../../demo/demo_mode.dart'; +import '../../../../session/session.dart'; +import '../../../../session/session_manager.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; @@ -26,28 +26,32 @@ class CustomEventsMigration { const CustomEventsMigration._(); static Future runOnce() async { - if (DemoMode.active) return; + // Only password accounts can derive the legacy identity. + final session = SessionManager().current; + if (session is! CredentialSession || session.isDemo) return; if (await _isDone()) return; - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final response = await GetCustomTimetableEvent( - GetCustomTimetableEventParams(AccountData().getUserSecret()), + GetCustomTimetableEventParams(session.legacyUserSecret), ).run(); for (final event in response.events) { // The POST authenticates with whoever is signed in now; after a // sign-out the previous account's events would land in the next one. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; await TimetableCustomEventsAdd().run(event); await RemoveCustomTimetableEvent( RemoveCustomTimetableEventParams(event.id), ).run(); } - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; await _markDone(); - log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); + log( + 'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.', + ); } catch (e) { // Leave the flag unset so the next launch retries; the delete-after-post // above keeps a partial run duplicate-free. diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index e6c2b03..c797c2e 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import '../model/account_data.dart'; +import '../session/session_manager.dart'; import 'api_response.dart'; import 'cache_store.dart'; import 'errors/parse_exception.dart'; @@ -47,7 +47,7 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final entry = await CacheStore.instance.read(document); var lastUpdate = entry?.lastUpdate ?? 0; @@ -85,7 +85,7 @@ abstract class RequestCache { final newValue = await onLoad(); // The cache is shared, so a late response of a signed-out // account would otherwise be cached for the next one. - if (!AccountData().isCurrentSession(epoch)) { + if (!SessionManager().isCurrentSession(epoch)) { onError(const StaleSessionException()); return; } diff --git a/lib/app.dart b/lib/app.dart index 37d1fae..b8a0fc2 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,7 +10,6 @@ import 'api/marianumconnect/marianumconnect_api.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; -import 'model/account_data.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_service.dart'; @@ -18,6 +17,7 @@ import 'notification/notification_tasks.dart'; import 'push/push_registration.dart'; import 'push/push_tap_router.dart'; import 'routing/app_routes.dart'; +import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; @@ -174,7 +174,7 @@ class _AppState extends State with WidgetsBindingObserver { _timetableWidgetSync = timetable.stream.listen((state) { final data = state.data; if (data is TimetableState && !state.isLoading) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; Debouncer.debounce( 'widgetPublish', const Duration(seconds: 1), @@ -194,7 +194,7 @@ class _AppState extends State with WidgetsBindingObserver { // frames are done (a fresh bloc emit in the meantime supersedes it). final initialData = timetable.state.data; if (initialData is TimetableState) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; Debouncer.debounce( 'widgetPublish', const Duration(seconds: 3), diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 82f71f3..2a465bf 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -18,7 +18,8 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dar import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../model/account_data.dart'; +import '../session/session.dart'; +import '../session/session_manager.dart'; import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_sync.dart'; @@ -81,17 +82,17 @@ class WidgetBackgroundTask { /// Throws on fetch failure so the worker path can signal a retry. static Future runRefreshNow({bool force = false}) async { await WidgetSync.ensureInitialized(); - bool populated; + Session? session; try { // Bounded: a hanging keystore read must not stall the caller's budget // (FCM handler ~25s on iOS) forever. - populated = await AccountData().waitForPopulation().timeout( + session = await SessionManager().waitForLoad().timeout( const Duration(seconds: 10), ); } on TimeoutException { - populated = false; + session = null; } - if (!populated) { + if (session == null) { // Deliberately does NOT flip the widget to logged-out: a failed or slow // keychain read (locked iOS device during the 06:00 silent push) is // indistinguishable from "never logged in" here, and blanking the @@ -105,7 +106,7 @@ class WidgetBackgroundTask { log('[widget-refresh] snapshot is fresh, skipping refresh'); return; } - await _refresh(); + await _refresh(session); } static Future cancelAll() async { @@ -142,7 +143,7 @@ void _callbackDispatcher() { }); } -Future _refresh() async { +Future _refresh(Session session) async { await WidgetSync.ensureInitialized(); // The background isolate doesn't go through main.dart's BlocBuilder, so we // re-apply the endpoint the foreground last persisted. Without this the @@ -181,11 +182,14 @@ Future _refresh() async { final timegridFuture = _runOrNull( () => TimetableGetTimegrid().run(), ); - final customEventsFuture = _runOrNull( - () => GetCustomTimetableEvent( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - ).run(), - ); + final customEventsFuture = switch (session) { + CredentialSession(:final legacyUserSecret) => + _runOrNull( + () => GetCustomTimetableEvent( + GetCustomTimetableEventParams(legacyUserSecret), + ).run(), + ), + }; final timetable = await timetableFuture; final subjects = await subjectsFuture; final rooms = await roomsFuture; diff --git a/lib/main.dart b/lib/main.dart index 6005a26..4d457c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,6 @@ import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; -import 'model/account_data.dart'; import 'model/session_wipe.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; @@ -34,6 +33,7 @@ import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; +import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; @@ -119,7 +119,7 @@ void _installErrorHandlers() { Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); - AccountData().markUiEngine(); + SessionManager().markUiEngine(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); @@ -152,12 +152,12 @@ Future main() async { _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; }), - // The keychain may still be locked right after device unlock; AccountData + // The keychain may still be locked right after device unlock; the session // keeps retrying, so on timeout the app starts on the loading screen and // flips to the real state once the session is readable (see _MainState). _startupStep( 'account data', - AccountData().waitForPopulation, + SessionManager().waitForLoad, timeout: const Duration(seconds: 5), // Expected on every background wake of a locked device; not an error. report: false, @@ -224,7 +224,7 @@ Future main() async { // has data ready by the time the user navigates to it. No-op when a // cached payload is already present, so this does not undo the day-long // root cache TTL. - if (AccountData().isPopulated()) { + if (SessionManager().hasNextcloud) { unawaited( ListFilesCache.prefetchRootListing().onError( (e, _) => log('Files root prefetch failed: $e'), @@ -262,9 +262,9 @@ Future main() async { } AccountStatus _initialAccountStatus() { - final account = AccountData(); - if (account.isPopulated()) return AccountStatus.loggedIn; - return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; + final session = SessionManager(); + if (session.isSignedIn) return AccountStatus.loggedIn; + return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; } class Main extends StatefulWidget { @@ -293,13 +293,13 @@ class _MainState extends State
{ Jiffy.setLocale('de'); _lastStatus = context.read().state.status; - AccountData().waitForPopulation().then((value) { + SessionManager().waitForLoad().then((session) { if (!mounted) return; final accountBloc = context.read(); accountBloc.setStatus( - value ? AccountStatus.loggedIn : AccountStatus.loggedOut, + session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut, ); - if (value) { + 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 @@ -323,7 +323,9 @@ class _MainState extends State
{ void _prefetchBaseData(BuildContext context) { context.read().refresh(); unawaited(context.read().refresh(silent: true)); - unawaited(ListFilesCache.prefetchRootListing()); + if (SessionManager().hasNextcloud) { + unawaited(ListFilesCache.prefetchRootListing()); + } } /// Registers/self-heals the push subscription whenever the backend advertises diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart deleted file mode 100644 index 567b01e..0000000 --- a/lib/model/account_data.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../push/push_secure_storage.dart'; -import '../utils/exponential_backoff.dart'; - -class AccountData { - static const _usernameField = 'username'; - static const _passwordField = 'password'; - // App passwords live in the push-shared (group-scoped) keystore so the iOS - // Notification Service Extension can authenticate Nextcloud calls too. - // The talk password authenticates the second (apptype=talk) push - // registration — Nextcloud binds each push subscription to its session - // token, so two registrations need two app passwords. - static const _appPasswordField = 'nextcloud_app_password'; - static const _appPasswordTalkField = 'nextcloud_app_password_talk'; - // Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA): - // the real password is not valid against Nextcloud, only the flow-issued - // app password is — and no further app passwords can be minted silently. - static const _loginFlowField = 'nextcloud_login_flow'; - // Persists the demo session across cold starts (see DemoMode). - static const _demoField = 'is_demo'; - // Keeps isPopulated()/getPassword() valid; demo mode never uses a real one. - static const _demoPasswordPlaceholder = 'demo'; - - // `first_unlock` so a background launch on a locked device (silent push, - // BGAppRefresh) can still read the session. Items written by older versions - // carry the plugin default `unlocked` and are invisible to this instance - // until _migrateKeychainAccessibility moved them over. - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( - iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), - ); - static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage( - iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked), - ); - static const List _sessionFields = [ - _usernameField, - _passwordField, - _demoField, - _loginFlowField, - ]; - - static final AccountData _instance = AccountData._construct(); - Completer _populated = Completer(); - - factory AccountData() => _instance; - - AccountData._construct() { - unawaited(_loadWithRetry()); - } - - int _sessionEpoch = 0; - - /// Bumped on every sign-out. 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; - - bool isCurrentSession(int epoch) => epoch == _sessionEpoch; - - String? _username; - String? _password; - String? _appPassword; - String? _appPasswordTalk; - bool _isDemo = false; - bool _usesLoginFlow = false; - - /// True while the active session is a local demo session (see DemoMode). - bool get isDemo => _isDemo; - - /// True when the Nextcloud credentials were obtained via Login Flow v2 - /// (browser login, e.g. because the account has two-factor authentication). - /// In that mode the stored real password only authenticates MarianumConnect; - /// every Nextcloud call must use the flow-issued app password. - bool get usesLoginFlow => _usesLoginFlow; - - String getUsername() { - if (_username == null) throw Exception('Username not initialized'); - return _username!; - } - - String getPassword() { - if (_password == null) throw Exception('Password not initialized'); - return _password!; - } - - String getUserSecret() => sha512 - .convert(utf8.encode('${getUsername()}:${getPassword()}')) - .toString(); - - Future setData(String username, String password) async { - await _secureStorage.write(key: _usernameField, value: username); - await _secureStorage.write(key: _passwordField, value: password); - _username = username; - _password = password; - if (!_populated.isCompleted) _populated.complete(); - } - - /// Enters a local demo session for [username] — no real credentials or token; - /// every backend is served from fixtures while [isDemo] is true (see DemoMode). - Future setDemo(String username) async { - await _secureStorage.write(key: _usernameField, value: username); - await _secureStorage.write( - key: _passwordField, - value: _demoPasswordPlaceholder, - ); - await _secureStorage.write(key: _demoField, value: 'true'); - _username = username; - _password = _demoPasswordPlaceholder; - _isDemo = true; - if (!_populated.isCompleted) _populated.complete(); - } - - Future removeData() async { - _sessionEpoch++; - _populated = Completer(); - _username = null; - _password = null; - _appPassword = null; - _appPasswordTalk = null; - _isDemo = false; - _usesLoginFlow = false; - await _secureStorage.delete(key: _usernameField); - await _secureStorage.delete(key: _passwordField); - await _secureStorage.delete(key: _demoField); - await _secureStorage.delete(key: _loginFlowField); - await _clearAppPasswordStorage(); - await _clearAppPasswordTalkStorage(); - } - - /// Persists a freshly minted Nextcloud app password. After this every - /// [getBasicAuthHeader] call authenticates with the app password instead of - /// the real password. - Future setAppPassword(String appPassword) async { - _appPassword = appPassword; - try { - await pushSecureStorage.write(key: _appPasswordField, value: appPassword); - } on Object { - // Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet - // provisioned). Keeping it in memory still lets this session use it. - } - } - - Future clearAppPassword() async { - _appPassword = null; - await _clearAppPasswordStorage(); - } - - /// Adopts an app password obtained via Login Flow v2 and switches the - /// account into flow mode (see [usesLoginFlow]). Any previously stored Talk - /// app password belonged to the old session era and is dropped — the second - /// (optional) flow pass stores a fresh one via [setAppPasswordTalk]. - Future setLoginFlow(String appPassword) async { - await setAppPassword(appPassword); - await clearAppPasswordTalk(); - _usesLoginFlow = true; - await _secureStorage.write(key: _loginFlowField, value: 'true'); - } - - bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty; - - /// Persists the app password backing the Talk push registration. - Future setAppPasswordTalk(String appPassword) async { - _appPasswordTalk = appPassword; - try { - await pushSecureStorage.write( - key: _appPasswordTalkField, - value: appPassword, - ); - } on Object { - // Group-scoped keystore may be unavailable — in-memory still works for - // this session, matching setAppPassword. - } - } - - Future clearAppPasswordTalk() async { - _appPasswordTalk = null; - await _clearAppPasswordTalkStorage(); - } - - bool hasAppPasswordTalk() => - _appPasswordTalk != null && _appPasswordTalk!.isNotEmpty; - - Future _clearAppPasswordStorage() async { - try { - await pushSecureStorage.delete(key: _appPasswordField); - } on Object { - // ignore — nothing stored or keystore unavailable - } - } - - Future _clearAppPasswordTalkStorage() async { - try { - await pushSecureStorage.delete(key: _appPasswordTalkField); - } on Object { - // ignore — nothing stored or keystore unavailable - } - } - - /// iOS keychain reads fail while protected data is unavailable (app launch - /// racing the unlock, background wake on a locked device). Without a retry - /// the completer never resolved and the app stayed on the launch screen. - Future _loadWithRetry() async { - for (var attempt = 1; !_populated.isCompleted; attempt++) { - try { - await _migrateAndLoad(); - return; - } catch (e, s) { - log('AccountData load failed (attempt $attempt): $e', stackTrace: s); - await Future.delayed(exponentialBackoff(attempt)); - } - } - } - - /// Stops waiting for the stored session; the app then behaves as logged - /// out. The keychain entries stay untouched so a later start can still - /// restore the session. - void abandonLoad() { - if (!_populated.isCompleted) _populated.complete(); - } - - Future _migrateAndLoad() async { - await _migrateFromLegacyStorage(); - await _migrateKeychainAccessibility(); - // Independent keystore reads, each a platform-channel round trip with - // decryption: issued together since this gates the first frame. - final (username, password, demo, loginFlow) = await ( - _secureStorage.read(key: _usernameField), - _secureStorage.read(key: _passwordField), - _secureStorage.read(key: _demoField), - _secureStorage.read(key: _loginFlowField), - ).wait; - _username = username; - _password = password; - _isDemo = demo == 'true'; - _usesLoginFlow = loginFlow == 'true'; - try { - final (appPassword, appPasswordTalk) = await ( - pushSecureStorage.read(key: _appPasswordField), - pushSecureStorage.read(key: _appPasswordTalkField), - ).wait; - _appPassword = appPassword; - _appPasswordTalk = appPasswordTalk; - } on Object { - _appPassword = null; - _appPasswordTalk = null; - } - if (!_populated.isCompleted) _populated.complete(); - } - - // 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 { - final prefs = await SharedPreferences.getInstance(); - final legacyUsername = prefs.getString(_usernameField); - final legacyPassword = prefs.getString(_passwordField); - if (legacyUsername == null || legacyPassword == null) return; - - final hasSecure = (await _secureStorage.read(key: _usernameField)) != null; - if (!hasSecure) { - await _secureStorage.write(key: _usernameField, value: legacyUsername); - await _secureStorage.write(key: _passwordField, value: legacyPassword); - } - await prefs.remove(_usernameField); - await prefs.remove(_passwordField); - } - - Future _migrateKeychainAccessibility() async { - if (!Platform.isIOS) return; - for (final field in _sessionFields) { - final value = await _legacySecureStorage.read(key: field); - if (value == null) continue; - // Same account+service: the legacy item has to go before the re-add. - await _legacySecureStorage.delete(key: field); - await _secureStorage.write(key: field, value: value); - } - } - - bool _isUiEngine = false; - - /// Called from `main()`; background entry points never run it. - void markUiEngine() => _isUiEngine = true; - - /// Username currently in the keystore. Other engines (widget task, push - /// isolates) sign out or in without this instance noticing. - Future readStoredUsername() => - _secureStorage.read(key: _usernameField); - - /// 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. - 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. - if (_isUiEngine) return; - try { - final username = await _secureStorage.read(key: _usernameField); - final password = await _secureStorage.read(key: _passwordField); - final isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; - final usesLoginFlow = - (await _secureStorage.read(key: _loginFlowField)) == 'true'; - String? appPassword; - String? appPasswordTalk; - try { - appPassword = await pushSecureStorage.read(key: _appPasswordField); - appPasswordTalk = await pushSecureStorage.read( - key: _appPasswordTalkField, - ); - } on Object { - // Group keystore unavailable: fall back to the real password. - } - if (username != _username) _sessionEpoch++; - _username = username; - _password = password; - _isDemo = isDemo; - _usesLoginFlow = usesLoginFlow; - _appPassword = appPassword; - _appPasswordTalk = appPasswordTalk; - if (!_populated.isCompleted) _populated.complete(); - } on Object catch (e) { - log('AccountData reload failed, keeping loaded state: $e'); - } - } - - Future waitForPopulation() async { - await _populated.future; - return isPopulated(); - } - - /// True once the stored session has been read (or given up on). - bool get isLoaded => _populated.isCompleted; - - bool isPopulated() => _username != null && _password != null; - - /// Returns the value for an HTTP `Authorization` header using HTTP Basic. - /// Prefer this over embedding credentials in URLs — error logs and crash - /// reports often capture the URL but not headers. - String getBasicAuthHeader() { - _requirePopulated(); - // Prefer the scoped app password once available; it survives real-password - // rotation and is what the push-v2 registration is bound to. - return _basicAuth(_appPassword ?? _password!); - } - - /// Basic-auth header using the Talk app password — authenticates the - /// apptype=talk push registration (and its unregister). Throws when the - /// talk password has not been minted yet; callers treat that as a failed - /// talk registration and retry on the next start. - String getTalkBasicAuthHeader() { - _requirePopulated(); - if (!hasAppPasswordTalk()) { - // Login-flow account whose second (talk) flow pass was skipped: no - // silent minting possible, the talk registration shares the single - // flow-issued credential. - if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!); - throw StateError('Talk app password not available yet'); - } - return _basicAuth(_appPasswordTalk!); - } - - /// Basic-auth header that always uses the real password. Needed exactly once, - /// to mint the app password via `core/getapppassword` (an app password cannot - /// mint another). - String getRealPasswordBasicAuthHeader() { - _requirePopulated(); - return _basicAuth(_password!); - } - - /// Secret authenticating against Nextcloud: the app password once available - /// (minted or flow-issued), otherwise the real password. Mirrors the - /// preference of [getBasicAuthHeader] for clients that need the raw secret - /// (WebDAV client construction). - String getNextcloudSecret() { - _requirePopulated(); - return _appPassword ?? _password!; - } - - void _requirePopulated() { - if (!isPopulated()) { - throw Exception( - 'AccountData (e.g. username or password) is not initialized!', - ); - } - } - - String _basicAuth(String secret) => - 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}'; - - /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry - /// header map ready to merge into HTTP request headers. - Map authHeaders() => {'Authorization': getBasicAuthHeader()}; -} diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index a2d1f42..6da0fe7 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:http/http.dart' as http; import '../api/marianumcloud/nextcloud_ocs.dart'; -import '../model/account_data.dart'; import '../notification/notification_service.dart'; +import '../session/session_manager.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_renderer.dart'; @@ -38,7 +38,7 @@ void _plog(String message) { /// Handles Talk notification actions (inline reply, mark-as-read). Runs in the /// background isolate spawned by flutter_local_notifications, so it may not /// share any app state — it reads credentials straight from secure storage via -/// the [AccountData] singleton after awaiting population. +/// the [SessionManager] singleton after awaiting the stored session. /// /// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one /// on [handleBackgroundResponse]: the callback is resolved via @@ -56,14 +56,14 @@ class PushActions { ) async { // The FLN action isolate starts WITHOUT main(): unlike the FCM background // isolate, plugins are not registered automatically there. Without this, - // AccountData's secure-storage/prefs reads throw or never complete → no + // The session's secure-storage/prefs reads throw or never complete → no // auth header, the Talk POST never happens and the RemoteInput spinner // runs forever. DartPluginRegistrant.ensureInitialized(); // The action engine lives as long as the process: without a reload a // reply would be sent with the account that was signed in when the // engine first started. - await AccountData().reloadFromStorage(); + await SessionManager().reloadFromStorage(); _plog( 'action=${response.actionId} payload=${response.payload} ' @@ -129,7 +129,8 @@ class PushActions { /// any) followed by the technical reason. static String actionFailureBody({String? lostText, required String detail}) { return [ - if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“', + if (lostText != null && lostText.isNotEmpty) + 'Deine Nachricht: „$lostText“', 'Grund: $detail', ].join('\n'); } @@ -192,7 +193,10 @@ class PushActions { static Future<({bool ok, String detail})> sendReply( String chatToken, String message, - ) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message}); + ) => _ocsPost( + 'apps/spreed/api/v1/chat/$chatToken', + body: {'message': message}, + ); static Future<({bool ok, String detail})> markRead(String chatToken) => _ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); @@ -204,10 +208,10 @@ class PushActions { try { // Bounded: a hanging population (e.g. keystore issue) must fail the // action instead of leaving the notification spinner running forever. - final populated = await AccountData().waitForPopulation().timeout( + final session = await SessionManager().waitForLoad().timeout( const Duration(seconds: 10), ); - if (!populated) { + if (session?.nextcloud == null) { _plog('Push action $path aborted: credentials unreadable in isolate'); return ( ok: false, diff --git a/lib/push/push_device_info.dart b/lib/push/push_device_info.dart new file mode 100644 index 0000000..8a09cea --- /dev/null +++ b/lib/push/push_device_info.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; + +/// Platform value MarianumConnect expects in push registrations. +String get pushPlatform => Platform.isIOS ? 'ios' : 'android'; + +/// App version sent along with push registrations; null when unavailable. +Future pushAppVersion() async { + try { + return (await PackageInfo.fromPlatform()).version; + } on Object { + return null; + } +} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index f53c11b..9ac324c 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -4,8 +4,8 @@ import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import '../background/widget_background_task.dart'; -import '../model/account_data.dart'; import '../notification/notification_service.dart'; +import '../session/session_manager.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_decryptor.dart'; @@ -56,7 +56,7 @@ PushKind classifyPush(Map data) { @pragma('vm:entry-point') Future pushOnBackgroundMessage(RemoteMessage message) async { // This engine outlives sign-outs and logins in the app. - await AccountData().reloadFromStorage(); + await SessionManager().reloadFromStorage(); await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index e9cd974..eaabb46 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:nextcloud/notifications.dart' show generatePushTokenHash; -import 'package:package_info_plus/package_info_plus.dart'; import '../api/demo/demo_mode.dart'; import '../api/marianumcloud/app_password/delete_app_password.dart'; @@ -11,9 +10,11 @@ import '../api/marianumcloud/app_password/get_app_password.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; -import '../model/account_data.dart'; import '../model/endpoint_data.dart'; +import '../session/nextcloud_credentials.dart'; +import '../session/session_manager.dart'; import 'nextcloud_push_api.dart'; +import 'push_device_info.dart'; import 'push_keypair.dart'; import 'push_registration_store.dart'; import 'push_registration_type.dart'; @@ -48,8 +49,6 @@ class PushRegistration { _store = store ?? const PushRegistrationStore(), _nextcloud = nextcloud ?? NextcloudPushApi(); - String get _platform => Platform.isIOS ? 'ios' : 'android'; - String get _talkUserAgent => Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid; @@ -63,20 +62,26 @@ class PushRegistration { /// slash) — persisted alongside the registration to detect endpoint changes. String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; + NextcloudCredentials? get _nextcloudOrNull => + SessionManager().current?.nextcloud; + /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// registration binds to it, so it must be obtained before registering. Future ensureAppPassword() async { - if (AccountData().hasAppPassword()) return; - if (AccountData().usesLoginFlow) { + final nextcloud = _nextcloudOrNull; + if (nextcloud == null || nextcloud.hasAppPassword) return; + if (nextcloud.usesLoginFlow) { // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt, // stilles Minting ist unmöglich. Reparatur nur interaktiv über // Einstellungen → „Nextcloud neu verbinden". - log('Push: login-flow account without app password, cannot mint silently'); + log( + 'Push: login-flow account without app password, cannot mint silently', + ); return; } try { final appPassword = await GetAppPassword().run(); - await AccountData().setAppPassword(appPassword); + await SessionManager().setAppPassword(appPassword); } on Object catch (e) { log('Push: could not obtain app password (non-blocking): $e'); } @@ -85,15 +90,16 @@ class PushRegistration { /// Ensures the second app password backing the Talk registration exists /// (each `getapppassword` call with the real password mints a fresh one). Future ensureTalkAppPassword() async { - if (AccountData().hasAppPasswordTalk()) return; + final nextcloud = _nextcloudOrNull; + if (nextcloud == null || nextcloud.hasAppPasswordTalk) return; // Flow-Konten können still kein zweites App-Passwort münzen — das // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin // teilt sich die Talk-Registrierung das eine App-Passwort (siehe - // AccountData.getTalkBasicAuthHeader). - if (AccountData().usesLoginFlow) return; + // NextcloudCredentials.talkBasicAuthHeader). + if (nextcloud.usesLoginFlow) return; try { final appPassword = await GetAppPassword().run(); - await AccountData().setAppPasswordTalk(appPassword); + await SessionManager().setAppPasswordTalk(appPassword); } on Object catch (e) { log('Push: could not obtain talk app password (non-blocking): $e'); } @@ -107,7 +113,8 @@ class PushRegistration { /// fire-and-forget (and simply ignore the result). Future register() async { if (DemoMode.active) return false; - final epoch = AccountData().sessionEpoch; + if (_nextcloudOrNull == null) return false; + final epoch = SessionManager().sessionEpoch; final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); @@ -135,16 +142,13 @@ class PushRegistration { return false; } - String? appVersion; - try { - appVersion = (await PackageInfo.fromPlatform()).version; - } on Object { - appVersion = null; - } + final appVersion = await pushAppVersion(); + // Re-read: the ensure* calls above may have swapped the credentials. + final nextcloud = SessionManager().requireNextcloud(); final types = registrationTypesFor( - usesLoginFlow: AccountData().usesLoginFlow, - hasTalkAppPassword: AccountData().hasAppPasswordTalk(), + usesLoginFlow: nextcloud.usesLoginFlow, + hasTalkAppPassword: nextcloud.hasAppPasswordTalk, ); if (!types.contains(PushRegistrationType.general)) { await _recordAttempt( @@ -183,7 +187,7 @@ class PushRegistration { devicePublicKeyPem: pems.publicKeyPem, proxyServer: proxyServer, authorizationHeader: isTalk - ? AccountData().getTalkBasicAuthHeader() + ? SessionManager().requireNextcloud().talkBasicAuthHeader : null, userAgent: isTalk ? _talkUserAgent : null, ); @@ -191,7 +195,7 @@ class PushRegistration { // Signed out while registering: persisting or announcing this // registration would keep delivering the previous account's pushes, // and logoutCleanup already ran so nothing would unregister it. - if (!AccountData().isCurrentSession(epoch)) return false; + if (!SessionManager().isCurrentSession(epoch)) return false; await _store.save( type: type, @@ -207,7 +211,7 @@ class PushRegistration { deviceIdentifierSignature: registration.signature, userPublicKey: registration.publicKey, pushToken: fcmToken, - platform: _platform, + platform: pushPlatform, registrationType: type.wireName, appVersion: appVersion, ); @@ -256,7 +260,7 @@ class PushRegistration { try { final endpoint = EndpointData().nextcloud(); await _store.saveNativeAuthContext( - username: AccountData().getUsername(), + username: SessionManager().requireNextcloud().username, baseUrl: 'https://${endpoint.full()}', ); } on Object catch (e) { @@ -274,7 +278,7 @@ class PushRegistration { // session token — each registration with its own app password. await _nextcloud.unregister( authorizationHeader: type == PushRegistrationType.talk - ? AccountData().getTalkBasicAuthHeader() + ? SessionManager().requireNextcloud().talkBasicAuthHeader : null, ); } on Object catch (e) { @@ -428,7 +432,7 @@ class PushRegistration { /// is called with), then clear them locally. Ordered so the proxy stops /// pushing before credentials are gone. Future logoutCleanup() async { - if (DemoMode.active) return; + if (DemoMode.active || _nextcloudOrNull == null) return; await unregister(); try { await DeleteAppPassword().run(); @@ -436,15 +440,16 @@ class PushRegistration { log('Push: delete app password failed: $e'); } try { - if (AccountData().hasAppPasswordTalk()) { + final nextcloud = SessionManager().requireNextcloud(); + if (nextcloud.hasAppPasswordTalk) { await DeleteAppPassword().run( - authorizationHeader: AccountData().getTalkBasicAuthHeader(), + authorizationHeader: nextcloud.talkBasicAuthHeader, ); } } on Object catch (e) { log('Push: delete talk app password failed: $e'); } - await AccountData().clearAppPassword(); - await AccountData().clearAppPasswordTalk(); + await SessionManager().clearAppPassword(); + await SessionManager().clearAppPasswordTalk(); } } diff --git a/lib/push/push_registration_store.dart b/lib/push/push_registration_store.dart index e5f3a18..3bdc372 100644 --- a/lib/push/push_registration_store.dart +++ b/lib/push/push_registration_store.dart @@ -24,7 +24,7 @@ class PushRegistrationStore { // (reply / mark-as-read) directly via URLSession while the Flutter engine is // not guaranteed to run. It needs the Nextcloud username and base URL from the // shared (group-scoped) keychain; the app password already lives there - // (AccountData writes `nextcloud_app_password` group-scoped). + // (SessionManager writes `nextcloud_app_password` group-scoped). static const _usernameKey = 'nextcloud_username'; static const _baseUrlKey = 'nextcloud_base_url'; // Mirror of the in-app notification toggle (`notificationSettings.enabled`), diff --git a/lib/push/push_secure_storage.dart b/lib/push/push_secure_storage.dart index 0ab50c9..9834edf 100644 --- a/lib/push/push_secure_storage.dart +++ b/lib/push/push_secure_storage.dart @@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions( ); /// Shared secure storage instance for all push key material and registration -/// bookkeeping. Kept separate from [AccountData]'s default storage because the +/// bookkeeping. Kept separate from the session's default storage because the /// entries here are group-scoped for NSE access. const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage( iOptions: kPushIosOptions, diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index 6e5f73e..3537d37 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import '../model/account_data.dart'; +import '../session/session_manager.dart'; import 'push_keypair.dart'; import 'push_registration.dart'; import 'push_registration_store.dart'; @@ -124,14 +124,15 @@ Future collectPushStatus({ lastRegistrationError: await store.lastRegistrationError(type), ); + final nextcloud = SessionManager().current?.nextcloud; return PushStatusReport( settingEnabled: settingEnabled, osPermission: await _osPermission(), serverCapability: !capabilitiesLoaded ? PushCheck.unknown : (capabilityPush ? PushCheck.ok : PushCheck.fail), - appPasswordPresent: AccountData().hasAppPassword(), - talkAppPasswordPresent: AccountData().hasAppPasswordTalk(), + appPasswordPresent: nextcloud?.hasAppPassword ?? false, + talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false, keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false, general: await typeStatus(PushRegistrationType.general), talk: await typeStatus(PushRegistrationType.talk), diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 7d64606..bfb8298 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../main.dart'; -import '../model/account_data.dart'; import '../notification/notification_tasks.dart'; +import '../session/session_manager.dart'; import '../share_intent/pending_share.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/app_modules.dart'; @@ -395,7 +395,10 @@ class AppRoutes { // ChatBloc._loadChat with the freshly-fetched maxId — sending one // here too with the chat list's possibly-stale room.lastMessage.id // would race the fresh one and could regress the server cursor. - context.read().markRoomAsRead(room.token, room.lastMessage.id); + context.read().markRoomAsRead( + room.token, + room.lastMessage.id, + ); NotificationTasks.clearNotificationsForChat(room.token); TalkNavigator.pushSplitView( context, @@ -425,7 +428,8 @@ class AppRoutes { static ResolvedPendingChat? resolvePendingChat(BuildContext context) { final token = pendingChatToken.value; if (token == null) return null; - if (!AccountData().isPopulated()) return null; + final nextcloud = SessionManager().current?.nextcloud; + if (nextcloud == null) return null; final rooms = context.read().state.data?.rooms; final room = _findRoomByToken(rooms, token); @@ -438,7 +442,7 @@ class AppRoutes { ); return ResolvedPendingChat( room: room, - selfId: AccountData().getUsername(), + selfId: nextcloud.username, avatar: avatar, ); } diff --git a/lib/session/nextcloud_credentials.dart b/lib/session/nextcloud_credentials.dart new file mode 100644 index 0000000..aecd59e --- /dev/null +++ b/lib/session/nextcloud_credentials.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +/// Nextcloud identity of a session. Immutable; the session manager swaps in a +/// new instance whenever an app password is minted or revoked. +class NextcloudCredentials { + final String username; + + /// The real account password. Invalid against Nextcloud when + /// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works. + final String password; + final String? appPassword; + + /// Backs the second (apptype=talk) push registration — Nextcloud binds each + /// push subscription to its session token, so two registrations need two + /// app passwords. + final String? appPasswordTalk; + + /// True when the credentials came from Login Flow v2 (browser login, e.g. + /// because the account has two-factor authentication). + final bool usesLoginFlow; + + const NextcloudCredentials({ + required this.username, + required this.password, + this.appPassword, + this.appPasswordTalk, + this.usesLoginFlow = false, + }); + + bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty; + + bool get hasAppPasswordTalk => + appPasswordTalk != null && appPasswordTalk!.isNotEmpty; + + /// The app password once available (minted or flow-issued), otherwise the + /// real password. It survives real-password rotation and is what the push + /// registration is bound to. + String get secret => hasAppPassword ? appPassword! : password; + + /// HTTP Basic header value. Prefer headers over credentials in URLs — error + /// logs and crash reports often capture the URL but not headers. + String get basicAuthHeader => _basicAuth(secret); + + Map get authHeaders => {'Authorization': basicAuthHeader}; + + /// Authenticates the apptype=talk push registration (and its unregister). + /// Throws when the talk password has not been minted yet; callers treat that + /// as a failed talk registration and retry on the next start. + String get talkBasicAuthHeader { + if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!); + // Login-flow account whose second (talk) flow pass was skipped: no silent + // minting possible, the talk registration shares the flow credential. + if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!); + throw StateError('Talk app password not available yet'); + } + + /// Always the real password. Needed to mint the app password via + /// `core/getapppassword` — an app password cannot mint another. + String get realPasswordBasicAuthHeader => _basicAuth(password); + + NextcloudCredentials copyWith({ + String? Function()? appPassword, + String? Function()? appPasswordTalk, + bool? usesLoginFlow, + }) => NextcloudCredentials( + username: username, + password: password, + appPassword: appPassword != null ? appPassword() : this.appPassword, + appPasswordTalk: appPasswordTalk != null + ? appPasswordTalk() + : this.appPasswordTalk, + usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow, + ); + + String _basicAuth(String secret) => + 'Basic ${base64Encode(utf8.encode('$username:$secret'))}'; +} diff --git a/lib/session/session.dart b/lib/session/session.dart new file mode 100644 index 0000000..955ac38 --- /dev/null +++ b/lib/session/session.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +import 'nextcloud_credentials.dart'; + +/// The signed-in account. Exactly one session is active at a time; features +/// check for the backend identities they need ([nextcloud]) instead of +/// assuming every account has all of them. +sealed class Session { + /// Local demo session: every backend is served from fixtures (see DemoMode). + final bool isDemo; + + const Session({this.isDemo = false}); + + /// Nextcloud identity, or null for accounts without one. + NextcloudCredentials? get nextcloud; +} + +/// Student, teacher or staff account: username + password, backed by +/// MarianumConnect and Nextcloud (with the same username and password). +final class CredentialSession extends Session { + @override + final NextcloudCredentials nextcloud; + + CredentialSession({ + required String username, + required String password, + String? appPassword, + String? appPasswordTalk, + bool usesLoginFlow = false, + super.isDemo, + }) : nextcloud = NextcloudCredentials( + username: username, + password: password, + appPassword: appPassword, + appPasswordTalk: appPasswordTalk, + usesLoginFlow: usesLoginFlow, + ); + + const CredentialSession._(this.nextcloud, {super.isDemo}); + + String get username => nextcloud.username; + + String get password => nextcloud.password; + + CredentialSession withNextcloud(NextcloudCredentials nextcloud) => + CredentialSession._(nextcloud, isDemo: isDemo); + + /// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom + /// events migration. + String get legacyUserSecret => + sha512.convert(utf8.encode('$username:$password')).toString(); +} + +/// Thrown when a Nextcloud-only feature is reached with a session that has no +/// Nextcloud identity. Indicates a missing gate, not a user error. +class NextcloudUnavailableException implements Exception { + const NextcloudUnavailableException(); + + @override + String toString() => + 'NextcloudUnavailableException: session has no Nextcloud account'; +} diff --git a/lib/session/session_codec.dart b/lib/session/session_codec.dart new file mode 100644 index 0000000..92c9146 --- /dev/null +++ b/lib/session/session_codec.dart @@ -0,0 +1,54 @@ +import 'session.dart'; + +/// Keychain keys of the session. Names are frozen: installed versions and the +/// iOS AppDelegate/NSE read them, so renaming would log every user out. +abstract final class SessionKeys { + static const username = 'username'; + static const password = 'password'; + static const appPassword = 'nextcloud_app_password'; + static const appPasswordTalk = 'nextcloud_app_password_talk'; + static const loginFlow = 'nextcloud_login_flow'; + static const demo = 'is_demo'; + + // Absent on installs from before account kinds — see [decodeSession]. + static const kind = 'session_kind'; + + static const kindCredential = 'credential'; +} + +/// Rebuilds the session from raw keychain values. Installs from before +/// account kinds carry no [SessionKeys.kind]; a stored username and password +/// then mean a credential session, so existing users stay signed in. +Session? decodeSession(Map raw) { + final isDemo = raw[SessionKeys.demo] == 'true'; + switch (raw[SessionKeys.kind]) { + case null: + case SessionKeys.kindCredential: + final username = raw[SessionKeys.username]; + final password = raw[SessionKeys.password]; + if (username == null || password == null) return null; + return CredentialSession( + username: username, + password: password, + appPassword: raw[SessionKeys.appPassword], + appPasswordTalk: raw[SessionKeys.appPasswordTalk], + usesLoginFlow: raw[SessionKeys.loginFlow] == 'true', + isDemo: isDemo, + ); + default: + // Written by a newer app version; unknown here, treat as signed out. + return null; + } +} + +/// Keychain values for [session], excluding the group-scoped app passwords +/// (written separately so the iOS NSE can read them). `null` = delete. +Map encodeSessionFields(Session session) => switch (session) { + CredentialSession() => { + SessionKeys.kind: SessionKeys.kindCredential, + SessionKeys.username: session.username, + SessionKeys.password: session.password, + SessionKeys.demo: session.isDemo ? 'true' : null, + SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null, + }, +}; diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart new file mode 100644 index 0000000..0526f0d --- /dev/null +++ b/lib/session/session_lifecycle.dart @@ -0,0 +1,29 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; +import '../push/push_registration.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. + static final ValueNotifier signOutNotice = ValueNotifier(null); + + /// 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 { + signOutNotice.value = notice; + try { + await PushRegistration().logoutCleanup(); + } on Object catch (e) { + log('Sign-out: push cleanup failed: $e'); + } + await AuthLogout().run(); + await SessionManager().signOut(); + } +} diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart new file mode 100644 index 0000000..44c17e8 --- /dev/null +++ b/lib/session/session_manager.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../push/push_secure_storage.dart'; +import '../utils/exponential_backoff.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. +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 + // carry the plugin default `unlocked` and are invisible to this instance + // until _migrateKeychainAccessibility moved them over. + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked), + ); + static const List _sessionFields = [ + SessionKeys.kind, + SessionKeys.username, + SessionKeys.password, + SessionKeys.demo, + SessionKeys.loginFlow, + ]; + + static final SessionManager _instance = SessionManager._(); + factory SessionManager() => _instance; + + SessionManager._() { + unawaited(_loadWithRetry()); + } + + Completer _loaded = Completer(); + Session? _current; + + Session? get current => _current; + + int _sessionEpoch = 0; + + /// Bumped whenever the signed-in account changes. 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; + + bool isCurrentSession(int epoch) => epoch == _sessionEpoch; + + bool _isUiEngine = false; + + /// Called from `main()`; background entry points never run it. + void markUiEngine() => _isUiEngine = true; + + bool get isSignedIn => _current != null; + + bool get isDemo => _current?.isDemo ?? false; + + /// Whether the session has a Nextcloud identity (Talk, Files, NC push). + bool get hasNextcloud => _current?.nextcloud != null; + + /// True once the stored session has been read (or given up on). + bool get isLoaded => _loaded.isCompleted; + + /// Resolves once the stored session is known. After [signOut] it stays + /// pending until the next sign-in. + Future waitForLoad() async { + await _loaded.future; + return _current; + } + + /// Stops waiting for the stored session; the app then behaves as signed + /// out. The keychain entries stay untouched so a later start can still + /// restore the session. + void abandonLoad() { + if (!_loaded.isCompleted) _loaded.complete(); + } + + NextcloudCredentials requireNextcloud() => + _current?.nextcloud ?? (throw const NextcloudUnavailableException()); + + /// Replaces any stored session completely; no prior [signOut] needed. + Future signIn(Session session) async { + if (!_isSameAccount(_current, session)) _sessionEpoch++; + await Future.wait([ + for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) + _writeSecret(key, value), + _writeGroupSecret( + SessionKeys.appPassword, + session.nextcloud?.appPassword, + ), + _writeGroupSecret( + SessionKeys.appPasswordTalk, + session.nextcloud?.appPasswordTalk, + ), + ]); + _current = session; + if (!_loaded.isCompleted) _loaded.complete(); + } + + Future signOut() async { + _sessionEpoch++; + _loaded = Completer(); + _current = null; + await Future.wait([ + for (final field in _sessionFields) _secureStorage.delete(key: field), + _writeGroupSecret(SessionKeys.appPassword, null), + _writeGroupSecret(SessionKeys.appPasswordTalk, null), + ]); + } + + 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, + }; + + /// Persists a freshly minted Nextcloud app password; from then on every + /// Nextcloud call authenticates with it instead of the real password. + Future setAppPassword(String appPassword) async { + _updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword)); + await _writeGroupSecret(SessionKeys.appPassword, appPassword); + } + + Future clearAppPassword() async { + _updateNextcloud((nc) => nc.copyWith(appPassword: () => null)); + await _writeGroupSecret(SessionKeys.appPassword, null); + } + + /// Adopts an app password obtained via Login Flow v2 and switches the + /// account into flow mode. A previously stored Talk app password belonged + /// to the old session era and is dropped — the second (optional) flow pass + /// stores a fresh one via [setAppPasswordTalk]. + Future setLoginFlow(String appPassword) async { + await setAppPassword(appPassword); + await clearAppPasswordTalk(); + _updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true)); + await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true'); + } + + Future setAppPasswordTalk(String appPassword) async { + _updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword)); + await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword); + } + + Future clearAppPasswordTalk() async { + _updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null)); + await _writeGroupSecret(SessionKeys.appPasswordTalk, null); + } + + void _updateNextcloud( + NextcloudCredentials Function(NextcloudCredentials) update, + ) { + final session = _current; + if (session is CredentialSession) { + _current = session.withNextcloud(update(session.nextcloud)); + } + } + + Future _writeSecret(String key, String? value) => value == null + ? _secureStorage.delete(key: key) + : _secureStorage.write(key: key, value: value); + + // App passwords live in the push-shared (group-scoped) keystore so the iOS + // Notification Service Extension can authenticate Nextcloud calls too. That + // keystore may be unavailable (entitlement not provisioned); the in-memory + // copy still serves this session. + Future _writeGroupSecret(String key, String? value) async { + try { + if (value == null) { + await pushSecureStorage.delete(key: key); + } else { + await pushSecureStorage.write(key: key, value: value); + } + } on Object { + // ignore — see above + } + } + + /// iOS keychain reads fail while protected data is unavailable (app launch + /// racing the unlock, background wake on a locked device). Without a retry + /// the completer never resolved and the app stayed on the launch screen. + Future _loadWithRetry() async { + for (var attempt = 1; !_loaded.isCompleted; attempt++) { + try { + await _migrateAndLoad(); + return; + } catch (e, s) { + log('Session load failed (attempt $attempt): $e', stackTrace: s); + await Future.delayed(exponentialBackoff(attempt)); + } + } + } + + Future _migrateAndLoad() async { + await _migrateFromLegacyStorage(); + await _migrateKeychainAccessibility(); + _current = await _readActive(); + if (!_loaded.isCompleted) _loaded.complete(); + } + + /// The session in the active slots. On the startup critical path (and every + /// background wake): read in parallel instead of one keychain round-trip + /// after the other. + Future _readActive() async { + final values = await Future.wait( + _sessionFields.map((field) => _secureStorage.read(key: field)), + ); + final raw = Map.fromIterables(_sessionFields, values); + try { + final (appPassword, appPasswordTalk) = await ( + pushSecureStorage.read(key: SessionKeys.appPassword), + pushSecureStorage.read(key: SessionKeys.appPasswordTalk), + ).wait; + raw[SessionKeys.appPassword] = appPassword; + raw[SessionKeys.appPasswordTalk] = appPasswordTalk; + } on Object { + // Group keystore unavailable: fall back to the real password. + } + return decodeSession(raw); + } + + /// Username currently in the keystore. Other engines (widget task, push + /// isolates) sign out or in 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. + 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. + if (_isUiEngine) return; + try { + final session = await _readActive(); + if (!_isSameAccount(_current, session)) _sessionEpoch++; + _current = session; + if (!_loaded.isCompleted) _loaded.complete(); + } on Object catch (e) { + log('Session reload failed, keeping loaded state: $e'); + } + } + + // 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 { + final prefs = await SharedPreferences.getInstance(); + final legacyUsername = prefs.getString(SessionKeys.username); + final legacyPassword = prefs.getString(SessionKeys.password); + if (legacyUsername == null || legacyPassword == null) return; + + final hasSecure = + (await _secureStorage.read(key: SessionKeys.username)) != null; + if (!hasSecure) { + await _secureStorage.write( + key: SessionKeys.username, + value: legacyUsername, + ); + await _secureStorage.write( + key: SessionKeys.password, + value: legacyPassword, + ); + } + await prefs.remove(SessionKeys.username); + await prefs.remove(SessionKeys.password); + } + + Future _migrateKeychainAccessibility() async { + if (!Platform.isIOS) return; + final legacyValues = await Future.wait( + _sessionFields.map((field) => _legacySecureStorage.read(key: field)), + ); + for (final (i, field) in _sessionFields.indexed) { + final value = legacyValues[i]; + if (value == null) continue; + // Same account+service: the legacy item has to go before the re-add. + await _legacySecureStorage.delete(key: field); + await _secureStorage.write(key: field, value: value); + } + } +} 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 78df01d..c09ea2a 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 @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/stale_session_exception.dart'; -import '../../../../../model/account_data.dart'; +import '../../../../../session/session_manager.dart'; import '../../../../../utils/session_single_flight.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; @@ -132,12 +132,12 @@ abstract class LoadableHydratedBloc< /// its async continuations, are dropped once the account signed out, so a /// late response of the previous account cannot refill the reset bloc. R runInSession(R Function() body) => - runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch}); + runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch}); @override void add(LoadableHydratedBlocEvent event) { final epoch = Zone.current[_sessionKey]; - if (epoch is int && !AccountData().isCurrentSession(epoch)) return; + if (epoch is int && !SessionManager().isCurrentSession(epoch)) return; super.add(event); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 1bc141b..a7a516c 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; -import '../../../../../model/account_data.dart'; +import '../../../../../session/session_manager.dart'; import 'capabilities_state.dart'; /// Holds the current user's mobile capability flags. Hydrated so the last @@ -35,12 +35,12 @@ class CapabilitiesCubit extends HydratedCubit { emit(DemoCapabilities.state()); return; } - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final response = await GetCapabilities().run(); // A slow answer for the previous account must not decide the modules // of the next one. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; emit( CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index c2e34be..af51c92 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart'; -import '../../../../../model/account_data.dart'; +import '../../../../../session/session_manager.dart'; import 'nextcloud_capabilities_state.dart'; /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so @@ -60,16 +60,17 @@ class NextcloudCapabilitiesCubit /// Refreshes capabilities from the server. On any failure the previously /// hydrated flags are kept but the state is marked `loaded`. Future load() async { + if (!SessionManager().hasNextcloud) return; if (DemoMode.active) { emit(DemoNextcloudCapabilities.state()); return; } - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final caps = await GetNextcloudCapabilities().run(); // A slow answer for the previous account must not decide the modules // of the next one. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; emit( NextcloudCapabilitiesState( apiEnabled: caps.apiEnabled, diff --git a/lib/utils/downloads/download_manager.dart b/lib/utils/downloads/download_manager.dart index 7b3959a..d7f2605 100644 --- a/lib/utils/downloads/download_manager.dart +++ b/lib/utils/downloads/download_manager.dart @@ -6,8 +6,8 @@ import 'package:background_downloader/background_downloader.dart' as bd; import 'package:flutter/foundation.dart'; import '../../api/marianumcloud/webdav/webdav_api.dart'; -import '../../model/account_data.dart'; import '../../notification/notification_service.dart'; +import '../../session/session_manager.dart'; import '../../share_intent/remote_file_ref.dart'; import 'download_job.dart'; @@ -116,7 +116,7 @@ class DownloadManager { final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); final task = bd.DownloadTask( url: '${WebdavApi.buildWebdavUrl()}$encodedPath', - headers: AccountData().authHeaders(), + headers: SessionManager().requireNextcloud().authHeaders, filename: name, baseDirectory: bd.BaseDirectory.temporary, directory: _directory, diff --git a/lib/utils/random_id.dart b/lib/utils/random_id.dart new file mode 100644 index 0000000..ffbb21a --- /dev/null +++ b/lib/utils/random_id.dart @@ -0,0 +1,19 @@ +import 'dart:math'; + +/// Random hex id from a cryptographic RNG, e.g. for per-install identifiers. +String randomHexId({int bytes = 16}) { + final random = Random.secure(); + return List.generate( + bytes, + (_) => random.nextInt(256), + ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form. +String randomUuidV4() { + final hex = randomHexId(); + final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-' + '${hex.substring(20)}'; +} diff --git a/lib/utils/session_single_flight.dart b/lib/utils/session_single_flight.dart index 5ee8d2e..ad159988 100644 --- a/lib/utils/session_single_flight.dart +++ b/lib/utils/session_single_flight.dart @@ -1,4 +1,4 @@ -import '../model/account_data.dart'; +import '../session/session_manager.dart'; /// Joins concurrent calls into the one already running for the same account /// session. A run left over from a signed-out session never blocks the next @@ -8,7 +8,7 @@ class SessionSingleFlight { int? _epoch; Future run(Future Function() action) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; final running = _running; if (running != null && _epoch == epoch) return running; _epoch = epoch; diff --git a/lib/view/login/account_loading_screen.dart b/lib/view/login/account_loading_screen.dart index 649f175..944ae05 100644 --- a/lib/view/login/account_loading_screen.dart +++ b/lib/view/login/account_loading_screen.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../model/account_data.dart'; +import '../../session/session_manager.dart'; import '../../theming/light_app_theme.dart'; import '../../widget/app_progress_indicator.dart'; @@ -61,7 +61,7 @@ class _AccountLoadingScreenState extends State { ), const SizedBox(height: 8), TextButton( - onPressed: AccountData().abandonLoad, + onPressed: SessionManager().abandonLoad, style: TextButton.styleFrom(foregroundColor: Colors.white), child: const Text('Zur Anmeldung'), ), diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 8b0262e..f559768 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../background/widget_background_task.dart'; +import '../../session/session_lifecycle.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'; @@ -11,6 +12,7 @@ import '../../storage/dev_tools_settings.dart'; import '../../storage/settings.dart' as model; import '../../theming/light_app_theme.dart'; import '../../utils/haptics.dart'; +import '../../widget/info_dialog.dart'; import '../pages/settings/widgets/endpoint_picker.dart'; import 'login_controller.dart'; import 'post_login_splash.dart'; @@ -34,6 +36,21 @@ class _LoginState extends State with SingleTickerProviderStateMixin { value: 1, ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _showSignOutNotice()); + } + + /// An involuntary sign-out (expired token, rotated password) otherwise just + /// drops the user here without a word. + void _showSignOutNotice() { + final notice = SessionLifecycle.signOutNotice.value; + if (notice == null || !mounted) return; + SessionLifecycle.signOutNotice.value = null; + InfoDialog.show(context, notice, title: 'Erneut anmelden'); + } + @override void didChangeDependencies() { super.didChangeDependencies(); diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index ee6c419..f3c781b 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -10,8 +10,9 @@ import '../../api/marianumconnect/auth/device_token_name.dart'; import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; -import '../../model/account_data.dart'; import '../../model/session_wipe.dart'; +import '../../session/session.dart'; +import '../../session/session_manager.dart'; import '../../widget_data/widget_sync.dart'; /// Outcome of a login attempt. @@ -56,25 +57,18 @@ class LoginController extends ChangeNotifier { // Demo login: the prefix enters local demo mode, password ignored, no // network (see DemoMode). if (DemoMode.matches(user)) { - await AccountData().removeData(); - await const MarianumConnectTokenStorage().clear(); - await WidgetSync.clear(); - await WidgetSync.triggerUpdate(); - await AccountData().setDemo(user); + await _discardPreviousAccount(); + await SessionManager().signIn( + CredentialSession(username: user, password: 'demo', isDemo: true), + ); _loading = false; notifyListeners(); return LoginResult.success; } + var signedIn = false; try { - await AccountData().removeData(); - // Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes - // Account hätte sonst noch einen aktiven Token in api_tokens. - await const MarianumConnectTokenStorage().clear(); - // Widget-Snapshot löschen, sonst blitzt nach Account-Wechsel kurz der - // Stundenplan des vorigen Users auf dem Home-Bildschirm. - await WidgetSync.clear(); - await WidgetSync.triggerUpdate(); + await _discardPreviousAccount(); // AuthLogin = Credential-Probe + Token-Create in einem Call. // 401 hier heißt: falsches Passwort. await AuthLogin().run( @@ -82,7 +76,10 @@ class LoginController extends ChangeNotifier { password: password, tokenName: await DeviceTokenName.resolve(), ); - await AccountData().setData(user, password); + await SessionManager().signIn( + CredentialSession(username: user, password: password), + ); + signedIn = true; // Mint the Nextcloud app password now — it doubles as the Nextcloud // credential probe: a rejection means 2FA is active (or the NC password // diverges) and the login must finish interactively in the browser. @@ -92,7 +89,9 @@ class LoginController extends ChangeNotifier { return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired; } catch (e) { log(e.toString()); - await AccountData().removeData(); + // Only the account signed in by this attempt; while adding an account + // the active one is parked and restored on cancel. + if (signedIn) await SessionManager().signOut(); await const MarianumConnectTokenStorage().clear(); final isWrongCredentials = e is AuthException && e.statusCode == 401; _errorMessage = isWrongCredentials @@ -105,6 +104,17 @@ class LoginController extends ChangeNotifier { } } + /// Vorherigen Token verwerfen, bevor ein neuer angefordert wird, und den + /// Widget-Snapshot löschen — sonst blitzt nach einem Account-Wechsel kurz + /// der Stundenplan des vorigen Users auf dem Home-Bildschirm. Die Session + /// selbst überschreibt signIn vollständig; beim Hinzufügen eines Kontos + /// liegt der Token des aktiven Kontos schon in dessen Tresor. + Future _discardPreviousAccount() async { + await const MarianumConnectTokenStorage().clear(); + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); + } + /// Tries to mint the Nextcloud app password with the just-verified password. /// `false` = Nextcloud rejected the credentials → Login Flow v2 required. /// Transport/server problems stay non-blocking (like the previous @@ -112,7 +122,7 @@ class LoginController extends ChangeNotifier { Future _prepareNextcloudAppPassword() async { try { final appPassword = await GetAppPassword().run(); - await AccountData().setAppPassword(appPassword); + await SessionManager().setAppPassword(appPassword); return true; } on AuthException { return false; @@ -131,7 +141,7 @@ class LoginController extends ChangeNotifier { } on Object catch (e) { log('Login rollback: MC logout failed: $e'); } - await AccountData().removeData(); + await SessionManager().signOut(); await const MarianumConnectTokenStorage().clear(); _errorMessage = 'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.'; diff --git a/lib/view/login/nextcloud_login_flow_page.dart b/lib/view/login/nextcloud_login_flow_page.dart index 57d1aed..08f5ba0 100644 --- a/lib/view/login/nextcloud_login_flow_page.dart +++ b/lib/view/login/nextcloud_login_flow_page.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/app_password/delete_app_password.dart'; import '../../api/marianumcloud/login_flow/login_flow_api.dart'; -import '../../model/account_data.dart'; +import '../../session/session_manager.dart'; import '../../utils/url_opener.dart'; import '../../widget/app_progress_indicator.dart'; @@ -19,7 +19,7 @@ enum _FlowStep { primary, talk } /// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the /// user confirmed it there (2FA happens inside the browser) and adopts the -/// returned app password via [AccountData.setLoginFlow]. A second, skippable +/// returned app password via [SessionManager.setLoginFlow]. A second, skippable /// pass mints the Talk app password so flow accounts keep BOTH push /// subscriptions (see PushRegistrationType). Pops `true` once the primary /// credential was adopted, `false`/`null` when the user backs out before that. @@ -104,7 +104,7 @@ class _NextcloudLoginFlowPageState extends State final credentials = await _api.poll(flow); if (credentials == null || _finished || !mounted) return; if (!LoginFlowApi.loginNameMatches( - expected: AccountData().getUsername(), + expected: SessionManager().requireNextcloud().username, actual: credentials.loginName, )) { _timer?.cancel(); @@ -120,7 +120,7 @@ class _NextcloudLoginFlowPageState extends State } switch (_step) { case _FlowStep.primary: - await AccountData().setLoginFlow(credentials.appPassword); + await SessionManager().setLoginFlow(credentials.appPassword); if (!mounted) return; // Zweite Freigabe direkt anstoßen: die Browser-Session besteht // bereits, es fehlt nur noch der Grant-Tipp. @@ -129,7 +129,7 @@ class _NextcloudLoginFlowPageState extends State case _FlowStep.talk: _finished = true; _timer?.cancel(); - await AccountData().setAppPasswordTalk(credentials.appPassword); + await SessionManager().setAppPasswordTalk(credentials.appPassword); if (!mounted) return; Navigator.of(context).pop(true); } diff --git a/lib/view/pages/files/widgets/file_leading.dart b/lib/view/pages/files/widgets/file_leading.dart index 3053ac4..8eb4fb2 100644 --- a/lib/view/pages/files/widgets/file_leading.dart +++ b/lib/view/pages/files/widgets/file_leading.dart @@ -2,8 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; -import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; import '../data/file_type_icon.dart'; /// Leading slot for a file row: shows the Nextcloud thumbnail when the @@ -35,7 +35,7 @@ class FileLeading extends StatelessWidget { 'https://${EndpointData().nextcloud().full()}' '/index.php/core/preview' '?fileId=$fileId&x=128&y=128&a=0', - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, fit: BoxFit.cover, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index 1d226de..d9ee475 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -7,7 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; -import '../../../model/account_data.dart'; +import '../../../session/session_manager.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_favorites_settings.dart'; import '../../../utils/haptics.dart'; @@ -56,7 +56,7 @@ class _ElementPickerPageState extends State { } Future> _loadFor(TimetableElementType type) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; final cached = _futures[type]; if (cached != null && cached.epoch == epoch && diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart index f11bdb6..f67959f 100644 --- a/lib/view/pages/settings/diagnostics_page.dart +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -13,10 +13,11 @@ import '../../../api/marianumconnect/marianumconnect_endpoint.dart'; import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart'; import '../../../background/widget_background_task.dart'; import '../../../extensions/date_time.dart'; -import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; import '../../../push/push_registration_store.dart'; import '../../../push/push_registration_type.dart'; +import '../../../session/session.dart'; +import '../../../session/session_manager.dart'; import '../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../utils/clipboard_helper.dart'; @@ -44,7 +45,8 @@ class _DiagnosticsPageState extends State { Future> _collect() async { final settings = context.read().val(); final accountStatus = context.read().state.status; - final account = AccountData(); + final session = SessionManager().current; + final nextcloud = session?.nextcloud; const tokens = MarianumConnectTokenStorage(); final push = PushRegistrationStore(); @@ -101,13 +103,17 @@ class _DiagnosticsPageState extends State { title: 'Sitzung', rows: await rows({ 'Status': () async => accountStatus.name, - 'Benutzer': () async => - account.isPopulated() ? account.getUsername() : null, - 'Demo-Modus': () async => account.isDemo, - 'Login Flow v2': () async => account.usesLoginFlow, - 'App-Passwort (Dateien)': () async => account.hasAppPassword(), - 'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(), - 'Session-Epoche': () async => account.sessionEpoch, + 'Benutzer': () async => switch (session) { + CredentialSession(:final username) => username, + null => null, + }, + 'Demo-Modus': () async => session?.isDemo ?? false, + 'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false, + 'App-Passwort (Dateien)': () async => + nextcloud?.hasAppPassword ?? false, + 'App-Passwort (Talk)': () async => + nextcloud?.hasAppPasswordTalk ?? false, + 'Session-Epoche': () async => SessionManager().sessionEpoch, 'MC-Token-ID': tokens.readTokenId, 'MC-Token läuft ab': tokens.readExpiresAt, }), diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 5b82b30..cfb9d8b 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart'; -import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; -import '../../../../model/account_data.dart'; import '../../../../push/push_registration.dart'; 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/app_progress_indicator.dart'; @@ -84,13 +84,17 @@ class _AccountSectionState extends State { setState(() => _avatarBusy = false); if (!ok) return; - invalidateAvatarCache(id: AccountData().getUsername(), isGroup: false); + invalidateAvatarCache( + id: SessionManager().requireNextcloud().username, + isGroup: false, + ); setState(() => _avatarVersion++); } @override Widget build(BuildContext context) { - final username = AccountData().getUsername(); + final nextcloud = SessionManager().requireNextcloud(); + final username = nextcloud.username; final displayName = _displayName; final theme = Theme.of(context); @@ -175,7 +179,7 @@ class _AccountSectionState extends State { // Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen // sich still über das App-Passwort-Minting und sollen von dem ganzen // Flow-Mechanismus nichts mitbekommen. - if (!AccountData().isDemo && AccountData().usesLoginFlow) + if (!SessionManager().isDemo && nextcloud.usesLoginFlow) AsyncListTile( leading: const Icon(Icons.cloud_sync_outlined), title: const Text('Nextcloud neu verbinden'), @@ -203,7 +207,7 @@ class _AccountSectionState extends State { Future _showLogoutDialog(BuildContext context) async { // Flip AccountBloc state only after the dialog fully closes: doing it from - // inside removeData (the previous approach) raced AsyncDialogAction's + // 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( @@ -219,14 +223,8 @@ class _AccountSectionState extends State { context.read().setStatus(AccountStatus.loggedOut); } - // Ordered teardown: unregister push at Nextcloud + proxy and revoke the app - // password (while Nextcloud credentials are still available), THEN revoke the - // MC bearer token, and finally wipe local credentials. Each step is - // best-effort so an offline logout still reaches a clean local state. Future _performLogout() async { - await PushRegistration().logoutCleanup(); - await AuthLogout().run(); - await AccountData().removeData(); + await SessionLifecycle.signOut(); _cachedDisplayName = null; } } diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 0f6de2e..ae69133 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; -import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../utils/emoji_detection.dart'; import '../../../../utils/url_opener.dart'; import '../widgets/highlighted_linkify.dart'; @@ -105,7 +105,7 @@ class ChatMessage { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, errorListener: (value) {}, - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, imageUrl: 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', ), diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index b5d036c..24ace16 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart'; -import '../../../../model/account_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/emoji_text.dart'; import '../../../../widget/loading_spinner.dart'; @@ -63,10 +63,10 @@ class _MessageReactionsState extends State { leading: CenteredLeading(EmojiText(entry.key)), title: Text('${entry.value.length} mal reagiert'), children: entry.value.map((e) { - final isSelf = AccountData().getUsername() == e.actorId; + final isSelf = + SessionManager().requireNextcloud().username == e.actorId; final isGuest = - e.actorType == - GetReactionsResponseObjectActorType.guests; + e.actorType == GetReactionsResponseObjectActorType.guests; return ListTile( leading: UserAvatar(id: e.actorId, isGroup: false), title: Text(e.actorDisplayName), diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index 3397b0e..25324df 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -2,7 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; -import '../../../../model/account_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../widget/user_avatar.dart'; import '../data/open_direct_chat.dart'; @@ -36,7 +36,7 @@ class ParticipantsListView extends StatelessWidget { (participant) => participant.participantType, ); - final selfId = AccountData().getUsername(); + final selfId = SessionManager().requireNextcloud().username; return Scaffold( appBar: AppBar(title: const Text('Mitglieder')), body: ListView( diff --git a/lib/view/pages/talk/details/shared_items_view.dart b/lib/view/pages/talk/details/shared_items_view.dart index 191f243..f288b4a 100644 --- a/lib/view/pages/talk/details/shared_items_view.dart +++ b/lib/view/pages/talk/details/shared_items_view.dart @@ -9,8 +9,8 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_ove import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/date_time.dart'; -import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../utils/downloads/download_job.dart'; import '../../../../widget/app_progress_indicator.dart'; @@ -54,9 +54,10 @@ class SharedItemsPage { const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore); } -List _fileItems(List items) => items - .where((item) => item.messageParameters?['file']?.path != null) - .toList(); +List _fileItems(List items) => + items + .where((item) => item.messageParameters?['file']?.path != null) + .toList(); SharedItemsPage buildSharedItemsPage( GetSharedItemsResponse response, @@ -140,7 +141,9 @@ class _SharedItemsViewState extends State Future _load() async { setState(() => _error = null); try { - final overview = await SharedItemsView.prefetchOverview(widget.room.token); + final overview = await SharedItemsView.prefetchOverview( + widget.room.token, + ); if (!mounted) return; _overview = overview; _prepareTabs(); @@ -501,7 +504,10 @@ class _SharedItemTileState extends State<_SharedItemTile> if (isDownloading) { confirmCancelDownload(); } else { - startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file)); + startDownload( + name: _file.name, + remoteFile: RemoteFileRef.fromTalk(_file), + ); } } @@ -533,7 +539,7 @@ class _SharedItemTileState extends State<_SharedItemTile> children: [ CachedNetworkImage( imageUrl: _previewUrl, - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, fit: BoxFit.cover, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 69cf23e..1aac3f5 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -9,9 +9,9 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../extensions/date_time.dart'; -import '../../../../model/account_data.dart'; import '../../../../notification/notification_tasks.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../session/session_manager.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../utils/haptics.dart'; @@ -53,13 +53,9 @@ class _ChatTileState extends State { @override void initState() { super.initState(); - AccountData().waitForPopulation().then((_) { + SessionManager().waitForLoad().then((session) { if (!mounted) return; - setState( - () => selfUsername = AccountData().isPopulated() - ? AccountData().getUsername() - : null, - ); + setState(() => selfUsername = session?.nextcloud?.username); }); } diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index fab6e68..593cb38 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -5,7 +5,7 @@ import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart'; import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart'; -import '../../../../model/account_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; @@ -186,7 +186,7 @@ class _PollOptionsListState extends State { Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) { final canClose = poll.canClose( - selfId: AccountData().getUsername(), + selfId: SessionManager().requireNextcloud().username, participantType: widget.room.participantType, ); if (!_isInteractive && !canClose) return const SizedBox.shrink(); diff --git a/lib/widget/file_viewer/unknown_preview_block.dart b/lib/widget/file_viewer/unknown_preview_block.dart index af297de..5386464 100644 --- a/lib/widget/file_viewer/unknown_preview_block.dart +++ b/lib/widget/file_viewer/unknown_preview_block.dart @@ -1,8 +1,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; +import '../../session/session_manager.dart'; import '../../share_intent/remote_file_ref.dart'; import '../app_progress_indicator.dart'; @@ -67,7 +67,7 @@ class _UnknownPreviewBlockState extends State { width: _previewSize, height: _previewSize, child: CachedNetworkImage( - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, imageUrl: _ncPreviewUrl(remote, width: 360), fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 5556d6c..f9f0f29 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -8,9 +8,9 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; import '../api/http_errors.dart'; -import '../model/account_data.dart'; import '../model/endpoint_data.dart'; import '../push/push_avatar.dart'; +import '../session/session_manager.dart'; import 'a11y/a11y_labels.dart'; import 'avatar_disk_cache.dart'; @@ -162,7 +162,9 @@ Future _fetchAvatarPayload(String url) async { () => http.get( Uri.parse(url), headers: { - ...AccountData().authHeaders(), + // User avatars are public in Nextcloud, so they also load while the + // active session has no Nextcloud identity. + ...?SessionManager().current?.nextcloud?.authHeaders, 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ), @@ -368,7 +370,6 @@ class _UserAvatarState extends State { return listEquals(a.bytes, b.bytes); } - @override Widget build(BuildContext context) { final radius = widget.size.toDouble(); diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index d087234..6dd8f39 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -4,7 +4,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../model/account_data.dart'; +import '../session/session_manager.dart'; import '../state/app/modules/timetable/bloc/timetable_state.dart'; import '../storage/settings.dart'; import 'widget_data_mapper.dart'; @@ -48,7 +48,7 @@ class WidgetPublisher { bool isTeacher = false, int? epoch, }) { - final sessionEpoch = epoch ?? AccountData().sessionEpoch; + final sessionEpoch = epoch ?? SessionManager().sessionEpoch; return _queue = _queue.then( (_) => _publish( state, @@ -65,7 +65,7 @@ class WidgetPublisher { required bool isTeacher, required int epoch, }) async { - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; @@ -106,7 +106,7 @@ class WidgetPublisher { ); // A publish still running at sign-out would put the previous account's // plan back onto the just cleared widget. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; // Most bloc emits (week swipes, prefetches) don't touch the widget's // window; skip the SharedPreferences commits and widget re-render then. final signature = jsonEncode([ diff --git a/test/session/session_codec_test.dart b/test/session/session_codec_test.dart new file mode 100644 index 0000000..6846ff6 --- /dev/null +++ b/test/session/session_codec_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/session/nextcloud_credentials.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/session/session_codec.dart'; + +String _basic(String user, String secret) => + 'Basic ${base64Encode(utf8.encode('$user:$secret'))}'; + +void main() { + group('decodeSession – installs from before account kinds', () { + test('username + password without a kind is a credential session', () { + final session = decodeSession({ + 'username': 'max', + 'password': 'pw', + 'nextcloud_app_password': 'app', + 'nextcloud_app_password_talk': 'talk', + }); + expect(session, isA()); + final credential = session! as CredentialSession; + expect(credential.username, 'max'); + expect(credential.password, 'pw'); + expect(credential.isDemo, isFalse); + expect(credential.nextcloud.appPassword, 'app'); + expect(credential.nextcloud.appPasswordTalk, 'talk'); + expect(credential.nextcloud.usesLoginFlow, isFalse); + }); + + test('login-flow and demo flags are carried over', () { + final session = + decodeSession({ + 'username': 'demo@x', + 'password': 'demo', + 'is_demo': 'true', + 'nextcloud_login_flow': 'true', + })! + as CredentialSession; + expect(session.isDemo, isTrue); + expect(session.nextcloud.usesLoginFlow, isTrue); + }); + + test('missing password means signed out', () { + expect(decodeSession({'username': 'max'}), isNull); + expect(decodeSession({}), isNull); + }); + }); + + group('decodeSession – kinds', () { + test('an unknown kind from a newer app version reads as signed out', () { + expect( + decodeSession({ + 'session_kind': 'something-new', + 'username': 'max', + 'password': 'pw', + }), + isNull, + ); + }); + }); + + group('encodeSessionFields', () { + test('round-trips a credential session', () { + final original = CredentialSession( + username: 'max', + password: 'pw', + usesLoginFlow: true, + ); + final decoded = + decodeSession(encodeSessionFields(original))! as CredentialSession; + expect(decoded.username, 'max'); + expect(decoded.password, 'pw'); + expect(decoded.nextcloud.usesLoginFlow, isTrue); + }); + }); + + group('NextcloudCredentials', () { + const base = NextcloudCredentials(username: 'max', password: 'pw'); + + test('prefers the app password once available', () { + expect(base.basicAuthHeader, _basic('max', 'pw')); + final withApp = base.copyWith(appPassword: () => 'app'); + expect(withApp.basicAuthHeader, _basic('max', 'app')); + expect(withApp.secret, 'app'); + expect(withApp.realPasswordBasicAuthHeader, _basic('max', 'pw')); + }); + + test('talk header needs its own app password', () { + expect(() => base.talkBasicAuthHeader, throwsStateError); + final withTalk = base.copyWith(appPasswordTalk: () => 't'); + expect(withTalk.talkBasicAuthHeader, _basic('max', 't')); + }); + + test('login-flow accounts share the flow password for talk', () { + final flow = base.copyWith( + appPassword: () => 'flow', + usesLoginFlow: true, + ); + expect(flow.talkBasicAuthHeader, _basic('max', 'flow')); + }); + + test('copyWith can clear an app password', () { + final cleared = base + .copyWith(appPassword: () => 'app') + .copyWith(appPassword: () => null); + expect(cleared.hasAppPassword, isFalse); + expect(cleared.secret, 'pw'); + }); + }); +}