diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f698..e7f284f 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
+
diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart
index a5db580..34f125d 100644
--- a/lib/api/errors/error_mapper.dart
+++ b/lib/api/errors/error_mapper.dart
@@ -6,13 +6,11 @@ import 'package:http/http.dart' as http;
import '../api_error.dart';
import '../marianumcloud/talk/talk_error.dart';
-import '../webuntis/webuntis_error.dart';
import 'app_exception.dart';
import 'network_exception.dart';
import 'parse_exception.dart';
import 'server_exception.dart';
import 'talk_exception.dart';
-import 'webuntis_exception.dart';
const String _defaultFallback =
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
@@ -57,7 +55,6 @@ 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 WebuntisError) return WebuntisException(error).userMessage;
if (error is DioException) {
final mapped = _dioToAppException(error);
@@ -90,7 +87,6 @@ String? errorToTechnicalDetails(Object? error) {
if (error == null) return null;
if (error is AppException) return error.technicalDetails ?? error.toString();
if (error is TalkError) return TalkException(error).technicalDetails;
- if (error is WebuntisError) return WebuntisException(error).technicalDetails;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart
deleted file mode 100644
index c09f48a..0000000
--- a/lib/api/errors/webuntis_exception.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-import '../webuntis/webuntis_error.dart';
-import 'app_exception.dart';
-
-class WebuntisException extends AppException {
- final WebuntisError source;
-
- WebuntisException(this.source)
- : super(
- userMessage: _mapMessage(source),
- technicalDetails: 'WebUntis (${source.code}): ${source.message}',
- allowRetry: true,
- );
-
- static String _mapMessage(WebuntisError e) {
- switch (e.code) {
- case -8504:
- case -8502:
- return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.';
- case -8520:
- return 'Bitte melde dich erneut an.';
- case -7004:
- return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.';
- case -32601:
- return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.';
- default:
- return e.message.isNotEmpty
- ? 'WebUntis: ${e.message}'
- : 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).';
- }
- }
-}
diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart
new file mode 100644
index 0000000..cc301e9
--- /dev/null
+++ b/lib/api/marianumconnect/auth/auth_interceptor.dart
@@ -0,0 +1,110 @@
+import 'package:dio/dio.dart';
+
+import '../../../model/account_data.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.
+class MarianumConnectAuthInterceptor extends Interceptor {
+ static const _retriedKey = 'mc_auth_retried';
+
+ final MarianumConnectTokenStorage _tokenStorage;
+ final Dio _retryDio;
+ final AuthLogin _loginClient;
+
+ // Single-flight lock: parallel 401s share the same login Future instead of
+ // each spawning a fresh row in api_tokens.
+ Future? _pendingReLogin;
+
+ MarianumConnectAuthInterceptor({
+ MarianumConnectTokenStorage tokenStorage =
+ const MarianumConnectTokenStorage(),
+ Dio? retryDio,
+ AuthLogin? loginClient,
+ }) : _tokenStorage = tokenStorage,
+ _retryDio = retryDio ?? Dio(),
+ _loginClient = loginClient ?? AuthLogin();
+
+ @override
+ Future onRequest(
+ RequestOptions options,
+ RequestInterceptorHandler handler,
+ ) async {
+ // Wait for an in-flight re-login so nachrückende Requests den frischen
+ // Token mitschicken statt ein eigenes 401 einzufangen.
+ final pending = _pendingReLogin;
+ if (pending != null) await pending;
+ final token = await _tokenStorage.readToken();
+ if (token != null && token.isNotEmpty) {
+ options.headers['Authorization'] = 'Bearer $token';
+ }
+ handler.next(options);
+ }
+
+ @override
+ Future onError(
+ DioException err,
+ ErrorInterceptorHandler handler,
+ ) async {
+ final response = err.response;
+ if (response?.statusCode != 401 ||
+ err.requestOptions.extra[_retriedKey] == true) {
+ handler.next(err);
+ return;
+ }
+ final refreshed = await _attemptReLogin();
+ if (!refreshed) {
+ handler.next(err);
+ return;
+ }
+ try {
+ final retried = await _retryWithFreshToken(err.requestOptions);
+ handler.resolve(retried);
+ } on DioException catch (retryError) {
+ handler.next(retryError);
+ }
+ }
+
+ Future _attemptReLogin() {
+ final inFlight = _pendingReLogin;
+ if (inFlight != null) return inFlight;
+ final fresh = _performReLogin();
+ _pendingReLogin = fresh;
+ fresh.whenComplete(() {
+ if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
+ });
+ return fresh;
+ }
+
+ Future _performReLogin() async {
+ if (!AccountData().isPopulated()) return false;
+ try {
+ await _loginClient.run(
+ username: AccountData().getUsername(),
+ password: AccountData().getPassword(),
+ tokenName: await DeviceTokenName.resolve(),
+ );
+ return true;
+ } catch (_) {
+ await _tokenStorage.clear();
+ return false;
+ }
+ }
+
+ Future> _retryWithFreshToken(
+ RequestOptions originalOptions,
+ ) async {
+ final freshToken = await _tokenStorage.readToken();
+ final headers = Map.of(originalOptions.headers);
+ if (freshToken != null && freshToken.isNotEmpty) {
+ headers['Authorization'] = 'Bearer $freshToken';
+ }
+ final clone = originalOptions.copyWith(
+ headers: headers,
+ extra: {...originalOptions.extra, _retriedKey: true},
+ );
+ return _retryDio.fetch(clone);
+ }
+}
diff --git a/lib/api/marianumconnect/auth/device_token_name.dart b/lib/api/marianumconnect/auth/device_token_name.dart
new file mode 100644
index 0000000..eeb672a
--- /dev/null
+++ b/lib/api/marianumconnect/auth/device_token_name.dart
@@ -0,0 +1,41 @@
+import 'dart:io';
+
+import 'package:device_info_plus/device_info_plus.dart';
+
+/// Bearer-token display name shown in the dashboard token list, in the form
+/// `"Marianum Fulda App (Pixel 10)"`. Cached because device-info never
+/// changes at runtime.
+class DeviceTokenName {
+ static const String _appName = 'Marianum Fulda App';
+
+ static String? _cached;
+
+ static Future resolve() async {
+ if (_cached != null) return _cached!;
+ final device = await _deviceLabel();
+ _cached = device.isEmpty ? _appName : '$_appName ($device)';
+ return _cached!;
+ }
+
+ static Future _deviceLabel() async {
+ try {
+ final info = DeviceInfoPlugin();
+ if (Platform.isAndroid) {
+ final android = await info.androidInfo;
+ final model = android.model.trim();
+ return model.isNotEmpty ? model : android.device.trim();
+ }
+ if (Platform.isIOS) {
+ final ios = await info.iosInfo;
+ // utsname.machine bleibt auch ohne user-zugewiesenen Gerätenamen
+ // verfügbar; ios.name liefert auf iOS 16+ nur noch Generika.
+ final machine = ios.utsname.machine.trim();
+ if (machine.isNotEmpty) return machine;
+ return ios.name.trim();
+ }
+ } catch (_) {
+ // Device-Plugin nicht verfügbar (z.B. Tests).
+ }
+ return '';
+ }
+}
diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart
new file mode 100644
index 0000000..bea519d
--- /dev/null
+++ b/lib/api/marianumconnect/auth/session_validator.dart
@@ -0,0 +1,32 @@
+import 'dart:developer';
+
+import '../../../model/account_data.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.
+class SessionValidator {
+ static Future probeStored({
+ required Future Function() onInvalidated,
+ }) async {
+ if (!AccountData().isPopulated()) return;
+ final username = AccountData().getUsername();
+ final password = AccountData().getPassword();
+ try {
+ await AuthVerify().run(username: username, password: password);
+ } on AuthException catch (e) {
+ if (e.statusCode != 401) return;
+ log('MC: stored credentials rejected — forcing re-login');
+ await AuthLogout().run();
+ await const MarianumConnectTokenStorage().clear();
+ await AccountData().removeData();
+ await onInvalidated();
+ } catch (e) {
+ log('MC: background credential check failed (transient): $e');
+ }
+ }
+}
diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart
new file mode 100644
index 0000000..f5f00e8
--- /dev/null
+++ b/lib/api/marianumconnect/auth/token_storage.dart
@@ -0,0 +1,45 @@
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+
+/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
+/// separate from `AccountData` 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';
+ static const _tokenIdKey = 'mc_token_id';
+ static const _expiresAtKey = 'mc_token_expires_at';
+
+ final FlutterSecureStorage _storage;
+
+ const MarianumConnectTokenStorage([
+ this._storage = const FlutterSecureStorage(),
+ ]);
+
+ Future readToken() => _storage.read(key: _tokenKey);
+
+ Future readTokenId() => _storage.read(key: _tokenIdKey);
+
+ Future readExpiresAt() async {
+ final raw = await _storage.read(key: _expiresAtKey);
+ if (raw == null || raw.isEmpty) return null;
+ return DateTime.tryParse(raw);
+ }
+
+ Future write({
+ required String token,
+ required String tokenId,
+ required DateTime? expiresAt,
+ }) async {
+ await _storage.write(key: _tokenKey, value: token);
+ await _storage.write(key: _tokenIdKey, value: tokenId);
+ await _storage.write(
+ key: _expiresAtKey,
+ value: expiresAt?.toIso8601String() ?? '',
+ );
+ }
+
+ Future clear() async {
+ await _storage.delete(key: _tokenKey);
+ await _storage.delete(key: _tokenIdKey);
+ await _storage.delete(key: _expiresAtKey);
+ }
+}
diff --git a/lib/api/marianumconnect/errors/marianumconnect_error.dart b/lib/api/marianumconnect/errors/marianumconnect_error.dart
new file mode 100644
index 0000000..4160cc4
--- /dev/null
+++ b/lib/api/marianumconnect/errors/marianumconnect_error.dart
@@ -0,0 +1,49 @@
+import 'package:dio/dio.dart';
+
+import '../../errors/app_exception.dart';
+import '../../errors/auth_exception.dart';
+import '../../errors/network_exception.dart';
+import '../../errors/parse_exception.dart';
+import '../../errors/server_exception.dart';
+
+/// Converts a DioException raised against the Marianum-Connect API into one of
+/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
+/// that just want to render an error message.
+AppException mapMarianumConnectError(DioException error) {
+ switch (error.type) {
+ case DioExceptionType.connectionTimeout:
+ case DioExceptionType.sendTimeout:
+ case DioExceptionType.receiveTimeout:
+ return NetworkException.timeout(technicalDetails: error.message);
+ case DioExceptionType.connectionError:
+ return NetworkException(technicalDetails: error.message);
+ case DioExceptionType.badCertificate:
+ return const NetworkException(
+ userMessage:
+ 'Die sichere Verbindung zum Marianum-Connect-Server wurde abgelehnt.',
+ );
+ case DioExceptionType.badResponse:
+ final status = error.response?.statusCode ?? -1;
+ if (status == 401) {
+ return AuthException.unauthorized(
+ technicalDetails: 'MC 401: ${error.response?.data}',
+ );
+ }
+ if (status == 403) {
+ return AuthException.forbidden(
+ technicalDetails: 'MC 403: ${error.response?.data}',
+ );
+ }
+ return ServerException(
+ statusCode: status,
+ technicalDetails: 'MC HTTP $status: ${error.response?.data}',
+ );
+ case DioExceptionType.cancel:
+ case DioExceptionType.unknown:
+ final inner = error.error;
+ if (inner is FormatException) {
+ return ParseException(technicalDetails: inner.message);
+ }
+ return NetworkException(technicalDetails: error.message);
+ }
+}
diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart
new file mode 100644
index 0000000..0d7c7bc
--- /dev/null
+++ b/lib/api/marianumconnect/marianumconnect_api.dart
@@ -0,0 +1,30 @@
+import 'package:dio/dio.dart';
+
+import 'auth/auth_interceptor.dart';
+
+/// Singleton dio instance for the Marianum-Connect mobile API. Wired with the
+/// bearer auth interceptor at startup; the base URL is resolved per request
+/// through [MarianumConnectEndpoint] so settings changes take effect without
+/// recreating the client.
+class MarianumConnectApi {
+ static const Duration _connectTimeout = Duration(seconds: 10);
+ static const Duration _receiveTimeout = Duration(seconds: 20);
+
+ static final Dio _instance = _build();
+
+ static Dio dio() => _instance;
+
+ static Dio _build() {
+ final dio = Dio(
+ BaseOptions(
+ connectTimeout: _connectTimeout,
+ sendTimeout: _connectTimeout,
+ receiveTimeout: _receiveTimeout,
+ responseType: ResponseType.json,
+ contentType: 'application/json',
+ ),
+ );
+ dio.interceptors.add(MarianumConnectAuthInterceptor());
+ return dio;
+ }
+}
diff --git a/lib/api/marianumconnect/marianumconnect_endpoint.dart b/lib/api/marianumconnect/marianumconnect_endpoint.dart
new file mode 100644
index 0000000..8a8c68c
--- /dev/null
+++ b/lib/api/marianumconnect/marianumconnect_endpoint.dart
@@ -0,0 +1,22 @@
+import '../../storage/dev_tools_settings.dart';
+
+/// Singleton holding the currently active Marianum-Connect base URL. Fed by a
+/// SettingsCubit listener in app.dart so every dio call picks up endpoint
+/// changes without holding a reference to the cubit.
+class MarianumConnectEndpoint {
+ static String _baseUrl = DevToolsSettings.liveUrl;
+
+ static String current() => _baseUrl;
+
+ static void update(String baseUrl) {
+ _baseUrl = baseUrl;
+ }
+
+ /// Joins the base URL with the mobile API prefix and the given path.
+ static String resolve(String relativePath) {
+ final path = relativePath.startsWith('/')
+ ? relativePath.substring(1)
+ : relativePath;
+ return '$_baseUrl/api/mobile/v1/$path';
+ }
+}
diff --git a/lib/api/marianumconnect/queries/auth_login/auth_login.dart b/lib/api/marianumconnect/queries/auth_login/auth_login.dart
new file mode 100644
index 0000000..7b37304
--- /dev/null
+++ b/lib/api/marianumconnect/queries/auth_login/auth_login.dart
@@ -0,0 +1,61 @@
+import 'package:dio/dio.dart';
+
+import '../../auth/token_storage.dart';
+import '../../errors/marianumconnect_error.dart';
+import '../../marianumconnect_endpoint.dart';
+import 'auth_login_response.dart';
+
+/// Performs the Marianum-Connect bearer login. Used both by the foreground
+/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
+/// run through the shared dio instance — that one has the interceptor, which
+/// would attempt to re-auth us into a loop if our credentials are wrong.
+class AuthLogin {
+ static const Duration _connectTimeout = Duration(seconds: 10);
+ static const Duration _receiveTimeout = Duration(seconds: 15);
+
+ final MarianumConnectTokenStorage _tokenStorage;
+ final Dio _dio;
+
+ AuthLogin({
+ MarianumConnectTokenStorage tokenStorage =
+ const MarianumConnectTokenStorage(),
+ Dio? dio,
+ }) : _tokenStorage = tokenStorage,
+ _dio =
+ dio ??
+ Dio(
+ BaseOptions(
+ connectTimeout: _connectTimeout,
+ receiveTimeout: _receiveTimeout,
+ sendTimeout: _connectTimeout,
+ responseType: ResponseType.json,
+ contentType: 'application/json',
+ ),
+ );
+
+ Future run({
+ required String username,
+ required String password,
+ required String tokenName,
+ }) async {
+ try {
+ final response = await _dio.post