import 'dart:developer'; import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import '../background/widget_background_task.dart'; import '../notification/notification_service.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_decryptor.dart'; import 'push_keypair.dart'; import 'push_registration_store.dart'; import 'push_renderer.dart'; import 'push_subject.dart'; /// Wire value of the FCM `type` field for silent widget-refresh pushes. /// Mirrors PUSH_TYPE_WIDGET_REFRESH in MarianumConnect's MarMobileApiService. const String widgetRefreshPushType = 'widget-refresh'; /// How an incoming FCM payload should be interpreted. enum PushKind { /// Encrypted Nextcloud push-v2 notification (`subject` + `signature`). nextcloud, /// Plaintext MarianumConnect direct push (`source == "connect"`). connect, /// Silent MarianumConnect push requesting a home-widget data refresh /// (`source == "connect"` + `type == "widget-refresh"`). Never rendered, /// processed even with notifications off. widgetRefresh, /// Neither — ignored. unknown, } /// Classifies a raw FCM data map. Pure, so it's unit-testable and safe in any /// isolate. Nextcloud pushes are distinguished by the presence of both /// `subject` and `signature`; Connect pushes by `source == "connect"`. PushKind classifyPush(Map data) { final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false; final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false; if (hasSubject && hasSignature) return PushKind.nextcloud; if (data['source'] == 'connect') { if (data['type'] == widgetRefreshPushType) return PushKind.widgetRefresh; return PushKind.connect; } return PushKind.unknown; } /// FCM background isolate entry point. Must be a TOP-LEVEL function with the /// entry-point pragma: AOT builds cannot invoke static class members from /// native code unless the class itself is annotated too (DartVM error /// "must be annotated"), so a plain function is the reliable form. @pragma('vm:entry-point') Future pushOnBackgroundMessage(RemoteMessage message) async { await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); } /// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel /// the matching tray notification via [NidStore]. Works both in the FCM /// background isolate and the foreground. class PushMessageHandler { final PushKeypair _keypair; final PushRegistrationStore _registrationStore; final PushRenderer _renderer; final NidStore _nidStore; final ChatThreadStore _threadStore; PushMessageHandler({ PushKeypair? keypair, PushRegistrationStore? registrationStore, PushRenderer? renderer, NidStore? nidStore, ChatThreadStore? threadStore, }) : _keypair = keypair ?? const PushKeypair(), _registrationStore = registrationStore ?? const PushRegistrationStore(), _renderer = renderer ?? PushRenderer(), _nidStore = nidStore ?? NidStore(), _threadStore = threadStore ?? ChatThreadStore(); /// Processes [message]. In the foreground, pass [foreground] true and /// [openChatToken] so a message for the currently open chat is suppressed /// (the long-poll already shows it) instead of raising a tray notification. Future handle( RemoteMessage message, { bool foreground = false, String? openChatToken, }) async { final data = message.data; // The device stays registered even when the user turns notifications off, // so silent sync pushes (deletes, data refresh) keep arriving. When off we // still process the message but skip raising a visible notification. final notificationsEnabled = await _registrationStore.notificationsEnabled(); switch (classifyPush(data)) { case PushKind.connect: await _handleConnect( message, foreground: foreground, notificationsEnabled: notificationsEnabled, ); break; case PushKind.nextcloud: await _handleNextcloud( data, foreground: foreground, openChatToken: openChatToken, notificationsEnabled: notificationsEnabled, ); break; case PushKind.widgetRefresh: // Deliberately before any notificationsEnabled gate: silent sync // pushes must work with notifications off. await _handleWidgetRefresh(); break; case PushKind.unknown: break; } } Future _handleWidgetRefresh() async { try { // The iOS FCM handler runs in the main isolate with a ~25s APNs // budget — bound the inline refresh below that so the completion // handler always fires in time. await WidgetBackgroundTask.requestImmediateRefresh( force: false, inlineTimeout: const Duration(seconds: 20), ); } on Exception catch (e) { log('[push] widget refresh failed: $e'); } } Future _handleConnect( RemoteMessage message, { required bool foreground, required bool notificationsEnabled, }) async { // Connect pushes carry no silent side effects, so nothing to do when the // user has notifications off. if (!notificationsEnabled) return; // On iOS the alert is delivered natively by the system; only Android needs // to render the plaintext payload locally. final data = message.data; final title = data['title'] as String?; final body = data['body'] as String?; if (title == null) return; await _renderer.renderConnect( title: title, body: body ?? '', data: data.map((k, v) => MapEntry(k, '$v')), ); } Future _handleNextcloud( Map data, { required bool foreground, required String? openChatToken, required bool notificationsEnabled, }) async { final subjectBase64 = data['subject'] as String; final signatureBase64 = data['signature'] as String; final privateKey = await _keypair.loadPrivateKey(); if (privateKey == null) { log('Push: no device private key, cannot decrypt'); return; } final serverPublicKey = await _loadServerPublicKey(); final decryptor = PushDecryptor( devicePrivateKey: privateKey, serverPublicKey: serverPublicKey, ); if (!decryptor.verify(subjectBase64, signatureBase64)) { log('Push: signature verification failed'); return; } final subject = decryptor.decrypt(subjectBase64); if (subject == null) { log('Push: could not decrypt subject'); return; } if (subject.isAnyDelete) { await _handleDelete(subject); return; } // Foreground + the referenced chat already open: the long-poll renders the // message, so just make sure no stale tray entry lingers. if (foreground && subject.isTalk && subject.id != null && subject.id == openChatToken) { return; } // Notifications turned off: the push was still processed (deletes above, // plus the foreground badge/provider refresh in NotificationController) — // only the visible tray notification is suppressed. if (!notificationsEnabled) return; await _renderer.render(subject); } Future _handleDelete(PushSubject subject) async { if (subject.deleteAll) { final all = await _nidStore.all(); for (final entry in all) { await _cancel(entry); } await _nidStore.clear(); await _threadStore.clearAll(); return; } final nids = [ if (subject.delete && subject.nid != null) subject.nid!, ...subject.nids, ]; for (final nid in nids) { final entry = await _nidStore.get(nid); final chatToken = entry?.chatToken; if (chatToken != null && chatToken.isNotEmpty) { // Stacked chat notification: drop only this message from the thread. // Remaining messages re-render WITHOUT alerting again; the last one // going away cancels the whole card. final remaining = await _threadStore.removeNid(chatToken, nid); if (remaining.isEmpty) { await _cancel(entry!); } else { await _renderer.renderTalkThread(chatToken, remaining, alert: false); } } else if (entry != null) { await _cancel(entry); } await _nidStore.delete(nid); } } Future _cancel(NidEntry entry) async { try { await NotificationService().flutterLocalNotificationsPlugin.cancel( id: entry.notificationId, tag: entry.tag, ); } on Object catch (e) { log('Push: cancel ${entry.nid} failed: $e'); } } Future _loadServerPublicKey() async { final pem = await _registrationStore.serverPublicKeyPem(); if (pem == null || pem.isEmpty) return null; try { return RSAPublicKey.fromPEM(pem); } on Object { return null; } } }