added support for multiple accounts, guardian login bugfixes, ui changes

This commit is contained in:
2026-09-23 20:50:12 +02:00
parent 630497abdd
commit 84098af7e2
37 changed files with 1759 additions and 376 deletions
+122 -3
View File
@@ -6,14 +6,22 @@ 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
@@ -34,6 +42,10 @@ class SessionManager {
SessionKeys.loginFlow,
];
static const _indexKey = 'accounts_index';
static String _vaultKey(String id) => 'account_vault_$id';
static const _tokenStorage = MarianumConnectTokenStorage();
static final SessionManager _instance = SessionManager._();
factory SessionManager() => _instance;
@@ -46,6 +58,13 @@ class SessionManager {
Session? get current => _current;
/// Every signed-in account and which one is active.
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
AccountIndex.empty,
);
AccountEntry? get activeAccount => accounts.value.active;
bool get isSignedIn => _current != null;
bool get isDemo => _current?.isDemo ?? false;
@@ -80,8 +99,22 @@ class SessionManager {
NextcloudCredentials requireNextcloud() =>
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
/// Replaces any stored session completely; no prior [signOut] needed.
/// Makes [session] the active account, replacing the active slots
/// completely. An account signed in before keeps its entry; call
/// [stashActive] first so the previously active one stays switchable.
Future<void> 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<void> _writeActive(Session session) async {
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
@@ -95,19 +128,78 @@ class SessionManager {
),
]);
_current = session;
if (!_loaded.isCompleted) _loaded.complete();
}
Future<void> signOut() async {
/// Wipes the active slots and forgets the active account. Other accounts
/// stay in their vaults; see [activate].
Future<AccountEntry?> 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<void> 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<void> 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<void> 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<String, String>)> 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<void> forget(String id) async {
await _secureStorage.delete(key: _vaultKey(id));
await _saveIndex(accounts.value.remove(id));
}
Future<void> _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<void> setAppPassword(String appPassword) async {
@@ -205,9 +297,36 @@ class SessionManager {
// Group keystore unavailable: fall back to the real password.
}
_current = decodeSession(raw);
await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
}
Future<void> _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<void> _migrateFromLegacyStorage() async {