added support for multiple accounts with an account switcher in settings
This commit is contained in:
@@ -17,7 +17,7 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
|||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
||||||
├── session/ Session-Modell, SessionManager, SessionLifecycle
|
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index
|
||||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||||
├── view/ Screens
|
├── view/ Screens
|
||||||
@@ -54,6 +54,8 @@ lib/
|
|||||||
|
|
||||||
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||||
|
|
||||||
|
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
|
||||||
|
|
||||||
## Build / Run
|
## Build / Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+71
-28
@@ -5,6 +5,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import '../utils/directory_size.dart';
|
import '../utils/directory_size.dart';
|
||||||
|
|
||||||
/// One cached response: the raw JSON payload and when it was stored.
|
/// 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.
|
/// 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
|
/// Here every access is async and touches exactly one file, and the payload is
|
||||||
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
|
/// 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 {
|
class CacheStore {
|
||||||
CacheStore._();
|
CacheStore._();
|
||||||
|
|
||||||
@@ -30,15 +34,30 @@ class CacheStore {
|
|||||||
/// Payloads above this size are decoded on a background isolate.
|
/// Payloads above this size are decoded on a background isolate.
|
||||||
static const int isolateDecodeThreshold = 64 * 1024;
|
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 = {};
|
final Map<String, Future<void>> _writes = {};
|
||||||
|
|
||||||
Future<Directory> _directory() => _dir ??= () async {
|
/// Directory name for an account's storage [namespace]; null while no
|
||||||
final base = await getApplicationCacheDirectory();
|
/// account is active. The account from before multi-account support
|
||||||
final dir = Directory('${base.path}/request_cache');
|
/// (namespace '') keeps the original directory.
|
||||||
await dir.create(recursive: true);
|
@visibleForTesting
|
||||||
return dir;
|
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 =>
|
Future<File> _file(String key) async =>
|
||||||
File('${(await _directory()).path}/${_safeName(key)}');
|
File('${(await _directory()).path}/${_safeName(key)}');
|
||||||
@@ -49,8 +68,9 @@ class CacheStore {
|
|||||||
|
|
||||||
Future<CacheEntry?> read(String key) async {
|
Future<CacheEntry?> read(String key) async {
|
||||||
try {
|
try {
|
||||||
await _writes[key];
|
final file = await _file(key);
|
||||||
return parse(await (await _file(key)).readAsString());
|
await _writes[file.path];
|
||||||
|
return parse(await file.readAsString());
|
||||||
} on Object {
|
} on Object {
|
||||||
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
||||||
return null;
|
return null;
|
||||||
@@ -64,18 +84,21 @@ class CacheStore {
|
|||||||
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> write(String key, String json) {
|
Future<void> write(String key, String json) async {
|
||||||
final previous = _writes[key] ?? Future<void>.value();
|
// 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;
|
late final Future<void> current;
|
||||||
current = previous.then((_) => _write(key, json)).whenComplete(() {
|
current = previous.then((_) => _write(file, json)).whenComplete(() {
|
||||||
if (identical(_writes[key], current)) _writes.remove(key);
|
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 {
|
try {
|
||||||
final file = await _file(key);
|
|
||||||
// Write-then-rename so a reader (or the widget background isolate)
|
// Write-then-rename so a reader (or the widget background isolate)
|
||||||
// never sees a half-written file.
|
// never sees a half-written file.
|
||||||
final tmp = File('${file.path}.tmp');
|
final tmp = File('${file.path}.tmp');
|
||||||
@@ -85,19 +108,21 @@ class CacheStore {
|
|||||||
);
|
);
|
||||||
await tmp.rename(file.path);
|
await tmp.rename(file.path);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.write($key) failed: $e');
|
debugPrint('CacheStore.write(${file.path}) failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(String key) async {
|
Future<void> delete(String key) async {
|
||||||
try {
|
try {
|
||||||
await _writes[key];
|
final file = await _file(key);
|
||||||
await (await _file(key)).delete();
|
await _writes[file.path];
|
||||||
|
await file.delete();
|
||||||
} on Object {
|
} on Object {
|
||||||
// A missing or locked file is as good as deleted for a cache.
|
// A missing or locked file is as good as deleted for a cache.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Empties the active account's cache.
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
try {
|
try {
|
||||||
final dir = await _directory();
|
final dir = await _directory();
|
||||||
@@ -105,7 +130,20 @@ class CacheStore {
|
|||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.clear failed: $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].
|
/// 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
|
/// Removes entries not written for [maxAge] from every account's cache,
|
||||||
/// has to be read or decoded.
|
/// judged by file mtime so nothing has to be read or decoded.
|
||||||
Future<void> deleteOlderThan(Duration maxAge) async {
|
Future<void> deleteOlderThan(Duration maxAge) async {
|
||||||
try {
|
try {
|
||||||
final dir = await _directory();
|
final base = await getApplicationCacheDirectory();
|
||||||
final cutoff = DateTime.now().subtract(maxAge);
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
await for (final entity in dir.list()) {
|
await for (final dir in base.list()) {
|
||||||
if (entity is! File) continue;
|
if (dir is! Directory) continue;
|
||||||
// ignore: avoid_slow_async_io
|
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
|
||||||
final modified = (await entity.stat()).modified;
|
if (!name.startsWith(_directoryPrefix)) continue;
|
||||||
if (modified.isBefore(cutoff)) await entity.delete();
|
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) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ class DeleteAppPassword {
|
|||||||
Future<void> run({String? authorizationHeader}) async {
|
Future<void> run({String? authorizationHeader}) async {
|
||||||
await _client.delete(
|
await _client.delete(
|
||||||
NextcloudOcs.uri('core/apppassword'),
|
NextcloudOcs.uri('core/apppassword'),
|
||||||
headers: {
|
// An explicit header may belong to an inactive account, so the active
|
||||||
...NextcloudOcs.headers(),
|
// session's headers must not be required then.
|
||||||
'Authorization': ?authorizationHeader,
|
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.
|
// each spawning a fresh row in api_tokens.
|
||||||
Future<bool>? _pendingReLogin;
|
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({
|
MarianumConnectAuthInterceptor({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
@@ -81,8 +90,10 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
if (inFlight != null) return inFlight;
|
if (inFlight != null) return inFlight;
|
||||||
final fresh = _performReLogin();
|
final fresh = _performReLogin();
|
||||||
_pendingReLogin = fresh;
|
_pendingReLogin = fresh;
|
||||||
|
_anyPendingReLogin = fresh;
|
||||||
fresh.whenComplete(() {
|
fresh.whenComplete(() {
|
||||||
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
||||||
|
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
|
||||||
});
|
});
|
||||||
return fresh;
|
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
|
/// 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.
|
/// next cold start even when the bearer token would still be accepted.
|
||||||
|
/// Another stored account then takes over.
|
||||||
class SessionValidator {
|
class SessionValidator {
|
||||||
static Future<void> probeStored({
|
static Future<void> probeStored({
|
||||||
required Future<void> Function() onInvalidated,
|
required Future<void> Function(String? nextAccountId) onInvalidated,
|
||||||
}) async {
|
}) async {
|
||||||
final session = SessionManager().current;
|
final session = SessionManager().current;
|
||||||
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||||
@@ -24,18 +25,18 @@ class SessionValidator {
|
|||||||
}
|
}
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
if (e.statusCode != 401) return;
|
if (e.statusCode != 401) return;
|
||||||
// The probed account already signed out; the 401 must not sign out
|
// The probed account is no longer the active one; the 401 must not
|
||||||
// whoever logged in meanwhile.
|
// sign out whoever took over meanwhile.
|
||||||
if (!SessionManager().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
log('MC: stored session rejected — forcing re-login');
|
log('MC: stored session rejected — forcing re-login');
|
||||||
await SessionLifecycle.signOut(
|
final next = await SessionLifecycle.signOut(
|
||||||
notice: switch (session) {
|
notice: switch (session) {
|
||||||
CredentialSession() =>
|
CredentialSession() =>
|
||||||
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
||||||
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
|
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await onInvalidated();
|
await onInvalidated(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('MC: background session check failed (transient): $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 {
|
Future<void> clear() async {
|
||||||
await _storage.delete(key: _tokenKey);
|
await _storage.delete(key: _tokenKey);
|
||||||
await _storage.delete(key: _tokenIdKey);
|
await _storage.delete(key: _tokenIdKey);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||||
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
|
|||||||
await _tokenStorage.clear();
|
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!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
-199
@@ -11,7 +11,6 @@ import 'package:flutter/scheduler.dart' show timeDilation;
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|
||||||
import 'package:jiffy/jiffy.dart';
|
import 'package:jiffy/jiffy.dart';
|
||||||
import 'package:loader_overlay/loader_overlay.dart';
|
import 'package:loader_overlay/loader_overlay.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -33,19 +32,20 @@ import 'push/push_registration.dart';
|
|||||||
import 'push/push_registration_store.dart';
|
import 'push/push_registration_store.dart';
|
||||||
import 'push/push_renderer.dart';
|
import 'push/push_renderer.dart';
|
||||||
import 'routing/app_routes.dart';
|
import 'routing/app_routes.dart';
|
||||||
|
import 'session/account_codec.dart';
|
||||||
|
import 'session/session_lifecycle.dart';
|
||||||
import 'session/session_manager.dart';
|
import 'session/session_manager.dart';
|
||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
|
import 'state/app/modules/account/account_scope.dart';
|
||||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
import 'state/app/modules/app_modules.dart';
|
import 'state/app/modules/app_modules.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
|
||||||
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
|
||||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import 'storage/hydrated_storage_bootstrap.dart';
|
import 'storage/account_storage.dart';
|
||||||
import 'storage/settings.dart';
|
import 'storage/settings.dart';
|
||||||
import 'theming/dark_app_theme.dart';
|
import 'theming/dark_app_theme.dart';
|
||||||
import 'theming/light_app_theme.dart';
|
import 'theming/light_app_theme.dart';
|
||||||
@@ -59,6 +59,7 @@ import 'widget/breaker/breaker.dart';
|
|||||||
import 'widget/debug/debug_stats_overlay.dart';
|
import 'widget/debug/debug_stats_overlay.dart';
|
||||||
import 'widget/downloads/download_tray.dart';
|
import 'widget/downloads/download_tray.dart';
|
||||||
import 'widget/emergency/emergency_notice_gate.dart';
|
import 'widget/emergency/emergency_notice_gate.dart';
|
||||||
|
import 'widget/info_dialog.dart';
|
||||||
import 'widget_data/widget_sync.dart';
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
/// Runs one startup step with a time limit. Anything that throws or hangs
|
/// Runs one startup step with a time limit. Anything that throws or hangs
|
||||||
@@ -146,8 +147,7 @@ Future<void> main() async {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
_startupStep('hydrated storage', () async {
|
_startupStep('hydrated storage', () async {
|
||||||
final path = (await getTemporaryDirectory()).path;
|
await AccountStorage.init((await getTemporaryDirectory()).path);
|
||||||
HydratedBloc.storage = await buildHydratedStorageWithFallback(path);
|
|
||||||
}, timeout: null),
|
}, timeout: null),
|
||||||
_startupStep('documents dir', () async {
|
_startupStep('documents dir', () async {
|
||||||
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
||||||
@@ -167,6 +167,16 @@ Future<void> main() async {
|
|||||||
|
|
||||||
log('starting app initialisation...');
|
log('starting app initialisation...');
|
||||||
await Future.wait(initialisationTasks);
|
await Future.wait(initialisationTasks);
|
||||||
|
// Snapshot: the session may still finish loading later (see _MainState),
|
||||||
|
// the bloc and the storage must agree on the account they start with.
|
||||||
|
final initialAccount = SessionManager().isLoaded
|
||||||
|
? SessionManager().activeAccount
|
||||||
|
: null;
|
||||||
|
await _startupStep(
|
||||||
|
'account storage',
|
||||||
|
() => AccountStorage.activate(initialAccount),
|
||||||
|
timeout: null,
|
||||||
|
);
|
||||||
log('app initialisation done!');
|
log('app initialisation done!');
|
||||||
|
|
||||||
// Independent of the notification setup below, so it runs alongside it.
|
// Independent of the notification setup below, so it runs alongside it.
|
||||||
@@ -237,34 +247,48 @@ Future<void> main() async {
|
|||||||
// placeholder flash.
|
// placeholder flash.
|
||||||
AvatarDiskCache.instance.warmUp();
|
AvatarDiskCache.instance.warmUp();
|
||||||
|
|
||||||
|
// Created eagerly so the endpoint is configured before anything below can
|
||||||
|
// issue a request (the timetable bloc loads on creation).
|
||||||
|
final settingsCubit = SettingsCubit(storage: AccountStorage.global);
|
||||||
|
_syncMarianumConnectEndpoint(settingsCubit.state);
|
||||||
|
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
|
||||||
|
|
||||||
log('running app...');
|
log('running app...');
|
||||||
runApp(
|
runApp(
|
||||||
MultiBlocProvider(
|
MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
|
BlocProvider<SettingsCubit>.value(value: settingsCubit),
|
||||||
BlocProvider<AccountBloc>(
|
BlocProvider<AccountBloc>(
|
||||||
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
|
create: (_) => AccountBloc(
|
||||||
|
initialStatus: _initialAccountStatus(initialAccount),
|
||||||
|
accountId: initialAccount?.id,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
|
||||||
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
|
||||||
BlocProvider<NextcloudCapabilitiesCubit>(
|
|
||||||
create: (_) => NextcloudCapabilitiesCubit(),
|
|
||||||
),
|
|
||||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
|
||||||
BlocProvider<ChatBloc>(
|
|
||||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
|
||||||
),
|
|
||||||
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
|
||||||
],
|
],
|
||||||
child: const Main(),
|
child: const Main(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountStatus _initialAccountStatus() {
|
String? _syncedMcBaseUrl;
|
||||||
final session = SessionManager();
|
|
||||||
if (session.isSignedIn) return AccountStatus.loggedIn;
|
/// Keeps the MC dio singleton aligned with the selected endpoint (live /
|
||||||
return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
|
/// beta / custom), mirrored into WidgetSync so the background isolate
|
||||||
|
/// refreshes against the same endpoint. Settings emit on every toggle; only
|
||||||
|
/// an actual URL change is applied.
|
||||||
|
void _syncMarianumConnectEndpoint(Settings settings) {
|
||||||
|
final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl();
|
||||||
|
if (url == _syncedMcBaseUrl) return;
|
||||||
|
_syncedMcBaseUrl = url;
|
||||||
|
MarianumConnectEndpoint.update(url);
|
||||||
|
unawaited(WidgetSync.setMarianumConnectBaseUrl(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountStatus _initialAccountStatus(AccountEntry? account) {
|
||||||
|
if (account != null) return AccountStatus.loggedIn;
|
||||||
|
return SessionManager().isLoaded
|
||||||
|
? AccountStatus.loggedOut
|
||||||
|
: AccountStatus.undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Main extends StatefulWidget {
|
class Main extends StatefulWidget {
|
||||||
@@ -293,31 +317,52 @@ class _MainState extends State<Main> {
|
|||||||
Jiffy.setLocale('de');
|
Jiffy.setLocale('de');
|
||||||
_lastStatus = context.read<AccountBloc>().state.status;
|
_lastStatus = context.read<AccountBloc>().state.status;
|
||||||
|
|
||||||
SessionManager().waitForLoad().then((session) {
|
SessionManager().waitForLoad().then((session) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final accountBloc = context.read<AccountBloc>();
|
final accountBloc = context.read<AccountBloc>();
|
||||||
accountBloc.setStatus(
|
if (session == null) {
|
||||||
session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut,
|
accountBloc.setStatus(AccountStatus.loggedOut);
|
||||||
);
|
return;
|
||||||
if (session != null) {
|
|
||||||
_scheduleSessionValidation(accountBloc);
|
|
||||||
// Cold start while already logged in: the account status doesn't
|
|
||||||
// change, so the loggedIn listener below never fires — refresh
|
|
||||||
// capabilities here, then self-heal the push registration.
|
|
||||||
final settingsCubit = context.read<SettingsCubit>();
|
|
||||||
unawaited(
|
|
||||||
context.read<CapabilitiesCubit>().load().then((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final capabilities = context.read<CapabilitiesCubit>();
|
|
||||||
_syncPush(settingsCubit, capabilities);
|
|
||||||
_applyRoleDefaults(settingsCubit, capabilities);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
|
||||||
}
|
}
|
||||||
|
// Covers a session that finished loading after the startup snapshot;
|
||||||
|
// otherwise the id is unchanged and nothing remounts.
|
||||||
|
final account = SessionManager().activeAccount;
|
||||||
|
await AccountStorage.activate(account);
|
||||||
|
if (!mounted) return;
|
||||||
|
accountBloc.activated(account?.id);
|
||||||
|
_scheduleSessionValidation(accountBloc);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs whenever the account-scoped tree mounts for a signed-in account:
|
||||||
|
/// pulls the capability flags, then registers push for this account.
|
||||||
|
void _onSessionActive(BuildContext scopeContext) {
|
||||||
|
final settingsCubit = scopeContext.read<SettingsCubit>();
|
||||||
|
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
|
||||||
|
unawaited(
|
||||||
|
capabilitiesCubit.load().then((_) {
|
||||||
|
// Closed: the account was switched away from while loading.
|
||||||
|
if (!mounted || capabilitiesCubit.isClosed) return;
|
||||||
|
_syncPush(settingsCubit, capabilitiesCubit);
|
||||||
|
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().load());
|
||||||
|
unawaited(SessionLifecycle.refreshDisplayName());
|
||||||
|
_prefetchBaseData(scopeContext);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A forced sign-out that handed over to another account never reaches the
|
||||||
|
/// login screen, which normally explains it.
|
||||||
|
void _showTakeoverNotice() {
|
||||||
|
final notice = SessionLifecycle.signOutNotice.value;
|
||||||
|
final overlayContext = AppRoutes.overlayContext;
|
||||||
|
if (notice == null || overlayContext == null) return;
|
||||||
|
SessionLifecycle.signOutNotice.value = null;
|
||||||
|
InfoDialog.show(overlayContext, notice, title: 'Abgemeldet');
|
||||||
|
}
|
||||||
|
|
||||||
/// Warms the core caches (timetable, chat list, files root) in the
|
/// Warms the core caches (timetable, chat list, files root) in the
|
||||||
/// background so the first screen render hits populated data.
|
/// background so the first screen render hits populated data.
|
||||||
void _prefetchBaseData(BuildContext context) {
|
void _prefetchBaseData(BuildContext context) {
|
||||||
@@ -362,19 +407,48 @@ class _MainState extends State<Main> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Background credential check: a 401 means the password was rotated
|
/// Background credential check: a 401 means the password was rotated
|
||||||
/// server-side, so the validator wipes the local session and flips the
|
/// server-side, so the validator signs the account out and the app moves
|
||||||
/// account bloc to `loggedOut` (sending the user to the login screen).
|
/// on to another stored account or the login screen.
|
||||||
void _scheduleSessionValidation(AccountBloc accountBloc) {
|
void _scheduleSessionValidation(AccountBloc accountBloc) {
|
||||||
unawaited(
|
unawaited(
|
||||||
SessionValidator.probeStored(
|
SessionValidator.probeStored(
|
||||||
onInvalidated: () async {
|
onInvalidated: (nextAccountId) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
accountBloc.setStatus(AccountStatus.loggedOut);
|
accountBloc.activated(nextAccountId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onAccountChanged(BuildContext context, AccountState accountState) {
|
||||||
|
if (accountState.status == AccountStatus.loggedIn &&
|
||||||
|
accountState.freshLogin) {
|
||||||
|
_showPostLoginSplash = true;
|
||||||
|
_appMounted = false;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _appMounted = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
|
||||||
|
_lastStatus = accountState.status;
|
||||||
|
// A cold start that merely resolves as signed out has nothing to wipe,
|
||||||
|
// and a share waiting for the login must survive it.
|
||||||
|
if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
|
||||||
|
// Deferred until the account-scoped tree is torn down.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => unawaited(SessionWipe.deviceLeft()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account-scoped tree only lives while its account is the one in use:
|
||||||
|
/// adding another account parks it, so its blocs go as well.
|
||||||
|
static Object _scopeOf(AccountState state) => (
|
||||||
|
state.accountId,
|
||||||
|
state.status == AccountStatus.addingAccount,
|
||||||
|
);
|
||||||
|
|
||||||
|
Object? _scope;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Directionality(
|
Widget build(BuildContext context) => Directionality(
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
@@ -384,7 +458,6 @@ class _MainState extends State<Main> {
|
|||||||
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
|
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
|
||||||
selector: (settings) => (
|
selector: (settings) => (
|
||||||
appTheme: settings.appTheme,
|
appTheme: settings.appTheme,
|
||||||
mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(),
|
|
||||||
notificationsEnabled: settings.notificationSettings.enabled,
|
notificationsEnabled: settings.notificationSettings.enabled,
|
||||||
showPerformanceOverlay:
|
showPerformanceOverlay:
|
||||||
settings.devToolsSettings.showPerformanceOverlay,
|
settings.devToolsSettings.showPerformanceOverlay,
|
||||||
@@ -395,12 +468,6 @@ class _MainState extends State<Main> {
|
|||||||
textScaleOverride: settings.devToolsSettings.textScaleOverride,
|
textScaleOverride: settings.devToolsSettings.textScaleOverride,
|
||||||
),
|
),
|
||||||
builder: (context, root) {
|
builder: (context, root) {
|
||||||
// Keep the MC dio singleton aligned with the currently selected
|
|
||||||
// endpoint (live / beta / custom). Idempotent when the URL is
|
|
||||||
// unchanged. Mirrored into WidgetSync so the background isolate
|
|
||||||
// refreshes against the same endpoint.
|
|
||||||
MarianumConnectEndpoint.update(root.mcBaseUrl);
|
|
||||||
unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl));
|
|
||||||
// Mirror the notification toggle into group-scoped storage so the FCM
|
// Mirror the notification toggle into group-scoped storage so the FCM
|
||||||
// background isolate and the iOS NSE can suppress rendering when off.
|
// background isolate and the iOS NSE can suppress rendering when off.
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -409,164 +476,116 @@ class _MainState extends State<Main> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
timeDilation = root.slowAnimations ? 5.0 : 1.0;
|
timeDilation = root.slowAnimations ? 5.0 : 1.0;
|
||||||
return MaterialApp(
|
return BlocConsumer<AccountBloc, AccountState>(
|
||||||
showPerformanceOverlay: root.showPerformanceOverlay,
|
listenWhen: (previous, current) =>
|
||||||
showSemanticsDebugger: root.showSemanticsDebugger,
|
previous.status != current.status ||
|
||||||
debugShowCheckedModeBanner: false,
|
previous.accountId != current.accountId,
|
||||||
navigatorKey: AppRoutes.rootNavigatorKey,
|
listener: _onAccountChanged,
|
||||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
buildWhen: (previous, current) =>
|
||||||
// DownloadRouteObserver tracks full-page navigations so the downloads
|
_scopeOf(previous) != _scopeOf(current),
|
||||||
// chip only surfaces once the user leaves the screen they started on.
|
builder: (context, account) {
|
||||||
navigatorObservers: _navigatorObservers,
|
final scope = _scopeOf(account);
|
||||||
localizationsDelegates: const [
|
if (scope != _scope) {
|
||||||
...GlobalMaterialLocalizations.delegates,
|
_scope = scope;
|
||||||
GlobalWidgetsLocalizations.delegate,
|
// The old navigator (and every route on it) goes with the old
|
||||||
],
|
// account; a shared GlobalKey would carry it over instead.
|
||||||
supportedLocales: const [Locale('de'), Locale('en')],
|
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
locale: const Locale('de'),
|
|
||||||
title: 'Marianum Fulda',
|
|
||||||
themeMode: root.appTheme,
|
|
||||||
theme: LightAppTheme.theme,
|
|
||||||
darkTheme: DarkAppTheme.theme,
|
|
||||||
// Brand-colored backdrop behind every route. During the logout
|
|
||||||
// home-swap and route pop animations the framework can briefly
|
|
||||||
// expose the layer below the topmost Scaffold; without this
|
|
||||||
// the dark Material default shows through and the user sees a
|
|
||||||
// black flash.
|
|
||||||
builder: (context, child) {
|
|
||||||
Widget app = ColoredBox(
|
|
||||||
color: LightAppTheme.marianumRed,
|
|
||||||
// Downloads tray mounted ABOVE the navigator so its chip floats
|
|
||||||
// over every route (folder views, chat, viewer are full-page
|
|
||||||
// pushes that would otherwise cover it).
|
|
||||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
|
||||||
);
|
|
||||||
final scale = root.textScaleOverride;
|
|
||||||
if (scale > 0) {
|
|
||||||
app = MediaQuery.withClampedTextScaling(
|
|
||||||
minScaleFactor: scale,
|
|
||||||
maxScaleFactor: scale,
|
|
||||||
child: app,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return DebugStatsOverlay(
|
return AccountScope(
|
||||||
showFrameStats: root.showFrameStats,
|
key: ValueKey(scope),
|
||||||
showMemoryStats: root.showMemoryStats,
|
onActive: _onSessionActive,
|
||||||
child: app,
|
child: _buildApp(root),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
home: EmergencyNoticeGate(
|
|
||||||
child: LoaderOverlay(
|
|
||||||
child: Breaker(
|
|
||||||
breaker: BreakerArea.global,
|
|
||||||
child: BlocConsumer<AccountBloc, AccountState>(
|
|
||||||
listenWhen: (previous, current) =>
|
|
||||||
previous.status != current.status,
|
|
||||||
listener: (context, accountState) {
|
|
||||||
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
|
|
||||||
_lastStatus = accountState.status;
|
|
||||||
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
|
||||||
// for the newly authenticated user, then register push right
|
|
||||||
// away instead of deferring it to the next app start.
|
|
||||||
if (accountState.status == AccountStatus.loggedIn) {
|
|
||||||
final settingsCubit = context.read<SettingsCubit>();
|
|
||||||
final capabilitiesCubit = context
|
|
||||||
.read<CapabilitiesCubit>();
|
|
||||||
unawaited(
|
|
||||||
capabilitiesCubit.load().then((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_syncPush(settingsCubit, capabilitiesCubit);
|
|
||||||
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
unawaited(
|
|
||||||
context.read<NextcloudCapabilitiesCubit>().load(),
|
|
||||||
);
|
|
||||||
_showPostLoginSplash = true;
|
|
||||||
_appMounted = false;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) setState(() => _appMounted = true);
|
|
||||||
});
|
|
||||||
_prefetchBaseData(context);
|
|
||||||
}
|
|
||||||
if (accountState.status != AccountStatus.loggedOut) return;
|
|
||||||
// A pending share would otherwise survive logout and be
|
|
||||||
// re-applied to the next account. A cold-start share that
|
|
||||||
// is merely waiting for the stored session to resolve as
|
|
||||||
// signed out stays, to be sent after login.
|
|
||||||
if (wasLoggedIn) SessionWipe.clearImmediate();
|
|
||||||
// Routes pushed via AppRoutes (e.g. Settings) live on the
|
|
||||||
// root navigator and survive the home swap below, so they
|
|
||||||
// would still cover the Login screen after logout. Pop
|
|
||||||
// them here so the user immediately sees Login.
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
if (navigator.canPop()) {
|
|
||||||
navigator.popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
// Capture bloc references before the post-frame callback
|
|
||||||
// — by the time it runs the dialog/Settings context is
|
|
||||||
// gone but this listener context is still valid.
|
|
||||||
final timetableBloc = context.read<TimetableBloc>();
|
|
||||||
final chatListBloc = context.read<ChatListBloc>();
|
|
||||||
final chatBloc = context.read<ChatBloc>();
|
|
||||||
final breakerBloc = context.read<BreakerBloc>();
|
|
||||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
|
||||||
final nextcloudCapabilitiesCubit = context
|
|
||||||
.read<NextcloudCapabilitiesCubit>();
|
|
||||||
// Defer the actual wipe until after this frame so the
|
|
||||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
|
||||||
// is already torn down. Resetting blocs while App is
|
|
||||||
// still in front caused a black-frame race.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
unawaited(
|
|
||||||
SessionWipe.run(
|
|
||||||
timetableBloc: timetableBloc,
|
|
||||||
chatListBloc: chatListBloc,
|
|
||||||
chatBloc: chatBloc,
|
|
||||||
breakerBloc: breakerBloc,
|
|
||||||
capabilitiesCubit: capabilitiesCubit,
|
|
||||||
nextcloudCapabilitiesCubit:
|
|
||||||
nextcloudCapabilitiesCubit,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
builder: (context, accountState) {
|
|
||||||
switch (accountState.status) {
|
|
||||||
case AccountStatus.loggedIn:
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
if (_appMounted)
|
|
||||||
const App(key: ValueKey('app-shell')),
|
|
||||||
if (_showPostLoginSplash)
|
|
||||||
PostLoginSplash(
|
|
||||||
key: const ValueKey('post-login-splash'),
|
|
||||||
onComplete: () => setState(
|
|
||||||
() => _showPostLoginSplash = false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
case AccountStatus.loggedOut:
|
|
||||||
return const Login();
|
|
||||||
case AccountStatus.undefined:
|
|
||||||
return const AccountLoadingScreen();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Widget _buildApp(_RootSettings root) => MaterialApp(
|
||||||
|
showPerformanceOverlay: root.showPerformanceOverlay,
|
||||||
|
showSemanticsDebugger: root.showSemanticsDebugger,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||||
|
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||||
|
// DownloadRouteObserver tracks full-page navigations so the downloads
|
||||||
|
// chip only surfaces once the user leaves the screen they started on.
|
||||||
|
navigatorObservers: _navigatorObservers,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
...GlobalMaterialLocalizations.delegates,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [Locale('de'), Locale('en')],
|
||||||
|
locale: const Locale('de'),
|
||||||
|
title: 'Marianum Fulda',
|
||||||
|
themeMode: root.appTheme,
|
||||||
|
theme: LightAppTheme.theme,
|
||||||
|
darkTheme: DarkAppTheme.theme,
|
||||||
|
// Brand-colored backdrop behind every route. During the logout
|
||||||
|
// home-swap and route pop animations the framework can briefly
|
||||||
|
// expose the layer below the topmost Scaffold; without this
|
||||||
|
// the dark Material default shows through and the user sees a
|
||||||
|
// black flash.
|
||||||
|
builder: (context, child) {
|
||||||
|
Widget app = ColoredBox(
|
||||||
|
color: LightAppTheme.marianumRed,
|
||||||
|
// Downloads tray mounted ABOVE the navigator so its chip floats
|
||||||
|
// over every route (folder views, chat, viewer are full-page
|
||||||
|
// pushes that would otherwise cover it).
|
||||||
|
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||||
|
);
|
||||||
|
final scale = root.textScaleOverride;
|
||||||
|
if (scale > 0) {
|
||||||
|
app = MediaQuery.withClampedTextScaling(
|
||||||
|
minScaleFactor: scale,
|
||||||
|
maxScaleFactor: scale,
|
||||||
|
child: app,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return DebugStatsOverlay(
|
||||||
|
showFrameStats: root.showFrameStats,
|
||||||
|
showMemoryStats: root.showMemoryStats,
|
||||||
|
child: app,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
home: EmergencyNoticeGate(
|
||||||
|
child: LoaderOverlay(
|
||||||
|
child: Breaker(
|
||||||
|
breaker: BreakerArea.global,
|
||||||
|
child: BlocBuilder<AccountBloc, AccountState>(
|
||||||
|
buildWhen: (previous, current) => previous.status != current.status,
|
||||||
|
builder: (context, accountState) {
|
||||||
|
switch (accountState.status) {
|
||||||
|
case AccountStatus.loggedIn:
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||||
|
if (_showPostLoginSplash)
|
||||||
|
PostLoginSplash(
|
||||||
|
key: const ValueKey('post-login-splash'),
|
||||||
|
onComplete: () =>
|
||||||
|
setState(() => _showPostLoginSplash = false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case AccountStatus.loggedOut:
|
||||||
|
return const Login();
|
||||||
|
case AccountStatus.addingAccount:
|
||||||
|
return const Login(addingAccount: true);
|
||||||
|
case AccountStatus.undefined:
|
||||||
|
return const AccountLoadingScreen();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef _RootSettings = ({
|
typedef _RootSettings = ({
|
||||||
ThemeMode appTheme,
|
ThemeMode appTheme,
|
||||||
String mcBaseUrl,
|
|
||||||
bool notificationsEnabled,
|
bool notificationsEnabled,
|
||||||
bool showPerformanceOverlay,
|
bool showPerformanceOverlay,
|
||||||
bool showSemanticsDebugger,
|
bool showSemanticsDebugger,
|
||||||
|
|||||||
+41
-70
@@ -4,11 +4,9 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../api/cache_store.dart';
|
|
||||||
import '../api/marianumcloud/talk/share_files_to_chat.dart';
|
import '../api/marianumcloud/talk/share_files_to_chat.dart';
|
||||||
import '../background/widget_background_task.dart';
|
import '../background/widget_background_task.dart';
|
||||||
import '../notification/notification_service.dart';
|
import '../notification/notification_service.dart';
|
||||||
@@ -18,20 +16,14 @@ import '../push/push_keypair.dart';
|
|||||||
import '../push/push_tap_router.dart';
|
import '../push/push_tap_router.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../share_intent/share_intent_listener.dart';
|
import '../share_intent/share_intent_listener.dart';
|
||||||
import '../state/app/modules/breaker/bloc/breaker_bloc.dart';
|
|
||||||
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
|
||||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
|
||||||
import '../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
|
||||||
import '../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
|
||||||
import '../utils/app_paths.dart';
|
import '../utils/app_paths.dart';
|
||||||
import '../utils/downloads/download_manager.dart';
|
import '../utils/downloads/download_manager.dart';
|
||||||
import '../utils/file_clipboard.dart';
|
import '../utils/file_clipboard.dart';
|
||||||
import '../widget_data/widget_sync.dart';
|
import '../widget_data/widget_sync.dart';
|
||||||
|
|
||||||
/// Removes everything the signed-out account left on the device. Every step
|
/// Removes what an account leaves on the device outside its own storage (see
|
||||||
/// is isolated: one failing step used to skip all later ones and leave the
|
/// `AccountStorage` for that). Every step is isolated: one failing step used
|
||||||
/// previous account's data behind.
|
/// to skip all later ones and leave the previous account's data behind.
|
||||||
abstract final class SessionWipe {
|
abstract final class SessionWipe {
|
||||||
static Future<void>? _running;
|
static Future<void>? _running;
|
||||||
|
|
||||||
@@ -39,8 +31,9 @@ abstract final class SessionWipe {
|
|||||||
/// wipe could clear what the next account just stored (widget job, caches).
|
/// wipe could clear what the next account just stored (widget job, caches).
|
||||||
static Future<void> get done => _running ?? Future.value();
|
static Future<void> get done => _running ?? Future.value();
|
||||||
|
|
||||||
/// State that must be gone before the next frame (a login screen can be
|
/// State that must be gone before the next frame (a login screen or another
|
||||||
/// reached right away): pending navigation and in-memory singletons.
|
/// account can be reached right away): pending navigation and in-memory
|
||||||
|
/// singletons.
|
||||||
static void clearImmediate() {
|
static void clearImmediate() {
|
||||||
_step('share intents', ShareIntentListener.instance.clearAll);
|
_step('share intents', ShareIntentListener.instance.clearAll);
|
||||||
_step('share folder cache', resetTalkShareFolderCache);
|
_step('share folder cache', resetTalkShareFolderCache);
|
||||||
@@ -52,66 +45,17 @@ abstract final class SessionWipe {
|
|||||||
_step('downloads', DownloadManager.instance.clearAll);
|
_step('downloads', DownloadManager.instance.clearAll);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> run({
|
/// Runs whenever the active account stops being active (sign-out or
|
||||||
required TimetableBloc timetableBloc,
|
/// switch). The tray and its bookkeeping belong to that account — a tap or
|
||||||
required ChatListBloc chatListBloc,
|
/// inline reply there would otherwise act as the next one. A new keypair
|
||||||
required ChatBloc chatBloc,
|
/// and FCM token make pushes still addressed to the previous registration
|
||||||
required BreakerBloc breakerBloc,
|
/// undecryptable and undeliverable (an offline sign-out or switch could not
|
||||||
required CapabilitiesCubit capabilitiesCubit,
|
/// unregister it).
|
||||||
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
|
static Future<void> accountLeft() async {
|
||||||
}) {
|
|
||||||
final wipe = _run(
|
|
||||||
timetableBloc: timetableBloc,
|
|
||||||
chatListBloc: chatListBloc,
|
|
||||||
chatBloc: chatBloc,
|
|
||||||
breakerBloc: breakerBloc,
|
|
||||||
capabilitiesCubit: capabilitiesCubit,
|
|
||||||
nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit,
|
|
||||||
);
|
|
||||||
_running = wipe;
|
|
||||||
return wipe.whenComplete(() {
|
|
||||||
if (identical(_running, wipe)) _running = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> _run({
|
|
||||||
required TimetableBloc timetableBloc,
|
|
||||||
required ChatListBloc chatListBloc,
|
|
||||||
required ChatBloc chatBloc,
|
|
||||||
required BreakerBloc breakerBloc,
|
|
||||||
required CapabilitiesCubit capabilitiesCubit,
|
|
||||||
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
|
|
||||||
}) async {
|
|
||||||
// SettingsCubit is deliberately left alone: its BlocBuilder wraps
|
|
||||||
// MaterialApp, and emitting a fresh state here tore down the freshly
|
|
||||||
// mounted Login tree (blank screen until the next interaction).
|
|
||||||
await Future.wait([
|
|
||||||
_stepAsync('timetable', timetableBloc.reset),
|
|
||||||
_stepAsync('chat list', chatListBloc.reset),
|
|
||||||
_stepAsync('chat', chatBloc.reset),
|
|
||||||
_stepAsync('breakers', breakerBloc.reset),
|
|
||||||
_stepAsync('capabilities', capabilitiesCubit.reset),
|
|
||||||
_stepAsync('nc capabilities', nextcloudCapabilitiesCubit.reset),
|
|
||||||
]);
|
|
||||||
await _stepAsync('shared preferences', () async {
|
|
||||||
await (await SharedPreferences.getInstance()).clear();
|
|
||||||
});
|
|
||||||
await _stepAsync('hydrated storage', HydratedBloc.storage.clear);
|
|
||||||
await _stepAsync('request cache', CacheStore.instance.clear);
|
|
||||||
await _stepAsync('chat background', () async {
|
|
||||||
final image = File(AppPaths.chatBackgroundImage);
|
|
||||||
if (image.existsSync()) await image.delete();
|
|
||||||
});
|
|
||||||
await _stepAsync('download files', () async {
|
await _stepAsync('download files', () async {
|
||||||
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
|
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
|
||||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push: the tray and its bookkeeping belong to the previous account — a
|
|
||||||
// tap or inline reply there would otherwise act as the next one. A new
|
|
||||||
// keypair and FCM token make pushes still addressed to the previous
|
|
||||||
// registration undecryptable and undeliverable (an offline sign-out
|
|
||||||
// could not unregister it).
|
|
||||||
await _stepAsync(
|
await _stepAsync(
|
||||||
'notification tray',
|
'notification tray',
|
||||||
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
|
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
|
||||||
@@ -120,8 +64,35 @@ abstract final class SessionWipe {
|
|||||||
await _stepAsync('push nid store', NidStore().clear);
|
await _stepAsync('push nid store', NidStore().clear);
|
||||||
await _stepAsync('push thread store', ChatThreadStore().clearAll);
|
await _stepAsync('push thread store', ChatThreadStore().clearAll);
|
||||||
await _stepAsync('push keypair', const PushKeypair().clear);
|
await _stepAsync('push keypair', const PushKeypair().clear);
|
||||||
await _stepAsync('fcm token', FirebaseMessaging.instance.deleteToken);
|
// Bounded: the next account waits for this, also without a network.
|
||||||
|
await _stepAsync(
|
||||||
|
'fcm token',
|
||||||
|
() => FirebaseMessaging.instance.deleteToken().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes what outlives accounts, once the last one signed out.
|
||||||
|
static Future<void> deviceLeft() {
|
||||||
|
final wipe = _deviceLeft();
|
||||||
|
_running = wipe;
|
||||||
|
return wipe.whenComplete(() {
|
||||||
|
if (identical(_running, wipe)) _running = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _deviceLeft() async {
|
||||||
|
// SettingsCubit is deliberately left alone: its selector wraps
|
||||||
|
// MaterialApp, and emitting a fresh state here tore down the freshly
|
||||||
|
// mounted Login tree (blank screen until the next interaction).
|
||||||
|
await _stepAsync('shared preferences', () async {
|
||||||
|
await (await SharedPreferences.getInstance()).clear();
|
||||||
|
});
|
||||||
|
await _stepAsync('chat background', () async {
|
||||||
|
final image = File(AppPaths.chatBackgroundImage);
|
||||||
|
if (image.existsSync()) await image.delete();
|
||||||
|
});
|
||||||
// Stop the periodic widget refresh job so the background isolate doesn't
|
// Stop the periodic widget refresh job so the background isolate doesn't
|
||||||
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
||||||
// on the next successful login.
|
// on the next successful login.
|
||||||
|
|||||||
@@ -427,6 +427,13 @@ class PushRegistration {
|
|||||||
/// first, then the proxy) with the new token.
|
/// first, then the proxy) with the new token.
|
||||||
Future<void> onTokenRefresh() => register();
|
Future<void> onTokenRefresh() => register();
|
||||||
|
|
||||||
|
/// Stops pushes for the active account before another one takes over. The
|
||||||
|
/// app passwords stay valid so switching back can re-register silently.
|
||||||
|
Future<void> deactivate() async {
|
||||||
|
if (DemoMode.active || _nextcloudOrNull == null) return;
|
||||||
|
await unregister();
|
||||||
|
}
|
||||||
|
|
||||||
/// Full teardown for logout: unregister push, revoke BOTH app passwords
|
/// Full teardown for logout: unregister push, revoke BOTH app passwords
|
||||||
/// (each authenticated with itself — the endpoint revokes the credential it
|
/// (each authenticated with itself — the endpoint revokes the credential it
|
||||||
/// is called with), then clear them locally. Ordered so the proxy stops
|
/// is called with), then clear them locally. Ordered so the proxy stops
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ class AppRoutes {
|
|||||||
/// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g.
|
/// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g.
|
||||||
/// the downloads tray, which lives above the navigator in `MaterialApp.builder`
|
/// the downloads tray, which lives above the navigator in `MaterialApp.builder`
|
||||||
/// and is therefore never covered by a pushed route) open full-page routes.
|
/// and is therefore never covered by a pushed route) open full-page routes.
|
||||||
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
/// Replaced per account (see `Main`), so a switch starts on a fresh
|
||||||
|
/// navigator.
|
||||||
|
static GlobalKey<NavigatorState> rootNavigatorKey =
|
||||||
GlobalKey<NavigatorState>();
|
GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
/// A context that is a descendant of the root navigator (its overlay), safe to
|
/// A context that is a descendant of the root navigator (its overlay), safe to
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'session.dart';
|
||||||
|
import 'session_codec.dart';
|
||||||
|
|
||||||
|
/// One signed-in account on this device. The session itself lives in the
|
||||||
|
/// keychain (active slots or the account's vault); this is the index entry.
|
||||||
|
class AccountEntry {
|
||||||
|
final String id;
|
||||||
|
final String kind;
|
||||||
|
|
||||||
|
/// Login name — the identity of the account.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Real name as known to the server; null until it was loaded once.
|
||||||
|
final String? displayName;
|
||||||
|
final bool isDemo;
|
||||||
|
|
||||||
|
/// Storage namespace of the account's local data. Empty for the account that
|
||||||
|
/// existed before multi-account support, so its data stays where it was.
|
||||||
|
final String namespace;
|
||||||
|
final int lastUsed;
|
||||||
|
|
||||||
|
const AccountEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.label,
|
||||||
|
required this.namespace,
|
||||||
|
this.displayName,
|
||||||
|
this.isDemo = false,
|
||||||
|
this.lastUsed = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
label: label,
|
||||||
|
namespace: namespace,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
isDemo: isDemo,
|
||||||
|
lastUsed: lastUsed ?? this.lastUsed,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'kind': kind,
|
||||||
|
'label': label,
|
||||||
|
'namespace': namespace,
|
||||||
|
'displayName': displayName,
|
||||||
|
'isDemo': isDemo,
|
||||||
|
'lastUsed': lastUsed,
|
||||||
|
};
|
||||||
|
|
||||||
|
static AccountEntry? fromJson(Object? json) {
|
||||||
|
if (json is! Map) return null;
|
||||||
|
final id = json['id'];
|
||||||
|
final kind = json['kind'];
|
||||||
|
final label = json['label'];
|
||||||
|
if (id is! String || kind is! String || label is! String) return null;
|
||||||
|
return AccountEntry(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
label: label,
|
||||||
|
namespace: json['namespace'] is String ? json['namespace'] as String : id,
|
||||||
|
displayName: json['displayName'] is String
|
||||||
|
? json['displayName'] as String
|
||||||
|
: null,
|
||||||
|
isDemo: json['isDemo'] == true,
|
||||||
|
lastUsed: json['lastUsed'] is int ? json['lastUsed'] as int : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountIndex {
|
||||||
|
final List<AccountEntry> accounts;
|
||||||
|
final String? activeId;
|
||||||
|
|
||||||
|
const AccountIndex({this.accounts = const [], this.activeId});
|
||||||
|
|
||||||
|
static const empty = AccountIndex();
|
||||||
|
|
||||||
|
AccountEntry? get active => byId(activeId);
|
||||||
|
|
||||||
|
AccountEntry? byId(String? id) {
|
||||||
|
if (id == null) return null;
|
||||||
|
for (final entry in accounts) {
|
||||||
|
if (entry.id == id) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entry [session] belongs to — same kind, identity and demo flag.
|
||||||
|
AccountEntry? matching(Session session) {
|
||||||
|
final (kind, label) = identityOf(session);
|
||||||
|
for (final entry in accounts) {
|
||||||
|
if (entry.kind == kind &&
|
||||||
|
entry.label == label &&
|
||||||
|
entry.isDemo == session.isDemo) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes [session] the active account, reusing its entry when it is already
|
||||||
|
/// known and otherwise adding one with [newId] (also its namespace).
|
||||||
|
AccountIndex activate(
|
||||||
|
Session session, {
|
||||||
|
required String newId,
|
||||||
|
String? namespace,
|
||||||
|
int now = 0,
|
||||||
|
}) {
|
||||||
|
final existing = matching(session);
|
||||||
|
if (existing != null) return select(existing.id, now: now);
|
||||||
|
final (kind, label) = identityOf(session);
|
||||||
|
return AccountIndex(
|
||||||
|
accounts: [
|
||||||
|
...accounts,
|
||||||
|
AccountEntry(
|
||||||
|
id: newId,
|
||||||
|
kind: kind,
|
||||||
|
label: label,
|
||||||
|
namespace: namespace ?? newId,
|
||||||
|
isDemo: session.isDemo,
|
||||||
|
lastUsed: now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
activeId: newId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountIndex select(String id, {int now = 0}) => AccountIndex(
|
||||||
|
accounts: accounts
|
||||||
|
.map((e) => e.id == id ? e.copyWith(lastUsed: now) : e)
|
||||||
|
.toList(),
|
||||||
|
activeId: id,
|
||||||
|
);
|
||||||
|
|
||||||
|
AccountIndex rename(String id, String displayName) => AccountIndex(
|
||||||
|
accounts: accounts
|
||||||
|
.map((e) => e.id == id ? e.copyWith(displayName: displayName) : e)
|
||||||
|
.toList(),
|
||||||
|
activeId: activeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Drops [id]; the active account is left unset when it was the one removed.
|
||||||
|
AccountIndex remove(String id) => AccountIndex(
|
||||||
|
accounts: [
|
||||||
|
for (final e in accounts)
|
||||||
|
if (e.id != id) e,
|
||||||
|
],
|
||||||
|
activeId: activeId == id ? null : activeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Most recently used account other than [excludedId], if any.
|
||||||
|
AccountEntry? mostRecentExcept(String? excludedId) {
|
||||||
|
AccountEntry? best;
|
||||||
|
for (final entry in accounts) {
|
||||||
|
if (entry.id == excludedId) continue;
|
||||||
|
if (best == null || entry.lastUsed > best.lastUsed) best = entry;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
String encode() => jsonEncode({
|
||||||
|
'activeId': activeId,
|
||||||
|
'accounts': [for (final e in accounts) e.toJson()],
|
||||||
|
});
|
||||||
|
|
||||||
|
static AccountIndex decode(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return empty;
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(raw);
|
||||||
|
if (json is! Map) return empty;
|
||||||
|
final list = json['accounts'];
|
||||||
|
return AccountIndex(
|
||||||
|
accounts: [
|
||||||
|
if (list is List)
|
||||||
|
for (final item in list) ?AccountEntry.fromJson(item),
|
||||||
|
],
|
||||||
|
activeId: json['activeId'] is String
|
||||||
|
? json['activeId'] as String
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} on FormatException {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(String kind, String label) identityOf(Session session) => switch (session) {
|
||||||
|
CredentialSession(:final username) => (SessionKeys.kindCredential, username),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Keychain fields of [session] including the app passwords, as stored in an
|
||||||
|
/// inactive account's vault. Unset fields are omitted.
|
||||||
|
Map<String, String> sessionVaultFields(Session session) => {
|
||||||
|
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||||
|
key: ?value,
|
||||||
|
SessionKeys.appPassword: ?session.nextcloud?.appPassword,
|
||||||
|
SessionKeys.appPasswordTalk: ?session.nextcloud?.appPasswordTalk,
|
||||||
|
};
|
||||||
|
|
||||||
|
String encodeVault(Map<String, String> fields) => jsonEncode(fields);
|
||||||
|
|
||||||
|
Map<String, String> decodeVault(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return const {};
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(raw);
|
||||||
|
if (json is! Map) return const {};
|
||||||
|
return {
|
||||||
|
for (final MapEntry(:key, :value) in json.entries)
|
||||||
|
if (key is String && value is String) key: value,
|
||||||
|
};
|
||||||
|
} on FormatException {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,36 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../api/marianumcloud/app_password/delete_app_password.dart';
|
||||||
|
import '../api/marianumconnect/auth/auth_interceptor.dart';
|
||||||
|
import '../api/marianumconnect/auth/token_storage.dart';
|
||||||
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||||
|
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
|
||||||
|
import '../background/widget_background_task.dart';
|
||||||
|
import '../model/session_wipe.dart';
|
||||||
import '../push/push_registration.dart';
|
import '../push/push_registration.dart';
|
||||||
|
import '../storage/account_storage.dart';
|
||||||
|
import '../widget_data/widget_sync.dart';
|
||||||
|
import 'session.dart';
|
||||||
import 'session_manager.dart';
|
import 'session_manager.dart';
|
||||||
|
|
||||||
abstract final class SessionLifecycle {
|
abstract final class SessionLifecycle {
|
||||||
/// Why the last sign-out happened, when the user did not ask for it. The
|
/// Why the last sign-out happened, when the user did not ask for it. The
|
||||||
/// login screen shows it once and clears it — without it an expired session
|
/// login screen (or, when another account takes over, the app) shows it
|
||||||
/// just drops the user on the login screen with no explanation.
|
/// once and clears it.
|
||||||
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
|
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
|
||||||
|
|
||||||
|
/// Account to return to while "add account" runs; null otherwise.
|
||||||
|
static String? _addReturnId;
|
||||||
|
|
||||||
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
|
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
|
||||||
/// (while those credentials still exist), then revoke the MC bearer token,
|
/// (while those credentials still exist), then revoke the MC bearer token,
|
||||||
/// finally wipe the local session. Each step is best-effort so an offline
|
/// finally wipe the local session and its data. Each step is best-effort so
|
||||||
/// sign-out still reaches a clean local state.
|
/// an offline sign-out still reaches a clean local state. Another signed-in
|
||||||
static Future<void> signOut({String? notice}) async {
|
/// account takes over when there is one; returns its id.
|
||||||
|
static Future<String?> signOut({String? notice}) async {
|
||||||
signOutNotice.value = notice;
|
signOutNotice.value = notice;
|
||||||
try {
|
try {
|
||||||
await PushRegistration().logoutCleanup();
|
await PushRegistration().logoutCleanup();
|
||||||
@@ -24,6 +38,166 @@ abstract final class SessionLifecycle {
|
|||||||
log('Sign-out: push cleanup failed: $e');
|
log('Sign-out: push cleanup failed: $e');
|
||||||
}
|
}
|
||||||
await AuthLogout().run();
|
await AuthLogout().run();
|
||||||
await SessionManager().signOut();
|
final removed = await SessionManager().signOut();
|
||||||
|
SessionWipe.clearImmediate();
|
||||||
|
await SessionWipe.accountLeft();
|
||||||
|
if (removed != null) await AccountStorage.delete(removed.namespace);
|
||||||
|
|
||||||
|
for (
|
||||||
|
var next = SessionManager().accounts.value.mostRecentExcept(null);
|
||||||
|
next != null;
|
||||||
|
next = SessionManager().accounts.value.mostRecentExcept(null)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await SessionManager().activate(next.id);
|
||||||
|
break;
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Sign-out: taking over ${next.id} failed: $e');
|
||||||
|
await SessionManager().forget(next.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await AccountStorage.activate(SessionManager().activeAccount);
|
||||||
|
if (SessionManager().isSignedIn) await _resetWidget();
|
||||||
|
return SessionManager().activeAccount?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the stored account [id] the active one. Push moves along: the
|
||||||
|
/// previous account is unregistered here, the new one registers once the
|
||||||
|
/// app has remounted for it.
|
||||||
|
static Future<void> switchTo(String id) async {
|
||||||
|
final previous = SessionManager().activeAccount;
|
||||||
|
if (previous?.id == id) return;
|
||||||
|
await MarianumConnectAuthInterceptor.idle();
|
||||||
|
try {
|
||||||
|
await PushRegistration().deactivate();
|
||||||
|
} on Object catch (e) {
|
||||||
|
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);
|
||||||
|
restored = true;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parks the active account before the login screen signs in another one.
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Leaves "add account" without a new account: a half-finished sign-in is
|
||||||
|
/// dropped and the previous account restored.
|
||||||
|
static Future<void> cancelAddAccount() async {
|
||||||
|
final returnId = _addReturnId;
|
||||||
|
_addReturnId = null;
|
||||||
|
if (returnId == null) return;
|
||||||
|
if (SessionManager().activeAccount?.id != returnId &&
|
||||||
|
SessionManager().isSignedIn) {
|
||||||
|
await SessionManager().signOut();
|
||||||
|
}
|
||||||
|
await SessionManager().activate(returnId);
|
||||||
|
await AccountStorage.activate(SessionManager().activeAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once a login finished. After "add account" the previous account's
|
||||||
|
/// push is unregistered with its own credentials before the new one takes
|
||||||
|
/// over. Returns the id of the now active account.
|
||||||
|
static Future<String> finishLogin() async {
|
||||||
|
final returnId = _addReturnId;
|
||||||
|
_addReturnId = null;
|
||||||
|
final added = SessionManager().activeAccount!;
|
||||||
|
if (returnId != null && returnId != added.id) {
|
||||||
|
await SessionManager().stashActive();
|
||||||
|
await SessionManager().activate(returnId);
|
||||||
|
try {
|
||||||
|
await PushRegistration().deactivate();
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Add account: push deactivation failed: $e');
|
||||||
|
}
|
||||||
|
await SessionManager().activate(added.id);
|
||||||
|
await SessionWipe.accountLeft();
|
||||||
|
}
|
||||||
|
await AccountStorage.activate(SessionManager().activeAccount);
|
||||||
|
return added.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the stored real name of the active account (best-effort).
|
||||||
|
static Future<void> refreshDisplayName() async {
|
||||||
|
if (SessionManager().current case final session? when !session.isDemo) {
|
||||||
|
try {
|
||||||
|
final user = await AuthMe().user();
|
||||||
|
final name = '${user.firstName} ${user.lastName}'.trim();
|
||||||
|
if (name.isNotEmpty) await SessionManager().setDisplayName(name);
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Display name refresh failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs out an account that is not active: revokes its tokens with the
|
||||||
|
/// stored credentials (best-effort) and deletes its local data. Its push
|
||||||
|
/// was already unregistered when it stopped being active.
|
||||||
|
static Future<void> removeInactive(String id) async {
|
||||||
|
final entry = SessionManager().accounts.value.byId(id);
|
||||||
|
if (entry == null || entry.id == SessionManager().activeAccount?.id) return;
|
||||||
|
final (session, fields) = await SessionManager().readVault(id);
|
||||||
|
if (session != null && !session.isDemo) {
|
||||||
|
await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]);
|
||||||
|
}
|
||||||
|
await SessionManager().forget(id);
|
||||||
|
await AccountStorage.delete(entry.namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _revoke(Session session, String? token) async {
|
||||||
|
if (token != null && token.isNotEmpty) await AuthLogout.revoke(token);
|
||||||
|
final nextcloud = session.nextcloud;
|
||||||
|
if (nextcloud == null) return;
|
||||||
|
try {
|
||||||
|
if (nextcloud.hasAppPassword) {
|
||||||
|
await DeleteAppPassword().run(
|
||||||
|
authorizationHeader: nextcloud.basicAuthHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nextcloud.hasAppPasswordTalk) {
|
||||||
|
await DeleteAppPassword().run(
|
||||||
|
authorizationHeader: nextcloud.talkBasicAuthHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Remove account: app password revoke failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget still shows the previous account's plan; the app republishes
|
||||||
|
/// once it mounted, the refresh covers a backgrounded app.
|
||||||
|
static Future<void> _resetWidget() async {
|
||||||
|
try {
|
||||||
|
await WidgetSync.clear();
|
||||||
|
await WidgetSync.triggerUpdate();
|
||||||
|
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Widget reset failed: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ import 'dart:async';
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../api/marianumconnect/auth/token_storage.dart';
|
||||||
import '../push/push_secure_storage.dart';
|
import '../push/push_secure_storage.dart';
|
||||||
import '../utils/exponential_backoff.dart';
|
import '../utils/exponential_backoff.dart';
|
||||||
|
import '../utils/random_id.dart';
|
||||||
|
import 'account_codec.dart';
|
||||||
import 'nextcloud_credentials.dart';
|
import 'nextcloud_credentials.dart';
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
import 'session_codec.dart';
|
import 'session_codec.dart';
|
||||||
|
|
||||||
/// Owns the active [Session] and its persistence. One instance per isolate;
|
/// Owns the active [Session] and its persistence. One instance per isolate;
|
||||||
/// the widget background isolate reads the same keychain.
|
/// 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 {
|
class SessionManager {
|
||||||
// `first_unlock` so a background launch on a locked device (silent push,
|
// `first_unlock` so a background launch on a locked device (silent push,
|
||||||
// BGAppRefresh) can still read the session. Items written by older versions
|
// BGAppRefresh) can still read the session. Items written by older versions
|
||||||
@@ -32,6 +41,10 @@ class SessionManager {
|
|||||||
SessionKeys.loginFlow,
|
SessionKeys.loginFlow,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static const _indexKey = 'accounts_index';
|
||||||
|
static String _vaultKey(String id) => 'account_vault_$id';
|
||||||
|
static const _tokenStorage = MarianumConnectTokenStorage();
|
||||||
|
|
||||||
static final SessionManager _instance = SessionManager._();
|
static final SessionManager _instance = SessionManager._();
|
||||||
factory SessionManager() => _instance;
|
factory SessionManager() => _instance;
|
||||||
|
|
||||||
@@ -46,7 +59,8 @@ class SessionManager {
|
|||||||
|
|
||||||
int _sessionEpoch = 0;
|
int _sessionEpoch = 0;
|
||||||
|
|
||||||
/// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its
|
/// 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
|
/// result when it changed meanwhile, so a request of the previous account
|
||||||
/// cannot land in the next account's state or cache.
|
/// cannot land in the next account's state or cache.
|
||||||
int get sessionEpoch => _sessionEpoch;
|
int get sessionEpoch => _sessionEpoch;
|
||||||
@@ -58,6 +72,13 @@ class SessionManager {
|
|||||||
/// Called from `main()`; background entry points never run it.
|
/// Called from `main()`; background entry points never run it.
|
||||||
void markUiEngine() => _isUiEngine = true;
|
void markUiEngine() => _isUiEngine = true;
|
||||||
|
|
||||||
|
/// 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 isSignedIn => _current != null;
|
||||||
|
|
||||||
bool get isDemo => _current?.isDemo ?? false;
|
bool get isDemo => _current?.isDemo ?? false;
|
||||||
@@ -85,8 +106,22 @@ class SessionManager {
|
|||||||
NextcloudCredentials requireNextcloud() =>
|
NextcloudCredentials requireNextcloud() =>
|
||||||
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
|
_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 {
|
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 {
|
||||||
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||||
@@ -101,10 +136,12 @@ class SessionManager {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
_current = session;
|
_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;
|
||||||
_sessionEpoch++;
|
_sessionEpoch++;
|
||||||
_loaded = Completer();
|
_loaded = Completer();
|
||||||
_current = null;
|
_current = null;
|
||||||
@@ -112,14 +149,70 @@ class SessionManager {
|
|||||||
for (final field in _sessionFields) _secureStorage.delete(key: field),
|
for (final field in _sessionFields) _secureStorage.delete(key: field),
|
||||||
_writeGroupSecret(SessionKeys.appPassword, null),
|
_writeGroupSecret(SessionKeys.appPassword, null),
|
||||||
_writeGroupSecret(SessionKeys.appPasswordTalk, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) {
|
/// Parks the active account (session, app passwords, MC token) in its vault
|
||||||
(final CredentialSession a, final CredentialSession b) =>
|
/// so the slots can be taken over by another account.
|
||||||
a.username == b.username && a.isDemo == b.isDemo,
|
Future<void> stashActive() async {
|
||||||
_ => a == b,
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
/// Persists a freshly minted Nextcloud app password; from then on every
|
/// Persists a freshly minted Nextcloud app password; from then on every
|
||||||
/// Nextcloud call authenticates with it instead of the real password.
|
/// Nextcloud call authenticates with it instead of the real password.
|
||||||
@@ -202,6 +295,7 @@ class SessionManager {
|
|||||||
await _migrateFromLegacyStorage();
|
await _migrateFromLegacyStorage();
|
||||||
await _migrateKeychainAccessibility();
|
await _migrateKeychainAccessibility();
|
||||||
_current = await _readActive();
|
_current = await _readActive();
|
||||||
|
await _loadIndex();
|
||||||
if (!_loaded.isCompleted) _loaded.complete();
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,28 +320,60 @@ class SessionManager {
|
|||||||
return decodeSession(raw);
|
return decodeSession(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Username currently in the keystore. Other engines (widget task, push
|
/// Username currently in the active slots. Other engines (widget task, push
|
||||||
/// isolates) sign out or in without this instance noticing.
|
/// isolates) sign out, in or switch without this instance noticing.
|
||||||
Future<String?> readStoredUsername() =>
|
Future<String?> readStoredUsername() =>
|
||||||
_secureStorage.read(key: SessionKeys.username);
|
_secureStorage.read(key: SessionKeys.username);
|
||||||
|
|
||||||
/// Re-reads the session for long-lived background engines: they load once
|
/// Re-reads the session for long-lived background engines: they load once
|
||||||
/// and would otherwise keep acting with an account that signed out in the
|
/// and would otherwise keep acting with an account that signed out or was
|
||||||
/// app meanwhile. Keeps the known state when the keystore is unreadable.
|
/// switched away from in the app meanwhile. Keeps the known state when the
|
||||||
|
/// keystore is unreadable.
|
||||||
Future<void> reloadFromStorage() async {
|
Future<void> reloadFromStorage() async {
|
||||||
// The UI engine performs sign-in and sign-out itself, so its state is
|
// The UI engine performs sign-in, sign-out and switches itself, so its
|
||||||
// current; re-reading mid sign-out could resurrect the removed account.
|
// state is current; re-reading in between could resurrect the removed
|
||||||
|
// account.
|
||||||
if (_isUiEngine) return;
|
if (_isUiEngine) return;
|
||||||
try {
|
try {
|
||||||
final session = await _readActive();
|
final session = await _readActive();
|
||||||
|
final index = AccountIndex.decode(
|
||||||
|
await _secureStorage.read(key: _indexKey),
|
||||||
|
);
|
||||||
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||||
_current = session;
|
_current = session;
|
||||||
|
accounts.value = index;
|
||||||
if (!_loaded.isCompleted) _loaded.complete();
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Session reload failed, keeping loaded state: $e');
|
log('Session reload failed, keeping loaded state: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Move credentials from the old SharedPreferences plain-text storage into the
|
||||||
// platform's secure keystore. Run once per install and clear the legacy keys.
|
// platform's secure keystore. Run once per install and clear the legacy keys.
|
||||||
Future<void> _migrateFromLegacyStorage() async {
|
Future<void> _migrateFromLegacyStorage() async {
|
||||||
|
|||||||
+10
@@ -126,6 +126,14 @@ abstract class LoadableHydratedBloc<
|
|||||||
add(Reset<TState>());
|
add(Reset<TState>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocs are rebuilt per account (see AccountScope). One that briefly
|
||||||
|
// outlives its account would otherwise load with the next account's
|
||||||
|
// credentials and persist the result into its own storage.
|
||||||
|
final int _ownSession = SessionManager().sessionEpoch;
|
||||||
|
|
||||||
|
/// Whether the account this bloc was created for is still the active one.
|
||||||
|
bool get ownsSession => SessionManager().isCurrentSession(_ownSession);
|
||||||
|
|
||||||
static const _sessionKey = #loadableSessionEpoch;
|
static const _sessionKey = #loadableSessionEpoch;
|
||||||
|
|
||||||
/// Runs [body] tagged with the current session: events it adds, also from
|
/// Runs [body] tagged with the current session: events it adds, also from
|
||||||
@@ -136,6 +144,7 @@ abstract class LoadableHydratedBloc<
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void add(LoadableHydratedBlocEvent<TState> event) {
|
void add(LoadableHydratedBlocEvent<TState> event) {
|
||||||
|
if (!ownsSession) return;
|
||||||
final epoch = Zone.current[_sessionKey];
|
final epoch = Zone.current[_sessionKey];
|
||||||
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
|
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
|
||||||
super.add(event);
|
super.add(event);
|
||||||
@@ -173,6 +182,7 @@ abstract class LoadableHydratedBloc<
|
|||||||
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
|
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
|
||||||
|
|
||||||
void fetch() {
|
void fetch() {
|
||||||
|
if (!ownsSession) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
_fetchFlight.run(() {
|
_fetchFlight.run(() {
|
||||||
log('Fetching data for ${TState.toString()}');
|
log('Fetching data for ${TState.toString()}');
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../breaker/bloc/breaker_bloc.dart';
|
||||||
|
import '../capabilities/bloc/capabilities_cubit.dart';
|
||||||
|
import '../chat/bloc/chat_bloc.dart';
|
||||||
|
import '../chat_list/bloc/chat_list_bloc.dart';
|
||||||
|
import '../nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||||
|
import '../timetable/bloc/timetable_bloc.dart';
|
||||||
|
import 'bloc/account_bloc.dart';
|
||||||
|
import 'bloc/account_state.dart';
|
||||||
|
|
||||||
|
/// The blocs of one account. Keyed by the account id above, so a switch
|
||||||
|
/// recreates all of them from the new account's storage instead of resetting
|
||||||
|
/// them one by one.
|
||||||
|
class AccountScope extends StatelessWidget {
|
||||||
|
/// Called with a context below the account's blocs once the scope mounted
|
||||||
|
/// for a signed-in account.
|
||||||
|
final void Function(BuildContext scopeContext) onActive;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const AccountScope({super.key, required this.onActive, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||||
|
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
||||||
|
BlocProvider<NextcloudCapabilitiesCubit>(
|
||||||
|
create: (_) => NextcloudCapabilitiesCubit(),
|
||||||
|
),
|
||||||
|
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||||
|
BlocProvider<ChatBloc>(
|
||||||
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
|
),
|
||||||
|
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
||||||
|
],
|
||||||
|
child: _Activation(onActive: onActive, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Activation extends StatefulWidget {
|
||||||
|
final void Function(BuildContext scopeContext) onActive;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _Activation({required this.onActive, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Activation> createState() => _ActivationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActivationState extends State<_Activation> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (context.read<AccountBloc>().state.status != AccountStatus.loggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.onActive(context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
@@ -4,12 +4,38 @@ import 'account_event.dart';
|
|||||||
import 'account_state.dart';
|
import 'account_state.dart';
|
||||||
|
|
||||||
class AccountBloc extends Bloc<AccountEvent, AccountState> {
|
class AccountBloc extends Bloc<AccountEvent, AccountState> {
|
||||||
AccountBloc({AccountStatus initialStatus = AccountStatus.undefined})
|
AccountBloc({
|
||||||
: super(AccountState(status: initialStatus)) {
|
AccountStatus initialStatus = AccountStatus.undefined,
|
||||||
|
String? accountId,
|
||||||
|
}) : super(AccountState(status: initialStatus, accountId: accountId)) {
|
||||||
on<AccountStatusChanged>(
|
on<AccountStatusChanged>(
|
||||||
(event, emit) => emit(state.copyWith(status: event.status)),
|
(event, emit) => emit(
|
||||||
|
AccountState(
|
||||||
|
status: event.status,
|
||||||
|
accountId: event.status == AccountStatus.loggedOut
|
||||||
|
? null
|
||||||
|
: state.accountId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<AccountActivated>(
|
||||||
|
(event, emit) => emit(
|
||||||
|
AccountState(
|
||||||
|
status: AccountStatus.loggedIn,
|
||||||
|
accountId: event.accountId,
|
||||||
|
freshLogin: event.freshLogin,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setStatus(AccountStatus status) => add(AccountStatusChanged(status));
|
void setStatus(AccountStatus status) => add(AccountStatusChanged(status));
|
||||||
|
|
||||||
|
/// [accountId] became the active account (login, switch or takeover after a
|
||||||
|
/// sign-out); null means no account is left.
|
||||||
|
void activated(String? accountId, {bool freshLogin = false}) => add(
|
||||||
|
accountId == null
|
||||||
|
? const AccountStatusChanged(AccountStatus.loggedOut)
|
||||||
|
: AccountActivated(accountId, freshLogin: freshLogin),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,9 @@ class AccountStatusChanged extends AccountEvent {
|
|||||||
final AccountStatus status;
|
final AccountStatus status;
|
||||||
const AccountStatusChanged(this.status);
|
const AccountStatusChanged(this.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AccountActivated extends AccountEvent {
|
||||||
|
final String accountId;
|
||||||
|
final bool freshLogin;
|
||||||
|
const AccountActivated(this.accountId, {required this.freshLogin});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
enum AccountStatus { undefined, loggedIn, loggedOut }
|
enum AccountStatus { undefined, loggedIn, loggedOut, addingAccount }
|
||||||
|
|
||||||
class AccountState {
|
class AccountState {
|
||||||
final AccountStatus status;
|
final AccountStatus status;
|
||||||
const AccountState({this.status = AccountStatus.undefined});
|
|
||||||
|
|
||||||
AccountState copyWith({AccountStatus? status}) =>
|
/// Active account; the account-scoped part of the app is keyed by it.
|
||||||
AccountState(status: status ?? this.status);
|
final String? accountId;
|
||||||
|
|
||||||
|
/// Set when [accountId] came from a login (not a switch), to show the
|
||||||
|
/// post-login splash.
|
||||||
|
final bool freshLogin;
|
||||||
|
|
||||||
|
const AccountState({
|
||||||
|
this.status = AccountStatus.undefined,
|
||||||
|
this.accountId,
|
||||||
|
this.freshLogin = false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,10 @@ class ChatListBloc
|
|||||||
|
|
||||||
/// Concurrent callers (tab switch, poll, push, resume) join the running
|
/// Concurrent callers (tab switch, poll, push, resume) join the running
|
||||||
/// refresh instead of each fetching and re-emitting the full room list.
|
/// refresh instead of each fetching and re-emitting the full room list.
|
||||||
Future<void> refresh({bool renew = true, bool silent = false}) =>
|
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
||||||
_refreshFlight.run(() => _refresh(renew: renew, silent: silent));
|
if (!ownsSession) return;
|
||||||
|
await _refreshFlight.run(() => _refresh(renew: renew, silent: silent));
|
||||||
|
}
|
||||||
|
|
||||||
/// Skips the refresh when the list was fetched within [maxAge].
|
/// Skips the refresh when the list was fetched within [maxAge].
|
||||||
Future<void> refreshIfOlderThan(Duration maxAge) {
|
Future<void> refreshIfOlderThan(Duration maxAge) {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ class SettingsCubit extends HydratedCubit<Settings> {
|
|||||||
Map<String, dynamic>? _lastEmittedJson;
|
Map<String, dynamic>? _lastEmittedJson;
|
||||||
Timer? _silentSave;
|
Timer? _silentSave;
|
||||||
|
|
||||||
SettingsCubit() : super(DefaultSettings.get());
|
final Storage? _storage;
|
||||||
|
|
||||||
|
// ignore: use_super_parameters
|
||||||
|
SettingsCubit({Storage? storage})
|
||||||
|
: _storage = storage,
|
||||||
|
super(DefaultSettings.get(), storage: storage);
|
||||||
|
|
||||||
Settings val({bool write = false}) {
|
Settings val({bool write = false}) {
|
||||||
if (write) {
|
if (write) {
|
||||||
@@ -50,7 +55,7 @@ class SettingsCubit extends HydratedCubit<Settings> {
|
|||||||
if (_silentSave == null) return;
|
if (_silentSave == null) return;
|
||||||
_silentSave!.cancel();
|
_silentSave!.cancel();
|
||||||
_silentSave = null;
|
_silentSave = null;
|
||||||
HydratedBloc.storage.write(storageToken, state.toJson());
|
(_storage ?? HydratedBloc.storage).write(storageToken, state.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _emitFreshInstance() {
|
void _emitFreshInstance() {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
|
|
||||||
|
import '../api/cache_store.dart';
|
||||||
|
import '../session/account_codec.dart';
|
||||||
|
import 'hydrated_storage_bootstrap.dart';
|
||||||
|
|
||||||
|
/// Per-account local data: every account gets its own HydratedBloc box and
|
||||||
|
/// request cache directory (see [CacheStore]), so switching back is instant
|
||||||
|
/// and offline. Only the app settings live in the shared [global] box.
|
||||||
|
abstract final class AccountStorage {
|
||||||
|
static const _settingsKey = 'SettingsCubit';
|
||||||
|
|
||||||
|
static late String _baseDir;
|
||||||
|
static final Map<String, Storage> _open = {};
|
||||||
|
static Storage _global = InMemoryStorage();
|
||||||
|
|
||||||
|
static Storage get global => _global;
|
||||||
|
|
||||||
|
/// Opens the global box. Settings of installs from before multi-account
|
||||||
|
/// support are copied over from the legacy box once.
|
||||||
|
static Future<void> init(String baseDir) async {
|
||||||
|
_baseDir = baseDir;
|
||||||
|
_global = await buildHydratedStorageWithFallback(
|
||||||
|
_ensureDir('$baseDir/hydrated_global'),
|
||||||
|
);
|
||||||
|
HydratedBloc.storage = InMemoryStorage();
|
||||||
|
if (_global.read(_settingsKey) != null) return;
|
||||||
|
final legacySettings = (await _storageFor('')).read(_settingsKey);
|
||||||
|
if (legacySettings != null) {
|
||||||
|
await _global.write(_settingsKey, legacySettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points HydratedBloc at [account]'s data; the request cache follows the
|
||||||
|
/// active account on its own. Blocs keep the storage they were created
|
||||||
|
/// with, so the per-account blocs have to be recreated afterwards.
|
||||||
|
static Future<void> activate(AccountEntry? account) async {
|
||||||
|
HydratedBloc.storage = account == null
|
||||||
|
? InMemoryStorage()
|
||||||
|
: await _storageFor(account.namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes all local data of the account with [namespace].
|
||||||
|
static Future<void> delete(String namespace) async {
|
||||||
|
try {
|
||||||
|
final storage = await _storageFor(namespace);
|
||||||
|
await storage.clear();
|
||||||
|
// Closed boxes ignore writes, so blocs of the account that are still
|
||||||
|
// mounted until the remount cannot leave data behind.
|
||||||
|
await storage.close();
|
||||||
|
_open.remove(namespace);
|
||||||
|
if (namespace.isNotEmpty) {
|
||||||
|
final dir = Directory(_dirFor(namespace));
|
||||||
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
} on Object catch (e, s) {
|
||||||
|
log('Account storage delete failed: $e', stackTrace: s);
|
||||||
|
}
|
||||||
|
await CacheStore.instance.deleteNamespace(namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Storage> _storageFor(String namespace) async =>
|
||||||
|
_open[namespace] ??= await buildHydratedStorageWithFallback(
|
||||||
|
_ensureDir(_dirFor(namespace)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The legacy account keeps the box at the root of the base directory.
|
||||||
|
static String _dirFor(String namespace) =>
|
||||||
|
namespace.isEmpty ? _baseDir : '$_baseDir/accounts/$namespace';
|
||||||
|
|
||||||
|
static String _ensureDir(String path) {
|
||||||
|
Directory(path).createSync(recursive: true);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,19 +9,43 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
Future<Storage> buildHydratedStorageWithFallback(String directoryPath) async {
|
Future<Storage> buildHydratedStorageWithFallback(String directoryPath) async {
|
||||||
final directory = HydratedStorageDirectory(directoryPath);
|
final directory = HydratedStorageDirectory(directoryPath);
|
||||||
try {
|
try {
|
||||||
return await HydratedStorage.build(storageDirectory: directory);
|
return await _build(directory);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('HydratedStorage open failed, rebuilding: $e', stackTrace: s);
|
log('HydratedStorage open failed, rebuilding: $e', stackTrace: s);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await _deleteHydratedBox(directoryPath);
|
await _deleteHydratedBox(directoryPath);
|
||||||
return await HydratedStorage.build(storageDirectory: directory);
|
return await _build(directory);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s);
|
log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s);
|
||||||
return InMemoryStorage();
|
return InMemoryStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Expando<List<String> Function()> _keyReaders = Expando();
|
||||||
|
|
||||||
|
Future<Storage> _build(HydratedStorageDirectory directory) async {
|
||||||
|
final storage = await HydratedStorage.build(storageDirectory: directory);
|
||||||
|
// hydrated_bloc exposes no key listing; the Hive instance is the only way
|
||||||
|
// in. Every build replaces the static one, so it is captured per storage.
|
||||||
|
// ignore: invalid_use_of_visible_for_testing_member
|
||||||
|
final hive = HydratedStorage.hive;
|
||||||
|
_keyReaders[storage] = () =>
|
||||||
|
hive.box<dynamic>('hydrated_box').keys.map((k) => '$k').toList();
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All keys persisted in [storage], sorted; `null` when they can't be
|
||||||
|
/// enumerated.
|
||||||
|
List<String>? hydratedStorageKeys(Storage storage) {
|
||||||
|
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
|
||||||
|
try {
|
||||||
|
return _keyReaders[storage]?.call()?..sort();
|
||||||
|
} on Object {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _deleteHydratedBox(String directoryPath) async {
|
Future<void> _deleteHydratedBox(String directoryPath) async {
|
||||||
final directory = Directory(directoryPath);
|
final directory = Directory(directoryPath);
|
||||||
if (!directory.existsSync()) return;
|
if (!directory.existsSync()) return;
|
||||||
|
|||||||
+92
-45
@@ -1,12 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../background/widget_background_task.dart';
|
import '../../background/widget_background_task.dart';
|
||||||
import '../../session/session_lifecycle.dart';
|
import '../../session/session_lifecycle.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_state.dart';
|
|
||||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../storage/dev_tools_settings.dart';
|
import '../../storage/dev_tools_settings.dart';
|
||||||
import '../../storage/settings.dart' as model;
|
import '../../storage/settings.dart' as model;
|
||||||
@@ -20,7 +21,10 @@ import 'widgets/login_branding.dart';
|
|||||||
import 'widgets/login_card.dart';
|
import 'widgets/login_card.dart';
|
||||||
|
|
||||||
class Login extends StatefulWidget {
|
class Login extends StatefulWidget {
|
||||||
const Login({super.key});
|
/// Signs in another account while one is active; offers to go back.
|
||||||
|
final bool addingAccount;
|
||||||
|
|
||||||
|
const Login({super.key, this.addingAccount = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<Login> createState() => _LoginState();
|
State<Login> createState() => _LoginState();
|
||||||
@@ -65,59 +69,100 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLoginSuccess() {
|
Future<void> _onLoginSuccess() async {
|
||||||
Haptics.heavyAccent();
|
Haptics.heavyAccent();
|
||||||
|
final accountBloc = context.read<AccountBloc>();
|
||||||
|
// Fade the login content out before handing over to the post-login splash.
|
||||||
|
// Both share the red backdrop, so this reads as one continuous transition
|
||||||
|
// instead of an abrupt swap.
|
||||||
|
final finished = SessionLifecycle.finishLogin();
|
||||||
|
await _fade.reverse().orCancel.onError<TickerCanceled>((_, _) {});
|
||||||
|
String? accountId;
|
||||||
|
try {
|
||||||
|
accountId = await finished;
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Login: finishing failed: $e');
|
||||||
|
accountId = SessionManager().activeAccount?.id;
|
||||||
|
}
|
||||||
// Re-register the periodic refresh (cancelAll runs on logout) and kick
|
// Re-register the periodic refresh (cancelAll runs on logout) and kick
|
||||||
// off an immediate one-off so the widget populates within seconds
|
// off an immediate one-off so the widget populates within seconds
|
||||||
// instead of waiting up to 30 minutes for the next periodic slot.
|
// instead of waiting up to 30 minutes for the next periodic slot.
|
||||||
unawaited(WidgetBackgroundTask.initialize());
|
unawaited(WidgetBackgroundTask.initialize());
|
||||||
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
|
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
|
||||||
// Fade the login content out before handing over to the post-login splash.
|
accountBloc.activated(accountId, freshLogin: true);
|
||||||
// Both share the red backdrop, so this reads as one continuous transition
|
}
|
||||||
// instead of an abrupt swap.
|
|
||||||
_fade.reverse().whenComplete(() {
|
Future<void> _cancelAddAccount() async {
|
||||||
if (!mounted) return;
|
final accountBloc = context.read<AccountBloc>();
|
||||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
|
await SessionLifecycle.cancelAddAccount();
|
||||||
});
|
accountBloc.activated(SessionManager().activeAccount?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => PopScope(
|
||||||
backgroundColor: _marianumRed,
|
canPop: !widget.addingAccount,
|
||||||
body: FadeTransition(
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
opacity: _fade,
|
if (!didPop && !_busy) unawaited(_cancelAddAccount());
|
||||||
child: SafeArea(
|
},
|
||||||
child: LayoutBuilder(
|
child: Scaffold(
|
||||||
builder: (context, constraints) => SingleChildScrollView(
|
backgroundColor: _marianumRed,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
appBar: widget.addingAccount
|
||||||
child: Center(
|
? AppBar(
|
||||||
child: ConstrainedBox(
|
backgroundColor: Colors.transparent,
|
||||||
constraints: BoxConstraints(
|
foregroundColor: Colors.white,
|
||||||
minHeight: constraints.maxHeight,
|
elevation: 0,
|
||||||
maxWidth: 420,
|
leading: ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) => IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Abbrechen',
|
||||||
|
onPressed: _busy ? null : _cancelAddAccount,
|
||||||
),
|
),
|
||||||
// spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde
|
),
|
||||||
// die Column bei Inhaltsänderungen im unteren Block auf die
|
title: const Text('Konto hinzufügen'),
|
||||||
// intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen.
|
)
|
||||||
child: Column(
|
: null,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
body: FadeTransition(
|
||||||
children: [
|
opacity: _fade,
|
||||||
Column(
|
child: SafeArea(
|
||||||
children: [
|
child: LayoutBuilder(
|
||||||
const LoginHeader(),
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
const SizedBox(height: 28),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
LoginCard(
|
child: Center(
|
||||||
controller: _controller,
|
child: ConstrainedBox(
|
||||||
onSuccess: _onLoginSuccess,
|
constraints: BoxConstraints(
|
||||||
),
|
minHeight: constraints.maxHeight,
|
||||||
const SizedBox(height: 12),
|
maxWidth: 420,
|
||||||
],
|
),
|
||||||
),
|
// spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde
|
||||||
const Column(
|
// die Column bei Inhaltsänderungen im unteren Block auf die
|
||||||
mainAxisSize: MainAxisSize.min,
|
// intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen.
|
||||||
children: [_EndpointLink(), LoginFooter()],
|
child: Column(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
],
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
LoginHeader(addingAccount: widget.addingAccount),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
LoginCard(
|
||||||
|
controller: _controller,
|
||||||
|
onSuccess: _onLoginSuccess,
|
||||||
|
addingAccount: widget.addingAccount,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// The new account must live on the server the app
|
||||||
|
// already talks to.
|
||||||
|
if (!widget.addingAccount) const _EndpointLink(),
|
||||||
|
const LoginFooter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -126,6 +171,8 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
bool get _busy => _controller.loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subtle text link above the footer that surfaces the currently selected
|
/// Subtle text link above the footer that surfaces the currently selected
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class LoginHeader extends StatelessWidget {
|
class LoginHeader extends StatelessWidget {
|
||||||
const LoginHeader({super.key});
|
/// Signs in an additional account instead of the first one.
|
||||||
|
final bool addingAccount;
|
||||||
|
|
||||||
|
const LoginHeader({this.addingAccount = false, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Column(
|
Widget build(BuildContext context) => Column(
|
||||||
@@ -29,7 +32,10 @@ class LoginHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.',
|
addingAccount
|
||||||
|
? 'Melde ein weiteres Konto an. In den Einstellungen kannst du '
|
||||||
|
'danach jederzeit zwischen deinen Konten wechseln.'
|
||||||
|
: 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.85),
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ class LoginCard extends StatefulWidget {
|
|||||||
final LoginController controller;
|
final LoginController controller;
|
||||||
final VoidCallback onSuccess;
|
final VoidCallback onSuccess;
|
||||||
|
|
||||||
|
/// Signs in an additional account instead of the first one.
|
||||||
|
final bool addingAccount;
|
||||||
|
|
||||||
const LoginCard({
|
const LoginCard({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.onSuccess,
|
required this.onSuccess,
|
||||||
|
this.addingAccount = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,8 +87,11 @@ class _LoginCardState extends State<LoginCard> {
|
|||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: LoginCardFrame(
|
child: LoginCardFrame(
|
||||||
title: 'Anmelden',
|
title: widget.addingAccount ? 'Konto hinzufügen' : 'Anmelden',
|
||||||
hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
|
hint: widget.addingAccount
|
||||||
|
? 'Melde dich mit den Marianum-Zugangsdaten des Kontos an, das '
|
||||||
|
'du hinzufügen möchtest.'
|
||||||
|
: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: const Key('login-username-field'),
|
key: const Key('login-username-field'),
|
||||||
@@ -127,7 +134,7 @@ class _LoginCardState extends State<LoginCard> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
LoginSubmitButton(
|
LoginSubmitButton(
|
||||||
key: const Key('login-submit-button'),
|
key: const Key('login-submit-button'),
|
||||||
label: 'Anmelden',
|
label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden',
|
||||||
loading: loading,
|
loading: loading,
|
||||||
onPressed: _submit,
|
onPressed: _submit,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import '../../../extensions/date_time.dart';
|
|||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../../push/push_registration_store.dart';
|
import '../../../push/push_registration_store.dart';
|
||||||
import '../../../push/push_registration_type.dart';
|
import '../../../push/push_registration_type.dart';
|
||||||
import '../../../session/session.dart';
|
import '../../../session/account_codec.dart';
|
||||||
import '../../../session/session_manager.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
@@ -103,10 +103,9 @@ class _DiagnosticsPageState extends State<DiagnosticsPage> {
|
|||||||
title: 'Sitzung',
|
title: 'Sitzung',
|
||||||
rows: await rows({
|
rows: await rows({
|
||||||
'Status': () async => accountStatus.name,
|
'Status': () async => accountStatus.name,
|
||||||
'Benutzer': () async => switch (session) {
|
'Benutzer': () async =>
|
||||||
CredentialSession(:final username) => username,
|
session == null ? null : identityOf(session).$2,
|
||||||
null => null,
|
'Konten': () async => SessionManager().accounts.value.accounts.length,
|
||||||
},
|
|
||||||
'Demo-Modus': () async => session?.isDemo ?? false,
|
'Demo-Modus': () async => session?.isDemo ?? false,
|
||||||
'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false,
|
'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false,
|
||||||
'App-Passwort (Dateien)': () async =>
|
'App-Passwort (Dateien)': () async =>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../../../../routing/app_routes.dart';
|
|||||||
import '../../../../session/session_lifecycle.dart';
|
import '../../../../session/session_lifecycle.dart';
|
||||||
import '../../../../session/session_manager.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
import '../../../../widget/account_switcher_sheet.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/avatar_actions_sheet.dart';
|
import '../../../../widget/avatar_actions_sheet.dart';
|
||||||
@@ -17,34 +17,43 @@ import '../../../../widget/confirm_dialog.dart';
|
|||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/user_avatar.dart';
|
import '../../../../widget/user_avatar.dart';
|
||||||
|
|
||||||
// Display-name is process-wide stable until the user logs out; cache it so
|
// Display-name is stable per account; cache it so every Settings rebuild
|
||||||
// every Settings rebuild doesn't re-issue the OCS request.
|
// doesn't re-issue the OCS request.
|
||||||
String? _cachedDisplayName;
|
String? _cachedDisplayName;
|
||||||
|
String? _cachedDisplayNameFor;
|
||||||
|
|
||||||
class AccountSection extends StatefulWidget {
|
class AccountSection extends StatelessWidget {
|
||||||
const AccountSection({super.key});
|
const AccountSection({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AccountSection> createState() => _AccountSectionState();
|
Widget build(BuildContext context) => const _SchoolAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AccountSectionState extends State<AccountSection> {
|
class _SchoolAccount extends StatefulWidget {
|
||||||
|
const _SchoolAccount();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SchoolAccount> createState() => _SchoolAccountState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SchoolAccountState extends State<_SchoolAccount> {
|
||||||
int _avatarVersion = 0;
|
int _avatarVersion = 0;
|
||||||
bool _avatarBusy = false;
|
bool _avatarBusy = false;
|
||||||
String? _displayName = _cachedDisplayName;
|
String? _displayName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (_displayName == null) _loadDisplayName();
|
final username = SessionManager().requireNextcloud().username;
|
||||||
|
if (_cachedDisplayNameFor == username) _displayName = _cachedDisplayName;
|
||||||
|
if (_displayName == null) _loadDisplayName(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDisplayName() async {
|
Future<void> _loadDisplayName(String username) async {
|
||||||
try {
|
try {
|
||||||
final info = await GetUserInfo().run();
|
final info = await GetUserInfo().run();
|
||||||
_cachedDisplayName = info.displayName.isEmpty
|
_cachedDisplayNameFor = username;
|
||||||
? null
|
_cachedDisplayName = info.displayName.isEmpty ? null : info.displayName;
|
||||||
: info.displayName;
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _displayName = _cachedDisplayName);
|
setState(() => _displayName = _cachedDisplayName);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -102,7 +111,7 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 20, 8, 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -113,8 +122,10 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () =>
|
onTap: () => AppRoutes.openLargeProfilePicture(
|
||||||
AppRoutes.openLargeProfilePicture(context, username),
|
context,
|
||||||
|
username,
|
||||||
|
),
|
||||||
child: UserAvatar(
|
child: UserAvatar(
|
||||||
key: ValueKey(_avatarVersion),
|
key: ValueKey(_avatarVersion),
|
||||||
id: username,
|
id: username,
|
||||||
@@ -134,7 +145,7 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -167,12 +178,8 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
TextButton.icon(
|
const _AccountActions(),
|
||||||
icon: const Icon(Icons.logout_outlined, size: 18),
|
|
||||||
label: const Text('Abmelden'),
|
|
||||||
onPressed: () => _showLogoutDialog(context),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -183,9 +190,7 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
AsyncListTile(
|
AsyncListTile(
|
||||||
leading: const Icon(Icons.cloud_sync_outlined),
|
leading: const Icon(Icons.cloud_sync_outlined),
|
||||||
title: const Text('Nextcloud neu verbinden'),
|
title: const Text('Nextcloud neu verbinden'),
|
||||||
subtitle: const Text(
|
subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'),
|
||||||
'Bei Anmeldeproblemen in Talk oder Dateien',
|
|
||||||
),
|
|
||||||
closeOnSuccess: false,
|
closeOnSuccess: false,
|
||||||
onPressed: _reconnectNextcloud,
|
onPressed: _reconnectNextcloud,
|
||||||
),
|
),
|
||||||
@@ -204,29 +209,49 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showLogoutDialog(BuildContext context) async {
|
Future<void> _confirmLogout(BuildContext context) async {
|
||||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
final accountBloc = context.read<AccountBloc>();
|
||||||
// inside the sign-out (the previous approach) raced AsyncDialogAction's
|
final others = SessionManager().accounts.value.accounts.length - 1;
|
||||||
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
String? nextAccountId;
|
||||||
// navigator in an inconsistent state.
|
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||||
final confirmed = await showDialog<bool>(
|
// inside the sign-out (the previous approach) raced AsyncDialogAction's
|
||||||
context: context,
|
// pop(true) against the navigator teardown of the account switch.
|
||||||
builder: (dialogContext) => ConfirmDialog(
|
final confirmed = await showDialog<bool>(
|
||||||
title: 'Abmelden?',
|
context: context,
|
||||||
content: 'Möchtest du dich wirklich abmelden?',
|
builder: (dialogContext) => ConfirmDialog(
|
||||||
confirmButton: 'Abmelden',
|
title: 'Abmelden?',
|
||||||
onConfirmAsync: _performLogout,
|
content: others > 0
|
||||||
|
? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu '
|
||||||
|
'einem deiner anderen Konten.'
|
||||||
|
: 'Möchtest du dich wirklich abmelden?',
|
||||||
|
confirmButton: 'Abmelden',
|
||||||
|
onConfirmAsync: () async =>
|
||||||
|
nextAccountId = await SessionLifecycle.signOut(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
accountBloc.activated(nextAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountActions extends StatelessWidget {
|
||||||
|
const _AccountActions();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
style: compactAccountButtonStyle,
|
||||||
|
icon: const Icon(Icons.logout_outlined, size: 18),
|
||||||
|
label: const Text('Abmelden'),
|
||||||
|
onPressed: () => _confirmLogout(context),
|
||||||
),
|
),
|
||||||
);
|
const AccountSwitchButton(),
|
||||||
if (confirmed != true || !context.mounted) return;
|
],
|
||||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _performLogout() async {
|
|
||||||
await SessionLifecycle.signOut();
|
|
||||||
_cachedDisplayName = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarEditBadge extends StatelessWidget {
|
class _AvatarEditBadge extends StatelessWidget {
|
||||||
@@ -254,11 +279,7 @@ class _AvatarEditBadge extends StatelessWidget {
|
|||||||
color: theme.colorScheme.onPrimary,
|
color: theme.colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary),
|
||||||
Icons.edit,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../session/account_codec.dart';
|
||||||
|
import '../session/session_lifecycle.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
|
import '../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
|
import '../state/app/modules/account/bloc/account_state.dart';
|
||||||
|
import '../utils/haptics.dart';
|
||||||
|
import 'app_progress_indicator.dart';
|
||||||
|
import 'async_action_button.dart';
|
||||||
|
import 'centered_leading.dart';
|
||||||
|
import 'confirm_dialog.dart';
|
||||||
|
import 'details_bottom_sheet.dart';
|
||||||
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
|
/// Lists the signed-in accounts: tap one to switch, add another, or sign out
|
||||||
|
/// of an inactive one.
|
||||||
|
Future<void> showAccountSwitcherSheet(BuildContext context) {
|
||||||
|
final accountBloc = context.read<AccountBloc>();
|
||||||
|
return showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
header: Builder(
|
||||||
|
builder: (headerContext) => ListTile(
|
||||||
|
title: const Text('Konten'),
|
||||||
|
trailing: TextButton.icon(
|
||||||
|
icon: const Icon(Icons.person_add_alt_outlined, size: 18),
|
||||||
|
label: const Text('Hinzufügen'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(headerContext);
|
||||||
|
unawaited(startAddAccount(accountBloc));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: (sheetContext) => [
|
||||||
|
ValueListenableBuilder<AccountIndex>(
|
||||||
|
valueListenable: SessionManager().accounts,
|
||||||
|
builder: (context, index, _) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final account in index.accounts)
|
||||||
|
_AccountTile(
|
||||||
|
account: account,
|
||||||
|
active: account.id == index.activeId,
|
||||||
|
accountBloc: accountBloc,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the login for another account; the active one is parked meanwhile.
|
||||||
|
Future<void> startAddAccount(AccountBloc accountBloc) async {
|
||||||
|
await SessionLifecycle.beginAddAccount();
|
||||||
|
accountBloc.setStatus(AccountStatus.addingAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stacked next to the account name, which needs the width more.
|
||||||
|
/// Only the horizontal padding is trimmed; height stays at a full tap target.
|
||||||
|
final ButtonStyle compactAccountButtonStyle = TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Below the sign-out button: adds an account, or opens the switcher once
|
||||||
|
/// there is more than one.
|
||||||
|
class AccountSwitchButton extends StatelessWidget {
|
||||||
|
const AccountSwitchButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ValueListenableBuilder<AccountIndex>(
|
||||||
|
valueListenable: SessionManager().accounts,
|
||||||
|
builder: (context, index, _) => index.accounts.length > 1
|
||||||
|
? TextButton.icon(
|
||||||
|
style: compactAccountButtonStyle,
|
||||||
|
icon: const Icon(Icons.switch_account_outlined, size: 18),
|
||||||
|
label: const Text('Wechseln'),
|
||||||
|
onPressed: () => showAccountSwitcherSheet(context),
|
||||||
|
)
|
||||||
|
: TextButton.icon(
|
||||||
|
style: compactAccountButtonStyle,
|
||||||
|
icon: const Icon(Icons.person_add_alt_outlined, size: 18),
|
||||||
|
label: const Text('Hinzufügen'),
|
||||||
|
onPressed: () => startAddAccount(context.read<AccountBloc>()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountTile extends StatefulWidget {
|
||||||
|
final AccountEntry account;
|
||||||
|
final bool active;
|
||||||
|
final AccountBloc accountBloc;
|
||||||
|
|
||||||
|
const _AccountTile({
|
||||||
|
required this.account,
|
||||||
|
required this.active,
|
||||||
|
required this.accountBloc,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AccountTile> createState() => _AccountTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountTileState extends State<_AccountTile> {
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
Future<void> _switch() async {
|
||||||
|
Haptics.selection();
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final ok = await runWithErrorDialog(
|
||||||
|
context,
|
||||||
|
() => SessionLifecycle.switchTo(widget.account.id),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _busy = false);
|
||||||
|
if (!ok) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
widget.accountBloc.activated(SessionManager().activeAccount?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmRemove() => ConfirmDialog(
|
||||||
|
title: 'Abmelden?',
|
||||||
|
content:
|
||||||
|
'${widget.account.displayName ?? widget.account.label} wird von diesem Gerät abgemeldet und seine '
|
||||||
|
'lokal gespeicherten Daten werden gelöscht.',
|
||||||
|
confirmButton: 'Abmelden',
|
||||||
|
onConfirmAsync: () => SessionLifecycle.removeInactive(widget.account.id),
|
||||||
|
).asDialog(context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final account = widget.account;
|
||||||
|
return ListTile(
|
||||||
|
leading: CenteredLeading(
|
||||||
|
_busy
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 24,
|
||||||
|
child: AppProgressIndicator.small(),
|
||||||
|
)
|
||||||
|
: _AccountAvatar(account: account),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
account.displayName ?? account.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
account.isDemo ? '${account.label} (Demo)' : account.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
// Sized like the IconButton so both line up.
|
||||||
|
trailing: widget.active
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: kMinInteractiveDimension,
|
||||||
|
child: Icon(Icons.check),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.logout_outlined),
|
||||||
|
tooltip: 'Abmelden',
|
||||||
|
onPressed: _busy ? null : _confirmRemove,
|
||||||
|
),
|
||||||
|
onTap: widget.active || _busy ? null : _switch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountAvatar extends StatelessWidget {
|
||||||
|
final AccountEntry account;
|
||||||
|
|
||||||
|
const _AccountAvatar({required this.account});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Demo accounts have no real Nextcloud user behind them.
|
||||||
|
if (!account.isDemo) return UserAvatar(id: account.label, size: 16);
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: colors.secondaryContainer,
|
||||||
|
foregroundColor: colors.onSecondaryContainer,
|
||||||
|
child: Text(account.label.characters.first.toUpperCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,30 +15,38 @@ import '../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
|||||||
import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
|
import '../../storage/account_storage.dart';
|
||||||
import '../../storage/hydrated_storage_bootstrap.dart';
|
import '../../storage/hydrated_storage_bootstrap.dart';
|
||||||
import '../confirm_dialog.dart';
|
import '../confirm_dialog.dart';
|
||||||
import '../placeholder_view.dart';
|
import '../placeholder_view.dart';
|
||||||
import 'json_viewer.dart';
|
import 'json_viewer.dart';
|
||||||
|
|
||||||
/// Lists every key persisted through [HydratedBloc.storage].
|
/// Lists every key persisted by the active account's blocs and the app-wide
|
||||||
|
/// settings.
|
||||||
class BlocStorageView extends StatefulWidget {
|
class BlocStorageView extends StatefulWidget {
|
||||||
const BlocStorageView({super.key});
|
const BlocStorageView({super.key});
|
||||||
|
|
||||||
/// All persisted keys; `null` when the storage can't be enumerated.
|
static List<Storage> get _storages => [
|
||||||
|
HydratedBloc.storage,
|
||||||
|
AccountStorage.global,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All persisted keys; `null` when a storage can't be enumerated.
|
||||||
static List<String>? keys() {
|
static List<String>? keys() {
|
||||||
final storage = HydratedBloc.storage;
|
final keys = <String>{};
|
||||||
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
|
for (final storage in _storages) {
|
||||||
try {
|
final own = hydratedStorageKeys(storage);
|
||||||
// hydrated_bloc exposes no key listing; the Hive instance is the only
|
if (own == null) return null;
|
||||||
// way in.
|
keys.addAll(own);
|
||||||
// ignore: invalid_use_of_visible_for_testing_member
|
|
||||||
final box = HydratedStorage.hive.box<dynamic>('hydrated_box');
|
|
||||||
return box.keys.map((k) => '$k').toList()..sort();
|
|
||||||
} on Object {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return keys.toList()..sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Storage _storageOf(String key) => _storages.firstWhere(
|
||||||
|
(storage) => hydratedStorageKeys(storage)?.contains(key) ?? false,
|
||||||
|
orElse: () => HydratedBloc.storage,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BlocStorageView> createState() => _BlocStorageViewState();
|
State<BlocStorageView> createState() => _BlocStorageViewState();
|
||||||
}
|
}
|
||||||
@@ -75,7 +83,7 @@ class _BlocStorageViewState extends State<BlocStorageView> {
|
|||||||
final keys = BlocStorageView.keys();
|
final keys = BlocStorageView.keys();
|
||||||
setState(
|
setState(
|
||||||
() => _entries = keys
|
() => _entries = keys
|
||||||
?.map((k) => _Entry(k, HydratedBloc.storage.read(k)))
|
?.map((k) => _Entry(k, BlocStorageView._storageOf(k).read(k)))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +122,7 @@ class _BlocStorageViewState extends State<BlocStorageView> {
|
|||||||
if (reset != null) {
|
if (reset != null) {
|
||||||
await reset();
|
await reset();
|
||||||
} else {
|
} else {
|
||||||
await HydratedBloc.storage.delete(key);
|
await BlocStorageView._storageOf(key).delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,20 @@ void main() {
|
|||||||
expect(CacheStore.parse('abc\n{}'), isNull);
|
expect(CacheStore.parse('abc\n{}'), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('CacheStore.directoryName', () {
|
||||||
|
test('keeps the original directory for the pre-multi-account account', () {
|
||||||
|
expect(CacheStore.directoryName(''), 'request_cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separates accounts and the signed-out state', () {
|
||||||
|
final names = {
|
||||||
|
CacheStore.directoryName(''),
|
||||||
|
CacheStore.directoryName('a1b2'),
|
||||||
|
CacheStore.directoryName('c3d4'),
|
||||||
|
CacheStore.directoryName(null),
|
||||||
|
};
|
||||||
|
expect(names, hasLength(4));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/session/account_codec.dart';
|
||||||
|
import 'package:marianum_mobile/session/session.dart';
|
||||||
|
import 'package:marianum_mobile/session/session_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final school = CredentialSession(username: 'max', password: 'pw');
|
||||||
|
final second = CredentialSession(username: 'erika', password: 'pw');
|
||||||
|
|
||||||
|
group('AccountIndex.activate', () {
|
||||||
|
test('adds an unknown account with its own namespace', () {
|
||||||
|
final index = AccountIndex.empty.activate(school, newId: 'a', now: 5);
|
||||||
|
expect(index.activeId, 'a');
|
||||||
|
final entry = index.active!;
|
||||||
|
expect(entry.kind, SessionKeys.kindCredential);
|
||||||
|
expect(entry.label, 'max');
|
||||||
|
expect(entry.namespace, 'a');
|
||||||
|
expect(entry.lastUsed, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy account keeps the un-namespaced storage', () {
|
||||||
|
final index = AccountIndex.empty.activate(
|
||||||
|
school,
|
||||||
|
newId: 'a',
|
||||||
|
namespace: '',
|
||||||
|
);
|
||||||
|
expect(index.active!.namespace, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a known account is reused instead of duplicated', () {
|
||||||
|
final index = AccountIndex.empty
|
||||||
|
.activate(school, newId: 'a')
|
||||||
|
.activate(second, newId: 'b')
|
||||||
|
.activate(
|
||||||
|
CredentialSession(username: 'max', password: 'new'),
|
||||||
|
newId: 'c',
|
||||||
|
now: 9,
|
||||||
|
);
|
||||||
|
expect(index.accounts.map((e) => e.id), ['a', 'b']);
|
||||||
|
expect(index.activeId, 'a');
|
||||||
|
expect(index.active!.lastUsed, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demo and real account with the same name stay apart', () {
|
||||||
|
final index = AccountIndex.empty
|
||||||
|
.activate(school, newId: 'a')
|
||||||
|
.activate(
|
||||||
|
CredentialSession(username: 'max', password: 'demo', isDemo: true),
|
||||||
|
newId: 'b',
|
||||||
|
);
|
||||||
|
expect(index.accounts, hasLength(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AccountIndex removal', () {
|
||||||
|
final index = AccountIndex.empty
|
||||||
|
.activate(school, newId: 'a', now: 1)
|
||||||
|
.activate(second, newId: 'b', now: 3)
|
||||||
|
.select('a', now: 2);
|
||||||
|
|
||||||
|
test('removing the active account leaves none active', () {
|
||||||
|
final removed = index.remove('a');
|
||||||
|
expect(removed.activeId, isNull);
|
||||||
|
expect(removed.accounts.map((e) => e.id), ['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing an inactive account keeps the active one', () {
|
||||||
|
expect(index.remove('b').activeId, 'a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('takeover picks the most recently used remaining account', () {
|
||||||
|
final third = index.activate(
|
||||||
|
CredentialSession(username: 'zoe', password: 'pw'),
|
||||||
|
newId: 'c',
|
||||||
|
now: 1,
|
||||||
|
);
|
||||||
|
expect(third.remove('a').mostRecentExcept(null)?.id, 'b');
|
||||||
|
expect(index.mostRecentExcept('b')?.id, 'a');
|
||||||
|
expect(AccountIndex.empty.mostRecentExcept(null), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AccountIndex codec', () {
|
||||||
|
test('round trip', () {
|
||||||
|
final index = AccountIndex.empty
|
||||||
|
.activate(school, newId: 'a', namespace: '', now: 1)
|
||||||
|
.activate(second, newId: 'b', now: 2);
|
||||||
|
final decoded = AccountIndex.decode(index.encode());
|
||||||
|
expect(decoded.activeId, 'b');
|
||||||
|
expect(decoded.accounts, hasLength(2));
|
||||||
|
expect(decoded.byId('a')!.namespace, '');
|
||||||
|
expect(decoded.byId('b')!.label, 'erika');
|
||||||
|
expect(decoded.byId('b')!.lastUsed, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display name survives rename and round trip', () {
|
||||||
|
final index = AccountIndex.empty
|
||||||
|
.activate(school, newId: 'a')
|
||||||
|
.activate(second, newId: 'b')
|
||||||
|
.rename('b', 'Erika Muster');
|
||||||
|
final decoded = AccountIndex.decode(index.encode());
|
||||||
|
expect(decoded.byId('b')!.displayName, 'Erika Muster');
|
||||||
|
expect(decoded.byId('a')!.displayName, isNull);
|
||||||
|
expect(decoded.activeId, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('garbage decodes to an empty index', () {
|
||||||
|
expect(AccountIndex.decode(null).accounts, isEmpty);
|
||||||
|
expect(AccountIndex.decode('{nope').accounts, isEmpty);
|
||||||
|
expect(AccountIndex.decode('[]').accounts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed entries are skipped', () {
|
||||||
|
final decoded = AccountIndex.decode(
|
||||||
|
'{"activeId":"a","accounts":[{"id":"a"},'
|
||||||
|
'{"id":"b","kind":"credential","label":"x"}]}',
|
||||||
|
);
|
||||||
|
expect(decoded.accounts.map((e) => e.id), ['b']);
|
||||||
|
expect(decoded.active, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('vault', () {
|
||||||
|
test('credential session survives the vault with app passwords', () {
|
||||||
|
final session = CredentialSession(
|
||||||
|
username: 'max',
|
||||||
|
password: 'pw',
|
||||||
|
appPassword: 'app',
|
||||||
|
appPasswordTalk: 'talk',
|
||||||
|
usesLoginFlow: true,
|
||||||
|
);
|
||||||
|
final fields = decodeVault(
|
||||||
|
encodeVault({...sessionVaultFields(session), 'mc_bearer_token': 't'}),
|
||||||
|
);
|
||||||
|
final restored = decodeSession(fields)! as CredentialSession;
|
||||||
|
expect(restored.username, 'max');
|
||||||
|
expect(restored.password, 'pw');
|
||||||
|
expect(restored.nextcloud.appPassword, 'app');
|
||||||
|
expect(restored.nextcloud.appPasswordTalk, 'talk');
|
||||||
|
expect(restored.nextcloud.usesLoginFlow, isTrue);
|
||||||
|
expect(fields['mc_bearer_token'], 't');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demo session survives the vault without app passwords', () {
|
||||||
|
final fields = decodeVault(
|
||||||
|
encodeVault(
|
||||||
|
sessionVaultFields(
|
||||||
|
CredentialSession(
|
||||||
|
username: 'demo@x',
|
||||||
|
password: 'demo',
|
||||||
|
isDemo: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final restored = decodeSession(fields)! as CredentialSession;
|
||||||
|
expect(restored.username, 'demo@x');
|
||||||
|
expect(restored.isDemo, isTrue);
|
||||||
|
expect(fields.containsKey(SessionKeys.appPassword), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broken vault yields no session', () {
|
||||||
|
expect(decodeSession(decodeVault('garbage')), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user