added guardian login with views for their assigned childs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user