133a8acbc8
65 flutter-analyze-Meldungen behoben, indem alle print()-Aufrufe in Core- Dateien (Push, LiveKit, Matrix-Client, VoIP, Screen-Share) durch das Flutter-eigene debugPrint() ersetzt wurden. Gleiche Funktion (Logging in Debug-Sessions), aber zeilenlängen-sicher und ohne Lint-Warnung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
662 lines
28 KiB
Dart
662 lines
28 KiB
Dart
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/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 == '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));
|
|
var room = client.getRoomById(roomId);
|
|
if (room == null) {
|
|
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
|
|
return;
|
|
}
|
|
|
|
// The event might not have synced yet — wait for the next sync (max 12 s).
|
|
// We use lastEvent as a proxy: after sync it will be the event we want.
|
|
Event? event = room.lastEvent;
|
|
final bool alreadyHave =
|
|
eventId == null || event?.eventId == eventId;
|
|
if (!alreadyHave || event == null) {
|
|
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
|
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
|
room = client.getRoomById(roomId);
|
|
if (room == null) return;
|
|
event = room.lastEvent;
|
|
}
|
|
|
|
if (event == null) {
|
|
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
|
|
return;
|
|
}
|
|
if (event.type != EventTypes.Message) return;
|
|
if (event.senderId == client.userID) return;
|
|
|
|
final rawBody = event.body;
|
|
// If decryption failed the body starts with "** Unable to decrypt"
|
|
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
|
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
|
return;
|
|
}
|
|
|
|
final notifBody =
|
|
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
|
|
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
final displayName = sender.displayName ??
|
|
event.senderId.split(':').first.replaceFirst('@', '');
|
|
final title = room.isDirectChat
|
|
? displayName
|
|
: '$displayName · ${room.getLocalizedDisplayname()}';
|
|
|
|
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
|
|
// replaces the existing notification rather than stacking a new one.
|
|
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
|
'title': title,
|
|
'body': notifBody,
|
|
'roomId': roomId,
|
|
'notifId': notifId ?? _stableRoomId(roomId),
|
|
});
|
|
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
|
} catch (e) {
|
|
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
|
}
|
|
}
|
|
|
|
// ─── Show notification ────────────────────────────────────────────────────────
|
|
|
|
Future<void> showMessageNotification({
|
|
required String roomId,
|
|
String? roomName,
|
|
required String senderName,
|
|
required String body,
|
|
}) async {
|
|
final title = roomName != null ? '$senderName · $roomName' : senderName;
|
|
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 _kInstallChannel.invokeMethod('showNativeNotification', {
|
|
'title': title,
|
|
'body': body,
|
|
'roomId': roomId,
|
|
'notifId': _stableRoomId(roomId),
|
|
});
|
|
} catch (e) {
|
|
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
|
}
|
|
} else if (Platform.isWindows) {
|
|
// 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 body ───────────────────────────────────────────
|
|
final rawBody = event.body;
|
|
final showPreview = ref.read(notifPreviewProvider);
|
|
final notifBody = showPreview
|
|
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
|
|
: 'Neue Nachricht';
|
|
|
|
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
final displayName = sender.displayName ??
|
|
event.senderId.split(':').first.replaceFirst('@', '');
|
|
|
|
showMessageNotification(
|
|
roomId: roomId,
|
|
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
|
senderName: displayName,
|
|
body: notifBody,
|
|
);
|
|
});
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|