diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index cc301e9..b78ac97 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -36,7 +36,15 @@ class MarianumConnectAuthInterceptor extends Interceptor { // Token mitschicken statt ein eigenes 401 einzufangen. final pending = _pendingReLogin; if (pending != null) await pending; - final token = await _tokenStorage.readToken(); + // Reading the keystore can throw while the device is locked (iOS + // errSecInteractionNotAllowed on background requests). Degrade to an + // unauthenticated request instead of surfacing a platform error. + String? token; + try { + token = await _tokenStorage.readToken(); + } catch (_) { + token = null; + } if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index f5f00e8..1e19f64 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -1,5 +1,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.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 +/// `-25308 errSecInteractionNotAllowed` when the device is locked. +const IOSOptions _mcIosOptions = IOSOptions( + accessibility: KeychainAccessibility.first_unlock, +); + /// 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. @@ -11,7 +19,7 @@ class MarianumConnectTokenStorage { final FlutterSecureStorage _storage; const MarianumConnectTokenStorage([ - this._storage = const FlutterSecureStorage(), + this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions), ]); Future readToken() => _storage.read(key: _tokenKey); diff --git a/lib/main.dart b/lib/main.dart index a1641c7..ee2880c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -121,10 +121,19 @@ Future main() async { ), ); + // Diagnostic log only. getToken() can fail transiently during cold start + // (Android: "IOException: FCM Registration failed!" from Play Services, + // iOS: apns-token-not-set until APNS registration completes), so tolerate + // the failure instead of letting it surface as an uncaught async error that + // gets reported to the telemetry backend as noise. unawaited( - FirebaseMessaging.instance.getToken().then( - (token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'), - ), + FirebaseMessaging.instance + .getToken() + .then( + (token) => + log('Firebase token: ${token ?? "Error: no Firebase token!"}'), + ) + .onError((e, _) => log('Firebase token unavailable: $e')), ); // Warm up the Nextcloud root listing in the background while the user is diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 8ba8a3a..3172a91 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -57,14 +56,6 @@ class AccountData { .convert(utf8.encode('${getUsername()}:${getPassword()}')) .toString(); - Future getDeviceId() async => sha512 - .convert( - utf8.encode( - '${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}', - ), - ) - .toString(); - Future setData(String username, String password) async { await _secureStorage.write(key: _usernameField, value: username); await _secureStorage.write(key: _passwordField, value: password); diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index ed88004..925980c 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -9,7 +9,7 @@ class DataCleaner { .get(); cacheData?.forEach((key, value) async { final lastUpdate = DateTime.fromMillisecondsSinceEpoch( - value['lastupdate'] as int, + ((value['lastupdate'] as num?) ?? 0).toInt(), ); if (DateTime.now() .subtract(const Duration(days: 200)) diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index edcdeda..9c07d3b 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -27,8 +27,12 @@ class LoadableStateBloc extends Bloc } }); - void emitConnectivity(List result) => - add(ConnectivityChanged(LoadableStateState(connections: result))); + void emitConnectivity(List result) { + // The initial checkConnectivity() future is not cancellable and may + // resolve after the bloc was disposed, so guard against a closed sink. + if (isClosed) return; + add(ConnectivityChanged(LoadableStateState(connections: result))); + } Connectivity().checkConnectivity().then(emitConnectivity); _updateStream = Connectivity().onConnectivityChanged.listen( @@ -48,10 +52,10 @@ class LoadableStateBloc extends Bloc // Re-check connectivity so the resulting [ConnectivityChanged] handler // clears a stale error bar and triggers [reFetch] once reachable again. unawaited( - Connectivity().checkConnectivity().then( - (result) => - add(ConnectivityChanged(LoadableStateState(connections: result))), - ), + Connectivity().checkConnectivity().then((result) { + if (isClosed) return; + add(ConnectivityChanged(LoadableStateState(connections: result))); + }), ); }