added support for multiple accounts with an account switcher in settings

This commit is contained in:
2026-09-27 14:54:10 +02:00
parent 5e65c4671a
commit 27563a6dc1
33 changed files with 1801 additions and 462 deletions
+71 -28
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
@@ -22,6 +23,9 @@ class CacheEntry {
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
@@ -30,15 +34,30 @@ class CacheStore {
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
Future<Directory>? _dir;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
Future<Directory> _directory() => _dir ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/request_cache');
await dir.create(recursive: true);
return dir;
}();
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
await dir.create(recursive: true);
return dir;
}();
}
Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}');
@@ -49,8 +68,9 @@ class CacheStore {
Future<CacheEntry?> read(String key) async {
try {
await _writes[key];
return parse(await (await _file(key)).readAsString());
final file = await _file(key);
await _writes[file.path];
return parse(await file.readAsString());
} on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss.
return null;
@@ -64,18 +84,21 @@ class CacheStore {
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
}
Future<void> write(String key, String json) {
final previous = _writes[key] ?? Future<void>.value();
Future<void> write(String key, String json) async {
// Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(key, json)).whenComplete(() {
if (identical(_writes[key], current)) _writes.remove(key);
current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[path], current)) _writes.remove(path);
});
return _writes[key] = current;
return _writes[path] = current;
}
Future<void> _write(String key, String json) async {
Future<void> _write(File file, String json) async {
try {
final file = await _file(key);
// Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file.
final tmp = File('${file.path}.tmp');
@@ -85,19 +108,21 @@ class CacheStore {
);
await tmp.rename(file.path);
} on Object catch (e) {
debugPrint('CacheStore.write($key) failed: $e');
debugPrint('CacheStore.write(${file.path}) failed: $e');
}
}
Future<void> delete(String key) async {
try {
await _writes[key];
await (await _file(key)).delete();
final file = await _file(key);
await _writes[file.path];
await file.delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
/// Empties the active account's cache.
Future<void> clear() async {
try {
final dir = await _directory();
@@ -105,7 +130,20 @@ class CacheStore {
} on Object catch (e) {
debugPrint('CacheStore.clear failed: $e');
}
_dir = null;
_dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
}
/// All stored keys, optionally filtered by [prefix].
@@ -124,17 +162,22 @@ class CacheStore {
}
}
/// Removes entries not written for [maxAge], judged by file mtime so nothing
/// has to be read or decoded.
/// Removes entries not written for [maxAge] from every account's cache,
/// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final dir = await _directory();
final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
}
} on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e');
@@ -16,10 +16,15 @@ class DeleteAppPassword {
Future<void> run({String? authorizationHeader}) async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: {
...NextcloudOcs.headers(),
'Authorization': ?authorizationHeader,
},
// An explicit header may belong to an inactive account, so the active
// session's headers must not be required then.
headers: authorizationHeader == null
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
);
}
}
@@ -19,6 +19,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
// each spawning a fresh row in api_tokens.
Future<bool>? _pendingReLogin;
static Future<bool>? _anyPendingReLogin;
/// Resolves once no silent re-login is running. An account switch waits for
/// it — the renewed token would otherwise land in the next account's slot.
static Future<void> idle() async {
final pending = _anyPendingReLogin;
if (pending != null) await pending;
}
MarianumConnectAuthInterceptor({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
@@ -81,8 +90,10 @@ class MarianumConnectAuthInterceptor extends Interceptor {
if (inFlight != null) return inFlight;
final fresh = _performReLogin();
_pendingReLogin = fresh;
_anyPendingReLogin = fresh;
fresh.whenComplete(() {
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
});
return fresh;
}
@@ -8,9 +8,10 @@ import '../queries/auth_verify/auth_verify.dart';
/// 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.
/// Another stored account then takes over.
class SessionValidator {
static Future<void> probeStored({
required Future<void> Function() onInvalidated,
required Future<void> Function(String? nextAccountId) onInvalidated,
}) async {
final session = SessionManager().current;
// The probes use their own dio (bypassing the demo interceptor), so a demo
@@ -24,18 +25,18 @@ class SessionValidator {
}
} on AuthException catch (e) {
if (e.statusCode != 401) return;
// The probed account already signed out; the 401 must not sign out
// whoever logged in meanwhile.
// The probed account is no longer the active one; the 401 must not
// sign out whoever took over meanwhile.
if (!SessionManager().isCurrentSession(epoch)) return;
log('MC: stored session rejected — forcing re-login');
await SessionLifecycle.signOut(
final next = await SessionLifecycle.signOut(
notice: switch (session) {
CredentialSession() =>
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
},
);
await onInvalidated();
await onInvalidated(next);
} catch (e) {
log('MC: background session check failed (transient): $e');
}
@@ -60,6 +60,26 @@ class MarianumConnectTokenStorage {
);
}
static const bearerKey = _tokenKey;
static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey];
/// Raw stored fields, for parking the token of an inactive account.
Future<Map<String, String>> readAll() async => {
for (final key in fieldKeys) key: ?await _storage.read(key: key),
};
/// Restores fields from [readAll]; missing ones are deleted.
Future<void> writeAll(Map<String, String> fields) async {
for (final key in fieldKeys) {
final value = fields[key];
if (value == null) {
await _storage.delete(key: key);
} else {
await _storage.write(key: key, value: value);
}
}
}
Future<void> clear() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _tokenIdKey);
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
await _tokenStorage.clear();
}
}
/// Revokes the token of an inactive account; the stored (active) token is
/// left alone. Best-effort.
static Future<void> revoke(String token) async {
try {
await MarianumConnectApi.plainDio().post<void>(
MarianumConnectEndpoint.resolve('auth/logout'),
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (_) {
// ignore
}
}
}
@@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
/// Reads the user behind the stored bearer token, which also probes that the
/// token is still accepted.
///
/// Bypasses the shared dio singleton so the auth interceptor does not react
/// to the 401 this probe is meant to observe.
class AuthMe extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthMe({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] when the token is missing or rejected.
Future<void> run() async {
await user();
}
/// The signed-in user (names, type). Throws like [run].
Future<AuthLoginUser> user() async {
final options = await _tokenStorage.requireBearerOptions('AuthMe');
return guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('auth/me'),
options: options,
);
return AuthLoginUser.fromJson(response.data!);
});
}
}