implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../notification/notification_service.dart';
|
||||
import '../notification/notification_tasks.dart';
|
||||
import 'nid_store.dart';
|
||||
import 'push_actions.dart';
|
||||
import 'push_subject.dart';
|
||||
|
||||
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
|
||||
/// notifications. Talk messages get a [MessagingStyleInformation] with inline
|
||||
/// reply + mark-as-read actions and a per-chat tag; everything else renders in
|
||||
/// a generic channel.
|
||||
class PushRenderer {
|
||||
static const talkChannelId = 'talk_messages';
|
||||
static const talkChannelName = 'Talk-Nachrichten';
|
||||
static const generalChannelId = 'nextcloud_general';
|
||||
static const generalChannelName = 'Benachrichtigungen';
|
||||
|
||||
static const String iosTalkCategory = 'TALK_MESSAGE';
|
||||
|
||||
final NidStore _nidStore;
|
||||
|
||||
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
|
||||
|
||||
FlutterLocalNotificationsPlugin get _plugin =>
|
||||
NotificationService().flutterLocalNotificationsPlugin;
|
||||
|
||||
/// Creates the Android notification channels. Safe to call repeatedly.
|
||||
static Future<void> ensureChannels() async {
|
||||
final android = NotificationService().flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (android == null) return;
|
||||
await android.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
talkChannelId,
|
||||
talkChannelName,
|
||||
description: 'Neue Nachrichten aus Nextcloud Talk',
|
||||
importance: Importance.high,
|
||||
),
|
||||
);
|
||||
await android.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
generalChannelId,
|
||||
generalChannelName,
|
||||
description: 'Allgemeine Benachrichtigungen',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a decrypted Nextcloud push subject.
|
||||
Future<void> render(PushSubject subject) async {
|
||||
if (subject.isTalk) {
|
||||
await _renderTalk(subject);
|
||||
} else {
|
||||
await _renderGeneric(subject);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renderTalk(PushSubject subject) async {
|
||||
final nid = subject.nid ?? _fallbackId(subject.id);
|
||||
final chatToken = subject.id;
|
||||
final tag = chatToken != null
|
||||
? NotificationTasks.chatTag(chatToken)
|
||||
: 'talk_$nid';
|
||||
final text = subject.subject ?? 'Neue Nachricht';
|
||||
final (senderName, messageText) = _splitSender(text);
|
||||
|
||||
final payload = _payload(chatToken: chatToken, nid: nid);
|
||||
|
||||
final messagingStyle = MessagingStyleInformation(
|
||||
const Person(key: 'self', name: 'Ich'),
|
||||
conversationTitle: senderName,
|
||||
groupConversation: false,
|
||||
messages: [
|
||||
Message(messageText, DateTime.now(), Person(name: senderName)),
|
||||
],
|
||||
);
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
talkChannelId,
|
||||
talkChannelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
tag: tag,
|
||||
styleInformation: messagingStyle,
|
||||
actions: const [
|
||||
AndroidNotificationAction(
|
||||
kTalkReplyActionId,
|
||||
'Antworten',
|
||||
showsUserInterface: false,
|
||||
cancelNotification: false,
|
||||
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
kTalkMarkReadActionId,
|
||||
'Gelesen',
|
||||
showsUserInterface: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
threadIdentifier: tag,
|
||||
categoryIdentifier: iosTalkCategory,
|
||||
);
|
||||
|
||||
await _nidStore.put(
|
||||
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
|
||||
);
|
||||
|
||||
await _plugin.show(
|
||||
id: nid,
|
||||
title: senderName,
|
||||
body: messageText,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _renderGeneric(PushSubject subject) async {
|
||||
final nid = subject.nid ?? _fallbackId(subject.subject);
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
generalChannelId,
|
||||
generalChannelName,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
await _nidStore.put(
|
||||
NidEntry(nid: nid, notificationId: nid, tag: 'nc_$nid'),
|
||||
);
|
||||
await _plugin.show(
|
||||
id: nid,
|
||||
title: subject.subject ?? 'Neue Benachrichtigung',
|
||||
body: null,
|
||||
notificationDetails: const NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
),
|
||||
payload: _payload(chatToken: null, nid: nid),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a plaintext MarianumConnect direct push (Android only — iOS shows
|
||||
/// the native alert itself).
|
||||
Future<void> renderConnect({
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, String>? data,
|
||||
}) async {
|
||||
final id = _fallbackId('$title$body');
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
generalChannelId,
|
||||
generalChannelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: const NotificationDetails(android: androidDetails),
|
||||
payload: data == null ? null : jsonEncode(data),
|
||||
);
|
||||
}
|
||||
|
||||
String _payload({required String? chatToken, required int nid}) =>
|
||||
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
||||
|
||||
/// Splits a `"Sender: message"` subject into its parts, falling back to a
|
||||
/// generic sender label when there's no delimiter.
|
||||
(String, String) _splitSender(String subject) {
|
||||
final idx = subject.indexOf(': ');
|
||||
if (idx > 0 && idx < subject.length - 2) {
|
||||
return (subject.substring(0, idx), subject.substring(idx + 2));
|
||||
}
|
||||
return ('Talk', subject);
|
||||
}
|
||||
|
||||
/// Deterministic non-negative 31-bit id from a string, used when the push
|
||||
/// carries no `nid`.
|
||||
int _fallbackId(String? seed) {
|
||||
if (seed == null || seed.isEmpty) return 0;
|
||||
var hash = 0;
|
||||
for (final unit in seed.codeUnits) {
|
||||
hash = (hash * 31 + unit) & 0x7fffffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user