import 'dart:developer'; 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'; 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 'nextcloud_push_api.dart'; import 'push_keypair.dart'; import 'push_registration_store.dart'; import 'push_registration_type.dart'; /// Orchestrates the full push-v2 registration lifecycle: /// Nextcloud device registration → MarianumConnect proxy registration, plus /// unregister and token-refresh handling. /// /// Every device maintains TWO Nextcloud registrations (see /// [PushRegistrationType]) sharing one keypair: a `general` one and a /// Talk-classified one, because NC routes Talk pushes only to apptype=talk /// subscriptions once the user has any (e.g. the official Talk app). class PushRegistration { /// User agents matching nextcloud/server `IRequest` Talk patterns /// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk /// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\) /// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so /// NC stores it with apptype `talk`. static const String talkUserAgentAndroid = 'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile'; static const String talkUserAgentIos = 'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile'; final PushKeypair _keypair; final PushRegistrationStore _store; final NextcloudPushApi _nextcloud; PushRegistration({ PushKeypair? keypair, PushRegistrationStore? store, NextcloudPushApi? nextcloud, }) : _keypair = keypair ?? const PushKeypair(), _store = store ?? const PushRegistrationStore(), _nextcloud = nextcloud ?? NextcloudPushApi(); String get _platform => Platform.isIOS ? 'ios' : 'android'; String get _talkUserAgent => Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid; /// Derives the push-proxy base URL from the active MarianumConnect endpoint, /// so a beta/dev build registers against the matching proxy automatically. /// Public so the push status view can compare it against the stored binding. String get currentProxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/'; /// Nextcloud origin the registration targets (full origin, no trailing /// slash) — persisted alongside the registration to detect endpoint changes. String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// registration binds to it, so it must be obtained before registering. Future ensureAppPassword() async { if (AccountData().hasAppPassword()) return; try { final appPassword = await GetAppPassword().run(); await AccountData().setAppPassword(appPassword); } on Object catch (e) { log('Push: could not obtain app password (non-blocking): $e'); } } /// Ensures the second app password backing the Talk registration exists /// (each `getapppassword` call with the real password mints a fresh one). Future ensureTalkAppPassword() async { if (AccountData().hasAppPasswordTalk()) return; try { final appPassword = await GetAppPassword().run(); await AccountData().setAppPasswordTalk(appPassword); } on Object catch (e) { log('Push: could not obtain talk app password (non-blocking): $e'); } } /// Registers this device end-to-end: both Nextcloud registrations (general, /// then talk) each followed by their MarianumConnect proxy registration. /// Partial results are persisted per type — one failing registration never /// blocks the other. Returns true only when BOTH succeeded. No-op-safe: /// transport failures are logged and swallowed so callers can /// fire-and-forget (and simply ignore the result). Future register() async { if (DemoMode.active) return false; final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); } on Object catch (e) { log('Push: could not obtain FCM token: $e'); await _recordAttempts('Kein FCM-Token verfügbar'); return false; } if (fcmToken == null || fcmToken.isEmpty) { log('Push: no FCM token, skipping registration'); await _recordAttempts('Kein FCM-Token verfügbar'); return false; } await ensureAppPassword(); await ensureTalkAppPassword(); await _persistNativeAuthContext(); final PushKeypairPems pems; try { pems = await _keypair.ensure(); } on Object catch (e) { log('Push: keypair unavailable: $e'); await _recordAttempts(_shortError(e)); return false; } String? appVersion; try { appVersion = (await PackageInfo.fromPlatform()).version; } on Object { appVersion = null; } var allOk = true; for (final type in PushRegistrationType.values) { final ok = await _registerType( type: type, fcmToken: fcmToken, pems: pems, appVersion: appVersion, ); allOk = allOk && ok; } return allOk; } Future _registerType({ required PushRegistrationType type, required String fcmToken, required PushKeypairPems pems, required String? appVersion, }) async { try { final proxyServer = currentProxyServer; final ncBaseUrl = currentNcBaseUrl; final isTalk = type == PushRegistrationType.talk; final registration = await _nextcloud.register( pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)), devicePublicKeyPem: pems.publicKeyPem, proxyServer: proxyServer, authorizationHeader: isTalk ? AccountData().getTalkBasicAuthHeader() : null, userAgent: isTalk ? _talkUserAgent : null, ); await _store.save( type: type, deviceIdentifier: registration.deviceIdentifier, serverPublicKeyPem: registration.publicKey, fcmToken: fcmToken, proxyServer: proxyServer, ncBaseUrl: ncBaseUrl, ); await PushDeviceRegister().run( deviceIdentifier: registration.deviceIdentifier, deviceIdentifierSignature: registration.signature, userPublicKey: registration.publicKey, pushToken: fcmToken, platform: _platform, registrationType: type.wireName, appVersion: appVersion, ); log( 'Push: registered ${type.wireName} ' '(created=${registration.created})', ); await _recordAttempt(type, null); return true; } on Object catch (e) { log('Push: ${type.wireName} registration failed: $e'); await _recordAttempt(type, _shortError(e)); return false; } } /// Persists the attempt outcome for the push status view. Storage failures /// must never mask the actual registration result. Future _recordAttempt(PushRegistrationType type, String? error) async { try { await _store.saveLastRegistrationAttempt( type: type, at: DateTime.now(), error: error, ); } on Object { // ignore — the status view simply shows the previous attempt } } Future _recordAttempts(String? error) async { for (final type in PushRegistrationType.values) { await _recordAttempt(type, error); } } static String _shortError(Object e) { final text = e.toString(); return text.length > 300 ? '${text.substring(0, 300)}…' : text; } /// Writes the username and Nextcloud base URL into the shared keychain so the /// native iOS Talk action handler can authenticate OCS calls without the /// Flutter engine. Best-effort — a failure here must not abort registration. Future _persistNativeAuthContext() async { try { final endpoint = EndpointData().nextcloud(); await _store.saveNativeAuthContext( username: AccountData().getUsername(), baseUrl: 'https://${endpoint.full()}', ); } on Object catch (e) { log('Push: could not persist native auth context: $e'); } } /// Removes both subscriptions from Nextcloud and the proxy. Best-effort — /// each step is independent so one failure never blocks the rest. Future unregister() async { for (final type in PushRegistrationType.values) { final deviceIdentifier = await _store.deviceIdentifier(type); try { // The DELETE removes the subscription bound to the authenticating // session token — each registration with its own app password. await _nextcloud.unregister( authorizationHeader: type == PushRegistrationType.talk ? AccountData().getTalkBasicAuthHeader() : null, ); } on Object catch (e) { log('Push: NC unregister (${type.wireName}) failed: $e'); } if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) { try { await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier); } on Object catch (e) { log('Push: proxy unregister (${type.wireName}) failed: $e'); } } } await _store.clear(); } /// Pure decision for whether a persisted registration endpoint no longer /// matches the currently active one. A missing/empty stored value never /// forces a re-registration — old installs (pre endpoint-tracking) heal via /// the regular register-on-start path instead of a forced extra roundtrip. static bool endpointChanged({ required String? registered, required String current, }) => registered != null && registered.isNotEmpty && registered != current; /// True when any existing registration was made against a different /// MarianumConnect proxy or Nextcloud base URL than the ones currently /// configured (dev-tools endpoint switch, live/beta/custom). Future needsEndpointReRegistration() async { for (final type in PushRegistrationType.values) { if (!await _store.isRegistered(type)) continue; final changed = endpointChanged( registered: await _store.registeredProxyServer(type), current: currentProxyServer, ) || endpointChanged( registered: await _store.registeredNcBaseUrl(type), current: currentNcBaseUrl, ); if (changed) return true; } return false; } /// Re-registers when the active endpoints diverge from the registered ones. /// The NC POST updates the existing subscription server-side to the new /// proxyServer URL; afterwards the device row lands at the NEW backend via /// `PUT me/push-device`. No DELETE at the old proxy: its bearer token belongs /// to a different token universe (cleared on endpoint switch) and the stale /// row ages out through the backend's cleanup cron once pushes stop. Future reRegisterIfEndpointChanged() async { if (!await needsEndpointReRegistration()) return; log('Push: endpoint changed, re-registering'); await register(); } /// Pure decision for whether push may be delivered given an OS permission /// [status]: only an explicit denial blocks registration. `authorized` and /// `provisional` obviously allow it; `notDetermined` is kept permissive so a /// transient plugin/platform hiccup never silently disables push (the OS /// simply won't show notifications until the user decides). static bool isPermissionUsable(AuthorizationStatus status) => status != AuthorizationStatus.denied; /// Requests the OS notification permission (covers iOS + Android 13) and /// returns whether registration should proceed. Errors from the plugin are /// treated as usable — better a possibly-idle registration than silently /// losing push over a transient failure. static Future requestOsPermission() async { try { final settings = await FirebaseMessaging.instance.requestPermission(); return isPermissionUsable(settings.authorizationStatus); } on Object catch (e) { log('Push: requestPermission failed: $e'); return true; } } /// True when the user has explicitly denied the OS notification permission. /// Read-only (no prompt) — used by the settings UI to surface the state. static Future isOsPermissionDenied() async { try { final settings = await FirebaseMessaging.instance .getNotificationSettings(); return settings.authorizationStatus == AuthorizationStatus.denied; } on Object { return false; } } /// True when the OS notification permission is already granted /// (`authorized`/`provisional`). Read-only — never triggers the OS prompt. /// Used by the cold-start/self-heal path so it registers only for devices /// that already opted in, leaving the actual prompt to the first Talk visit. static Future isOsPermissionGranted() async { try { final settings = await FirebaseMessaging.instance .getNotificationSettings(); return settings.authorizationStatus == AuthorizationStatus.authorized || settings.authorizationStatus == AuthorizationStatus.provisional; } on Object { return false; } } /// Registers this device whenever the backend advertises the push capability. /// Deliberately independent of the in-app notification toggle: a user who /// turned notifications off stays registered so silent sync pushes keep /// flowing — the display is suppressed downstream via the mirrored flag (see /// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS /// notification permission is *already* granted — it never triggers the OS /// prompt itself. Requesting the permission is the job of the first Talk visit /// (see `maybePromptTalkNotifications`), which keeps the prompt out of the /// cold-start path. Safe to call on every start — Nextcloud dedups an /// unchanged registration — which also self-heals a device whose registration /// was lost. /// Returns whether registration was actually *attempted* (all gates passed). /// Even a partial success persists the `general` device identifier, so the /// caller re-emits telemetry on `true` to reflect the fresh registration in /// the same session instead of lagging until the next launch. static Future syncSubscription({required bool capable}) async { if (!capable) return false; if (!await isOsPermissionGranted()) { log('Push: OS notification permission not granted, skipping registration'); return false; } final registration = PushRegistration(); // register() below refreshes an unchanged subscription anyway; the check // only surfaces the endpoint switch in the log for diagnosability. if (await registration.needsEndpointReRegistration()) { log('Push: registered endpoints outdated, re-registering'); } await registration.register(); return true; } /// Re-registers after an FCM token refresh. The Nextcloud device identifier /// stays stable across refreshes, so this simply re-runs registration (NC /// first, then the proxy) with the new token. Future onTokenRefresh() => register(); /// Full teardown for logout: unregister push, revoke BOTH app passwords /// (each authenticated with itself — the endpoint revokes the credential it /// is called with), then clear them locally. Ordered so the proxy stops /// pushing before credentials are gone. Future logoutCleanup() async { if (DemoMode.active) return; await unregister(); try { await DeleteAppPassword().run(); } on Object catch (e) { log('Push: delete app password failed: $e'); } try { if (AccountData().hasAppPasswordTalk()) { await DeleteAppPassword().run( authorizationHeader: AccountData().getTalkBasicAuthHeader(), ); } } on Object catch (e) { log('Push: delete talk app password failed: $e'); } await AccountData().clearAppPassword(); await AccountData().clearAppPasswordTalk(); } }