Files
pyramid/docs/NOTIFICATIONS.md
T
Bernd SteckmeisterandClaude Opus 5.5 ef1d881967 feat: Push als Chat-Verlauf - mehrere Nachrichten, voller Text, Bildvorschau, echtes "Gelesen"
Bernds Beta-Feedback: pro Raum wurde jede Nachricht durch die naechste ersetzt
und auf 100 Zeichen gekuerzt, ohne Bild. Jetzt:
- NotificationHelper: MessagingStyle mit den letzten 8 Nachrichten pro Raum
  (Verlauf in SharedPreferences, nur solange die Benachrichtigung sichtbar ist),
  gleiche eventId ersetzt (Platzhalter -> Klartext), nur Neues klingelt.
- notification_content.dart: Text bis 1000 Zeichen, Antwort-Zitat entfernt,
  Medien-Labels, Vorschau-Einstellung auch bei gekillter App; Bild (E2EE
  entschluesselt) + Avatar per FileProvider an die System-UI.
- "Gelesen" setzt eine echte Lesebestaetigung (Haupt-Engine bzw. BgEngine).
- Laufende App holt genau das gepushte Event statt room.lastEvent.
- Debug-only DebugNotifyReceiver zum Testen per adb ohne Login.
Emulator (API 36): Verlauf, Ersetzen, Bild, Avatar, Gelesen geprueft;
9 neue Tests. Echtes Geraet (Bernd) steht aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:27:56 +02:00

8.6 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).

Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)

Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):

  • Pro Raum ein Chat-Verlauf (Android MessagingStyle) mit den letzten 8 Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel. Der Verlauf liegt in SharedPreferences (pyramid_notif_history), weil bei gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt oder Raum geöffnet → nächster Push beginnt neu).
  • Gleiche eventId = Aktualisierung, nicht neue Nachricht: der „Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln (setOnlyAlertOnce(!isNew)).
  • Text: nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw. Logik in lib/core/notification_content.dart (Tests: test/notification_content_test.dart). Einstellung „Nachrichtenvorschau“ gilt jetzt auch bei gekillter App.
  • Bilder/Avatare: Dart legt sie unter <cache>/downloads/notif/ ab (Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native reicht sie per FileProvider-URI an die System-UI weiter (grantUriPermission("com.android.systemui")). Dateien älter als 2 Tage werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt NotificationHelper auf reinen Text zurück.
  • „Gelesen“ setzt jetzt eine echte Lesebestätigung bis zur neuesten Nachricht (Haupt-Engine markReadFromNotification bzw. BgEngine markRead), statt nur zu schließen.
  • Testen ohne Login (nur Debug-Build): DebugNotifyReceiver (android/app/src/debug/), z. B. adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'" (--ez image true für ein Testbild). Nicht im Release-APK.

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: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen
lib/core/notification_content.dart Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien)
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.