386 lines
13 KiB
Dart
386 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:developer';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
|
|
import '../notification/notification_service.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 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';
|
|
static const generalChannelId = 'nextcloud_general';
|
|
static const generalChannelName = 'Benachrichtigungen';
|
|
|
|
static const String iosTalkCategory = 'TALK_MESSAGE';
|
|
|
|
/// Brand accent: colors the (monochrome) small icon and action buttons in
|
|
/// the notification shade instead of the default grey.
|
|
static const _accentColor = LightAppTheme.marianumRed;
|
|
|
|
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;
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// 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 chatToken = subject.id;
|
|
final parsed = parseTalkSubject(subject.subject ?? 'Neue Nachricht');
|
|
final nid =
|
|
subject.nid ??
|
|
_fallbackId('${chatToken ?? ''}${parsed.sender}${parsed.text}');
|
|
|
|
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;
|
|
// A silent render only ever UPDATES an existing card (delete-push shrunk
|
|
// the thread, late avatar arrived, failed reply needs its spinner
|
|
// stopped). If the card is verifiably gone meanwhile (reply/mark-read
|
|
// cleanup cancelled it, or the user swiped it away), re-posting would
|
|
// resurrect it — and Android would re-attach a pending inline reply on
|
|
// top. Probe failure (null) still renders: stopping a possible reply
|
|
// spinner outweighs a rare resurrection.
|
|
if (!alert && await _isChatNotificationActive(chatToken) == false) {
|
|
debugPrint('PushRenderer: skip silent re-render, card gone ($chatToken)');
|
|
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: header.conversationTitle,
|
|
groupConversation: header.groupConversation,
|
|
messages: styleMessages,
|
|
);
|
|
|
|
final androidDetails = AndroidNotificationDetails(
|
|
talkChannelId,
|
|
talkChannelName,
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
category: AndroidNotificationCategory.message,
|
|
color: _accentColor,
|
|
tag: tag,
|
|
silent: !alert,
|
|
styleInformation: messagingStyle,
|
|
actions: _talkActions,
|
|
);
|
|
|
|
final iosDetails = DarwinNotificationDetails(
|
|
threadIdentifier: tag,
|
|
categoryIdentifier: iosTalkCategory,
|
|
presentSound: alert ? null : false,
|
|
presentBanner: alert ? null : false,
|
|
);
|
|
|
|
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: 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(chatToken: null, nid: nid),
|
|
);
|
|
}
|
|
|
|
// Both actions keep cancelNotification: true (the default): the plugin's
|
|
// Java receiver then removes the card NATIVELY the moment the action fires.
|
|
// The reply action must not rely on our Dart-side cancel instead — MIUI/
|
|
// HyperOS ignores an app-issued cancel while an inline reply is pending, so
|
|
// the card would stay behind showing the reply as an attached "Ich" row.
|
|
// If sending subsequently fails, the error card (with the typed text)
|
|
// replaces the lost thread card.
|
|
static const _talkActions = [
|
|
AndroidNotificationAction(
|
|
kTalkReplyActionId,
|
|
'Antworten',
|
|
showsUserInterface: false,
|
|
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
|
|
),
|
|
AndroidNotificationAction(
|
|
kTalkMarkReadActionId,
|
|
'Gelesen',
|
|
showsUserInterface: false,
|
|
),
|
|
];
|
|
|
|
/// Renders a failure card for a Talk notification action (reply/mark-read).
|
|
/// Separate tag per chat, so it neither replaces the thread notification
|
|
/// nor stacks across repeated failures. Carries no payload — tapping it
|
|
/// just opens the app.
|
|
Future<void> renderTalkActionError({
|
|
required String chatToken,
|
|
required String title,
|
|
required String body,
|
|
}) async {
|
|
await _plugin.show(
|
|
id: stableChatNotificationId(chatToken),
|
|
title: title,
|
|
body: body,
|
|
notificationDetails: NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
talkChannelId,
|
|
talkChannelName,
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
color: _accentColor,
|
|
tag: 'talk_error_$chatToken',
|
|
styleInformation: BigTextStyleInformation(body),
|
|
),
|
|
iOS: const DarwinNotificationDetails(),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(
|
|
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,
|
|
color: _accentColor,
|
|
);
|
|
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});
|
|
|
|
/// 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;
|
|
}
|
|
}
|