implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching

This commit is contained in:
2026-07-05 22:48:04 +02:00
parent 35e144799e
commit 483fea62ba
31 changed files with 2807 additions and 320 deletions
+190 -47
View File
@@ -1,17 +1,22 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../notification/notification_service.dart';
import '../notification/notification_tasks.dart';
import '../theming/light_app_theme.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_actions.dart';
import 'push_avatar.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.
/// notifications. Talk messages of one chat stack into a SINGLE
/// [MessagingStyleInformation] notification (stable id/tag per chat, history
/// from [ChatThreadStore]) with inline reply + mark-as-read actions;
/// everything else renders in a generic channel.
class PushRenderer {
static const talkChannelId = 'talk_messages';
static const talkChannelName = 'Talk-Nachrichten';
@@ -20,9 +25,21 @@ class PushRenderer {
static const String iosTalkCategory = 'TALK_MESSAGE';
final NidStore _nidStore;
/// Brand accent: colors the (monochrome) small icon and action buttons in
/// the notification shade instead of the default grey.
static const _accentColor = LightAppTheme.marianumRed;
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
final NidStore _nidStore;
final PushAvatarStore _avatarStore;
final ChatThreadStore _threadStore;
PushRenderer({
NidStore? nidStore,
PushAvatarStore? avatarStore,
ChatThreadStore? threadStore,
}) : _nidStore = nidStore ?? NidStore(),
_avatarStore = avatarStore ?? PushAvatarStore(),
_threadStore = threadStore ?? ChatThreadStore();
FlutterLocalNotificationsPlugin get _plugin =>
NotificationService().flutterLocalNotificationsPlugin;
@@ -60,24 +77,120 @@ class PushRenderer {
}
}
/// Tri-state probe of whether this chat's stacked notification is still in
/// the tray. `null` when the query fails or isn't supported (native code not
/// linked, older platform) — the caller then keeps stacking defensively. A
/// successful query is authoritative: absence means dismissed/read.
/// Works in the background isolate (pure plugin call, no BuildContext).
Future<bool?> _isChatNotificationActive(String chatToken) async {
final id = stableChatNotificationId(chatToken);
final tag = chatNotificationTag(chatToken);
try {
final actives = await _plugin.getActiveNotifications();
return actives.any((n) => n.id == id || n.tag == tag);
} on Object catch (e) {
log('Push: getActiveNotifications probe failed: $e');
return null;
}
}
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 parsed = parseTalkSubject(subject.subject ?? 'Neue Nachricht');
final nid =
subject.nid ??
_fallbackId('${chatToken ?? ''}${parsed.sender}${parsed.text}');
final payload = _payload(chatToken: chatToken, nid: nid);
if (chatToken == null || chatToken.isEmpty) {
// Without a chat token there is nothing to stack under — render a
// standalone card keyed by the nid.
await _renderTalkStandalone(nid, parsed.sender, parsed.text);
return;
}
// Restart the thread when the previous notification is gone (swiped away
// or the chat was read without our cleanup running) — otherwise the new
// push would resurrect the already-seen history. A failed/unsupported
// probe keeps stacking (see threadAfterIncoming).
final isActive = await _isChatNotificationActive(chatToken);
final messages = await _threadStore.appendConsideringActive(
chatToken,
ThreadMessage(
nid: nid,
sender: parsed.sender,
text: parsed.text,
timestampMs: DateTime.now().millisecondsSinceEpoch,
roomName: parsed.roomName,
),
isActive,
);
await _nidStore.put(
NidEntry(
nid: nid,
notificationId: stableChatNotificationId(chatToken),
tag: chatNotificationTag(chatToken),
chatToken: chatToken,
),
);
await renderTalkThread(chatToken, messages);
}
/// Shows/updates the ONE stacked notification of [chatToken] from the given
/// history. Identity is stable per chat ([stableChatNotificationId] +
/// [chatNotificationTag]) so each new message updates the same card.
/// [alert] false re-renders silently — used when a delete-push removed one
/// of several messages and the remaining thread must not ping again.
Future<void> renderTalkThread(
String chatToken,
List<ThreadMessage> messages, {
bool alert = true,
}) async {
if (messages.isEmpty) return;
final tag = chatNotificationTag(chatToken);
final id = stableChatNotificationId(chatToken);
final latest = messages.last;
// Conversation avatar (the person's picture in 1:1 chats), pre-masked
// round. Bounded by the store's fetch timeout; when the icon isn't ready
// in time the notification renders without it and a still-running fetch
// triggers ONE silent re-render below once bytes arrive.
final lookup = await _avatarStore.roomAvatarIcon(chatToken);
final avatarBytes = lookup.icon;
if (alert && avatarBytes == null && lookup.late != null) {
unawaited(
lookup.late!.then((bytes) async {
if (bytes == null) return;
final fresh = await _threadStore.messages(chatToken);
if (fresh.isEmpty) return;
// Re-render hits the processed cache → icon present, no re-schedule.
await renderTalkThread(chatToken, fresh, alert: false);
}),
);
}
final senders = <String, Person>{};
Person personFor(String sender) => senders.putIfAbsent(
sender,
() => Person(
key: sender,
name: sender,
icon: avatarBytes == null ? null : ByteArrayAndroidIcon(avatarBytes),
),
);
final styleMessages = [
for (final m in messages)
Message(
m.text,
DateTime.fromMillisecondsSinceEpoch(m.timestampMs),
personFor(m.sender),
),
];
final header = conversationHeader(messages);
final messagingStyle = MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
conversationTitle: senderName,
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
conversationTitle: header.conversationTitle,
groupConversation: header.groupConversation,
messages: styleMessages,
);
final androidDetails = AndroidNotificationDetails(
@@ -86,50 +199,89 @@ class PushRenderer {
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag,
silent: !alert,
styleInformation: messagingStyle,
actions: const [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
],
actions: _talkActions,
);
final iosDetails = DarwinNotificationDetails(
threadIdentifier: tag,
categoryIdentifier: iosTalkCategory,
presentSound: alert ? null : false,
presentBanner: alert ? null : false,
);
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
await _plugin.show(
id: id,
title: latest.sender,
body: latest.text,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: _payload(chatToken: chatToken, nid: latest.nid),
);
}
Future<void> _renderTalkStandalone(
int nid,
String senderName,
String messageText,
) async {
final tag = 'talk_$nid';
await _nidStore.put(NidEntry(nid: nid, notificationId: nid, tag: tag));
await _plugin.show(
id: nid,
title: senderName,
body: messageText,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag,
// Single sender ⇒ no conversationTitle (1:1 convention): the sender
// Person is the header, a title would repeat the name per line.
styleInformation: MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
),
),
iOS: const DarwinNotificationDetails(),
),
payload: payload,
payload: _payload(chatToken: null, nid: nid),
);
}
static const _talkActions = [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
];
Future<void> _renderGeneric(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.subject);
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
color: _accentColor,
);
const iosDetails = DarwinNotificationDetails();
await _nidStore.put(
@@ -160,6 +312,7 @@ class PushRenderer {
generalChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
);
await _plugin.show(
id: id,
@@ -173,16 +326,6 @@ class PushRenderer {
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) {