import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../api/marianumconnect/auth/token_storage.dart'; import '../push/push_secure_storage.dart'; import '../utils/exponential_backoff.dart'; import '../utils/random_id.dart'; import 'account_codec.dart'; import 'nextcloud_credentials.dart'; import 'session.dart'; import 'session_codec.dart'; /// Owns the active [Session] and its persistence. One instance per isolate; /// the widget background isolate reads the same keychain. /// /// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC /// token and the group-keychain app passwords always hold the *active* one /// (native code and the background isolate read only those). Inactive /// accounts are parked in a per-account vault entry. class SessionManager { // `first_unlock` so a background launch on a locked device (silent push, // BGAppRefresh) can still read the session. Items written by older versions // 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.guardianEmail, SessionKeys.demo, SessionKeys.loginFlow, ]; static const _indexKey = 'accounts_index'; static String _vaultKey(String id) => 'account_vault_$id'; static const _tokenStorage = MarianumConnectTokenStorage(); static final SessionManager _instance = SessionManager._(); factory SessionManager() => _instance; SessionManager._() { unawaited(_loadWithRetry()); } Completer _loaded = Completer(); Session? _current; Session? get current => _current; /// Every signed-in account and which one is active. final ValueNotifier accounts = ValueNotifier( AccountIndex.empty, ); AccountEntry? get activeAccount => accounts.value.active; bool get isSignedIn => _current != null; bool get isDemo => _current?.isDemo ?? false; /// 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(); } /// Bumped when the server rejects a token that cannot be renewed silently /// (passwordless accounts). The app confirms via [SessionValidator] before /// signing out, so a transient 401 does not cost the session. final ValueNotifier unauthorizedSignal = ValueNotifier(0); void reportUnauthorized() => unauthorizedSignal.value++; NextcloudCredentials requireNextcloud() => _current?.nextcloud ?? (throw const NextcloudUnavailableException()); /// Makes [session] the active account, replacing the active slots /// completely. An account signed in before keeps its entry; call /// [stashActive] first so the previously active one stays switchable. Future signIn(Session session) async { await _writeActive(session); await _saveIndex( accounts.value.activate( session, newId: randomHexId(bytes: 8), now: _now(), ), ); if (!_loaded.isCompleted) _loaded.complete(); } Future _writeActive(Session session) async { 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; } /// Wipes the active slots and forgets the active account. Other accounts /// stay in their vaults; see [activate]. Future signOut() async { final removed = activeAccount; _loaded = Completer(); _current = null; await Future.wait([ for (final field in _sessionFields) _secureStorage.delete(key: field), _writeGroupSecret(SessionKeys.appPassword, null), _writeGroupSecret(SessionKeys.appPasswordTalk, null), if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)), ]); if (removed != null) await _saveIndex(accounts.value.remove(removed.id)); return removed; } /// Parks the active account (session, app passwords, MC token) in its vault /// so the slots can be taken over by another account. Future stashActive() async { final session = _current; final entry = activeAccount; if (session == null || entry == null) return; final fields = { ...sessionVaultFields(session), ...await _tokenStorage.readAll(), }; await _secureStorage.write( key: _vaultKey(entry.id), value: encodeVault(fields), ); } /// Loads the vault of [id] into the active slots. The active account must /// have been stashed before, or its session is lost. Future activate(String id) async { final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); final session = decodeSession(fields); if (session == null) throw StateError('No stored session for account $id'); await _writeActive(session); await _tokenStorage.writeAll(fields); await _saveIndex(accounts.value.select(id, now: _now())); if (!_loaded.isCompleted) _loaded.complete(); } /// Remembers the real name of the active account for the account list. Future setDisplayName(String displayName) async { final entry = activeAccount; if (entry == null || entry.displayName == displayName) return; await _saveIndex(accounts.value.rename(entry.id, displayName)); } /// Session and MC token of an inactive account, e.g. to revoke them. Future<(Session?, Map)> readVault(String id) async { final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); return (decodeSession(fields), fields); } /// Drops an inactive account without touching the active slots. Future forget(String id) async { await _secureStorage.delete(key: _vaultKey(id)); await _saveIndex(accounts.value.remove(id)); } Future _saveIndex(AccountIndex index) async { accounts.value = index; await _secureStorage.write(key: _indexKey, value: index.encode()); } static int _now() => DateTime.now().millisecondsSinceEpoch; /// Persists a freshly minted Nextcloud app password; from then on every /// Nextcloud call authenticates with it instead of the real password. 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(); // On the startup critical path (and every background wake): read in // parallel instead of one keychain round-trip after the other. 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. } _current = decodeSession(raw); await _loadIndex(); if (!_loaded.isCompleted) _loaded.complete(); } Future _loadIndex() async { var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey)); final session = _current; if (session != null) { if (index.active == null || index.matching(session)?.id != index.activeId) { // First start after the update: the existing account keeps its data // in the un-namespaced storage. index = index.activate( session, newId: randomHexId(bytes: 8), namespace: index.accounts.isEmpty ? '' : null, now: _now(), ); await _secureStorage.write(key: _indexKey, value: index.encode()); } accounts.value = index; return; } accounts.value = index.remove(index.activeId ?? ''); // Interrupted switch or sign-out: fall back to another stored account // instead of showing the login screen. final fallback = accounts.value.mostRecentExcept(null); if (fallback != null) await activate(fallback.id); } // Move credentials from the old SharedPreferences plain-text storage into the // platform's secure keystore. Run once per install and clear the legacy keys. Future _migrateFromLegacyStorage() async { 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); } } }