# Push-Benachrichtigungen & E2EE im Hintergrund Stand: 2026-06-04. Dieses Dokument beschreibt, wie Pyramid auf Android verschlüsselte Push-Benachrichtigungen verarbeitet — inklusive Entschlüsselung und verschlüsseltem Antworten **bei komplett gekillter App**, ohne dass sich die App öffnet. ## Überblick der Kette ``` Matrix-Homeserver (Continuwuity) │ (event_id_only Push) ▼ Sygnal (self-hosted: push.steggi-matrix.work) ▼ Firebase Cloud Messaging (FCM) ▼ PushService.kt (FirebaseMessagingService, nativ) ├─ App lebt im Vordergrund → App zeigt selbst, Push wird übersprungen ├─ App lebt im Hintergrund → Haupt-Engine entschlüsselt (warm, schnell) └─ App gekillt → BgEngine bootet ein headless Flutter-Isolate ``` Der Flutter-eigene FCM-Dienst (`FlutterFirebaseMessagingService`) ist im `AndroidManifest.xml` **deaktiviert** — alle Pushes laufen über `PushService.kt`. Push-Format ist `event_id_only` (nur die Event-ID wird übertragen, nicht der Inhalt — datensparsam). ## Warum dieser Aufbau? Die E2EE-Schlüssel (olm/megolm) liegen **nur** im Matrix-Client (verschlüsselte SQLite-DB), nicht im nativen Kotlin-Code. Kotlin kann daher Sender + Raumname (unverschlüsselte Room-States) per HTTP holen, aber **nicht** den Nachrichtentext entschlüsseln. Dafür braucht es einen laufenden Matrix-Client. **Sicherheitsregel:** Niemals zwei Matrix-Clients gleichzeitig auf derselben olm-DB — das korrumpiert den Ratchet-State und führt zu dauerhaften „Unable to decrypt"-Fehlern. Deshalb läuft der Hintergrund-Client (`BgEngine`) **nur**, wenn die Haupt-App nachweislich tot ist (`FlutterEngineCache.get("main") == null`). ## Die drei Zustände | Zustand | Erkennung | Pfad | |---|---|---| | Vordergrund | Heartbeat < 20s (`flutter.notif_app_heartbeat`) | Push übersprungen, App zeigt selbst | | Hintergrund (lebt) | `FlutterEngineCache.get("main") != null` | Platzhalter sofort → Haupt-Engine entschlüsselt + aktualisiert | | Gekillt | keine Haupt-Engine | `BgEngine` bootet headless Isolate, **decrypt-first** | ## Gekillter Zustand im Detail (decrypt-first) 1. `PushService.onMessageReceived` holt Titel (Sender · Raum) per HTTP. 2. Statt sofort einen Platzhalter zu zeigen: - `BgEngine.scheduleFallback(…, 6s)` plant einen „Neue Nachricht"-Platzhalter, der **nur** erscheint, falls die Entschlüsselung > 6s dauert oder scheitert. - `BgEngine.run("decryptAndShow", …)` bootet das Isolate. 3. `BgEngine` (BgEngine.kt) startet eine headless `FlutterEngine` über den gespeicherten Callback-Handle (`flutter.bg_engine_handle`) und führt das Dart-Entrypoint `notificationEngineMain` (background_push.dart) aus. 4. Dart `_bgDecryptAndShow`: - `_buildClient()` öffnet **dieselbe** DB (`pyramid.sqlite`, `MatrixSdkDatabase.init('pyramid')`, `NativeImplementationsDummy`). - `client.getEventByPushNotification(...)` entschlüsselt mit den lokal vorhandenen megolm-Schlüsseln (oneShotSync nur als Fallback). - Gibt Titel + Text per MethodChannel `showDecrypted` an Native zurück. 5. `BgEngine` zeigt via `NotificationHelper.show` die echte Nachricht und bricht den Fallback-Platzhalter ab. Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s). ## Verschlüsselt antworten (gekillt, vollständig still) - Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`), **nicht** MainActivity — die App öffnet sich also nicht. - `ReplyReceiver`: - App lebt → MethodChannel `replyFromNotification` an die Haupt-Engine. - Gekillt → `BgEngine.run("sendReply", …)` → Dart `_bgSendReply` → oneShotSync + `room.sendTextEvent` (verschlüsselt). `goAsync()` + finish nach 15 s hält den Receiver-Prozess am Leben, bis der Versand durch ist. - RemoteInput-Extraktion hat mehrere Key-Fallbacks (OEM-Eigenheiten) und funktioniert auf Samsung S23 trotz der alten „getBroadcast verschluckt RemoteInput"-Warnung. ## Gelöste Fallstricke (NICHT wieder einbauen) 1. **Loader-Reihenfolge:** `FlutterCallbackInformation.lookupCallbackInformation` erst **nach** `flutterLoader.startInitialization` + `ensureInitializationComplete` aufrufen — sonst `UnsatisfiedLinkError` (native Methode, libflutter.so noch nicht geladen). 2. **vodozemac doppelt:** Wenn Decrypt- und Reply-Task dasselbe wiederverwendete Isolate nutzen, wirft das zweite `vod.init()` „already initialized". In `_buildClient` in eigenem try/catch schlucken — sonst gibt `_buildClient` still `null` zurück und die Antwort geht nicht raus. 3. **`database_closed`-Rauschen:** Vor `client.dispose()` 2 s warten, damit der asynchrone Key-Backup-Upload fertig wird. ## Relevante Dateien | Datei | Rolle | |---|---| | `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing | | `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter | | `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) | | `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) | | `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback | | `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply | | `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal | | `lib/core/notification_service.dart` | Vordergrund/Hintergrund-Notifs, Heartbeat | | `lib/main.dart` | `registerBgEngineHandle()` beim Start | ## Debugging `flutter run` verliert die Verbindung, sobald die App gekillt wird — daher direkt `adb logcat` nutzen: ``` adb logcat -v time PYRAMID-PUSH:* PYRAMID-BGENGINE:* PYRAMID-REPLY:* PYRAMID-INTENT:* flutter:* AndroidRuntime:E *:S ``` Erwartete Log-Sequenz (gekillt, Erfolg): ``` PYRAMID-PUSH: decrypt-first via background engine for PYRAMID-BGENGINE: background engine started PYRAMID-BGENGINE: Dart background engine ready — flushing 1 task(s) PYRAMID-BGENGINE: showDecrypted → shown decrypted notification for ``` Reply (gekillt): ``` PYRAMID-REPLY: extracted replyText=… (remoteInput=true) PYRAMID-REPLY: Flutter engine not running → background engine send PYRAMID-BGENGINE: (sendReply → sendTextEvent OK) ``` Hinweis: Kotlin lässt sich nur auf einem Rechner mit JAVA_HOME/Android-SDK bauen.