# 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.
