merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+19 -16
View File
@@ -10,8 +10,7 @@ import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
import '../auth_link/guardian_link_listener.dart';
import '../background/widget_background_task.dart';
import '../push/chat_thread_store.dart';
import '../push/nid_store.dart';
import '../model/session_wipe.dart';
import '../push/push_registration.dart';
import '../storage/account_storage.dart';
import '../widget_data/widget_sync.dart';
@@ -41,10 +40,11 @@ abstract final class SessionLifecycle {
}
await AuthLogout().run();
final removed = await SessionManager().signOut();
SessionWipe.clearImmediate();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
await _clearPushStores();
await SessionWipe.accountLeft();
if (removed != null) await AccountStorage.delete(removed.namespace);
for (
@@ -78,14 +78,22 @@ abstract final class SessionLifecycle {
log('Switch: push deactivation failed: $e');
}
await SessionManager().stashActive();
SessionWipe.clearImmediate();
var restored = false;
try {
await SessionManager().activate(id);
} on Object {
if (previous != null) await SessionManager().activate(previous.id);
if (previous != null) {
await SessionManager().activate(previous.id);
restored = true;
}
rethrow;
} finally {
await _clearPushStores();
await SessionWipe.accountLeft();
await AccountStorage.activate(SessionManager().activeAccount);
// Nothing remounts for the account that stays, so its push would
// remain unregistered until the next start.
if (restored) unawaited(PushRegistration().register());
}
await _resetWidget();
}
@@ -94,6 +102,10 @@ abstract final class SessionLifecycle {
static Future<void> beginAddAccount() async {
await SessionManager().stashActive();
_addReturnId = SessionManager().activeAccount?.id;
SessionWipe.clearImmediate();
// Whatever runs behind the login screen must not write into the parked
// account's data.
await AccountStorage.activate(null);
}
static bool get isAddingAccount => _addReturnId != null;
@@ -109,6 +121,7 @@ abstract final class SessionLifecycle {
await SessionManager().signOut();
}
await SessionManager().activate(returnId);
await AccountStorage.activate(SessionManager().activeAccount);
}
/// Called once a login finished. After "add account" the previous account's
@@ -127,7 +140,7 @@ abstract final class SessionLifecycle {
log('Add account: push deactivation failed: $e');
}
await SessionManager().activate(added.id);
await _clearPushStores();
await SessionWipe.accountLeft();
}
await AccountStorage.activate(SessionManager().activeAccount);
return added.id;
@@ -191,14 +204,4 @@ abstract final class SessionLifecycle {
log('Widget reset failed: $e');
}
}
// Tray bookkeeping of the previous account's pushes.
static Future<void> _clearPushStores() async {
try {
await NidStore().clear();
await ChatThreadStore().clearAll();
} on Object catch (e) {
log('Push store cleanup failed: $e');
}
}
}
+60 -5
View File
@@ -58,6 +58,21 @@ class SessionManager {
Session? get current => _current;
int _sessionEpoch = 0;
/// Bumped whenever the active account changes (sign-out, switch, another
/// account signing in). 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;
/// Every signed-in account and which one is active.
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
AccountIndex.empty,
@@ -115,6 +130,7 @@ class SessionManager {
}
Future<void> _writeActive(Session session) async {
if (!_isSameAccount(_current, session)) _sessionEpoch++;
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
@@ -134,6 +150,7 @@ class SessionManager {
/// stay in their vaults; see [activate].
Future<AccountEntry?> signOut() async {
final removed = activeAccount;
_sessionEpoch++;
_loaded = Completer();
_current = null;
await Future.wait([
@@ -193,6 +210,11 @@ class SessionManager {
await _saveIndex(accounts.value.remove(id));
}
static bool _isSameAccount(Session? a, Session? b) {
if (a == null || b == null) return a == b;
return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo;
}
Future<void> _saveIndex(AccountIndex index) async {
accounts.value = index;
await _secureStorage.write(key: _indexKey, value: index.encode());
@@ -280,8 +302,15 @@ class SessionManager {
Future<void> _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.
_current = await _readActive();
await _loadIndex();
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<Session?> _readActive() async {
final values = await Future.wait(
_sessionFields.map((field) => _secureStorage.read(key: field)),
);
@@ -296,9 +325,35 @@ class SessionManager {
} on Object {
// Group keystore unavailable: fall back to the real password.
}
_current = decodeSession(raw);
await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
return decodeSession(raw);
}
/// Username currently in the active slots. Other engines (widget task, push
/// isolates) sign out, in or switch without this instance noticing.
Future<String?> 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 or was
/// switched away from in the app meanwhile. Keeps the known state when the
/// keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in, sign-out and switches itself, so its
// state is current; re-reading in between could resurrect the removed
// account.
if (_isUiEngine) return;
try {
final session = await _readActive();
final index = AccountIndex.decode(
await _secureStorage.read(key: _indexKey),
);
if (!_isSameAccount(_current, session)) _sessionEpoch++;
_current = session;
accounts.value = index;
if (!_loaded.isCompleted) _loaded.complete();
} on Object catch (e) {
log('Session reload failed, keeping loaded state: $e');
}
}
Future<void> _loadIndex() async {