added guardian login with views for their assigned childs

This commit is contained in:
2026-09-20 11:11:58 +02:00
parent e4e2b1a4fb
commit 67c935c05b
117 changed files with 4784 additions and 1514 deletions
+71
View File
@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../utils/random_id.dart';
import 'push_device_info.dart';
import 'push_secure_storage.dart';
/// Push registration for accounts without Nextcloud (guardians): the device
/// registers straight with MarianumConnect and only receives its direct
/// pushes (newsletter, widget refresh, later guardian messages). Nextcloud
/// normally supplies the device identifier; here a random one is kept per
/// install.
class DirectPushRegistration {
static const String registrationType = 'direct';
static const _deviceIdentifierKey = 'push_direct_device_identifier';
final FlutterSecureStorageLike _storage;
const DirectPushRegistration({
FlutterSecureStorageLike storage = const PushSecureStorage(),
}) : _storage = storage;
Future<bool> register() async {
try {
final (fcmToken, appVersion, identifier) = await (
FirebaseMessaging.instance.getToken(),
pushAppVersion(),
deviceIdentifier(),
).wait;
if (fcmToken == null || fcmToken.isEmpty) {
log('Push (direct): no FCM token, skipping registration');
return false;
}
await PushDeviceRegister().run(
deviceIdentifier: identifier,
pushToken: fcmToken,
platform: pushPlatform,
registrationType: registrationType,
appVersion: appVersion,
);
return true;
} on Object catch (e) {
log('Push (direct): registration failed: $e');
return false;
}
}
Future<void> unregister() async {
final identifier = await _storage.read(key: _deviceIdentifierKey);
if (identifier == null) return;
try {
await PushDeviceUnregister().run(deviceIdentifier: identifier);
} on Object catch (e) {
log('Push (direct): unregister failed: $e');
}
await _storage.delete(key: _deviceIdentifierKey);
}
/// Stable per install until [unregister], so re-registrations upsert the
/// same server row instead of piling up devices.
Future<String> deviceIdentifier() async {
final stored = await _storage.read(key: _deviceIdentifierKey);
if (stored != null && stored.isNotEmpty) return stored;
final fresh = randomHexId();
await _storage.write(key: _deviceIdentifierKey, value: fresh);
return fresh;
}
}
+11 -7
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,7 +56,7 @@ 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();
@@ -125,7 +125,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');
}
@@ -188,7 +189,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');
@@ -200,10 +204,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;
}
}
+41 -29
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,12 @@ 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 'direct_push_registration.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 +50,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 +63,29 @@ class PushRegistration {
/// slash) — persisted alongside the registration to detect endpoint changes.
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
NextcloudCredentials? get _nextcloudOrNull =>
SessionManager().current?.nextcloud;
/// Channel for sessions without Nextcloud; see [DirectPushRegistration].
static const _direct = DirectPushRegistration();
/// 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 +94,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,6 +117,7 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return _direct.register();
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
@@ -134,16 +145,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(
@@ -180,7 +188,7 @@ class PushRegistration {
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader()
? SessionManager().requireNextcloud().talkBasicAuthHeader
: null,
userAgent: isTalk ? _talkUserAgent : null,
);
@@ -199,7 +207,7 @@ class PushRegistration {
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
platform: pushPlatform,
registrationType: type.wireName,
appVersion: appVersion,
);
@@ -248,7 +256,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) {
@@ -266,7 +274,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) {
@@ -405,7 +413,9 @@ class PushRegistration {
static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false;
if (!await isOsPermissionGranted()) {
log('Push: OS notification permission not granted, skipping registration');
log(
'Push: OS notification permission not granted, skipping registration',
);
return false;
}
final registration = PushRegistration();
@@ -429,6 +439,7 @@ class PushRegistration {
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister();
try {
await DeleteAppPassword().run();
@@ -436,15 +447,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,7 +1,7 @@
import 'package:firebase_messaging/firebase_messaging.dart';
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';
@@ -125,14 +125,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),