265 lines
8.9 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|