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

137 lines
6.4 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).
## 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 <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.