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]>
171 lines
8.6 KiB
Markdown
171 lines
8.6 KiB
Markdown
# 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.
|