194 lines
5.8 KiB
Dart
194 lines
5.8 KiB
Dart
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<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') 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<void> 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<void> 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<void> _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<void> _handleNextcloud(
|
|
Map<String, dynamic> 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<void> _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 = <int>[
|
|
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<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;
|
|
}
|
|
}
|
|
}
|