import 'dart:io'; import 'dart:isolate'; import 'dart:ui'; 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 = {}; // ─── Notification tap payload → navigate to room ────────────────────────────── final notificationTapProvider = StateProvider((ref) => null); // ─── Permission state (Android) ─────────────────────────────────────────────── /// True when the user has permanently denied notification permission on Android. final notificationsBlockedProvider = StateProvider((ref) => false); // ─── Init ───────────────────────────────────────────────────────────────────── Future initNotifications(WidgetRef ref) async { if (Platform.isAndroid) { await _initAndroid(ref); } else if (Platform.isWindows) { await _initWindows(); } } Future _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)); print('[NOTIF] IsolateNameServer port registered: $kMainIsolateName'); await _androidPlugin.initialize( const InitializationSettings( android: AndroidInitializationSettings('@drawable/ic_notification'), ), onDidReceiveNotificationResponse: (details) { print('[NOTIF-FG] onDidReceiveNotificationResponse fired'); print('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}'); if (details.actionId == 'reply') { final text = details.input?.trim() ?? ''; final roomId = details.payload; print('[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); print('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}'); if (room != null) { try { await room.sendTextEvent(text); print('[NOTIF-FG] sendTextEvent OK'); } catch (e) { print('[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 { print('[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; print('[NOTIF-FG] sync arrived — retrying _sendPendingReply'); await _sendPendingReply(ref); } catch (e) { print('[NOTIF-FG] sync/send error: $e'); } } }); } else { print('[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)); print('[NOTIF-FG] read action — dismissed'); return; } final roomId = details.payload; print('[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?; print('[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?; print('[NOTIF-FG] replyFromNotification: room=$roomId text=$text'); if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) { _androidPlugin.cancel(_stableRoomId(roomId)); Future 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); print('[NOTIF-FG] replyFromNotification: sent OK'); } else { print('[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) { print('[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().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('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('getInitialReply'); if (reply is Map) { final roomId = reply['room_id'] as String?; final text = reply['text'] as String?; print('[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('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().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?; print('[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); print('[NOTIF-ISOLATE] reply sent OK'); } catch (e) { print('[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 { print('[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); } print('[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; print('[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 _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'); print('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text'); if (roomId == null || text == null || text.isEmpty) return; final client = await ref.read(matrixClientProvider.future); print('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}'); final room = client.getRoomById(roomId); print('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}'); if (room == null) { print('[NOTIF-REPLY] room not in memory yet, retrying after next sync'); client.onSync.stream.first .then((_) => _sendPendingReply(ref)) .catchError((_) {}); return; } await room.sendTextEvent(text); print('[NOTIF-REPLY] sendTextEvent OK, clearing prefs'); await prefs.remove('notif_pending_reply_room'); await prefs.remove('notif_pending_reply_text'); } catch (e) { print('[NOTIF-REPLY] error: $e — prefs kept for retry'); } } /// Opens the OS notification-settings screen for this app (Android only). Future 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 openBatteryOptimizationSettings() async { if (!Platform.isAndroid) return; await _kInstallChannel.invokeMethod('openBatterySettings'); } Future _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 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 _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) { print('[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) { print('[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) { print('[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('**')) { print('[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), }); print('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"'); } catch (e) { print('[NOTIF] decryptAndUpdate error: $e'); } } // ─── Show notification ──────────────────────────────────────────────────────── Future 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) { print('[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 _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 checkPendingReply(WidgetRef ref) => _sendPendingReply(ref); // ─── Watcher provider ───────────────────────────────────────────────────────── final notificationWatcherProvider = Provider((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('m.mentions') ?.tryGetList('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(0, (sum, r) => sum + r.notificationCount); _updateTaskbar(total); _pingHeartbeat(); // keep PushService.kt from double-notifying } catch (_) {} }, onError: (_) {}, cancelOnError: false, ); }