Files
pyramid/docs/NOTIFICATIONS.md
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
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
2026-07-03 05:47:18 +02:00

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)

  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 (getBroadcastReplyReceiver), 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 <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.