Files
Client/lib/push/push_message_handler.dart
T
2026-08-06 20:22:32 +02:00

265 lines
8.9 KiB
Dart

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<String, dynamic> 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<void> 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<void> 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<void> _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<void> _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<void> _handleNextcloud(
Map<String, dynamic> 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<void> _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 = <int>[
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<void> _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<RSAPublicKey?> _loadServerPublicKey() async {
final pem = await _registrationStore.serverPublicKeyPem();
if (pem == null || pem.isEmpty) return null;
try {
return RSAPublicKey.fromPEM(pem);
} on Object {
return null;
}
}
}