replaced account data singleton with a session model

This commit is contained in:
2026-09-27 14:49:38 +02:00
parent 79b33ab248
commit 5e65c4671a
60 changed files with 1019 additions and 679 deletions
+12 -8
View File
@@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_renderer.dart';
@@ -38,7 +38,7 @@ void _plog(String message) {
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population.
/// the [SessionManager] singleton after awaiting the stored session.
///
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
/// on [handleBackgroundResponse]: the callback is resolved via
@@ -56,14 +56,14 @@ class PushActions {
) async {
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
// isolate, plugins are not registered automatically there. Without this,
// AccountData's secure-storage/prefs reads throw or never complete → no
// The session's secure-storage/prefs reads throw or never complete → no
// auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever.
DartPluginRegistrant.ensureInitialized();
// The action engine lives as long as the process: without a reload a
// reply would be sent with the account that was signed in when the
// engine first started.
await AccountData().reloadFromStorage();
await SessionManager().reloadFromStorage();
_plog(
'action=${response.actionId} payload=${response.payload} '
@@ -129,7 +129,8 @@ class PushActions {
/// any) followed by the technical reason.
static String actionFailureBody({String? lostText, required String detail}) {
return [
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“',
if (lostText != null && lostText.isNotEmpty)
'Deine Nachricht: „$lostText“',
'Grund: $detail',
].join('\n');
}
@@ -192,7 +193,10 @@ class PushActions {
static Future<({bool ok, String detail})> sendReply(
String chatToken,
String message,
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message});
) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<({bool ok, String detail})> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
@@ -204,10 +208,10 @@ class PushActions {
try {
// Bounded: a hanging population (e.g. keystore issue) must fail the
// action instead of leaving the notification spinner running forever.
final populated = await AccountData().waitForPopulation().timeout(
final session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10),
);
if (!populated) {
if (session?.nextcloud == null) {
_plog('Push action $path aborted: credentials unreadable in isolate');
return (
ok: false,
+15
View File
@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
/// Platform value MarianumConnect expects in push registrations.
String get pushPlatform => Platform.isIOS ? 'ios' : 'android';
/// App version sent along with push registrations; null when unavailable.
Future<String?> pushAppVersion() async {
try {
return (await PackageInfo.fromPlatform()).version;
} on Object {
return null;
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../background/widget_background_task.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
@@ -56,7 +56,7 @@ PushKind classifyPush(Map<String, dynamic> data) {
@pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
// This engine outlives sign-outs and logins in the app.
await AccountData().reloadFromStorage();
await SessionManager().reloadFromStorage();
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
+36 -31
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/demo/demo_mode.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart';
@@ -11,9 +10,11 @@ import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../session/nextcloud_credentials.dart';
import '../session/session_manager.dart';
import 'nextcloud_push_api.dart';
import 'push_device_info.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';
@@ -48,8 +49,6 @@ class PushRegistration {
_store = store ?? const PushRegistrationStore(),
_nextcloud = nextcloud ?? NextcloudPushApi();
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
@@ -63,20 +62,26 @@ class PushRegistration {
/// slash) — persisted alongside the registration to detect endpoint changes.
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
NextcloudCredentials? get _nextcloudOrNull =>
SessionManager().current?.nextcloud;
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return;
if (AccountData().usesLoginFlow) {
final nextcloud = _nextcloudOrNull;
if (nextcloud == null || nextcloud.hasAppPassword) return;
if (nextcloud.usesLoginFlow) {
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
// stilles Minting ist unmöglich. Reparatur nur interaktiv über
// Einstellungen → „Nextcloud neu verbinden".
log('Push: login-flow account without app password, cannot mint silently');
log(
'Push: login-flow account without app password, cannot mint silently',
);
return;
}
try {
final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword);
await SessionManager().setAppPassword(appPassword);
} on Object catch (e) {
log('Push: could not obtain app password (non-blocking): $e');
}
@@ -85,15 +90,16 @@ class PushRegistration {
/// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return;
final nextcloud = _nextcloudOrNull;
if (nextcloud == null || nextcloud.hasAppPasswordTalk) return;
// Flow-Konten können still kein zweites App-Passwort münzen — das
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe
// AccountData.getTalkBasicAuthHeader).
if (AccountData().usesLoginFlow) return;
// NextcloudCredentials.talkBasicAuthHeader).
if (nextcloud.usesLoginFlow) return;
try {
final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword);
await SessionManager().setAppPasswordTalk(appPassword);
} on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e');
}
@@ -107,7 +113,8 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
if (DemoMode.active) return false;
final epoch = AccountData().sessionEpoch;
if (_nextcloudOrNull == null) return false;
final epoch = SessionManager().sessionEpoch;
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
@@ -135,16 +142,13 @@ class PushRegistration {
return false;
}
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
final appVersion = await pushAppVersion();
// Re-read: the ensure* calls above may have swapped the credentials.
final nextcloud = SessionManager().requireNextcloud();
final types = registrationTypesFor(
usesLoginFlow: AccountData().usesLoginFlow,
hasTalkAppPassword: AccountData().hasAppPasswordTalk(),
usesLoginFlow: nextcloud.usesLoginFlow,
hasTalkAppPassword: nextcloud.hasAppPasswordTalk,
);
if (!types.contains(PushRegistrationType.general)) {
await _recordAttempt(
@@ -183,7 +187,7 @@ class PushRegistration {
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader()
? SessionManager().requireNextcloud().talkBasicAuthHeader
: null,
userAgent: isTalk ? _talkUserAgent : null,
);
@@ -191,7 +195,7 @@ class PushRegistration {
// Signed out while registering: persisting or announcing this
// registration would keep delivering the previous account's pushes,
// and logoutCleanup already ran so nothing would unregister it.
if (!AccountData().isCurrentSession(epoch)) return false;
if (!SessionManager().isCurrentSession(epoch)) return false;
await _store.save(
type: type,
@@ -207,7 +211,7 @@ class PushRegistration {
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
platform: pushPlatform,
registrationType: type.wireName,
appVersion: appVersion,
);
@@ -256,7 +260,7 @@ class PushRegistration {
try {
final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext(
username: AccountData().getUsername(),
username: SessionManager().requireNextcloud().username,
baseUrl: 'https://${endpoint.full()}',
);
} on Object catch (e) {
@@ -274,7 +278,7 @@ class PushRegistration {
// session token — each registration with its own app password.
await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader()
? SessionManager().requireNextcloud().talkBasicAuthHeader
: null,
);
} on Object catch (e) {
@@ -428,7 +432,7 @@ class PushRegistration {
/// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
if (DemoMode.active) return;
if (DemoMode.active || _nextcloudOrNull == null) return;
await unregister();
try {
await DeleteAppPassword().run();
@@ -436,15 +440,16 @@ class PushRegistration {
log('Push: delete app password failed: $e');
}
try {
if (AccountData().hasAppPasswordTalk()) {
final nextcloud = SessionManager().requireNextcloud();
if (nextcloud.hasAppPasswordTalk) {
await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
authorizationHeader: nextcloud.talkBasicAuthHeader,
);
}
} on Object catch (e) {
log('Push: delete talk app password failed: $e');
}
await AccountData().clearAppPassword();
await AccountData().clearAppPasswordTalk();
await SessionManager().clearAppPassword();
await SessionManager().clearAppPasswordTalk();
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ class PushRegistrationStore {
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the
// shared (group-scoped) keychain; the app password already lives there
// (AccountData writes `nextcloud_app_password` group-scoped).
// (SessionManager writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
+1 -1
View File
@@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions(
);
/// Shared secure storage instance for all push key material and registration
/// bookkeeping. Kept separate from [AccountData]'s default storage because the
/// bookkeeping. Kept separate from the session's default storage because the
/// entries here are group-scoped for NSE access.
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions,
+4 -3
View File
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart';
import '../model/account_data.dart';
import '../session/session_manager.dart';
import 'push_keypair.dart';
import 'push_registration.dart';
import 'push_registration_store.dart';
@@ -124,14 +124,15 @@ Future<PushStatusReport> collectPushStatus({
lastRegistrationError: await store.lastRegistrationError(type),
);
final nextcloud = SessionManager().current?.nextcloud;
return PushStatusReport(
settingEnabled: settingEnabled,
osPermission: await _osPermission(),
serverCapability: !capabilitiesLoaded
? PushCheck.unknown
: (capabilityPush ? PushCheck.ok : PushCheck.fail),
appPasswordPresent: AccountData().hasAppPassword(),
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(),
appPasswordPresent: nextcloud?.hasAppPassword ?? false,
talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false,
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
general: await typeStatus(PushRegistrationType.general),
talk: await typeStatus(PushRegistrationType.talk),