import 'dart:io'; import 'dart:ui'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:matrix/matrix.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart' as sqflite_native; /// Port name used to locate the main isolate from background isolates. /// Registered by notification_service.dart when the app starts. const kMainIsolateName = 'pyramid_main'; int _stableId(String roomId) { var h = 0; for (final c in roomId.codeUnits) { h = ((h * 31) + c) & 0x7FFFFFFF; } return h == 0 ? 1 : h; } // ── FCM background handler ──────────────────────────────────────────────────── // Runs in its own Dart isolate (or fresh process) when the app is killed or // backgrounded. Firebase calls this after Firebase.initializeApp() is done. @pragma('vm:entry-point') Future handleBackgroundMessage(RemoteMessage message) async { // PushService.kt handles all FCM-triggered notifications natively. // This callback is registered for API completeness but never fires because // FlutterFirebaseMessagingService is removed from the manifest. } // ── Background engine entrypoint (killed-app E2EE work) ─────────────────────── // PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this // entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel // so native can ask us to (a) decrypt a push event and update its notification, // or (b) send an encrypted reply. Building a Matrix client here is safe ONLY // because native gates this on the main app being dead — never two clients on // the same olm DB at once. const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine'; @pragma('vm:entry-point') void notificationEngineMain() { WidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel(_kBgEngineChannel); channel.setMethodCallHandler((call) async { try { final args = (call.arguments as Map?)?.cast() ?? {}; switch (call.method) { case 'decryptAndShow': return await _bgDecryptAndShow( args['room_id'] as String?, args['event_id'] as String?, (args['notif_id'] as num?)?.toInt(), ); case 'sendReply': return await _bgSendReply( args['room_id'] as String?, args['text'] as String?, ); } } catch (e) { debugPrint('[NOTIF-BGENGINE] handler error: $e'); } return false; }); // Signal native that the channel handler is installed and tasks can be sent. channel.invokeMethod('bgEngineReady'); } Future _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async { if (roomId == null || eventId == null) return false; final client = await _buildClient(); if (client == null) return false; try { final event = await client .getEventByPushNotification( PushNotification(roomId: roomId, eventId: eventId, devices: const []), storeInDatabase: false, returnNullIfSeen: false, ) .timeout(const Duration(seconds: 20)); if (event == null) return false; // Bad-encrypted body still starts with "**" — keep the native placeholder. if (event.body.startsWith('**')) return false; final room = client.getRoomById(roomId); final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ?? event.senderId.split(':').first.replaceFirst('@', ''); final title = (room?.isDirectChat ?? true) ? sender : '$sender · ${room?.getLocalizedDisplayname() ?? ''}'; // Hand the decrypted content back to native so it re-shows the SAME // notification via NotificationHelper — that keeps the reliable // MainActivity-based reply action (the flutter_local_notifications reply // path doesn't fire dependably when the app is killed). await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', { 'title': title, 'body': _buildBody(event), 'room_id': roomId, 'notif_id': notifId ?? _stableId(roomId), }); return true; } catch (e) { debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e'); return false; } finally { // Let the async key-backup upload settle before closing the DB — otherwise // it logs a harmless "database_closed" error mid-flight. await Future.delayed(const Duration(seconds: 2)); await client.dispose().catchError((_) {}); } } Future _bgSendReply(String? roomId, String? text) async { if (roomId == null || text == null || text.trim().isEmpty) return false; final client = await _buildClient(); if (client == null) return false; try { // A one-shot sync ensures the room and recipient device keys are present so // the message can be encrypted. Safe here: main app is dead (native-gated). await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null); final room = client.getRoomById(roomId); if (room == null) { // Fall back to the prefs queue so the main app sends it on next launch. final prefs = await SharedPreferences.getInstance(); await prefs.setString('notif_pending_reply_room', roomId); await prefs.setString('notif_pending_reply_text', text); return false; } await room.sendTextEvent(text.trim()); debugPrint('[NOTIF-BGENGINE] encrypted reply sent to $roomId'); return true; } catch (e) { debugPrint('[NOTIF-BGENGINE] sendReply error: $e'); final prefs = await SharedPreferences.getInstance(); await prefs.setString('notif_pending_reply_room', roomId); await prefs.setString('notif_pending_reply_text', text); return false; } finally { // Allow the encrypted send + any key upload to flush before closing the DB. await Future.delayed(const Duration(seconds: 2)); await client.dispose().catchError((_) {}); } } /// Stores the raw callback handle of [notificationEngineMain] so native code can /// bootstrap the background engine. Call once at app startup. Future registerBgEngineHandle() async { if (!Platform.isAndroid) return; try { final handle = PluginUtilities.getCallbackHandle(notificationEngineMain); if (handle == null) return; final prefs = await SharedPreferences.getInstance(); await prefs.setInt('bg_engine_handle', handle.toRawHandle()); } catch (e) { debugPrint('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e'); } } // ── Build a short-lived Matrix client for background work ──────────────────── // Uses NativeImplementationsDummy so Vodozemac runs synchronously in this // isolate — NativeImplementationsIsolate(compute) would try to spawn a // sub-isolate, which fails inside a Firebase background isolate. Future _buildClient() async { try { // vodozemac may already be initialised in this isolate (e.g. a decrypt task // ran before this reply task reused the same engine). Calling init() twice // throws — swallow it so it doesn't fail the whole client build. try { await vod.init(); } catch (_) {} final appDir = await getApplicationSupportDirectory(); final dbPath = p.join(appDir.path, 'pyramid.sqlite'); final db = await sqflite_native.databaseFactory .openDatabase(dbPath) .timeout(const Duration(seconds: 5)); // Prevent "database is locked" if the main process still has the DB open. try { await db.execute('PRAGMA busy_timeout = 5000'); await db.execute('PRAGMA journal_mode = WAL'); } catch (_) {} final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); final client = Client( 'Pyramid', database: sdkDb, nativeImplementations: NativeImplementationsDummy(), logLevel: Level.error, ); await client .init( waitForFirstSync: false, waitUntilLoadCompletedLoaded: false, ) .timeout(const Duration(seconds: 15)); return client; } catch (e) { debugPrint('[NOTIF-BGENGINE] _buildClient failed: $e'); return null; } } String _buildBody(Event event) { final msgType = event.messageType; final rawBody = event.body; return switch (msgType) { 'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody, 'm.image' => '📷 Bild', 'm.video' => '🎥 Video', 'm.audio' => '🎵 Audio', 'm.file' => '📄 Datei', 'm.sticker' => '🏷️ Sticker', _ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody), }; } // ── Notification action handler ─────────────────────────────────────────────── // Called in a fresh Dart isolate when the user presses Reply or Gelesen. // Must return quickly — Android keeps the notification's "sending" spinner // visible until this function returns. @pragma('vm:entry-point') Future handleBackgroundNotificationResponse( NotificationResponse details) async { WidgetsFlutterBinding.ensureInitialized(); debugPrint('[NOTIF-BG] handleBackgroundNotificationResponse fired'); debugPrint('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}'); // ── Fast path: main isolate is running ─────────────────────────────────── // If the Pyramid UI process is alive (app is backgrounded but not killed), // forward the action directly via IsolateNameServer. This avoids starting // any new engine and works silently without bringing the app to foreground. final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName); if (sendPort != null) { debugPrint('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer'); sendPort.send({ 'actionId': details.actionId, 'payload': details.payload, 'input': details.input, 'id': details.id, }); return; } // ── Slow path: app was killed ───────────────────────────────────────────── // Main isolate is gone. Dismiss the notification and, for reply actions, // persist the text in SharedPreferences so _sendPendingReply picks it up // the next time the app opens (via getInitialReply cold-start flow). debugPrint('[NOTIF-BG] main isolate not running → SharedPreferences fallback'); final plugin = FlutterLocalNotificationsPlugin(); await plugin.initialize( const InitializationSettings( android: AndroidInitializationSettings('@drawable/ic_notification')), onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse, ); final roomId = details.payload; if (roomId != null) { await plugin.cancel(_stableId(roomId)); debugPrint('[NOTIF-BG] cancelled notification for room $roomId'); } else if (details.id != null) { await plugin.cancel(details.id!); debugPrint('[NOTIF-BG] cancelled notification id ${details.id}'); } if (details.actionId != 'reply') { debugPrint('[NOTIF-BG] not a reply action, done'); return; } final text = details.input?.trim() ?? ''; if (text.isEmpty || roomId == null) { debugPrint('[NOTIF-BG] text empty or roomId null, aborting'); return; } // App killed — send the encrypted reply directly from this background isolate. // _bgSendReply falls back to the prefs queue if it can't send (so the main app // delivers it on next launch). final sent = await _bgSendReply(roomId, text); debugPrint('[NOTIF-BG] background reply sent=$sent room=$roomId'); }