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 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 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 _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 _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 renderTalkThread( String chatToken, List messages, { bool alert = true, }) async { if (messages.isEmpty) return; // A silent render only UPDATES an existing card. If the card is verifiably // gone (cleanup cancelled it or the user swiped it away), re-posting would // resurrect it — with Android re-attaching 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 = {}; 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 _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 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 _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 renderConnect({ required String title, required String body, Map? 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; } }