Files
pyramid/lib/core/notification_service.dart
T
Bernd SteckmeisterandClaude Opus 5.5 ef1d881967 feat: Push als Chat-Verlauf - mehrere Nachrichten, voller Text, Bildvorschau, echtes "Gelesen"
Bernds Beta-Feedback: pro Raum wurde jede Nachricht durch die naechste ersetzt
und auf 100 Zeichen gekuerzt, ohne Bild. Jetzt:
- NotificationHelper: MessagingStyle mit den letzten 8 Nachrichten pro Raum
  (Verlauf in SharedPreferences, nur solange die Benachrichtigung sichtbar ist),
  gleiche eventId ersetzt (Platzhalter -> Klartext), nur Neues klingelt.
- notification_content.dart: Text bis 1000 Zeichen, Antwort-Zitat entfernt,
  Medien-Labels, Vorschau-Einstellung auch bei gekillter App; Bild (E2EE
  entschluesselt) + Avatar per FileProvider an die System-UI.
- "Gelesen" setzt eine echte Lesebestaetigung (Haupt-Engine bzw. BgEngine).
- Laufende App holt genau das gepushte Event statt room.lastEvent.
- Debug-only DebugNotifyReceiver zum Testen per adb ohne Login.
Emulator (API 36): Verlauf, Ersetzen, Bild, Avatar, Gelesen geprueft;
9 neue Tests. Echtes Geraet (Bernd) steht aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:27:56 +02:00

651 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
const _kInstallChannel = MethodChannel('chat.pyramid.pyramid/install');
// ─── Stable notification ID per room (same algorithm as PushService.kt) ──────
// Dart's String.hashCode is randomised per-process; this gives a deterministic
// integer that matches the Kotlin side so FCM and sync notifications collapse.
int _stableRoomId(String roomId) {
var h = 0;
for (final c in roomId.codeUnits) {
h = ((h * 31) + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
// ─── Android plugin ───────────────────────────────────────────────────────────
final _androidPlugin = FlutterLocalNotificationsPlugin();
bool _androidInitialized = false;
// ─── IsolateNameServer port ───────────────────────────────────────────────────
// Registered on startup so background isolates can forward notification actions
// to the running main isolate instead of starting a new Flutter engine.
ReceivePort? _mainIsolatePort;
// Room → active Windows notification (so we can close it on room-open).
final _windowsNotifs = <String, LocalNotification>{};
// ─── Notification tap payload → navigate to room ──────────────────────────────
final notificationTapProvider = StateProvider<String?>((ref) => null);
// ─── Permission state (Android) ───────────────────────────────────────────────
/// True when the user has permanently denied notification permission on Android.
final notificationsBlockedProvider = StateProvider<bool>((ref) => false);
// ─── Init ─────────────────────────────────────────────────────────────────────
Future<void> initNotifications(WidgetRef ref) async {
if (Platform.isAndroid) {
await _initAndroid(ref);
} else if (Platform.isWindows) {
await _initWindows();
}
}
Future<void> _initAndroid(WidgetRef ref) async {
if (_androidInitialized) return;
_androidInitialized = true;
// Register the IsolateNameServer port so background isolates can forward
// notification actions to us instead of starting a new Flutter engine.
_mainIsolatePort?.close();
_mainIsolatePort = ReceivePort();
IsolateNameServer.removePortNameMapping(kMainIsolateName);
IsolateNameServer.registerPortWithName(
_mainIsolatePort!.sendPort, kMainIsolateName);
_mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
debugPrint('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
await _androidPlugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification'),
),
onDidReceiveNotificationResponse: (details) {
debugPrint('[NOTIF-FG] onDidReceiveNotificationResponse fired');
debugPrint('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
if (details.actionId == 'reply') {
final text = details.input?.trim() ?? '';
final roomId = details.payload;
debugPrint('[NOTIF-FG] reply: text="$text" roomId=$roomId');
if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) {
_androidPlugin.cancel(_stableRoomId(roomId));
ref.read(matrixClientProvider.future).then((client) async {
final room = client.getRoomById(roomId);
debugPrint('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
if (room != null) {
try {
await room.sendTextEvent(text);
debugPrint('[NOTIF-FG] sendTextEvent OK');
} catch (e) {
debugPrint('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
}
} else {
debugPrint('[NOTIF-FG] room null — storing in prefs, waiting for sync');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
try {
await client.onSync.stream.first;
debugPrint('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
await _sendPendingReply(ref);
} catch (e) {
debugPrint('[NOTIF-FG] sync/send error: $e');
}
}
});
} else {
debugPrint('[NOTIF-FG] reply skipped: text empty or roomId null');
}
return;
}
if (details.actionId == 'read') {
final roomId = details.payload;
if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
debugPrint('[NOTIF-FG] read action — dismissed');
return;
}
final roomId = details.payload;
debugPrint('[NOTIF-FG] tap → opening room $roomId');
if (roomId != null && roomId.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
},
// Registers the background action handler so flutter_local_notifications
// can invoke it in a fresh isolate when the app is killed and the user
// presses Reply or Gelesen on a background notification.
onDidReceiveBackgroundNotificationResponse:
handleBackgroundNotificationResponse,
);
// Handle notification taps from PushService.kt (native FCM notifications).
// "openRoom" arrives when app is already running and user taps a notification.
_kInstallChannel.setMethodCallHandler((call) async {
final args = call.arguments;
if (call.method == 'openRoom') {
final roomId = (args is Map ? args['room_id'] : null) as String?;
debugPrint('[NOTIF-FG] openRoom: $roomId');
if (roomId != null && roomId.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
await _sendPendingReply(ref);
} else if (call.method == 'replyFromNotification') {
// Native Android path: ReplyReceiver forwarded the inline reply text via
// MethodChannel — no Activity was opened.
final roomId = (args is Map ? args['room_id'] : null) as String?;
final text = (args is Map ? args['text'] : null) as String?;
debugPrint('[NOTIF-FG] replyFromNotification: room=$roomId text=$text');
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
_androidPlugin.cancel(_stableRoomId(roomId));
Future<void> storePending() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
}
try {
final client = await ref.read(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room != null) {
await room.sendTextEvent(text);
debugPrint('[NOTIF-FG] replyFromNotification: sent OK');
} else {
debugPrint('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
await storePending();
// Retry on the next sync — room list is populated after first sync.
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
}
} catch (e) {
debugPrint('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
await storePending();
// Resume-based retry (checkPendingReply in app.dart) will pick this up.
}
}
} else if (call.method == 'sharedContent') {
// "Teilen nach Pyramid" während die App läuft — AppShell zeigt den Picker.
final text = (args is Map ? args['text'] : null) as String?;
final paths = (args is Map ? args['paths'] : null);
final share = PendingShare(
text: text,
paths: paths is List ? paths.whereType<String>().toList() : const [],
);
if (!share.isEmpty) {
ref.read(pendingShareProvider.notifier).state = share;
}
} else if (call.method == 'markReadFromNotification') {
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
if (roomId != null && eventId != null) {
try {
final client = await ref.read(matrixClientProvider.future);
await client
.getRoomById(roomId)
?.setReadMarker(eventId, mRead: eventId);
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
}
}
} else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite
// the notification with the real sender name and message body.
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
final notifId = (args is Map ? args['notif_id'] : null) as int?;
if (roomId != null && roomId.isNotEmpty) {
_decryptAndUpdateNotification(ref, roomId, eventId, notifId).catchError((_) {});
}
}
});
// Cold-start tap: app was killed, user tapped notification → check launch intent.
try {
final roomId = await _kInstallChannel.invokeMethod<String?>('getInitialRoomId');
if (roomId != null && roomId.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (_) {}
// Cold-start reply: app was killed, user pressed Reply → reply text is in the
// launch intent (onCreate fires instead of onNewIntent when app is killed).
try {
final reply = await _kInstallChannel.invokeMethod<Object?>('getInitialReply');
if (reply is Map) {
final roomId = reply['room_id'] as String?;
final text = reply['text'] as String?;
debugPrint('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text');
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
// Persist so _sendPendingReply sends it after the first sync.
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
// Send the app back to background immediately — the user replied from
// the notification and doesn't want the full UI to appear.
try {
await _kInstallChannel.invokeMethod('minimizeApp');
} catch (_) {}
}
}
} catch (_) {}
// Cold-start share: app was launched via "Teilen nach Pyramid".
try {
final share = await _kInstallChannel.invokeMethod<Object?>('getInitialShare');
if (share is Map) {
final text = share['text'] as String?;
final paths = share['paths'];
final pending = PendingShare(
text: text,
paths: paths is List ? paths.whereType<String>().toList() : const [],
);
if (!pending.isEmpty) {
ref.read(pendingShareProvider.notifier).state = pending;
}
}
} catch (_) {}
final androidPlugin = _androidPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
// Pre-create channel so it exists before the FCM background isolate runs.
await androidPlugin?.createNotificationChannel(
const AndroidNotificationChannel(
'pyramid_messages',
'Nachrichten',
description: 'Neue Nachrichten',
importance: Importance.high,
enableVibration: true,
playSound: true,
),
);
final granted = await androidPlugin?.requestNotificationsPermission();
if (granted == false) {
ref.read(notificationsBlockedProvider.notifier).state = true;
}
// FCM: Firebase initialisieren + Pusher beim Homeserver registrieren.
// Use .future so we wait for the Matrix client to be fully ready
// rather than potentially getting null from .value during startup.
try {
final client = await ref.read(matrixClientProvider.future);
await initFcm(client);
// Rooms are only reliably in memory after the first sync completes.
// Send any reply that was stored while the app was killed.
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
} catch (_) {}
}
// ─── IsolateNameServer message handler ───────────────────────────────────────
// Called when a background isolate (handleBackgroundNotificationResponse)
// forwards a notification action to the running main isolate.
void _onIsolateMessage(dynamic message, WidgetRef ref) {
if (message is! Map) return;
final actionId = message['actionId'] as String?;
final payload = message['payload'] as String?;
final input = message['input'] as String?;
final id = message['id'] as int?;
debugPrint('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input');
switch (actionId) {
case 'reply':
final roomId = payload;
final text = (input ?? '').trim();
if (roomId == null || roomId.isEmpty || text.isEmpty) return;
_androidPlugin.cancel(id ?? _stableRoomId(roomId));
ref.read(matrixClientProvider.future).then((client) async {
final room = client.getRoomById(roomId);
if (room != null) {
try {
await room.sendTextEvent(text);
debugPrint('[NOTIF-ISOLATE] reply sent OK');
} catch (e) {
debugPrint('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
}
} else {
debugPrint('[NOTIF-ISOLATE] room not in memory — storing in prefs');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
}
});
case 'read':
if (payload != null) {
_androidPlugin.cancel(_stableRoomId(payload));
} else if (id != null) {
_androidPlugin.cancel(id);
}
debugPrint('[NOTIF-ISOLATE] read → notification dismissed');
default:
// Plain notification tap → navigate to room.
if (payload != null && payload.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = payload;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
debugPrint('[NOTIF-ISOLATE] tap → navigated to $payload');
}
}
}
/// Sends a reply stored by the background notification handler.
/// Called on cold start and on every app resume so no reply is ever lost.
Future<void> _sendPendingReply(WidgetRef ref) async {
try {
final prefs = await SharedPreferences.getInstance();
final roomId = prefs.getString('notif_pending_reply_room');
final text = prefs.getString('notif_pending_reply_text');
debugPrint('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text');
if (roomId == null || text == null || text.isEmpty) return;
final client = await ref.read(matrixClientProvider.future);
debugPrint('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}');
final room = client.getRoomById(roomId);
debugPrint('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
if (room == null) {
debugPrint('[NOTIF-REPLY] room not in memory yet, retrying after next sync');
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
return;
}
await room.sendTextEvent(text);
debugPrint('[NOTIF-REPLY] sendTextEvent OK, clearing prefs');
await prefs.remove('notif_pending_reply_room');
await prefs.remove('notif_pending_reply_text');
} catch (e) {
debugPrint('[NOTIF-REPLY] error: $e — prefs kept for retry');
}
}
/// Opens the OS notification-settings screen for this app (Android only).
Future<void> openNotificationSettings() async {
if (!Platform.isAndroid) return;
await _kInstallChannel.invokeMethod('openNotificationSettings');
}
/// Opens the system battery optimisation dialog for Pyramid (Android only).
/// Samsung and other OEMs kill FCM background processes unless this is set
/// to "Unrestricted".
Future<void> openBatteryOptimizationSettings() async {
if (!Platform.isAndroid) return;
await _kInstallChannel.invokeMethod('openBatterySettings');
}
Future<void> _initWindows() async {
await localNotifier.setup(appName: 'Pyramid');
}
// ─── Heartbeat — tells the FCM background handler the app is running ──────────
bool _appInForeground = true;
/// Call from the app lifecycle observer in app.dart.
void setAppForeground(bool value) {
_appInForeground = value;
if (!value && Platform.isAndroid) {
// Clear immediately so the FCM background handler runs as soon as the app
// is no longer in the foreground / is killed.
SharedPreferences.getInstance()
.then((p) => p.setInt('notif_app_heartbeat', 0));
}
}
void _pingHeartbeat() {
if (!Platform.isAndroid || !_appInForeground) return;
SharedPreferences.getInstance().then(
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
);
}
// ─── Reset notification timestamp on app resume ───────────────────────────────
// Events that arrived while the app was backgrounded were already notified by
// the FCM background handler. Update _watchStartTime so the notification
// watcher skips those catch-up events when the app comes back to foreground.
void resetNotificationStartTime() {
_watchStartTime = DateTime.now();
}
// ─── Cancel notification for a room (call when the chat is opened) ────────────
Future<void> cancelNotificationForRoom(String roomId) async {
if (Platform.isAndroid && _androidInitialized) {
await _androidPlugin.cancel(_stableRoomId(roomId));
} else if (Platform.isWindows) {
final notif = _windowsNotifs.remove(roomId);
notif?.close();
}
}
// ─── Decrypt & update notification (called by PushService via MethodChannel) ──
// PushService shows a "Neue Nachricht" placeholder immediately. This function
// is called while the app is backgrounded — the Matrix client has the E2EE keys
// so it can decrypt the event and overwrite the placeholder with real content.
Future<void> _decryptAndUpdateNotification(
WidgetRef ref, String roomId, String? eventId, int? notifId) async {
try {
final client = await ref
.read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5));
if (eventId == null) return;
// Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null || event.senderId == client.userID) return;
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) {
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
return;
}
// Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
// Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
await deliverMessageNotification(
event,
content,
notifId: notifId ?? _stableRoomId(roomId),
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
);
debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
} catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e');
}
}
// ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification(
Event event,
MessageNotificationContent content,
) async {
final roomId = content.roomId;
if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies.
try {
await deliverMessageNotification(
event,
content,
notifId: _stableRoomId(roomId),
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
);
} catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e');
}
} else if (Platform.isWindows) {
final title = content.title;
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
final body = content.body.length > 300
? '${content.body.substring(0, 300)}…'
: content.body;
// Close the previous notification for this room before showing a new one.
_windowsNotifs.remove(roomId)?.close();
final notif = LocalNotification(
identifier: 'msg_$roomId',
title: title,
body: body,
);
notif.onShow = () {};
_windowsNotifs[roomId] = notif;
await localNotifier.notify(notif);
// Also flash the taskbar button
WindowsTaskbar.setFlashTaskbarAppIcon();
}
}
// ─── Taskbar badge (Windows) ──────────────────────────────────────────────────
bool _badgeVisible = false;
Future<void> _updateTaskbar(int unreadCount) async {
if (!Platform.isWindows) return;
try {
if (unreadCount > 0 && !_badgeVisible) {
_badgeVisible = true;
await WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
} else if (unreadCount == 0 && _badgeVisible) {
_badgeVisible = false;
await WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
}
} catch (_) {}
}
// Exported so AppShell can call it when the app resumes from background.
Future<void> checkPendingReply(WidgetRef ref) => _sendPendingReply(ref);
// ─── Watcher provider ─────────────────────────────────────────────────────────
final notificationWatcherProvider = Provider<void>((ref) {
ref.watch(matrixClientProvider).whenData((client) {
_watchEvents(ref, client);
_watchBadge(client);
});
});
// Set once on first _watchEvents call so we can skip catchup events from the
// initial SDK sync (they were already shown by the FCM background handler).
DateTime? _watchStartTime;
void _watchEvents(Ref ref, Client client) {
_watchStartTime ??= DateTime.now();
client.onNotification.stream.listen((Event event) {
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
// Skip events that predate this app session — the FCM background handler
// already showed a notification for them while the app was killed.
if (event.originServerTs.isBefore(_watchStartTime!)) return;
// ── Master notification toggle (desktop/Windows setting) ──────────────
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
if (!ref.read(notifDesktopProvider)) return;
}
final roomId = event.room.id;
// Suppress only when the window is focused AND the room is currently open.
// If the window is minimized or in the background, always show the notification.
final windowFocused = ref.read(windowFocusedProvider);
if (windowFocused && roomId == ref.read(activeRoomIdProvider)) return;
final room = event.room;
if (room.membership != Membership.join) return;
// ── Mention-only filter ───────────────────────────────────────────────
if (ref.read(notifMentionOnlyDmProvider)) {
final myId = client.userID ?? '';
// Check MSC3952 m.mentions field first, then fall back to body scan.
final mentionedIds = (event.content
.tryGetMap<String, dynamic>('m.mentions')
?.tryGetList<String>('user_ids')) ??
[];
final bodyMentionsUs = event.body.contains(myId) ||
event.body.contains('@${myId.split(':').first.replaceFirst('@', '')}');
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
}
// ── Build notification content ────────────────────────────────────────
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) return;
showMessageNotification(event, content);
});
}
void _watchBadge(Client client) {
client.onSync.stream.listen(
(_) {
try {
final total = client.rooms
.where((r) => r.membership == Membership.join)
.fold<int>(0, (sum, r) => sum + r.notificationCount);
_updateTaskbar(total);
_pingHeartbeat(); // keep PushService.kt from double-notifying
} catch (_) {}
},
onError: (_) {},
cancelOnError: false,
);
}