6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
6.4 KiB
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)
PushService.onMessageReceivedholt Titel (Sender · Raum) per HTTP.- 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.
BgEngine(BgEngine.kt) startet eine headlessFlutterEngineüber den gespeicherten Callback-Handle (flutter.bg_engine_handle) und führt das Dart-EntrypointnotificationEngineMain(background_push.dart) aus.- 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
showDecryptedan Native zurück.
BgEnginezeigt viaNotificationHelper.showdie 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
replyFromNotificationan 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.
- App lebt → MethodChannel
- 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)
- Loader-Reihenfolge:
FlutterCallbackInformation.lookupCallbackInformationerst nachflutterLoader.startInitialization+ensureInitializationCompleteaufrufen — sonstUnsatisfiedLinkError(native Methode, libflutter.so noch nicht geladen). - vodozemac doppelt: Wenn Decrypt- und Reply-Task dasselbe wiederverwendete
Isolate nutzen, wirft das zweite
vod.init()„already initialized". In_buildClientin eigenem try/catch schlucken — sonst gibt_buildClientstillnullzurück und die Antwort geht nicht raus. database_closed-Rauschen: Vorclient.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 <room>
PYRAMID-BGENGINE: background engine started
PYRAMID-BGENGINE: Dart background engine ready — flushing 1 task(s)
PYRAMID-BGENGINE: showDecrypted → shown decrypted notification for <room>
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.