25ed765a03
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
137 lines
6.4 KiB
Markdown
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.
|