import 'dart:developer'; import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import '../notification/notification_service.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'; /// 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, /// 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') return PushKind.connect; return PushKind.unknown; } /// 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; PushMessageHandler({ PushKeypair? keypair, PushRegistrationStore? registrationStore, PushRenderer? renderer, NidStore? nidStore, }) : _keypair = keypair ?? const PushKeypair(), _registrationStore = registrationStore ?? const PushRegistrationStore(), _renderer = renderer ?? PushRenderer(), _nidStore = nidStore ?? NidStore(); /// Background isolate entry point registered with /// `FirebaseMessaging.onBackgroundMessage`. @pragma('vm:entry-point') static Future onBackgroundMessage(RemoteMessage message) async { await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); } /// 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; switch (classifyPush(data)) { case PushKind.connect: await _handleConnect(message, foreground: foreground); break; case PushKind.nextcloud: await _handleNextcloud( data, foreground: foreground, openChatToken: openChatToken, ); break; case PushKind.unknown: break; } } Future _handleConnect( RemoteMessage message, { required bool foreground, }) async { // 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, }) 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; } 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(); return; } final nids = [ if (subject.delete && subject.nid != null) subject.nid!, ...subject.nids, ]; for (final nid in nids) { final entry = await _nidStore.get(nid); 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; } } }