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
This commit is contained in:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+136
View File
@@ -0,0 +1,136 @@
# 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.
+43
View File
@@ -0,0 +1,43 @@
# Pyramid Fortschritt & offene Punkte
Stand: 2026-06-04
## Erledigt
### Chat / UI
- **Reply-Zitat-Karte** im Discord/WhatsApp-Stil (kein „In reply to @user" mehr), medien-bewusste Vorschau, getönter Hintergrund bündig mit Akzent-Strich.
- **Per-Message-Selektion & -Hover** — einzelne Nachrichten statt ganzer Sender-Gruppen werden markiert/hervorgehoben.
- **Scroll-Stabilität** — Crash durch doppelte Keys behoben, Pagination (kein endloses „Zurückziehen", lädt aber bis zum echten Anfang), Bild-/PDF-Platz wird vorab reserviert (kein Springen beim Laden).
- **„Neue Nachrichten"-Divider** — korrekte Position, scrollt zuverlässig hin, blendet beim Erreichen des Endes aus; erscheint nur bei echten ungelesenen Nachrichten von anderen.
- **Composer-Höhe** bündig mit dem Profil-Block; **Verbindungs-Statusbanner** bei Sync-Verlust; **fehlgeschlagene Nachrichten** bleiben mit „Wiederholen/Löschen" stehen statt zu verschwinden.
- **„Zuletzt online"** nutzt echten Presence-Status + Frische statt `currentlyActive` (kein falsches „online" mehr).
- **Members-Panel** mobil schließbar + schmaler.
### Medien & Dokumente
- **Media-Cache** (LRU, byte-budgetiert, geteilt) — zuletzt betrachtete Medien sofort da, ältere automatisch verworfen.
- **Datei-Vorschau** — höhere Auflösung (PNG statt JPEG), viele neue Text/Code/Daten-Formate, **immersiver Fullscreen-Viewer** (Pan, +/ 20%-Zoom, Ctrl+Mausrad, Seiten-Navigation, mobil-optimiert, Tap blendet UI aus).
- **Eingebettete Link-Vorschauen** (Open-Graph-Karten) — serverseitig in Continuwuity aktiviert + Client-Karte.
### Suche
- **Lokale Suche** über entschlüsselte Events (server-seitige `/search` funktioniert nicht für E2EE) — Nachrichten, Medien (mit **Thumbnail**), Personen/Räume.
- **Sprung zur Nachricht** aus dem Suchergebnis inkl. Cross-Chat, mit sanft ausblendendem Highlight.
### Benachrichtigungen (Android)
- Bei **gekillter App**: Hintergrund-Entschlüsselung (decrypt-first) + **verschlüsseltes Antworten** ohne App-Öffnen. Details siehe [NOTIFICATIONS.md](NOTIFICATIONS.md).
### Einstellungen
- **Mobile Settings** im Element-Stil: Kategorie-Liste → Untermenü mit Zurück-Pfeil (statt gedrängtem Icon-Block). Desktop unverändert.
### Server (Pi 5, steggi@192.168.178.71)
- rclone-Mount für Pixeldrain-Medien repariert + Boot-Reihenfolge abgesichert; Medien liegen nur auf Pixeldrain.
- URL-Preview-Allowlist in `conduit.toml` aktiviert.
### Lesebestätigungen (Element-Stil, mit Haken)
- **✓✓** an der zuletzt gesehenen Nachricht des Gegenübers (auch dessen eigener Antwort), berechnet aus `room.receiptState`. Wandert mit, statt an der eigenen letzten Antwort zu kleben.
- **grauer ✓** nur auf der eigenen neuesten Nachricht, solange sie noch ungelesen ist; verschwindet sobald gelesen (dann nur ✓✓).
## Offen / geplant
- **Weiterleiten** von Nachrichten (Forward) — später.
- **Presence-Genauigkeit:** Nutzer, die den PC vor ~10 min unsauber heruntergefahren haben, werden teils noch als „online" angezeigt (Server expired Presence nicht zuverlässig). Außerdem: Online-Status im Chat-Header und in der Chat-Liste sind nicht immer konsistent — sollten dieselbe Quelle nutzen.
- PDF-Seiten beim Upload vorrendern — bewusst zurückgestellt (geringer Mehrwert wegen Caches).
- Selection-Menü-Politur (kein Bug, optional).
+276 -206
View File
@@ -1,6 +1,19 @@
# Matrix / Element Feature Checklist für Pyramid
*Stand: April 2026 — Pyramid v0.1.x (Early Development)*
*Letzte Tiefenanalyse: 2026-04-29 (Vergleich mit FluffyChat / Element Web)*
---
## Legende
-`[x]` — Implementiert und funktioniert korrekt
- ⚠️ `[~]` — Teilweise / fehlerhaft implementiert (Details in Technische Analyse)
-`[ ]` — Fehlt komplett
- 🔴 P0 — Kritisch (Kernfunktionalität kaputt)
- 🟠 P1 — Hoch (wichtige Funktion defekt oder nicht vorhanden)
- 🟡 P2 — Mittel
- 🔵 P3 — Niedrig / Nice-to-have
---
@@ -15,17 +28,19 @@
- [x] Verschlüsselte Nachrichten anzeigen (Entschlüsselungsstatus)
- [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden)
- [x] Ungelesene-Nachrichten-Tracking
- [~] Nachrichtenbearbeitung (Edit-Event erkannt, `getDisplayEvent` wird nicht gerendert) 🟠 P1
- [~] Nachrichtenvorschau bei Reply (Zitat-Preview vorhanden, kein Jump-to-original) 🟡 P2
### Nachrichtenaktionen
- [x] Nachrichtentext kopieren
- [x] Eigene Nachrichten löschen (redact)
- [x] Nachrichten anpinnen / Pinboard anzeigen
- [~] Auf Nachrichten antworten (in-reply-to vorhanden, kein visuelles Zitatfeld)
- [~] Nachrichten bearbeiten (m.edit im SDK erkannt, kein vollständiges UI)
- [x] Auf Nachrichten antworten (in-reply-to)
- [ ] Nachrichten weiterleiten
- [ ] Zitat-Styling (visuelle Blockquote-Anzeige)
### Textformatierung
### Textformatierung (formatted_body)
- [~] Markdown-Rendering — `formatted_body` / `org.matrix.custom.html` wird nicht gesendet und nicht gerendert 🔵 P3
- [ ] Fett (**text**)
- [ ] Kursiv (*text*)
- [ ] Durchgestrichen (~~text~~)
@@ -33,7 +48,7 @@
- [ ] Code-Block (```code```)
- [ ] Aufzählungslisten
- [ ] Klickbare Links
- [ ] @Erwähnungen (User)
- [~] @Erwähnungen — Button vorhanden, `onTap: null` 🟡 P2
- [ ] #Kanal-Erwähnungen
- [ ] Spoiler-Tags (||text||)
@@ -62,14 +77,118 @@
- [x] Mehrere Reaktionen pro Nachricht
- [x] Kategoriebasierte Emoji-Organisation
### Tipp-Indikatoren (Typing)
- [~] `room.setTyping()` wird **nirgendwo aufgerufen** — Einstellung existiert, aber Composer sendet nie "tippt…" 🟡 P2
- [ ] "X tippt gerade"-Anzeige empfangen (kein Widget)
- [ ] Timeout nach Tipp-Ende
### Threads
- [ ] Thread-Antworten (m.in_reply_to vorhanden, kein Thread-View)
- [~] Thread-Button vorhanden (`onPressed: () {}` — leer) 🔵 P3
- [ ] Thread aufklappen / eigene Ansicht
- [ ] Thread-Benachrichtigungen
- [ ] Thread als gelöst markieren
---
## Lesebestätigungen (Read Receipts)
- [x] Senden von `m.read`-Receipts via `room.setReadMarker()`
- [x] Privacy-Toggle (kein Senden wenn deaktiviert)
- [x] ✓-Icon (Nachricht gesendet/zugestellt) auf eigenen Nachrichten
- [x] ✓✓-Icon (gelesen) in DMs
- [x] Sende-Status (Uhr/Fehler/gesendet/gelesen)
- [~] ✓✓-Logik nur für DMs — in Gruppenräumen wird `receiptState.global.otherUsers` nicht ausgewertet 🟡 P2
- [~] **Off-by-one**: Divider erscheint *über* der Gruppe die das `fullyRead`-Event enthält, nicht darunter 🟡 P2
- [ ] Gruppen-Read-Receipt-Display (Seen-by-Avatare wie in FluffyChat) 🟡 P2
- [ ] Hover-Tooltip "Gelesen von: ..." auf ✓✓
- [ ] **Notification-Cancellation**: `cancelNotification(roomId)` beim Setzen des Read-Markers 🟡 P2
---
## „Neue Nachrichten" Trennlinie
- [x] `_UnreadDivider`-Widget korrekt gestylt
- [x] `_sessionFullyReadId` einmalig beim Öffnen erfasst (stabil für die Session)
- [x] Keine Anzeige wenn Raum bereits gelesen
- [~] **Position falsch** — Divider über der Gruppe mit `fullyRead`-Event statt darunter 🟡 P2
- [ ] **Auto-Scroll zum Divider** beim Öffnen eines Raums mit ungelesenen Nachrichten 🟡 P2
---
## Presence / Zuletzt Online
- [x] `_PresenceLine` im DM-Header mit Live-Updates
- [x] 4s-Debounce gegen Sync-Spike-Fehlmeldungen
- [x] Relative Zeitanzeige (gerade eben / vor Xmin / vor Xh)
- [x] Guard: eigene Presence wird nie angezeigt
- [x] Presence-Dot im Rooms-Panel und Members-Panel
- [x] `setPresence(online/offline)` in den Settings
- [~] **Kein Auto-Heartbeat**`setPresence` nur in Settings, nie periodisch während Nutzung 🔵 P3
- [~] **Kein `WidgetsBindingObserver`** — App-Lifecycle (Hintergrund/Vordergrund) löst kein Presence-Event aus 🔵 P3
- [ ] Presence-Dot in DM-Listen-Items (`room_list_item.dart`) 🔵 P3
---
## Benachrichtigungen
### Local Notifications (App im Vordergrund)
- [x] Android Local Notifications (`flutter_local_notifications`)
- [x] Windows Toast-Notifications (`local_notifier`)
- [x] Windows Taskbar-Badge (Progress-Error-Mode)
- [x] Tap-Handler navigiert zum richtigen Raum (Android)
- [x] Guards: nur fremde Nachrichten, nur aktive Rooms, nur `Membership.join`
- [x] `notificationsBlockedProvider` + Banner in `app_shell.dart`
- [~] **Alle 4 Notification-Prefs werden ignoriert**`notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` haben keinen Effekt 🟠 P1
- [~] Android-Grouping ohne Summary-Notification 🔵 P3
### Remote Push (App im Hintergrund / geschlossen)
- [ ] 🔴 **Keine Remote-Push-Integration** — FCM/UnifiedPush fehlt komplett, App muss laufen
- [ ] iOS APNs-Setup
- [ ] macOS-Notifications
- [ ] Android Icon-Badge (Zahlenbadge)
---
## E2EE Verschlüsselungs-Pipeline
### Implementiert
- [x] Vodozemac (Rust-Olm) via Flutter-Isolate — modern, performant
- [x] Bootstrap: SSSS, Cross-Signing, Online-Key-Backup
- [x] `_decryptLegacyEvents` — entschlüsselt ältere Events in-place
- [x] Device-Key-Preload vor erstem Senden
- [x] Key-Verification UI (SAS Emoji, QR-Code, alle States)
- [x] Megolm Session Export/Import (Element-kompatibles Format)
- [x] Encrypted-Attachment-Handling
- [x] `m.fully_read`-Marker beim Timeline-Open
- [x] `timeline.requestKeys(onlineKeyBackupOnly: false)`
### Fehlerhaft / Fehlend
- [~] **"Entschlüsselt…"-Placeholder bleibt dauerhaft** wenn Key fehlt — kein Recovery-Button 🟠 P1
- [~] `loadAllKeysFromRoom` Fehler werden silent geschluckt (`catch (_) {}`) 🟡 P2
- [~] **Kein Incoming-Verification-Request-Handler**`client.onKeyVerificationRequest` wird nicht abonniert 🔴 P0
- [~] **Doppelte Bootstrap-Implementierung** (page vs. dialog) mit leicht inkonsistenter Logik 🔵 P3
- [ ] Device-Trust-Indicator in der Timeline 🔵 P3
- [ ] Unverified-Device-Warning beim Senden 🔵 P3
- [ ] "Schlüssel anfordern"-Button bei dauerhaft verschlüsselten Events 🟠 P1
---
## Suche
- [x] Suchdialog öffnen (UI vorhanden)
- [x] Server-seitige Volltextsuche via Matrix `/search` API
- [x] Filter: Alle / Medien / Personen
- [x] Ergebnisse: Raum + Absender + Body + Zeitstempel
- [x] Keyboard-Navigation in Ergebnissen
- [x] 400ms Debounce
- [ ] Jump-to-Message bei Klick auf Suchergebnis
- [ ] Suche nach Absender filtern
- [ ] Filter nach Datum
- [ ] Suchergebnisse im Chat hervorheben
- [ ] Suchverlauf
---
## Raumverwaltung
### Grundfunktionen
@@ -79,14 +198,12 @@
- [x] Raumname & Avatar anzeigen
- [x] Verschlüsselungsstatus (Schloss-Icon)
- [x] Einladungen annehmen / ablehnen
- [ ] Neuen Raum erstellen
- [ ] Neuen Raum erstellen (UI-Einstiegspunkt)
- [ ] Raumname bearbeiten
- [ ] Raumthema/Beschreibung bearbeiten
- [ ] Raum-Avatar ändern
- [ ] Raumvisibilität (öffentlich / privat)
- [ ] Zugriffsregeln (invite/knock/public)
- [ ] Verlaufsvisibilität konfigurieren
- [ ] Raum-Aliases verwalten
### Direktnachrichten (DMs)
- [x] 1:1 DM-Räume anzeigen & öffnen
@@ -94,261 +211,214 @@
- [x] Echtzeit-Presence-Updates
- [x] Sprachanruf aus DM starten
- [x] Videoanruf aus DM starten
- [ ] DM starten (neuen Chat beginnen, nur über Suchfeld)
- [ ] DM starten (neuen Chat beginnen)
### Mitglieder & Rollen
- [x] Mitgliederliste anzeigen
- [x] Mitgliederanzahl anzeigen
- [x] Avatar & Anzeigename der Mitglieder
- [x] Nutzer in Raum einladen (Raum-Kontextmenü)
- [x] Nutzer in Raum einladen
- [ ] Nutzer aus Raum entfernen (Kick)
- [ ] Nutzer sperren (Ban)
- [ ] Power Levels bearbeiten
- [ ] Nutzer zum Moderator / Admin befördern
- [ ] Bans aufheben
### Raumeinstellungen
- [x] Raumeinstellungen-Dialog (Name, Thema, Moderationsregeln)
- [ ] Raumverschlüsselung aktivieren/deaktivieren
- [ ] Erweiterte Raumeinstellungen (Fluffychat-Level)
- [ ] Widget-/App-Integration in Räume
---
## Spaces & Gemeinschaften
### Spaces
- [x] Spaces in der Seitenleiste anzeigen
- [x] Zwischen Spaces navigieren
- [x] Space-Avatar anzeigen
- [x] Space-Banner hochladen & anzeigen (Discord-Style)
- [x] Space-Avatar & Banner anzeigen
- [x] Räume nach Space filtern
- [x] Virtuelle "Rooms"- und "DMs"-Spaces
- [x] Unterkategorien (Sub-Spaces) als Kategorien in der Kanalleiste
- [x] Unterkategorien (Sub-Spaces)
- [x] Drag-and-drop Kanalreihenfolge für Admins
- [x] Space-Verwaltungsdialog (Name, Avatar, Banner, Einladungen, Mitglieder, Power Levels)
- [x] Space-Verwaltungsdialog
- [ ] Neuen Space erstellen
- [ ] Räume zum Space hinzufügen / entfernen
- [ ] Space-Regeln / Beschreibung
- [ ] Space-Entdeckung (öffentliche Spaces durchsuchen)
### Kanalorganisation
- [x] Kanäle nach letzter Aktivität sortieren
- [x] Ungelesene-Badge auf Kanälen
- [x] Sprachkanal-Teilnehmerliste (Discord-Style, mit Echtzeit-Updates)
- [x] Kollabierbare Kategorien
- [ ] Favoriten / angeheftete Räume
- [ ] Räume stummschalten
- [ ] Räume ausblenden / archivieren
- [ ] Kanalreihenfolge für reguläre Nutzer
- [ ] Space-Entdeckung (öffentliche Spaces)
---
## Sprach- & Videoanrufe
## Sprach- & Videoanrufe (LiveKit)
### Sprachanrufe (LiveKit)
- [x] Sprachkanal betreten / verlassen (Toggle)
- [x] Sprachkanal betreten / verlassen
- [x] Mikrofon stummschalten / Deafen
- [x] Teilnehmerliste im Sprachkanal
- [x] Mikrofon stummschalten
- [x] Audio-Ausgabe deaktivieren (Deafen)
- [x] Anruf-UI im Profil-Panel (aktiver Anruf)
- [x] Anruf-Ansicht öffnen (Vollansicht)
- [x] Mini-Call-Widget bei VOIP-Anrufen
- [x] Anruf-UI (Vollansicht + Mini-Widget)
- [x] Videoanruf mit Kamera-Toggle
- [ ] Klingelton bei eingehenden Anrufen
- [ ] Anruf annehmen / ablehnen (UI)
- [ ] Anrufdauer-Timer
- [ ] Anrufverlauf
### Videoanrufe
- [x] Videoanruf starten (aus DM)
- [x] Kamera-Toggle
- [x] Videoteilnehmer-Anzeige
- [ ] Bildschirmfreigabe (Toggle-UI vorhanden, Implementierung unklar)
- [ ] Picture-in-Picture-Modus
- [ ] Virtueller Hintergrund
### Gruppenanrufe
- [x] Mehrere Teilnehmer im Sprachkanal (LiveKit)
- [ ] Gruppen-Videogitter
- [ ] Spotlight-/Galerie-Ansicht
---
## Benachrichtigungen & Presence
### Push-Benachrichtigungen
- [ ] Desktop-Benachrichtigungen (Windows/macOS/Linux)
- [ ] Mobile Push-Benachrichtigungen (Android/iOS)
- [ ] Benachrichtigungseinstellungen pro Raum
- [ ] Nicht-Stören-Modus
- [ ] Benachrichtigungssound konfigurieren
### Presence & Status
- [x] Nutzer-Presence abrufen (online/offline/abwesend)
- [x] Presence-Indikator anzeigen
- [x] Letzte-Aktivität-Zeitstempel
- [x] Echtzeit-Presence-Updates
- [ ] Eigenen Status setzen (online/abwesend/unsichtbar)
- [ ] Benutzerdefinierte Statusmeldung
- [ ] Aktivitätsbasierte Status-Änderung
### Tipp-Indikatoren
- [ ] "X tippt gerade"-Anzeige empfangen
- [ ] Eigene Tipp-Benachrichtigung senden
- [ ] Timeout nach Tipp-Ende
- [ ] Bildschirmfreigabe
---
## Nutzereinstellungen & Profil
### Profilverwaltung
- [x] Eigene User-ID anzeigen
- [x] Eigenen Avatar anzeigen (Initialen-Fallback)
- [x] Anzeigenamen anzeigen
- [ ] Anzeigenamen bearbeiten
- [ ] Eigenen Avatar hochladen / entfernen
- [ ] Status-Nachricht setzen
### Kontosicherheit
- [x] Hell / Dunkel-Theme, Akzentfarbe, Eckradius, Dichte
- [x] Eigene User-ID, Avatar (Initialen-Fallback), Anzeigename anzeigen
- [ ] Anzeigenamen & Avatar bearbeiten
- [ ] Passwort ändern
- [ ] E-Mail-Adresse verwalten
- [ ] Sitzungsverwaltung (alle Sitzungen anzeigen)
- [ ] Andere Sitzungen abmelden
- [ ] Konto deaktivieren / löschen
- [ ] Zwei-Faktor-Authentifizierung (2FA)
### App-Einstellungen
- [x] Hell / Dunkel-Theme
- [x] Akzentfarbe (6 Voreinstellungen)
- [x] Eckradius anpassen
- [x] UI-Dichte / Kompaktheit
- [x] Animationssteuerung
- [ ] Schriftgröße anpassen
- [ ] Sprachauswahl (i18n)
- [ ] Kompaktansicht (kein Avatar in Nachrichten)
---
## Datenschutz & Sicherheit
### Ende-zu-Ende-Verschlüsselung (E2EE)
- [x] E2EE-Unterstützung (Olm/Megolm via vodozemac)
- [x] E2EE-Schlüssel beim ersten Login einrichten (Bootstrap)
- [x] Verschlüsselte Nachrichten-Indikatoren
- [x] Entschlüsselungsstatus anzeigen
- [x] SSSS (Symmetric Secret Storage)
- [x] Recovery-Key erzeugen & anzeigen
- [x] Recovery-Key aus bestehender Einrichtung importieren
- [x] Schlüssel-Caching (lokal speichern)
- [x] Session-Keys von anderen Geräten anfordern
- [ ] Schlüssel-Backup (Server-seitig)
- [ ] Schlüssel-Backup verifizieren
### Geräte-Verifizierung
- [x] Cross-Signing (Master/Self-Signing/User-Signing Keys)
- [x] Gerät per Emoji verifizieren
- [x] Gerät per Zahlencodes verifizieren
- [x] Geräte als vertrauenswürdig markieren
- [ ] Gerätename / Modell anzeigen
- [ ] Sitzungsverwaltung (alle Geräte dieses Accounts)
- [ ] Fremde Geräte abmelden
### Vertrauen & Sicherheit
- [ ] Geräte-Vertrauensstatus-Indikator in Nachrichten
- [ ] Nutzer blockieren / entsperren
- [ ] Blockierte Nutzer-Liste
- [ ] Verifizierter-Nutzer-Badge
---
## Suche
### Nachrichtensuche
- [x] Suchdialog öffnen (UI vorhanden)
- [ ] Volltextsuche in Nachrichteninhalten
- [ ] Filter nach Absender
- [ ] Filter nach Datum
- [ ] Filter nach Raum
- [ ] Filter nach Medientyp
- [ ] Suchergebnisse hervorheben
- [ ] Suchverlauf
### Nutzer- / Raumsuche
- [ ] Nutzer nach Anzeigenamen suchen
- [ ] Nutzer nach User-ID suchen
- [ ] Öffentliche Räume entdecken (Directory)
- [ ] Eigene Räume filtern
- [ ] Räume nach Name / Thema suchen
- [ ] Sitzungsverwaltung
- [ ] Konto deaktivieren
---
## Auto-Update
- [x] GitHub-Releases als Update-Quelle
- [x] Versions-Vergleich (SemVer)
- [x] Rate-Limiting (max. alle 4 Stunden prüfen)
- [x] Update-Banner im App-Header (Windows)
- [x] Versions-Vergleich (SemVer), Rate-Limiting, Update-Banner
- [x] "Diese Version überspringen"-Option
- [x] Manueller Update-Check in den Einstellungen
- [ ] Automatischer Download & Installation (Squirrel/NSIS)
- [ ] Delta-Updates (nur geänderte Dateien)
- [ ] Update-Kanal wählen (Stable/Beta/Nightly)
- [ ] Automatischer Download & Installation
---
## Plattform-spezifisch
### Desktop (Windows / macOS / Linux)
- [x] Native Desktop-App (Flutter Desktop)
- [x] Tastenkürzel (Enter = Senden, Shift+Enter = Zeile)
- [x] Drag-and-Drop für Dateianhänge
### Desktop
- [x] Native Desktop-App, Tastenkürzel, Drag-and-Drop
- [ ] System-Tray-Integration
- [ ] Desktop-Benachrichtigungen
- [ ] Minimieren in System-Tray
- [ ] Fenster-State speichern (Größe, Position)
- [ ] MSIX / Installer-Paketierung
### Mobile (Android / iOS)
- [x] Mobile App (Flutter)
- [x] Drawer-Navigation (Hamburger-Menü)
- [ ] Touch-optimiertes UI (derzeit Desktop-first)
### Mobile
- [x] Mobile App (Flutter), Drawer-Navigation
- [ ] Touch-optimiertes UI
- [ ] Mobile Push-Benachrichtigungen
- [ ] Zurück-Geste Navigation
- [ ] Bottom-Sheet-Modals
- [ ] Share-Sheet-Integration
- [ ] Haptisches Feedback
---
---
# Technische Tiefenanalyse (2026-04-29)
*Vergleichsbasis: FluffyChat (krille-chan/fluffychat) und Element Web*
*Format: Befund → betroffene Datei:Zeile → Referenz-Implementierung*
---
## IMPLEMENTIERUNGS-BACKLOG (nach Priorität)
### 🔴 P0 — Kritisch
#### [P0-1] Incoming Key-Verification-Request-Handler fehlt
**Problem:** Wenn ein anderes Gerät eine Verifikations-Anfrage (`m.key.verification.request`) an Pyramid schickt, gibt es keinen Listener. Die Anfrage läuft ab, ohne dass der Nutzer sie sieht. Das bedeutet: andere Geräte können Pyramid de facto nicht verifizieren — nur Pyramid kann aktiv verifizieren.
**Betroffene Datei:** `lib/widgets/settings_modal.dart` (kein `onKeyVerificationRequest`-Listener)
**FluffyChat-Referenz:** `lib/pages/key_verification/key_verification_dialog.dart` + `client.onKeyVerificationRequest.stream.listen(...)` in `lib/pages/matrix.dart`
**Fix:** Stream auf `client.onKeyVerificationRequest` in `matrix_client.dart` oder `app_shell.dart` subscriben und `KeyVerificationDialog` öffnen.
---
### 🟠 P1 — Hoch
#### [P1-1] Notification-Prefs werden ignoriert
**Problem:** `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` sind in `settings_prefs.dart` gespeichert, werden in `notification_service.dart/_watchEvents()` aber nie ausgelesen. Notifications erscheinen immer, unabhängig von Nutzereinstellungen.
**Betroffene Datei:** `lib/core/notification_service.dart`, Zeile 145168
**Fix:** In `_watchEvents()` die Prefs vor `showMessageNotification()` prüfen.
#### [P1-2] Edits werden nicht gerendert
**Problem:** `_isVisible()` filtert Edit-Events korrekt heraus, aber `event.getDisplayEvent(timeline)` wird nirgendwo aufgerufen. Nachrichten zeigen immer die Originalversion, auch wenn sie via `m.replace` bearbeitet wurden. Ein `(bearbeitet)`-Label fehlt ebenfalls.
**Betroffene Datei:** `lib/features/chat/message_group.dart` — Event-Rendering-Bereich
**FluffyChat-Referenz:** `event.getDisplayEvent(timeline)` vor dem Rendern jedes Events
**Fix:** Beim Rendern von Events `event.getDisplayEvent(timeline)` statt `event` verwenden; `(bearbeitet)`-Label wenn `event != displayEvent`.
#### [P1-3] Encrypted-Placeholder bleibt dauerhaft — kein Recovery-Button
**Problem:** `EventTypes.Encrypted` zeigt "Nachricht wird entschlüsselt…" dauerhaft wenn die Entschlüsselung scheitert. Es gibt keinen Button zum Anfordern fehlender Keys.
**Betroffene Datei:** `lib/features/chat/message_group.dart`, Zeilen ~408421
**FluffyChat-Referenz:** `_requestMissingKey()` mit `room.client.sendToDeviceEncrypted(...)` / SDK-`requestKey()`
**Fix:** Wenn `event.status == EventStatus.error` oder nach Timeout immer noch `EventTypes.Encrypted`: "Schlüssel anfordern"-Button anzeigen, der `timeline.requestKeys()` aufruft.
---
### 🟡 P2 — Mittel
#### [P2-1] Typing-Indikator: `room.setTyping()` nie aufgerufen
**Problem:** `privacyTypingProvider` existiert, `chat_composer.dart` ruft aber nie `room.setTyping()` auf. Andere Nutzer sehen nie "X tippt gerade…".
**Betroffene Datei:** `lib/features/chat/chat_input.dart` (oder `chat_composer.dart`)
**FluffyChat-Referenz:** `onInputBarChanged` → 2s Debounce → `room.setTyping(true, timeout: 30000)`; bei Senden/Leerfeld: `room.setTyping(false)`
**Fix:** Im Composer-Input `onChanged`-Handler mit Debounce und `room.setTyping()` ergänzen.
#### [P2-2] Typing-Indikator: Kein Display-Widget
**Problem:** Kein Widget das `room.typingUsers` anzeigt.
**Fix:** `_TypingIndicator`-Widget in `chat_view.dart` unter der Nachrichtenliste einfügen, das `room.onUpdate`-Stream abonniert und `room.typingUsers` anzeigt.
#### [P2-3] Unread-Divider: Position off-by-one
**Problem:** Divider erscheint *über* der Gruppe die das `fullyRead`-Event *enthält*, nicht darunter. Das bereits-gelesene Event liegt unter dem "Neue Nachrichten"-Strich.
**Betroffene Datei:** `lib/features/chat/chat_view.dart`, Zeile ~715
**Fix:** Divider nach der Gruppe einsetzen, die das `fullyReadId`-Event enthält, nicht davor.
#### [P2-4] Unread-Divider: Kein Auto-Scroll beim Öffnen
**Problem:** Beim Öffnen eines Raums mit ungelesenen Nachrichten landet die View unten, nicht beim Divider.
**FluffyChat-Referenz:** Initialer `scrollController.jumpTo()` zum Index des `fullyRead`-Events nach dem ersten Frame.
**Fix:** In `initState`/`WidgetsBinding.addPostFrameCallback` zum Divider-Index springen wenn `_sessionHasUnread`.
#### [P2-5] Gruppen-Read-Receipts fehlen
**Problem:** ✓✓-Logik greift nur bei `room.isDirectChat`. In Gruppenräumen sehen eigene Nachrichten nie ✓✓.
**FluffyChat-Referenz:** `SeenByRow` zeigt Avatare aller Leser unter der letzten Nachricht.
**Fix:** Receipt-Prüfung auch in Gruppenräumen aktivieren; ggf. Avatar-Reihe unter letzter Nachricht.
#### [P2-6] Notification-Cancellation beim Lesen fehlt
**Problem:** Android-Notifications bleiben in der Benachrichtigungsleiste stehen, auch wenn der Chat geöffnet und die Nachrichten gelesen wurden.
**FluffyChat-Referenz:** `backgroundPush?.cancelNotification(roomId)` in `_markRead()`
**Fix:** In `_markRead()` in `chat_view.dart` die Android-Notification für `roomId` canceln.
---
### 🔵 P3 — Niedrig
#### [P3-1] Presence: Kein Auto-Heartbeat
**Problem:** `setPresence(online)` nur in Settings — Server setzt Client nach Timeout offline.
**Fix:** Periodischer Timer (alle 60s) der `setPresence(online)` sendet solange App im Vordergrund.
#### [P3-2] Presence: Kein App-Lifecycle-Hook
**Problem:** Kein `WidgetsBindingObserver` — kein `unavailable` bei App-Hintergrund.
**Fix:** `WidgetsBindingObserver.didChangeAppLifecycleState` in `matrix_client.dart` oder `app_shell.dart`.
#### [P3-3] Markdown/`formatted_body` nicht gerendert
**Problem:** `sendTextEvent` sendet nur plain text. Empfangene `formatted_body`-Felder werden nicht gerendert.
**Fix:** Empfang: `flutter_html` oder `flutter_markdown` für `formatted_body`; Senden: simple Markdown-Erkennung im Composer.
#### [P3-4] `requestFuture()` fehlt
**Problem:** Kein Forward-Pagination-Mechanismus. Nach Jump-to-Event in der Vergangenheit kein Weg zurück.
**Fix:** `timeline.requestFuture()` im Scroll-Listener für die Vorwärts-Richtung.
#### [P3-5] `message_bubble.dart` — ungenutzter Code
**Problem:** Veraltete WhatsApp-Style-Implementation koexistiert neben aktiver Discord-Style-Implementierung.
**Fix:** Datei löschen oder auskommentieren.
---
## Statistik
| Bereich | Implementiert | Teilweise | Fehlend |
| Bereich | Implementiert | Fehlerhaft | Fehlend |
|---|---|---|---|
| Messaging | 14 | 2 | 14 |
| Raumverwaltung | 8 | 0 | 14 |
| Spaces | 10 | 0 | 6 |
| Anrufe | 8 | 1 | 10 |
| Benachrichtigungen | 4 | 0 | 8 |
| Einstellungen/Profil | 5 | 0 | 11 |
| Sicherheit/E2EE | 9 | 0 | 7 |
| Suche | 1 | 0 | 10 |
| Auto-Update | 6 | 0 | 3 |
| Plattform | 5 | 0 | 10 |
| **Gesamt** | **~70** | **~3** | **~93** |
| Chat-Mechanik | Solide Basis | Edits, Typing, `requestFuture` | Jump-to-quote, Threads, Edited-Badge |
| Read Receipts | DM-Logik ✓ | Gruppen, Off-by-one, Notif-Cancel | Seen-by-Avatare, Tooltip |
| Presence | Anzeige ✓ | Broadcasting, Lifecycle | Heartbeat, Dot in Raumliste |
| Benachrichtigungen | Local ✓ | Prefs ignoriert, Grouping | Remote Push, iOS, macOS |
| Unread-Divider | Konzept ✓ | Position, Auto-Scroll | |
| E2EE | Stärkster Bereich | Incoming-Verif., Placeholder | Device-Trust-Indicator, Warn beim Senden |
| Suche | API-Basis ✓ | | Jump-to-message, Filter |
**Implementierungsgrad: ~42 %** (inkl. Teilimplementierungen)
**Implementierungsgrad gesamt: ~42 %** (inkl. Teilimplementierungen)
---
## Empfohlene Feature-Prioritäten
## Implementierungs-Fortschritt
1. **Textformatierung** — häufig genutzt, von Nutzern erwartet
2. **Push-Benachrichtigungen** — Desktop-Benachrichtigungen für Windows
3. **Tipp-Indikatoren** — verbessert Gesprächsgefühl deutlich
4. **Nachrichtenbearbeitung** — SDK-Grundlage vorhanden, nur UI fehlt
5. **Profilbearbeitung** — Avatar & Anzeigename ändern
6. **Raum erstellen** — Basis-Workflow, derzeit nicht möglich
7. **Sprachnachricht abspielen** — Struktur vorhanden
8. **Suche** — Dialog vorhanden, Backend-Abfrage fehlt
9. **Nutzer blockieren** — Sicherheits-Grundfunktion
10. **System-Tray** — Desktop-Erlebnis verbessern
| ID | Beschreibung | Status |
|---|---|---|
| P0-1 | Incoming Key-Verification-Request-Handler | ✅ War bereits implementiert (`app_shell.dart`) |
| P1-1 | Notification-Prefs auswerten | ✅ Implementiert (`notification_service.dart`) |
| P1-2 | Edits rendern (`getDisplayEvent`) | ✅ Implementiert (`message_group.dart`) |
| P1-3 | Encrypted-Placeholder + Recovery-Button | ✅ Implementiert (`message_group.dart`) |
| P2-1 | Typing: `room.setTyping()` senden | ✅ Implementiert (`chat_composer.dart`) |
| P2-2 | Typing: Display-Widget | ✅ Implementiert (`chat_view.dart``_TypingIndicator`) |
| P2-3 | Unread-Divider: Position + leere fullyRead-Guard | ✅ Neu implementiert (`chat_view.dart`) |
| P2-4 | Unread-Divider: Auto-Scroll (Schätzung) | ✅ Implementiert (`_scrollToUnread`) |
| P2-5 | Gruppen-Read-Receipts (DM-Guard entfernt) | ✅ Implementiert (`chat_view.dart`) |
| P2-6 | Notification-Cancellation beim Lesen | ✅ Implementiert (`chat_view.dart/_markRead`) |
| P3-1 | Presence: Auto-Heartbeat (10s) | ✅ Implementiert (`app_shell.dart`) |
| P3-2 | Presence: App-Lifecycle-Hook | ✅ Implementiert (`app_shell.dart``WidgetsBindingObserver`) |
| P3-3 | HTML/Markdown (`_MatrixHtmlText`, `_PlainLinkText`) | ✅ Implementiert (`message_group.dart`) |
| P3-4 | `requestFuture()` | ✅ Implementiert (`chat_view.dart/_onScroll`) |
| P3-5 | `message_bubble.dart` entfernen | ⏳ Offen |
+241
View File
@@ -0,0 +1,241 @@
# 01 — Authentifizierung & Session
---
## Inhaltsverzeichnis
- [Login mit Passwort](#login-mit-passwort)
- [Login mit Token](#login-mit-token)
- [SSO / OAuth](#sso--oauth)
- [Register](#registrierung)
- [Session prüfen & wiederherstellen](#session-prüfen--wiederherstellen)
- [Logout](#logout)
- [Multi-Account / Device](#multi-account--device)
- [Homeserver ermitteln](#homeserver-ermitteln)
- [Flows & Login-Typen abfragen](#flows--login-typen-abfragen)
---
## Login mit Passwort
```dart
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
final loginResponse = await client.login(
LoginType.mLoginPassword,
password: 'geheimesPasswort',
identifier: AuthenticationUserIdentifier(user: '@alice:example.org'),
// oder nur Username (ohne Server):
// identifier: AuthenticationUserIdentifier(user: 'alice'),
initialDeviceDisplayName: 'Pyramid Android',
);
// loginResponse.accessToken, .deviceId, .userId
```
---
## Login mit Token
```dart
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
await client.login(
LoginType.mLoginToken,
token: 'einmaliger_login_token',
initialDeviceDisplayName: 'Pyramid',
);
```
---
## SSO / OAuth
```dart
// Verfügbare Login-Flows abfragen
final flows = await client.getLoginFlows();
final hasSso = flows.flows?.any((f) => f.type == AuthenticationTypes.sso) ?? false;
// SSO-URL erzeugen (öffnet Browser/WebView)
final redirectUri = 'io.pyramid.app://login-callback';
final ssoUrl = client.homeserver!.toString() +
'/_matrix/client/v3/login/sso/redirect?redirectUrl=${Uri.encodeComponent(redirectUri)}';
// → Uri im Browser öffnen, nach Redirect den Token extrahieren
// Nach Redirect mit Token einloggen
await client.login(
LoginType.mLoginToken,
token: tokenAusRedirectUrl,
initialDeviceDisplayName: 'Pyramid',
);
```
---
## Registrierung
```dart
// Homeserver-Registrierung prüfen
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
// UIAA-Flow starten
try {
await client.uiaRequestBackground(
(auth) => client.register(
username: 'neuernutzer',
password: 'sicheresPasswort',
initialDeviceDisplayName: 'Pyramid',
auth: auth,
),
);
} on MatrixException catch (e) {
// e.error: M_USER_IN_USE, M_INVALID_USERNAME, etc.
}
```
---
## Session prüfen & wiederherstellen
```dart
// Nach client.init() prüfen ob Session noch gültig ist
if (client.isLogged()) {
print('Eingeloggt als: ${client.userID}');
print('Homeserver: ${client.homeserver}');
print('Device ID: ${client.deviceID}');
print('Access Token: ${client.accessToken}');
}
// Auf Login-State reagieren
client.onLoginStateChanged.stream.listen((LoginState state) {
switch (state) {
case LoginState.loggedIn: // Session aktiv
case LoginState.softLoggedOut: // Token abgelaufen, Re-Auth nötig
case LoginState.loggedOut: // Ausgeloggt
}
});
```
---
## Logout
```dart
// Nur dieses Device
await client.logout();
// Alle Devices
await client.logoutAll();
```
---
## Multi-Account / Device
```dart
// Eigene Devices (Sessions) auflisten
final devices = await client.getDevices();
for (final device in devices ?? []) {
print('${device.deviceId}: ${device.displayName}${device.lastSeenIp}');
}
// Device umbenennen
await client.updateDevice(
deviceId: 'DEVICEID',
displayName: 'Pyramid (Wohnzimmer)',
);
// Device löschen (andere Session killen)
await client.deleteDevice(
'ANDERES_DEVICE_ID',
auth: AuthenticationPassword(
identifier: AuthenticationUserIdentifier(user: client.userID!),
password: 'meinPasswort',
),
);
// Mehrere Devices auf einmal löschen
await client.deleteDevices(
['DEVICE1', 'DEVICE2'],
auth: AuthenticationPassword(...),
);
```
---
## Homeserver ermitteln
```dart
// .well-known auflösen (Autodiscovery)
final discovery = await client.checkHomeserver(
Uri.parse('https://example.org'), // Domain reicht, SDK findet Matrix-Server
);
// client.homeserver ist jetzt gesetzt
// Direkt mit bekannter HS-URL
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
// Homeserver-Versioninfo
final serverVersion = await client.getVersions();
print(serverVersion.versions); // ['v1.1', 'v1.2', ...]
```
---
## Flows & Login-Typen abfragen
```dart
final response = await client.getLoginFlows();
for (final flow in response.flows ?? []) {
switch (flow.type) {
case AuthenticationTypes.password: // m.login.password
case AuthenticationTypes.sso: // m.login.sso
case AuthenticationTypes.token: // m.login.token
case 'm.login.cas': // Legacy-CAS
}
}
// SSO-Provider Details (für Brand-Buttons im UI)
final ssoProviders = response.flows
?.where((f) => f.type == AuthenticationTypes.sso)
.expand((f) => f.identityProviders ?? [])
.toList();
// ssoProvider.id, .name, .brand, .icon
```
---
## UIAA (User-Interactive Auth) Flows
```dart
// Generisches UIAA-Handling (z.B. für Account-Löschung)
await client.uiaRequestBackground(
(auth) => client.deactivateAccount(auth: auth, eraseData: false),
);
// Passwort ändern (braucht Re-Auth)
await client.uiaRequestBackground(
(auth) => client.changePassword(
'neuesPasswort',
auth: auth,
oldPassword: 'altesPasswort', // optional, falls Server es erlaubt
),
);
```
---
## Fehlerbehandlung
```dart
try {
await client.login(...);
} on MatrixException catch (e) {
switch (e.error) {
case MatrixError.M_FORBIDDEN: // Falsches Passwort
case MatrixError.M_USER_IN_USE: // Username vergeben
case MatrixError.M_INVALID_USERNAME:
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limiting
case MatrixError.M_UNKNOWN:
}
print(e.errorMessage); // Menschenlesbarer Text vom Server
}
```
+436
View File
@@ -0,0 +1,436 @@
# 02 — Räume & Spaces
---
## Inhaltsverzeichnis
- [Raum erstellen](#raum-erstellen)
- [Raum beitreten](#raum-beitreten)
- [Raum verlassen](#raum-verlassen)
- [Raumliste](#raumliste)
- [Raum-Metadaten lesen](#raum-metadaten-lesen)
- [Raum-Einstellungen ändern](#raum-einstellungen-ändern)
- [Mitglieder](#mitglieder)
- [Einladen & Kick & Ban](#einladen--kick--ban)
- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis)
- [Spaces](#spaces)
- [Room Upgrades](#room-upgrades)
---
## Raum erstellen
```dart
// Einfacher privater Raum
final roomId = await client.createRoom(
name: 'Mein Raum',
topic: 'Willkommen!',
visibility: Visibility.private, // oder .public
preset: CreateRoomPreset.privateChat, // oder .publicChat, .trustedPrivateChat
);
// Raum mit Einladungen
final roomId = await client.createRoom(
name: 'Projekt Alpha',
invite: ['@bob:example.org', '@charlie:example.org'],
enableEncryption: true, // E2EE aktivieren
);
// Raum mit Alias
final roomId = await client.createRoom(
name: 'Öffentlicher Kanal',
roomAliasName: 'kanal', // → #kanal:example.org
visibility: Visibility.public,
preset: CreateRoomPreset.publicChat,
);
// Raum mit Custom Power Levels
final roomId = await client.createRoom(
name: 'Moderiert',
powerLevelContentOverride: PowerLevelsContent(
eventsDefault: 50, // Nur Moderatoren senden
usersDefault: 0,
users: {'@admin:example.org': 100},
),
);
// Raum mit History-Visibility
final roomId = await client.createRoom(
initialState: [
StateEvent(
type: EventTypes.HistoryVisibility,
content: {'history_visibility': 'invited'}, // shared|invited|joined|world_readable
),
],
);
```
---
## Raum beitreten
```dart
// Per Raum-ID
await client.joinRoom('!roomId:server');
// Per Alias
await client.joinRoomOrAlias('#kanal:example.org');
// Per Invite-Link / Matrix-URI
// matrix:r/kanal:example.org
// https://matrix.to/#/#kanal:example.org
final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org');
if (uri != null) {
await client.joinRoomOrAlias(uri.primaryIdentifier);
}
// Mit Via-Servern (für föderierte Räume)
await client.joinRoom(
'!roomId:remoteserver.org',
serverName: ['remoteserver.org', 'matrix.org'],
);
```
---
## Raum verlassen
```dart
// Verlassen (bleibt in Left-State)
final room = client.getRoomById('!roomId:server')!;
await room.leave();
// Vergessen (entfernt aus Raumliste)
await room.forget();
// Verlassen + Vergessen
await room.leave();
await room.forget();
```
---
## Raumliste
```dart
// Alle Räume (joined + invited)
final allRooms = client.rooms;
// Nur beigetretene
final joinedRooms = client.rooms
.where((r) => r.membership == Membership.join)
.toList();
// Nur Einladungen
final invites = client.rooms
.where((r) => r.membership == Membership.invite)
.toList();
// Sortiert nach letzter Aktivität
final sorted = [...client.rooms]
..sort((a, b) => (b.lastEvent?.originServerTs ?? DateTime(0))
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
// Raum per ID
final room = client.getRoomById('!roomId:server');
// Raum per Alias auflösen
final resolved = await client.getRoomIdByAlias('#alias:server');
// resolved.roomId
// Ungelesene Nachrichten
final unread = client.rooms.fold<int>(
0, (sum, r) => sum + r.notificationCount);
```
---
## Raum-Metadaten lesen
```dart
final room = client.getRoomById('!roomId:server')!;
// Basis-Info
print(room.id); // !roomId:server
print(room.name); // Anzeigename (aufgelöst)
print(room.topic); // Thema
print(room.avatar); // mxc://… URI
print(room.canonicalAlias); // #alias:server
print(room.membership); // Membership.join/invite/leave/ban
print(room.isDirectChat); // DM?
print(room.directChatMatrixID); // Gegenüber-MXID bei DM
print(room.isEncrypted); // E2EE aktiv?
print(room.joinRules); // JoinRules.public/invite/knock/restricted
print(room.guestAccess); // GuestAccess.forbidden/canJoin
print(room.historyVisibility); // HistoryVisibility.*
print(room.notificationCount); // Ungelesene Notifications
print(room.highlightCount); // Mentions
print(room.summary.mJoinedMemberCount); // Mitgliederzahl
// Letztes Event
final last = room.lastEvent;
print(last?.body);
print(last?.originServerTs);
// Display-Name (lokalisiert, wie FluffyChat)
final displayName = room.getLocalizedDisplayname();
```
---
## Raum-Einstellungen ändern
```dart
final room = client.getRoomById('!roomId:server')!;
// Name
await room.setName('Neuer Name');
// Thema
await room.setDescription('Neues Thema');
// Avatar (mxc:// URI nach Upload)
final uri = await client.uploadContent(imageBytes, filename: 'avatar.png');
await room.setAvatar(uri);
// Alias setzen / entfernen
await client.setRoomAlias('#neu:example.org', room.id);
await client.deleteRoomAlias('#alt:example.org');
await room.setCanonicalAlias('#neu:example.org'); // Kanonischen Alias setzen
// Join-Rules
await room.setJoinRules(JoinRules.invite); // private
await room.setJoinRules(JoinRules.public); // öffentlich
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
await room.setJoinRules(
JoinRules.restricted,
allow: [SpaceRoomJoinRulesAllowInner(
roomId: '!spaceId:server',
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
)],
);
// History-Visibility
await room.setHistoryVisibility(HistoryVisibility.shared);
// .shared | .invited | .joined | .worldReadable
// Guest Access
await room.setGuestAccess(GuestAccess.canJoin);
await room.setGuestAccess(GuestAccess.forbidden);
// E2EE aktivieren (nicht mehr deaktivierbar!)
await room.enableEncryption();
// Generischer State-Event senden
await room.client.sendState(
room.id,
'm.room.custom_event',
{'key': 'value'},
stateKey: '',
);
```
---
## Mitglieder
```dart
final room = client.getRoomById('!roomId:server')!;
// Alle Mitglieder (gecacht)
final members = room.getParticipants();
// Vollständige Mitgliederliste vom Server laden
final allMembers = await room.requestParticipants();
// Einzelnen User holen
final user = room.unsafeGetUserFromMemoryOrFallback('@alice:server');
print(user.displayName);
print(user.avatarUrl);
print(user.powerLevel);
print(user.membership); // Membership.join/invite/leave/ban/knock
// Aktuell tippende User
final typingUsers = room.typingUsers; // List<User>
// Power Level eines Users
final pl = room.getPowerLevelByUserId('@alice:server');
// Eigener Power Level
final myPl = room.ownPowerLevel;
```
---
## Einladen, Kick & Ban
```dart
final room = client.getRoomById('!roomId:server')!;
// Einladen
await room.invite('@alice:server');
// Kick (entfernt, kann wieder beitreten)
await room.kick('@alice:server', reason: 'Regeln verletzt');
// Ban (dauerhaft gesperrt)
await room.ban('@alice:server', reason: 'Spam');
// Unban
await room.unban('@alice:server');
// Eigene Berechtigungen prüfen
room.canBan; // bool
room.canKick; // bool
room.canInvite; // bool
room.canSendEvent(EventTypes.Message); // bool
```
---
## Power Levels
```dart
final room = client.getRoomById('!roomId:server')!;
// Power Level eines Users setzen
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
// Power Level Content auslesen
final plContent = room.getState(EventTypes.RoomPowerLevels)
?.content
.tryGet<Map>('users');
// Vollständig setzen
await room.client.sendState(
room.id,
EventTypes.RoomPowerLevels,
{
'ban': 50,
'events': {
'm.room.name': 50,
'm.room.power_levels': 100,
},
'events_default': 0,
'invite': 50,
'kick': 50,
'redact': 50,
'state_default': 50,
'users': {
'@alice:server': 100,
'@mod:server': 50,
},
'users_default': 0,
},
stateKey: '',
);
// Berechtigungen prüfen
room.canSendDefaultMessages; // Nachrichten senden
room.canChangeStateEvent(EventTypes.RoomName); // State-Events ändern
```
---
## Öffentliches Raumverzeichnis
```dart
// Öffentliche Räume suchen
final result = await client.queryPublicRooms(
limit: 20,
filter: PublicRoomQueryFilter(genericSearchTerm: 'flutter'),
server: 'matrix.org', // optionaler anderer Server
);
for (final room in result.chunk) {
print(room.name);
print(room.roomId);
print(room.numJoinedMembers);
print(room.worldReadable);
}
// Nächste Seite
final next = await client.queryPublicRooms(
since: result.nextBatch,
limit: 20,
);
```
---
## Spaces
```dart
// Space erstellen
final spaceId = await client.createRoom(
name: 'Mein Space',
creationContent: {'type': 'm.space'}, // markiert als Space
visibility: Visibility.private,
);
// Kind-Raum zu Space hinzufügen
final space = client.getRoomById(spaceId)!;
await space.client.sendState(
spaceId,
EventTypes.SpaceChild,
{
'via': ['example.org'],
'suggested': true,
'auto_join': false,
},
stateKey: '!childRoomId:server',
);
// Space-Kinder auflisten (vom Server)
final children = await client.getSpaceHierarchy(
spaceId,
maxDepth: 2,
suggestedOnly: false,
);
for (final room in children.rooms) {
print(room.name);
print(room.roomType); // 'm.space' oder null
print(room.childrenState); // Sub-Spaces
}
// Kind-Raum entfernen
await space.client.sendState(
spaceId,
EventTypes.SpaceChild,
{}, // leeres Content → entfernt
stateKey: '!childRoomId:server',
);
// Space-Parent setzen (vom Kind-Raum aus)
await client.sendState(
'!childRoomId:server',
EventTypes.SpaceParent,
{
'via': ['example.org'],
'canonical': true,
},
stateKey: spaceId,
);
// Alle Spaces des Users
final spaces = client.rooms.where((r) =>
r.membership == Membership.join &&
r.getState('m.room.create')?.content['type'] == 'm.space');
```
---
## Room Upgrades
```dart
// Raum auf neuere Version upgraden (erstellt Nachfolger-Raum)
final newRoomId = await client.upgradeRoom(
'!alterRaumId:server',
newVersion: '11', // Aktuelle empfohlene Version
);
// Alle Mitglieder werden automatisch in den neuen Raum eingeladen
// tombstone-Event wird im alten Raum gesetzt
// Tombstone lesen
final tombstone = room.getState(EventTypes.RoomTombstone);
final replacement = tombstone?.content['replacement_room'] as String?;
```
+463
View File
@@ -0,0 +1,463 @@
# 03 — Nachrichten & Events
---
## Inhaltsverzeichnis
- [Textnachricht senden](#textnachricht-senden)
- [HTML / Markdown senden](#html--markdown-senden)
- [Antworten (Reply)](#antworten-reply)
- [Bearbeiten (Edit)](#bearbeiten-edit)
- [Reaktionen](#reaktionen)
- [Löschen (Redact)](#löschen-redact)
- [Threads](#threads)
- [Polls](#polls)
- [Standort](#standort)
- [Timeline lesen](#timeline-lesen)
- [Event-Typen Referenz](#event-typen-referenz)
- [Typing-Indikator](#typing-indikator)
- [Read Markers / Lesebestätigung](#read-markers--lesebestätigung)
- [Pinned Messages](#pinned-messages)
- [Fehlgeschlagene Events erneut senden](#fehlgeschlagene-events-erneut-senden)
---
## Textnachricht senden
```dart
final room = client.getRoomById('!roomId:server')!;
// Einfacher Text
final eventId = await room.sendTextEvent('Hallo Welt!');
// Gibt ein temporäres Event zurück (für optimistische UI)
// Das Event bekommt Status EventStatus.sending → .sent → .synced
```
---
## HTML / Markdown senden
```dart
// Mit HTML-Formatierung
await room.sendTextEvent(
'Fett und kursiv', // Fallback-Text (Plain)
htmlBody: '<b>Fett</b> und <em>kursiv</em>',
parseMarkdown: false, // Kein automatisches Markdown-Parsing
);
// Automatisches Markdown-Parsing (SDK parst ** → <b>)
await room.sendTextEvent(
'**Fett** und *kursiv*',
parseMarkdown: true, // Standard: true
);
```
---
## Antworten (Reply)
```dart
// Event, auf das geantwortet wird
final replyTo = timeline.events.first; // Event-Objekt
await room.sendTextEvent(
'Das sehe ich auch so!',
inReplyTo: replyTo,
);
// Prüfen ob ein Event eine Antwort ist
final isReply = event.isReply; // bool
final repliedId = event.inReplyTo; // eventId als String?
// Das referenzierte Event laden
final original = await room.getEventById(repliedId!);
```
---
## Bearbeiten (Edit)
```dart
// Nachricht bearbeiten
await room.sendTextEvent(
'Korrigierter Text',
editEventId: altesEvent.eventId, // Referenz auf Original
htmlBody: '<s>Alt</s> <b>Korrigiert</b>', // optional
);
// Prüfen ob ein Event bearbeitet wurde
final isEdit = event.isEdit; // bool
// Gerendertes Event (mit letzter Bearbeitung) holen
final displayed = event.getDisplayEvent(); // gibt die letzte Version zurück
print(displayed.body); // aktuelle Nachricht
// Alle Bearbeitungen eines Events
final edits = timeline.events
.where((e) => e.isEdit && e.relationshipEventId == originalEvent.eventId)
.toList();
```
---
## Reaktionen
```dart
// Reaktion senden (Emoji oder Text)
await room.sendReaction(event.eventId, '👍');
await room.sendReaction(event.eventId, '❤️');
// Alle Reaktionen eines Events lesen
final reactions = event.aggregatedEvents(
timeline,
RelationshipTypes.reaction,
);
// Gruppiert nach Emoji-Key:
// Map<String, List<Event>>
// Eigene Reaktion entfernen (= Redact)
final myReaction = reactions['👍']
?.firstWhere((e) => e.senderId == client.userID);
if (myReaction != null) {
await myReaction.redactEvent(reason: null);
}
```
---
## Löschen (Redact)
```dart
// Event löschen
await room.redactEvent(
event.eventId,
reason: 'Unpassender Inhalt', // optional
);
// Über das Event-Objekt direkt
await event.redactEvent(reason: 'Spam');
// Prüfen ob redacted
final redacted = event.isRedacted; // bool
```
---
## Threads
```dart
// Thread-Antwort senden
await room.sendEvent(
{
'm.relates_to': {
'rel_type': 'm.thread',
'event_id': rootEvent.eventId,
'is_falling_back': true, // true wenn kein echter Thread-Client
'm.in_reply_to': {
'event_id': rootEvent.eventId,
},
},
'msgtype': 'm.text',
'body': 'Antwort im Thread',
},
EventTypes.Message,
);
// Thread-Events eines Root-Events laden
final threadEvents = timeline.events
.where((e) =>
e.relationshipType == RelationshipTypes.thread &&
e.relationshipEventId == rootEvent.eventId)
.toList();
```
---
## Polls
```dart
// Poll erstellen (MSC3381)
await room.sendEvent(
{
'org.matrix.msc3381.poll.start': {
'question': {
'msgtype': 'm.text',
'body': 'Was ist dein Lieblingsessen?',
},
'kind': 'org.matrix.msc3381.poll.disclosed', // oder .undisclosed
'max_selections': 1,
'answers': [
{'id': '1', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pizza'}},
{'id': '2', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pasta'}},
{'id': '3', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Salat'}},
],
},
'msgtype': 'm.text',
'body': 'Was ist dein Lieblingsessen? (Poll)',
},
'org.matrix.msc3381.poll.start',
);
// Abstimmen
await room.sendEvent(
{
'm.relates_to': {
'rel_type': 'm.reference',
'event_id': pollEvent.eventId,
},
'org.matrix.msc3381.poll.response': {
'answers': ['1'], // Ausgewählte IDs
},
'msgtype': 'm.text',
'body': 'Meine Antwort: Pizza',
},
'org.matrix.msc3381.poll.response',
);
// Poll beenden
await room.sendEvent(
{
'm.relates_to': {
'rel_type': 'm.reference',
'event_id': pollEvent.eventId,
},
'msgtype': 'm.text',
'body': 'Poll beendet.',
},
'org.matrix.msc3381.poll.end',
);
```
---
## Standort
```dart
// Standort senden
await room.sendEvent(
{
'msgtype': 'm.location',
'body': 'Mein Standort: geo:52.5200,13.4050',
'geo_uri': 'geo:52.5200,13.4050',
'org.matrix.msc3488.location': {
'uri': 'geo:52.5200,13.4050',
'description': 'Berlin',
},
},
EventTypes.Message,
);
```
---
## Timeline lesen
```dart
final room = client.getRoomById('!roomId:server')!;
// Timeline erstellen (Callbacks werden bei Änderungen gerufen)
Timeline? timeline;
timeline = await room.getTimeline(
onUpdate: () {
// Vollständiges UI-Rebuild
setState(() {});
},
onChange: (int index) {
// Einzelnes Event geändert (z.B. Reaktion, Edit, Status)
setState(() {});
},
onInsert: (int index) {
// Neues Event eingefügt
setState(() {});
},
onRemove: (int index) {
// Event entfernt (Redaction)
setState(() {});
},
);
// WICHTIG: Immer aufräumen!
@override
void dispose() {
timeline?.cancelSubscriptions();
super.dispose();
}
// Events abrufen (neueste zuerst, Index 0 = aktuellstes)
final events = timeline.events;
// Ältere Nachrichten nachladen (z.B. beim Scrollen nach oben)
await timeline.requestHistory(count: 30);
// Gibt true zurück wenn es noch mehr gibt
final hasMore = timeline.canRequestHistory;
// Bestimmtes Event aus Timeline oder Server laden
final event = await room.getEventById('$eventId');
// Neuere Events nachladen (nach Gap)
await timeline.requestFuture(count: 20);
```
---
## Event-Typen Referenz
```dart
// Nachrichten-Typen (msgtype)
'm.text' // Textnachricht
'm.emote' // /me Aktion
'm.notice' // Bot-Nachricht (dezenter)
'm.image' // Bild
'm.video' // Video
'm.audio' // Audio/Sprachnachricht
'm.file' // Datei
'm.location' // Standort
'm.sticker' // Sticker (eigener Event-Typ: m.sticker)
// Event-Typen (type)
EventTypes.Message // m.room.message
EventTypes.Sticker // m.sticker
EventTypes.Reaction // m.reaction
EventTypes.RoomRedaction // m.room.redaction
EventTypes.Encrypted // m.room.encrypted
EventTypes.RoomName // m.room.name
EventTypes.RoomTopic // m.room.topic
EventTypes.RoomAvatar // m.room.avatar
EventTypes.RoomMember // m.room.member
EventTypes.RoomPowerLevels // m.room.power_levels
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
EventTypes.RoomJoinRules // m.room.join_rules
EventTypes.RoomGuestAccess // m.room.guest_access
EventTypes.HistoryVisibility // m.room.history_visibility
EventTypes.RoomCreate // m.room.create
EventTypes.RoomTombstone // m.room.tombstone
EventTypes.Encryption // m.room.encryption
EventTypes.SpaceChild // m.space.child
EventTypes.SpaceParent // m.space.parent
EventTypes.CallInvite // m.call.invite
EventTypes.CallAnswer // m.call.answer
EventTypes.CallHangup // m.call.hangup
// Event-Status prüfen
event.status == EventStatus.sending // Wird gesendet (optimistisch)
event.status == EventStatus.sent // Vom Server bestätigt
event.status == EventStatus.synced // Im eigenen Sync empfangen
event.status == EventStatus.error // Fehlgeschlagen
event.status == EventStatus.removed // Gelöscht
// Nützliche Event-Properties
event.type // String: Event-Typ
event.eventId // String: ID
event.senderId // String: MXID des Senders
event.originServerTs // DateTime: Sendezeitpunkt
event.body // String: Nachrichtentext (entschlüsselt)
event.messageType // String?: msgtype
event.formattedBody // String?: HTML-Inhalt
event.isReply // bool
event.isEdit // bool
event.isReacted // bool
event.isRedacted // bool
event.isEncrypted // bool
event.isBadEncrypted // bool: Entschlüsselung fehlgeschlagen
event.isState // bool: State-Event?
event.relationshipEventId // String?: ID des referenzierten Events
event.relationshipType // String?: Beziehungstyp (m.replace, m.thread, etc.)
// Sender-User holen
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
print(sender.displayName);
print(sender.avatarUrl);
```
---
## Typing-Indikator
```dart
// Schreiben anzeigen
await room.setTyping(true, timeout: 30000); // 30 Sekunden
// Schreiben beenden
await room.setTyping(false);
// Tippende User im Raum beobachten
final typing = room.typingUsers; // List<User>
// Automatisch aktuell durch Sync
```
---
## Read Markers / Lesebestätigung
```dart
// Gelesenes Event markieren
await room.setReadMarker(
event.eventId,
mRead: event.eventId, // private receipt (unsichtbar für andere)
// public receipt weglassen für private-only
);
// Öffentliche + private Lesebestätigung
await room.setReadMarker(
event.eventId,
mRead: event.eventId,
);
// Letztes gelesenes Event
final lastRead = room.lastReadEventId;
// Ungelesen-Badge
final count = room.notificationCount; // Notifications
final hl = room.highlightCount; // Mentions/Highlights
```
---
## Pinned Messages
```dart
// Events anpinnen
final currentPinned = (room.getState(EventTypes.RoomPinnedEvents)
?.content['pinned'] as List?)
?.cast<String>() ?? [];
await room.client.sendState(
room.id,
EventTypes.RoomPinnedEvents,
{'pinned': [...currentPinned, eventId]},
stateKey: '',
);
// Angepinnte Events lesen
final pinned = (room.getState(EventTypes.RoomPinnedEvents)
?.content['pinned'] as List?)
?.cast<String>() ?? [];
// Events laden
final pinnedEvents = await Future.wait(
pinned.map((id) => room.getEventById(id)),
);
```
---
## Fehlgeschlagene Events erneut senden
```dart
// Events mit Status error finden
final failed = timeline.events
.where((e) => e.status == EventStatus.error)
.toList();
// Erneut senden
for (final event in failed) {
await event.sendAgain();
}
// Oder aus dem Timeline-Controller:
await room.sendEvent(
event.content,
event.type,
txnId: event.eventId, // Gleiche txnId → überschreibt fehlgeschlagenes
);
```
+221
View File
@@ -0,0 +1,221 @@
# 04 — Direct Messages (DMs)
---
## Inhaltsverzeichnis
- [DM starten oder finden](#dm-starten-oder-finden)
- [DM-Raum identifizieren](#dm-raum-identifizieren)
- [DM-Liste](#dm-liste)
- [Gruppen-DM](#gruppen-dm)
- [DM-Raum schließen / vergessen](#dm-raum-schließen--vergessen)
- [User-Profil für DM laden](#user-profil-für-dm-laden)
- [Verschlüsselung in DMs](#verschlüsselung-in-dms)
---
## DM starten oder finden
```dart
// Existierenden DM finden oder neuen erstellen
// Gibt immer einen joined Room zurück
final room = await client.startDirectChat('@alice:server');
print(room.id); // !roomId:server
// Mit expliziten Optionen
final room = await client.startDirectChat(
'@alice:server',
// enableEncryption: true, // Standard: true
);
// Navigieren zum DM-Raum
// (z.B. in Pyramid:)
ref.read(activeRoomIdProvider.notifier).state = room.id;
```
---
## DM-Raum identifizieren
```dart
final room = client.getRoomById('!roomId:server')!;
// Ist es ein DM?
final isDm = room.isDirectChat; // bool
// Mit wem?
final partner = room.directChatMatrixID; // '@alice:server' oder null
// DM zu einem bestimmten User finden
final existingDm = client.getDirectChatFromUserId('@alice:server');
// Gibt Room? zurück (null wenn kein DM vorhanden)
// Alle DMs des Users (Account-Data)
final dmMap = client.directChats;
// Map<String, List<String>>: { '@alice:server': ['!roomId:server'] }
```
---
## DM-Liste
```dart
// Alle DM-Räume (joined)
final dms = client.rooms.where((r) =>
r.isDirectChat &&
r.membership == Membership.join,
).toList();
// Sortiert nach letzter Aktivität
dms.sort((a, b) =>
(b.lastEvent?.originServerTs ?? DateTime(0))
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
// DMs mit ungelesenen Nachrichten
final unreadDms = dms.where((r) => r.notificationCount > 0).toList();
// Display-Info für DM-Liste
for (final dm in dms) {
final partner = client.getDirectChatFromUserId(dm.directChatMatrixID!);
final user = dm.unsafeGetUserFromMemoryOrFallback(dm.directChatMatrixID!);
print(user.displayName ?? dm.directChatMatrixID);
print(user.avatarUrl);
print(dm.lastEvent?.body); // Vorschautext
print(dm.lastEvent?.originServerTs); // Zeitstempel
print(dm.notificationCount); // Ungelesen
}
```
---
## Gruppen-DM
```dart
// Gruppen-DM mit mehreren Personen erstellen
final roomId = await client.createRoom(
name: 'Gruppenunterhaltung',
invite: ['@bob:server', '@charlie:server'],
isDirect: true, // Als DM markieren
preset: CreateRoomPreset.trustedPrivateChat,
enableEncryption: true,
);
// Oder mit startDirectChat (für Einzel-DMs) und manuell Teilnehmer hinzufügen
final room = await client.startDirectChat('@bob:server');
await room.invite('@charlie:server');
// Gruppe als DM im Account-Data kennzeichnen
// (passiert automatisch bei isDirect: true)
```
---
## DM-Raum schließen / vergessen
```dart
final room = client.getRoomById('!roomId:server')!;
// Verlassen (Gegenüber kann weiter chatten)
await room.leave();
// Aus Raumliste entfernen
await room.forget();
// DM aus Account-Daten entfernen
final dmMap = Map<String, dynamic>.from(client.directChats);
dmMap.remove('@alice:server');
// oder nur den spezifischen Raum entfernen:
final rooms = List<String>.from(dmMap['@alice:server'] ?? []);
rooms.remove(room.id);
if (rooms.isEmpty) {
dmMap.remove('@alice:server');
} else {
dmMap['@alice:server'] = rooms;
}
await client.setAccountData(
client.userID!,
'm.direct',
dmMap,
);
```
---
## User-Profil für DM laden
```dart
// Profil des DM-Partners laden
final partnerMxid = room.directChatMatrixID!;
// Aus Room-Membership (gecacht, schnell)
final user = room.unsafeGetUserFromMemoryOrFallback(partnerMxid);
print(user.displayName);
print(user.avatarUrl);
// Frisch vom Server (genauer, aber langsamer)
final profile = await client.getProfileFromUserId(partnerMxid);
print(profile.displayname);
print(profile.avatarUrl);
// Avatar-URL in Widget verwenden
// (mxc:// URI → HTTP-URL via SDK)
final httpUrl = user.avatarUrl?.getThumbnail(
client,
width: 56,
height: 56,
method: ThumbnailMethod.crop,
);
```
---
## Verschlüsselung in DMs
```dart
// E2EE-Status prüfen
final isEncrypted = room.isEncrypted;
// E2EE aktivieren (einmalig, nicht mehr rückgängig!)
if (!room.isEncrypted) {
await room.enableEncryption();
}
// Geräte des Partners verifizieren
final partnerDevices = await client.getUserDeviceKeys('@alice:server');
for (final device in partnerDevices?.deviceKeys.values ?? []) {
print('${device.deviceId}: verified=${device.verified}');
}
// Fehlende Schlüssel für Entschlüsselung anfordern
await client.requestMissingSessionKeys();
// Schlechte Verschlüsselung erkennen
final badEvents = timeline.events
.where((e) => e.isBadEncrypted)
.toList();
```
---
## Einladungen (DM-Invite-Ansicht)
```dart
// Eingehende DM-Einladungen
final invites = client.rooms.where((r) =>
r.membership == Membership.invite &&
r.isDirectChat).toList();
// Einladung anzeigen
for (final invite in invites) {
final senderId = invite.getState(EventTypes.RoomMember, client.userID!)
?.senderId; // Wer hat eingeladen?
final sender = await client.getProfileFromUserId(senderId ?? '');
print('Einladung von ${sender.displayname}');
// Annehmen
await invite.join();
// Ablehnen
await invite.leave();
await invite.forget();
}
```
+355
View File
@@ -0,0 +1,355 @@
# 05 — Medien & Dateien
---
## Inhaltsverzeichnis
- [Datei hochladen](#datei-hochladen)
- [Bild senden](#bild-senden)
- [Video senden](#video-senden)
- [Audio / Sprachnachricht senden](#audio--sprachnachricht-senden)
- [Datei senden](#datei-senden)
- [Sticker senden](#sticker-senden)
- [Medien herunterladen & entschlüsseln](#medien-herunterladen--entschlüsseln)
- [Thumbnails & Vorschaubilder](#thumbnails--vorschaubilder)
- [MXC-URL zu HTTP-URL](#mxc-url-zu-http-url)
- [Verschlüsselte Medien](#verschlüsselte-medien)
- [Avatar hochladen](#avatar-hochladen)
- [MatrixFile-Objekte](#matrixfile-objekte)
---
## Datei hochladen
```dart
// Bytes hochladen → mxc:// URI zurück
final mxcUri = await client.uploadContent(
fileBytes, // Uint8List
filename: 'datei.pdf',
contentType: 'application/pdf', // optional, SDK erkennt es
);
print(mxcUri); // mxc://example.org/AbCdEf...
```
---
## Bild senden
```dart
import 'package:matrix/matrix.dart';
// MatrixImageFile erstellen
final imageFile = MatrixImageFile(
bytes: imageBytes, // Uint8List
name: 'foto.jpg',
mimeType: 'image/jpeg',
width: 1920, // optional, für Info
height: 1080,
);
// Mit Thumbnail
final thumbnail = MatrixImageFile(
bytes: thumbnailBytes,
name: 'foto_thumb.jpg',
mimeType: 'image/jpeg',
width: 320,
height: 180,
);
// Senden (lädt automatisch hoch + sendet m.image Event)
await room.sendFileEvent(
imageFile,
thumbnail: thumbnail, // optional
);
// Alternativ mit sendImageEvent
await room.sendImageEvent(
imageFile,
thumbnail: thumbnail,
);
```
---
## Video senden
```dart
final videoFile = MatrixVideoFile(
bytes: videoBytes,
name: 'video.mp4',
mimeType: 'video/mp4',
duration: 45000, // Millisekunden
width: 1920,
height: 1080,
);
final thumbFile = MatrixImageFile(
bytes: thumbnailBytes,
name: 'thumb.jpg',
mimeType: 'image/jpeg',
width: 320,
height: 180,
);
await room.sendVideoEvent(
videoFile,
thumbnail: thumbFile,
);
```
---
## Audio / Sprachnachricht senden
```dart
// Audio-Datei
final audioFile = MatrixAudioFile(
bytes: audioBytes,
name: 'aufnahme.ogg',
mimeType: 'audio/ogg',
duration: 12000, // Millisekunden
);
await room.sendAudioEvent(audioFile);
// Sprachnachricht (Voice Message MSC3245)
await room.sendFileEvent(
audioFile,
extraContent: {
'org.matrix.msc3245.voice': {}, // markiert als Sprachnachricht
'org.matrix.msc1767.audio': {
'duration': 12000,
'waveform': [0, 50, 100, 75, ...], // Amplituden (0-1024)
},
},
);
```
---
## Datei senden
```dart
final file = MatrixFile(
bytes: fileBytes,
name: 'dokument.pdf',
mimeType: 'application/pdf',
);
await room.sendFileEvent(file);
// Mit Extra-Metadaten
await room.sendFileEvent(
file,
extraContent: {
'org.matrix.msc3554.caption': {
'msgtype': 'm.text',
'body': 'Hier das Dokument',
},
},
);
```
---
## Sticker senden
```dart
// Sticker sind ein eigener Event-Typ (m.sticker)
await room.sendEvent(
{
'body': 'Ein Sticker',
'url': 'mxc://example.org/stickerId',
'info': {
'mimetype': 'image/png',
'w': 256,
'h': 256,
},
},
EventTypes.Sticker, // 'm.sticker'
);
```
---
## Medien herunterladen & entschlüsseln
```dart
// Aus Event-Attachment herunterladen (+ automatisch entschlüsseln bei E2EE)
final data = await event.downloadAndDecryptAttachment(
getThumbnail: false, // true für Thumbnail statt Original
);
// data ist MatrixFile mit .bytes: Uint8List
// Direkte MXC-URL herunterladen
final bytes = await client.database.getFile(mxcUri);
// oder über HTTP:
final httpUri = mxcUri.getDownloadUri(client);
// → https://homeserver/_matrix/media/v3/download/server/mediaId
```
---
## Thumbnails & Vorschaubilder
```dart
// Thumbnail-URL für Widget
final thumbnailUri = event.thumbnailUrl; // mxc:// URI
// HTTP-URL für Thumbnail (mit Größe)
final httpUrl = thumbnailUri?.getThumbnail(
client,
width: 800,
height: 600,
method: ThumbnailMethod.scale, // oder .crop
);
// Avatar-Thumbnail
final avatarUrl = user.avatarUrl?.getThumbnail(
client,
width: 56,
height: 56,
method: ThumbnailMethod.crop,
);
// Im Flutter Widget (CachedNetworkImage o.ä.):
Image.network(httpUrl.toString());
```
---
## MXC-URL zu HTTP-URL
```dart
// Download-URL
final downloadUrl = Uri.parse(mxcUriString).getDownloadUri(client);
// → https://homeserver/_matrix/media/v3/download/server/mediaId
// Thumbnail-URL
final thumbUrl = Uri.parse(mxcUriString).getThumbnail(
client,
width: 300,
height: 300,
method: ThumbnailMethod.crop,
);
// Direkt als String
final urlString = downloadUrl.toString();
```
---
## Verschlüsselte Medien
```dart
// In verschlüsselten Räumen werden Medien automatisch verschlüsselt.
// Das SDK übernimmt Ver- und Entschlüsselung transparent.
// Verschlüsselter Download (automatisch):
final file = await event.downloadAndDecryptAttachment();
// file.bytes enthält die entschlüsselten Rohdaten
// Manuell ver-/entschlüsseln (selten nötig):
// Nutze EncryptedFile aus dem matrix-package
final encryptedFile = EncryptedFile(
url: 'mxc://...',
key: {/* JWK */},
iv: 'base64...',
hashes: {'sha256': 'base64...'},
v: 'v2',
);
```
---
## Avatar hochladen
```dart
// Eigener Avatar
final avatarBytes = await File('avatar.png').readAsBytes();
final mxcUri = await client.uploadContent(
avatarBytes,
filename: 'avatar.png',
contentType: 'image/png',
);
await client.setAvatarUrl(client.userID!, mxcUri);
// Raum-Avatar
final roomAvatarMxc = await client.uploadContent(imageBytes, filename: 'room.png');
await room.setAvatar(roomAvatarMxc);
```
---
## MatrixFile-Objekte
```dart
// Basis-Klasse
MatrixFile({
required Uint8List bytes,
required String name,
String? mimeType,
})
// Spezialisierte Unterklassen:
MatrixImageFile({
required Uint8List bytes,
required String name,
String? mimeType, // z.B. 'image/jpeg'
int? width,
int? height,
bool? animated, // für GIFs
})
MatrixVideoFile({
required Uint8List bytes,
required String name,
String? mimeType, // 'video/mp4'
int? duration, // ms
int? width,
int? height,
MatrixImageFile? thumbnail,
})
MatrixAudioFile({
required Uint8List bytes,
required String name,
String? mimeType, // 'audio/ogg'
int? duration, // ms
})
// Eigenschaften
file.bytes // Uint8List
file.name // Dateiname
file.mimeType // MIME-Typ
file.sizeString // '1.4 MB' (formatiert)
file.size // Bytes als int
```
---
## Medien-Events lesen
```dart
// Attachment-Info aus Event
if (event.messageType == 'm.image') {
final url = event.attachmentMxcUrl; // mxc:// URI
final mime = event.attachmentMimetype; // 'image/jpeg'
final size = event.attachmentSize; // Bytes
final width = event.infoMap['w'] as int?;
final height = event.infoMap['h'] as int?;
}
if (event.messageType == 'm.video') {
final duration = event.infoMap['duration'] as int?; // ms
}
if (event.messageType == 'm.audio') {
final duration = event.infoMap['duration'] as int?;
final waveform = event.content
.tryGetMap('org.matrix.msc1767.audio')
?.tryGetList<int>('waveform');
}
// Datei-Größe als lesbarer String
print(event.sizeString); // '2.3 MB'
```
+313
View File
@@ -0,0 +1,313 @@
# 06 — E2EE Verschlüsselung
---
## Inhaltsverzeichnis
- [Setup & Initialisierung](#setup--initialisierung)
- [Verschlüsselung aktivieren](#verschlüsselung-aktivieren)
- [Entschlüsselung & Fehlerfälle](#entschlüsselung--fehlerfälle)
- [Device-Verifikation (SAS)](#device-verifikation-sas)
- [Cross-Signing](#cross-signing)
- [Key Backup](#key-backup)
- [Fehlende Session Keys anfordern](#fehlende-session-keys-anfordern)
- [Geräte-Keys abfragen](#geräte-keys-abfragen)
- [Trust-Status](#trust-status)
---
## Setup & Initialisierung
```dart
// flutter_vodozemac muss initialisiert sein BEVOR Client erstellt wird
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
await vod.init();
// Client mit E2EE-Support
final client = Client(
'Pyramid',
database: await MatrixSdkDatabase.init('pyramid', database: db),
nativeImplementations: NativeImplementationsIsolate(compute),
// Oder für Hintergrund-Isolaten (kein compute):
// nativeImplementations: NativeImplementationsDummy(),
verificationMethods: {
KeyVerificationMethod.numbers,
KeyVerificationMethod.emoji,
// KeyVerificationMethod.qrCodeScan, // falls QR-Scanner vorhanden
// KeyVerificationMethod.qrCodeShow,
},
);
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
);
// E2EE verfügbar prüfen
print(client.encryptionEnabled); // true wenn Vodozemac/Olm geladen
```
---
## Verschlüsselung aktivieren
```dart
// Raum-Verschlüsselung einschalten (PERMANENT, nicht rückgängig!)
if (!room.isEncrypted) {
await room.enableEncryption();
}
// Verschlüsselung beim Erstellen aktivieren
final roomId = await client.createRoom(
name: 'Verschlüsselter Raum',
enableEncryption: true,
preset: CreateRoomPreset.privateChat,
);
// Status prüfen
print(room.isEncrypted); // bool
print(room.encryptionAlgorithm); // 'm.megolm.v1.aes-sha2'
```
---
## Entschlüsselung & Fehlerfälle
```dart
// Beim Anzeigen von Events prüfen:
if (event.isBadEncrypted) {
// Anzeigen: "Nachricht kann nicht entschlüsselt werden"
// Mögliche Ursachen:
// - Gerät war nicht in der Raumschlüssel-Verteilung
// - Session fehlt (Megolm Session nicht vorhanden)
// - Falsches Gerät / fehlende Olm-Session
}
// Entschlüsselungs-Fehler-Typ
if (event.content['can_not_decrypt'] != null) {
// Generischer Fehler
}
if (event.type == EventTypes.Encrypted && event.plaintextContent == null) {
// Noch nicht entschlüsselt
}
// Event-Body bei Fehler prüfen
final body = event.body;
// Enthält "** Unable to decrypt: ..." bei Fehlern
// Fehlende Schlüssel anfordern (von anderen Geräten)
await client.requestMissingSessionKeys();
```
---
## Device-Verifikation (SAS)
```dart
// ── Verifikation starten (ich verifiziere ein anderes Gerät) ──────────────
// Verifikations-Anfrage senden
final request = await client.userDeviceKeys[otherUserId]!
.startVerification();
// request.onUpdate: Stream mit Zustandsänderungen
// ── Verifikation empfangen (anderes Gerät will mich verifizieren) ─────────
// Eingehende Anfragen beobachten
client.onKeyVerificationRequest.stream.listen((request) async {
// Anfrage annehmen
await request.acceptVerification();
});
// ── SAS-Flow ─────────────────────────────────────────────────────────────
// Auf SAS-Ready warten und Emojis/Zahlen anzeigen
request.onUpdate.stream.listen((_) async {
if (request.state == KeyVerificationState.showSas) {
// Emojis anzeigen
final emojis = request.sasEmojis; // List<KeyVerificationEmoji>
for (final emoji in emojis) {
print('${emoji.emoji} ${emoji.name}');
}
// Oder Dezimalzahlen
final decimals = request.sasDecimals; // List<int> (3 Zahlen)
print(decimals); // z.B. [1234, 5678, 9012]
// Wenn User bestätigt dass sie übereinstimmen:
await request.confirmSas();
// Wenn sie nicht übereinstimmen:
// await request.rejectSas();
}
if (request.state == KeyVerificationState.done) {
print('Verifikation erfolgreich!');
}
if (request.state == KeyVerificationState.error) {
print('Verifikation fehlgeschlagen: ${request.cancelCode}');
}
});
// ── QR-Code Verifikation ─────────────────────────────────────────────────
// QR-Code zum Anzeigen generieren
final qrData = await request.generateQrCodeData();
// qrData als QR-Code rendern (z.B. mit qr_flutter-Package)
// Gescannten QR-Code verarbeiten
await request.scanQrCode(scannedData);
// Bestätigen dass QR gescannt wurde
await request.confirmQrCodeScanned();
```
---
## Cross-Signing
```dart
// Cross-Signing bootstrappen (erstellt Master-, Self- und User-Signing-Keys)
// Nur einmal pro Account nötig
await client.encryption!.bootstrapCrossSigning(
setupMasterKey: true,
setupSelfSigningKey: true,
setupUserSigningKey: true,
);
// Cross-Signing-Status
final crossSigning = client.encryption!.crossSigning;
print(crossSigning.enabled); // bool
print(crossSigning.selfSigned); // bool: eigenes Gerät ist self-signed
// Eigenes Gerät ist verifiziert?
final myDevice = client.userDeviceKeys[client.userID]
?.deviceKeys[client.deviceID];
print(myDevice?.verified); // bool
// Anderen User als vertrauenswürdig markieren
await client.encryption!.crossSigning.signUser(otherUserId);
// Gerät verifizieren (nach SAS/QR)
await myDevice?.setVerified(true);
```
---
## Key Backup
```dart
// ── Key Backup einrichten ─────────────────────────────────────────────────
// Vollständiges Crypto-Setup (empfohlen):
// Erstellt Secret Storage + Cross-Signing + Key Backup
await client.bootstrapCryptoIdentity(
wipeRecovery: false, // true: bestehende Recovery überschreiben
);
// Nur Key Backup:
final backup = client.encryption!.keyManager;
// Recovery-Key generieren und anzeigen
final recoveryKey = await client.encryption!.ssss.generateKey();
print(recoveryKey); // Langer alphanumerischer Schlüssel → User aufschreiben lassen!
// ── Key Backup Status ─────────────────────────────────────────────────────
// Ist Key Backup aktiv?
final keyBackupEnabled = client.encryption!.keyManager.enabled;
// Alle Sessions hochladen
await client.encryption!.keyManager.uploadInboundGroupSessions();
// ── Backup wiederherstellen (Recovery) ───────────────────────────────────
// Keys von Server-Backup wiederherstellen
await client.encryption!.keyManager.loadFromResponse(
await client.getRoomKeysBackup(),
);
// Oder mit Recovery-Key:
await client.encryption!.ssss.unlock(recoveryKey: eingabe);
await client.encryption!.keyManager.restore();
```
---
## Fehlende Session Keys anfordern
```dart
// Schlüssel von anderen Geräten desselben Users anfordern
await client.requestMissingSessionKeys();
// Für einen spezifischen Raum
await client.requestMissingSessionKeys(
rooms: [room],
);
// Einzelne Session anfordern (für ein spezifisches Event)
if (event.isBadEncrypted) {
final sessionId = event.content
.tryGet<String>('session_id');
final senderKey = event.content
.tryGet<String>('sender_key');
if (sessionId != null) {
await room.requestKey(sessionId);
}
}
```
---
## Geräte-Keys abfragen
```dart
// Alle Geräte eines Users laden
final userDevices = await client.getUserDeviceKeys(userId);
// → Map<String, DeviceKeysList>
// Geräte eines Raums (alle Mitglieder)
final roomDevices = await room.getUserDeviceKeys();
// Eigene Geräte
final myDevices = client.userDeviceKeys[client.userID]?.deviceKeys;
// Gerät-Details
for (final device in myDevices?.values ?? []) {
print(device.deviceId);
print(device.displayName);
print(device.verified);
print(device.blocked);
print(device.ed25519Key); // Signatur-Schlüssel
print(device.curve25519Key); // Encryption-Schlüssel
}
// Gerät sperren (Nachrichten werden nicht mehr entschlüsselt gesendet)
await myDevices?['DEVICEID']?.setBlocked(true);
```
---
## Trust-Status
```dart
// Trust-Level für Raum berechnen
final encryption = room.client.encryption;
if (encryption != null) {
final trust = await room.calcEncryptionHealthState();
// trust: .allVerified | .unverifiedDevices | .unknown
}
// User-Trust
final userTrust = client.userDeviceKeys[userId]?.verified;
// null = unbekannt, true = verifiziert, false = blockiert
// Nachrichten-Verifikation
if (!event.encryptionHealthState.allVerified) {
// Anzeigen: "Gesendet von unverifiziertem Gerät"
}
// Eigene Identität
final myIdentity = client.encryption?.crossSigning;
print(myIdentity?.selfSigned); // Eigenes Gerät cross-signed?
```
+291
View File
@@ -0,0 +1,291 @@
# 07 — Push & Benachrichtigungen
---
## Inhaltsverzeichnis
- [Pusher registrieren (FCM)](#pusher-registrieren-fcm)
- [Pusher abrufen & verwalten](#pusher-abrufen--verwalten)
- [Push Rules](#push-rules)
- [onNotification Stream](#onnotification-stream)
- [Lokale Benachrichtigungen (Android)](#lokale-benachrichtigungen-android)
- [Benachrichtigungen: Pyramid-Architektur](#benachrichtigungen-pyramid-architektur)
- [Notification Counts](#notification-counts)
---
## Pusher registrieren (FCM)
```dart
// FCM-Token holen
import 'package:firebase_messaging/firebase_messaging.dart';
final fcmToken = await FirebaseMessaging.instance.getToken();
// Pusher beim Homeserver registrieren
await client.postPusher(
Pusher(
pushkey: fcmToken!,
kind: 'http',
appId: 'chat.pyramid.pyramid', // Unique App-ID (muss zu Push-Gateway passen)
appDisplayName: 'Pyramid',
deviceDisplayName: 'Mein Android',
lang: 'de',
data: PusherData(
url: Uri.parse('https://push.example.org/_matrix/push/v1/notify'),
format: 'event_id_only', // Empfohlen (nur Event-ID, nicht der Inhalt)
),
),
append: true, // Nicht andere Pusher dieses Geräts löschen
);
// Token-Refresh überwachen
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
await client.postPusher(
Pusher(pushkey: newToken, /* ... */ ),
append: true,
);
});
```
---
## Pusher abrufen & verwalten
```dart
// Alle Pusher dieses Users
final pushers = await client.getPushers() ?? [];
for (final pusher in pushers) {
print(pusher.appId);
print(pusher.pushkey);
print(pusher.data.url);
}
// Pusher entfernen
await client.deletePusher(
Pusher(
pushkey: fcmToken,
appId: 'chat.pyramid.pyramid',
kind: 'http', // Pflichtfeld beim Löschen
// ...
),
);
// Beim Logout alle Pusher entfernen
final myPushers = (await client.getPushers() ?? [])
.where((p) => p.appId == 'chat.pyramid.pyramid');
for (final p in myPushers) {
await client.deletePusher(p);
}
```
---
## Push Rules
```dart
// Alle Push Rules abrufen
final ruleset = await client.getPushRules();
// Globale Regeln
for (final rule in ruleset.global.content ?? []) {
print(rule.ruleId);
print(rule.enabled);
print(rule.actions);
}
// Kategorien der Regeln:
// .override — höchste Priorität (z.B. DND-Modus)
// .content — Inhalt-basiert (z.B. eigener Name erwähnt)
// .room — Raum-spezifisch
// .sender — Sender-spezifisch
// .underride — niedrigste Priorität (Fallback)
// Push Rule aktivieren/deaktivieren
await client.setPushRuleEnabled(
'global',
PushRuleKind.override,
'.m.rule.master', // DND: stumm schalten
false, // false = Regel deaktivieren
);
// Eigene Push Rule erstellen (z.B. Raum stumm schalten)
await client.setPushRule(
'global',
PushRuleKind.room,
'!roomId:server', // RuleId = RoomId für Raum-Regeln
[PushRuleAction.dontNotify],
);
// Push Rule löschen
await client.deletePushRule(
'global',
PushRuleKind.room,
'!roomId:server',
);
// Standard-Rules für Erwähnungen
// Pyramid prüft lokal ob ein Event eine Mention enthält:
final myId = client.userID!;
final mentions = event.content
.tryGetMap<String, dynamic>('m.mentions')
?.tryGetList<String>('user_ids') ?? [];
final isMentioned = mentions.contains(myId) ||
event.body.contains(myId);
// Raum stumm schalten
await client.setPushRule(
'global',
PushRuleKind.override,
'!roomId:server',
[PushRuleAction.dontNotify],
conditions: [
PushCondition(kind: 'event_match', key: 'room_id', pattern: '!roomId:server'),
],
);
```
---
## onNotification Stream
```dart
// Matrix-SDK emittiert Events die Benachrichtigungen auslösen sollen
// (nach Push-Rule-Matching, entschlüsselt)
client.onNotification.stream.listen((Event event) {
final roomId = event.room.id;
final room = client.getRoomById(roomId)!;
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final title = room.isDirectChat
? sender.displayName ?? event.senderId
: '${sender.displayName} · ${room.getLocalizedDisplayname()}';
final body = event.body;
// Lokale Benachrichtigung anzeigen
showLocalNotification(title, body, roomId);
});
```
---
## Lokale Benachrichtigungen (Android)
```dart
// Siehe auch: lib/core/notification_service.dart und PushService.kt
// flutter_local_notifications initialisieren
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification'),
),
onDidReceiveNotificationResponse: _handleNotificationTap,
onDidReceiveBackgroundNotificationResponse: handleBackgroundResponse,
);
// Notification-Kanal erstellen (Android 8+)
await plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
'pyramid_messages',
'Nachrichten',
description: 'Neue Nachrichten',
importance: Importance.high,
),
);
// Benachrichtigung anzeigen (mit Reply-Action)
await plugin.show(
notifId,
'Absender',
'Nachrichtentext',
NotificationDetails(
android: AndroidNotificationDetails(
'pyramid_messages',
'Nachrichten',
importance: Importance.high,
priority: Priority.high,
icon: '@drawable/ic_notification',
actions: [
AndroidNotificationAction(
'reply', 'Antworten',
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
allowGeneratedReplies: true,
showsUserInterface: false, // BroadcastReceiver-Pfad
),
AndroidNotificationAction('read', 'Gelesen',
showsUserInterface: false),
],
),
),
payload: roomId,
);
// Benachrichtigung schließen
await plugin.cancel(notifId);
```
---
## Benachrichtigungen: Pyramid-Architektur
```
FCM-Payload → PushService.kt (Kotlin, kein Dart-Overhead)
├─ App im Vordergrund? → Heartbeat-Check → Skip
├─ HTTP-Fetch: Absender + Raumname (ohne Entschlüsselung möglich)
├─ Native Notification anzeigen: "Neue Nachricht"
│ └─ Reply-Action → getActivity(MainActivity) → onNewIntent
└─ Flutter-Engine im Cache? → MethodChannel "decryptAndUpdateNotification"
└─ Dart: decryptAndUpdateNotification() → showNativeNotification() [mit echtem Text]
Reply-Flow:
User tippt Antwort → Reply-PendingIntent (getActivity)
→ MainActivity.onNewIntent → MethodChannel "replyFromNotification"
→ Dart: room.sendTextEvent(text)
→ moveTaskToBack(true)
```
```dart
// Heartbeat (App aktiv?) setzen:
void _pingHeartbeat() {
SharedPreferences.getInstance().then(
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
);
}
// Heartbeat löschen (App im Hintergrund):
void setAppForeground(bool value) {
if (!value) {
SharedPreferences.getInstance()
.then((p) => p.setInt('notif_app_heartbeat', 0));
}
}
```
---
## Notification Counts
```dart
// Pro Raum
print(room.notificationCount); // Ungelesene Nachrichten
print(room.highlightCount); // Mentions / Highlights
// Gesamt
final totalUnread = client.rooms.fold<int>(
0, (sum, r) => sum + r.notificationCount);
final totalHighlights = client.rooms.fold<int>(
0, (sum, r) => sum + r.highlightCount);
// Badge für Windows Taskbar
import 'package:windows_taskbar/windows_taskbar.dart';
if (totalUnread > 0) {
WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
} else {
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
}
```
+422
View File
@@ -0,0 +1,422 @@
# 08 — VoIP, Calls & LiveKit (MatrixRTC)
---
## Inhaltsverzeichnis
- [Architektur-Überblick](#architektur-überblick)
- [1:1 Call (Legacy m.call)](#11-call-legacy-mcall)
- [MatrixRTC (Gruppen-Calls)](#matrixrtc-gruppen-calls)
- [LiveKit-Integration](#livekit-integration)
- [Klingel-Flow](#klingel-flow)
- [WebRTC Setup in Flutter](#webrtc-setup-in-flutter)
- [Call-State Management](#call-state-management)
- [Pyramid VoIP-Architektur](#pyramid-voip-architektur)
---
## Architektur-Überblick
```
Matrix Signaling (to-device / room events)
MatrixRTC Layer
┌─────────────────────────────────┐
│ com.famedly.call.member Events │
│ (Membership, Backend, Expiry) │
└──────────────┬──────────────────┘
┌────────┴────────┐
│ │
Mesh (P2P) LiveKit (SFU)
WebRTC direct livekit-server
(1:1, max ~4) (skalierbar, n:m)
```
---
## 1:1 Call (Legacy m.call)
```dart
// ── Anruf starten ────────────────────────────────────────────────────────
// Wird vom SDK über room events (m.call.invite, m.call.candidates,
// m.call.answer, m.call.hangup) signalisiert.
// Anruf-Einladung senden (nach WebRTC offer)
await room.sendEvent(
{
'call_id': 'eindeutige-call-id',
'version': 1,
'lifetime': 60000, // ms, wie lange das Invite gültig ist
'offer': {
'type': 'offer',
'sdp': sdpString, // WebRTC SDP Offer
},
},
EventTypes.CallInvite, // 'm.call.invite'
);
// ICE-Kandidaten senden
await room.sendEvent(
{
'call_id': callId,
'version': 1,
'candidates': [
{
'candidate': 'candidate:...',
'sdpMid': '0',
'sdpMLineIndex': 0,
},
],
},
EventTypes.CallCandidates, // 'm.call.candidates'
);
// Anruf annehmen (SDP Answer)
await room.sendEvent(
{
'call_id': callId,
'version': 1,
'answer': {
'type': 'answer',
'sdp': sdpAnswerString,
},
},
EventTypes.CallAnswer, // 'm.call.answer'
);
// Auflegen
await room.sendEvent(
{
'call_id': callId,
'version': 1,
'reason': 'user_hangup', // oder 'ice_failed', 'invite_timeout'
},
EventTypes.CallHangup, // 'm.call.hangup'
);
// Ablehnen (Reject)
await room.sendEvent(
{
'call_id': callId,
'version': 1,
'reason': 'user_hangup',
},
EventTypes.CallReject, // 'm.call.reject'
);
// Eingehende Anrufe überwachen
client.onCallInvite.stream.listen((CallInvite invite) {
print('Eingehender Anruf von ${invite.roomId}');
print('Call-ID: ${invite.callId}');
});
```
---
## MatrixRTC (Gruppen-Calls)
```dart
// MatrixRTC verwendet com.famedly.call.member State-Events
// ── Call beitreten (Membership senden) ───────────────────────────────────
final myDeviceId = client.deviceID!;
final callId = room.id; // Normalerweise = Raum-ID
// Membership-Event senden (signalisiert "ich bin im Call")
await room.client.sendState(
room.id,
'org.matrix.msc3401.call.member', // oder 'com.famedly.call.member'
{
'application': 'm.call',
'call_id': callId,
'device_id': myDeviceId,
'expires_ts': DateTime.now()
.add(const Duration(hours: 1))
.millisecondsSinceEpoch,
'backend': {
'type': 'm.sfu', // LiveKit
'livekit_service_url': 'https://livekit.example.org',
},
// Für Mesh:
// 'backend': {'type': 'm.mesh'},
'membershipID': 'eindeutige-membership-id',
},
stateKey: '$myDeviceId:$callId',
);
// ── Call verlassen (Membership entfernen) ─────────────────────────────────
await room.client.sendState(
room.id,
'org.matrix.msc3401.call.member',
{}, // Leeres Content = Austritt
stateKey: '$myDeviceId:$callId',
);
// ── Aktive Call-Mitglieder auflesen ──────────────────────────────────────
final callStates = room.states['org.matrix.msc3401.call.member'];
final now = DateTime.now().millisecondsSinceEpoch;
final activeMembers = callStates?.values.where((event) {
final expiresTs = event.content['expires_ts'] as int? ?? 0;
return expiresTs > now; // Nicht abgelaufen
}).toList() ?? [];
for (final member in activeMembers) {
print(member.content['device_id']);
print(member.stateKey); // 'deviceId:callId'
}
```
---
## LiveKit-Integration
```dart
// LiveKit-Token vom Matrix-Homeserver anfragen (OpenID)
// Dieser Flow authentifiziert den User beim LiveKit-Server ohne eigenes Passwort.
// 1. OpenID-Token vom Homeserver holen
final openIdToken = await client.requestOpenIdToken(client.userID!);
// openIdToken.accessToken, .matrixServerName
// 2. LiveKit-Token beim MatrixRTC-Service tauschen
// (Pyramid sendet openIdToken an livekit_service_url des Raums)
final response = await http.post(
Uri.parse('https://livekit-service.example.org/sfu/get'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'room': room.id,
'openid_token': {
'access_token': openIdToken.accessToken,
'token_type': 'Bearer',
'matrix_server_name': openIdToken.matrixServerName,
},
'device_id': client.deviceID,
}),
);
final livekitToken = jsonDecode(response.body)['token'];
// 3. Mit LiveKit verbinden
import 'package:livekit_client/livekit_client.dart';
final room = await LiveKitClient.connect(
'wss://livekit.example.org',
livekitToken,
roomOptions: const RoomOptions(
adaptiveStream: true,
dynacast: true,
defaultVideoPublishOptions: VideoPublishOptions(
simulcast: true,
),
),
);
// 4. Mikrofon aktivieren
await room.localParticipant?.setMicrophoneEnabled(true);
// 5. Kamera aktivieren (Video-Call)
await room.localParticipant?.setCameraEnabled(true);
// 6. Teilnehmer observieren
room.addListener(() {
for (final participant in room.participants.values) {
print(participant.identity); // Matrix User-ID
for (final track in participant.videoTracks) {
// Widget: VideoTrackRenderer(track.track!)
}
}
});
```
---
## Klingel-Flow
```dart
// ── Anruf initiieren ──────────────────────────────────────────────────────
// 1. Membership-Event senden (MatrixRTC)
// 2. To-Device-Event senden für Push-Benachrichtigung beim Empfänger
// Klingelanruf via to-device-Event
await client.sendToDevice(
'com.famedly.call', // Event-Typ
{
otherUserId: {
'*': { // Alle Geräte des Users
'call_id': callId,
'room_id': room.id,
'type': 'invite',
'sender_session_id': client.deviceID,
},
},
},
);
// ── Eingehenden Anruf empfangen ───────────────────────────────────────────
client.onToDeviceEvent.stream
.where((e) => e.type == 'com.famedly.call')
.listen((event) {
final callId = event.content['call_id'];
final roomId = event.content['room_id'];
final type = event.content['type']; // 'invite' | 'hangup'
// Klingelton abspielen, UI anzeigen
});
// ── Anruf ablehnen (während Klingeln) ────────────────────────────────────
await client.sendToDevice(
'com.famedly.call',
{
callerId: {'*': {'call_id': callId, 'type': 'reject'}},
},
);
```
---
## WebRTC Setup in Flutter
```dart
// flutter_webrtc Package
import 'package:flutter_webrtc/flutter_webrtc.dart';
// Media-Stream holen
final stream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': false, // true für Video-Call
});
// PeerConnection erstellen
final pc = await createPeerConnection({
'iceServers': [
{'urls': 'stun:stun.matrix.org:3478'},
// TURN-Server (für NAT-Traversal):
{
'urls': 'turn:turn.example.org:5349',
'username': 'user',
'credential': 'pass',
},
],
'iceCandidatePoolSize': 10,
});
// Track hinzufügen
for (final track in stream.getTracks()) {
await pc.addTrack(track, stream);
}
// ICE-Kandidaten sammeln
pc.onIceCandidate = (RTCIceCandidate candidate) {
// Kandidaten via Matrix senden
sendIceCandidates([candidate]);
};
// Angebot erstellen
final offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// offer.sdp → via Matrix senden
// Angebot annehmen
await pc.setRemoteDescription(RTCSessionDescription(sdpOffer, 'offer'));
final answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// answer.sdp → via Matrix senden
// Eingehende Streams
pc.onTrack = (RTCTrackEvent event) {
final remoteStream = event.streams.first;
// Widget: RTCVideoRenderer() mit remoteStream
};
// Aufräumen
await pc.close();
await stream.dispose();
```
---
## Call-State Management
```dart
// Call-Zustand in Pyramid
enum CallState {
idle,
calling, // Anruf geht raus
ringing, // Eingehender Anruf
connecting, // ICE/SDP in Bearbeitung
connected, // Verbunden
ended,
}
// Stummschalten
final audioTrack = localStream.getAudioTracks().first;
audioTrack.enabled = false; // Mute
audioTrack.enabled = true; // Unmute
// Lautsprecher vs. Hörer (Android/iOS)
// flutter_webrtc kümmert sich darum via Helper.setSpeakerphoneOn
// DTMF (Zifferntasten)
final sender = pc.getSenders().firstWhere(
(s) => s.track?.kind == 'audio');
await sender.dtmf?.insertDTMF('1');
// Screen-Sharing starten
final screenStream = await navigator.mediaDevices.getDisplayMedia({
'video': true,
'audio': false,
});
final screenTrack = screenStream.getVideoTracks().first;
final videoSender = pc.getSenders()
.firstWhere((s) => s.track?.kind == 'video');
await videoSender.replaceTrack(screenTrack);
```
---
## Pyramid VoIP-Architektur
```
Pyramid Call-Stack:
┌─────────────────────────────────────────────────────┐
│ Dart (Flutter) │
│ lib/features/call/ │
│ call_notifier.dart — Zustand, Aktionen │
│ call_page.dart — UI (Kamera/Mic Controls) │
│ livekit_service.dart — LiveKit-Token-Tausch │
├─────────────────────────────────────────────────────┤
│ Matrix SDK │
│ room.sendState(com.famedly.call.member) │
│ client.sendToDevice(com.famedly.call) │
│ client.requestOpenIdToken() │
├─────────────────────────────────────────────────────┤
│ LiveKit Client (livekit_client package) │
│ LiveKitClient.connect(url, token) │
│ room.localParticipant.setMicrophoneEnabled() │
│ VideoTrackRenderer Widget │
├─────────────────────────────────────────────────────┤
│ Native (Android/iOS) │
│ FlutterForegroundService — hält Call am Leben │
│ Klingelton via AudioManager │
└─────────────────────────────────────────────────────┘
TURN/STUN-Server Konfiguration (from Matrix):
await client.getTurnServer() →
TurnServerCredentials(
username, password, ttl,
uris: ['turn:turn.example.org:5349?transport=udp']
)
```
```dart
// TURN-Credentials vom Homeserver holen
final turn = await client.getTurnServer();
final iceServers = [
{'urls': turn.uris, 'username': turn.username, 'credential': turn.password},
{'urls': 'stun:stun.matrix.org:3478'},
];
```
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
# 09 — Profil, Präsenz & Account-Daten
---
## Inhaltsverzeichnis
- [Eigenes Profil lesen](#eigenes-profil-lesen)
- [Profil aktualisieren](#profil-aktualisieren)
- [Profil anderer User](#profil-anderer-user)
- [Präsenz (Presence)](#präsenz-presence)
- [Account-Daten (Account Data)](#account-daten-account-data)
- [Ignorierte User](#ignorierte-user)
- [Tags / Favoriten](#tags--favoriten)
- [Account löschen / deaktivieren](#account-löschen--deaktivieren)
---
## Eigenes Profil lesen
```dart
// Aus dem Client (gecacht)
print(client.userID); // '@alice:server'
print(client.displayName); // 'Alice'
print(client.avatarUrl); // mxc://...
// Frisch vom Server
final profile = await client.fetchOwnProfile();
print(profile.displayname);
print(profile.avatarUrl);
// Avatar als HTTP-URL
final avatarHttp = client.avatarUrl?.getThumbnail(
client,
width: 128,
height: 128,
method: ThumbnailMethod.crop,
);
```
---
## Profil aktualisieren
```dart
// Displayname setzen
await client.setDisplayName(client.userID!, 'Neuer Name');
// Avatar hochladen und setzen
final avatarBytes = await File('avatar.png').readAsBytes();
final mxcUri = await client.uploadContent(
avatarBytes,
filename: 'avatar.png',
contentType: 'image/png',
);
await client.setAvatarUrl(client.userID!, mxcUri);
// Avatar entfernen
await client.setAvatarUrl(client.userID!, null);
```
---
## Profil anderer User
```dart
// Profil via API
final profile = await client.getProfileFromUserId('@bob:server');
print(profile.displayname);
print(profile.avatarUrl);
// Spezifisches Feld (Matrix 1.16+ API)
final displayname = await client.getProfileField(
'@bob:server',
'displayname',
);
final avatar = await client.getProfileField(
'@bob:server',
'avatar_url',
);
// User in einem Raum (schneller, gecacht)
final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server');
print(user.displayName);
print(user.avatarUrl);
print(user.id);
// User-Suche
final results = await client.searchUserDirectory(
'alice',
limit: 20,
);
for (final user in results.results) {
print(user.userId);
print(user.displayName);
print(user.avatarUrl);
}
```
---
## Präsenz (Presence)
```dart
// Eigene Präsenz setzen
await client.setPresence(
client.userID!,
PresenceType.online, // .online | .unavailable | .offline
statusMsg: 'Im Meeting', // Optionaler Status-Text
);
// Präsenz anderer User abrufen
final presence = await client.fetchCurrentPresence('@bob:server');
print(presence.presence); // PresenceType
print(presence.currentlyActive); // bool?
print(presence.statusMsg); // String?
print(presence.lastActiveAgo); // int? (Millisekunden seit letzter Aktivität)
// Präsenz aus Raum-Mitglieder
final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server');
// Präsenz wird nicht direkt im User gecacht — fetchCurrentPresence nötig
// Präsenz-Anzeige im UI
String getPresenceText(PresenceType? type, int? lastActiveAgo) {
switch (type) {
case PresenceType.online: return 'Online';
case PresenceType.unavailable:
return 'Abwesend';
case PresenceType.offline:
if (lastActiveAgo != null) {
final mins = lastActiveAgo ~/ 60000;
if (mins < 60) return 'Vor $mins Min. aktiv';
final hours = mins ~/ 60;
if (hours < 24) return 'Vor $hours Std. aktiv';
return 'Vor ${hours ~/ 24} Tagen aktiv';
}
return 'Offline';
default: return '';
}
}
```
---
## Account-Daten (Account Data)
```dart
// Account-Daten sind private, servergespeicherte Einstellungen.
// Bleiben über Geräte hinweg synchron.
// Lesen
final data = client.accountData['com.example.my_setting']?.content;
// Schreiben
await client.setAccountData(
client.userID!,
'com.example.my_setting',
{'key': 'value', 'enabled': true},
);
// Wichtige Standard-Account-Daten:
// 'm.direct' → DM-Raumliste
// 'm.push_rules' → Push-Regel-Overrides
// 'm.ignored_user_list' → Ignorierte User
// 'm.identity_server' → Identity-Server-URL
// 'im.vector.setting.breadcrumbs' → FluffyChat letzte Räume
// Raumspezifische Account-Daten
await client.setRoomAccountData(
client.userID!,
'!roomId:server',
'm.fully_read',
{'event_id': eventId},
);
// Tag für Raum setzen (Favorit, Low-Priority)
await client.setRoomTag(
client.userID!,
'!roomId:server',
'm.favourite',
TagContent(order: 0.5),
);
await client.setRoomTag(client.userID!, '!roomId:server', 'm.lowpriority', TagContent());
await client.deleteRoomTag(client.userID!, '!roomId:server', 'm.favourite');
```
---
## Ignorierte User
```dart
// User ignorieren
final ignoredList = client.ignoredUsers; // List<String>
final updated = [...ignoredList, '@spammer:server'];
await client.setAccountData(
client.userID!,
'm.ignored_user_list',
{'ignored_users': Map.fromIterable(updated, value: (_) => {})},
);
// User entblockieren
final filtered = ignoredList.where((id) => id != '@spammer:server').toList();
await client.setAccountData(
client.userID!,
'm.ignored_user_list',
{'ignored_users': Map.fromIterable(filtered, value: (_) => {})},
);
// Ignorierte User abrufen
final ignored = client.ignoredUsers; // automatisch aus Account-Data
```
---
## Tags / Favoriten
```dart
// Raum als Favorit markieren
await room.addTag('m.favourite', order: 0.1);
// Raum als Low-Priority markieren
await room.addTag('m.lowpriority');
// Alle Tags eines Raums
final tags = room.tags; // Map<String, Tag>
final isFavourite = tags.containsKey('m.favourite');
final isLowPriority = tags.containsKey('m.lowpriority');
// Tag entfernen
await room.removeTag('m.favourite');
// Eigene Tags
await room.addTag('com.example.myapp.tag', order: 0.5);
// Räume nach Tag filtern
final favourites = client.rooms
.where((r) => r.tags.containsKey('m.favourite'))
.toList();
```
---
## Account löschen / deaktivieren
```dart
// Account deaktivieren (PERMANENT, nicht rückgängig!)
await client.uiaRequestBackground(
(auth) => client.deactivateAccount(
auth: auth,
eraseData: true, // true: alle Nachrichten löschen (soweit möglich)
),
);
// Passwort ändern (braucht Re-Auth via UIAA)
await client.uiaRequestBackground(
(auth) => client.changePassword(
'neuesPasswort',
auth: auth,
oldPassword: 'altesPasswort',
),
);
// E-Mail-Adresse hinzufügen (über Identity-Server)
// Dies ist ein mehrstufiger UIAA-Flow und erfordert E-Mail-Bestätigung.
// Normalerweise über den Homeserver-spezifischen Registration-Flow.
```
+410
View File
@@ -0,0 +1,410 @@
# 10 — Raum-Administration & Moderation
---
## Inhaltsverzeichnis
- [Power Levels vollständig](#power-levels-vollständig)
- [Moderation: Kick, Ban, Unban](#moderation-kick-ban-unban)
- [Server-Side Room Admin](#server-side-room-admin)
- [Raum-Einstellungen komplett](#raum-einstellungen-komplett)
- [Raum-Alias verwalten](#raum-alias-verwalten)
- [Server ACL (IP-Sperren)](#server-acl-ip-sperren)
- [Raum-Audit / State inspizieren](#raum-audit--state-inspizieren)
- [Widgets / Bots verwalten](#widgets--bots-verwalten)
- [Room Tombstone / Upgrade](#room-tombstone--upgrade)
- [Reporting (Melden)](#reporting-melden)
---
## Power Levels vollständig
```dart
// Power Level eines Users ändern
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
// Alle Power Levels auf einmal setzen
await room.client.sendState(
room.id,
EventTypes.RoomPowerLevels,
{
// Welche Power-Level für Standard-Aktionen benötigt werden:
'ban': 50,
'kick': 50,
'invite': 0,
'redact': 50,
'events_default': 0, // Nachrichten senden
'state_default': 50, // State-Events ändern
// Spezifische Event-Typen:
'events': {
'm.room.name': 50,
'm.room.topic': 50,
'm.room.avatar': 50,
'm.room.power_levels': 100,
'm.room.canonical_alias': 50,
'm.room.history_visibility': 100,
'm.room.encryption': 100,
'm.space.child': 50,
},
// User-Overrides:
'users': {
'@admin:server': 100,
'@moderator:server': 50,
},
'users_default': 0, // Alle anderen User
'notifications': {
'room': 50, // @room mention power level
},
},
stateKey: '',
);
// Power Level lesen
final plContent = room.getState(EventTypes.RoomPowerLevels)?.content;
final usersMap = plContent?.tryGetMap<String, dynamic>('users') ?? {};
final adminPl = usersMap['@alice:server'] as int? ?? 0;
// Berechtigungen prüfen
room.ownPowerLevel; // int: eigener PL
room.getPowerLevelByUserId('@alice:server'); // int
room.canBan; // bool
room.canKick; // bool
room.canInvite; // bool
room.canRedactEvent(event); // bool
room.canSendEvent(EventTypes.Message); // bool
room.canSendEvent(EventTypes.RoomName); // bool
room.canChangeStateEvent(EventTypes.RoomTopic); // bool
room.canChangeNameAndTopic; // bool
room.canChangeJoinRules; // bool
room.canChangeHistoryVisibility; // bool
```
---
## Moderation: Kick, Ban, Unban
```dart
// Kick (User wird entfernt, kann wieder beitreten)
await room.kick('@alice:server', reason: 'Verstoß gegen Regeln');
// Ban (User kann nicht mehr beitreten)
await room.ban('@alice:server', reason: 'Dauerhafter Ausschluss');
// Unban
await room.unban('@alice:server');
// Alle gebannten User anzeigen
final bannedMembers = room.getParticipants(
membershipFilter: [Membership.ban],
);
// Eigene Berechtigungen prüfen vor Moderation-Aktionen
if (room.canKick) {
await room.kick(userId);
}
if (room.canBan) {
await room.ban(userId);
}
// User-PL prüfen (kann man diesen User kicken/bannen?)
// Man kann nur User mit niedrigerem PL als dem eigenen moderieren.
final theirPl = room.getPowerLevelByUserId(userId);
final myPl = room.ownPowerLevel;
final canModerate = myPl > theirPl;
```
---
## Server-Side Room Admin
> ⚠️ **Pyramid läuft auf Continuwuity** — Synapse Admin API (`/_synapse/admin/`) **existiert nicht!**
> Für vollständige Continuwuity-Admin-Dokumentation: **[→ 13-server-admin-continuwuity.md](13-server-admin-continuwuity.md)**
```dart
// Server-Admin-Status prüfen (Matrix-Standard, funktioniert überall)
final isAdmin = await client.getIsAdmin();
// Für Continuwuity: Admin-Aktionen über Standard Matrix Client API
// oder über den Admin-Room (#admins:steggi-matrix.work)
// Power Level in einem Raum als Admin setzen:
await client.setRoomStateWithKey(
room.id,
EventTypes.RoomPowerLevels,
'',
{
'users': {'@admin:server': 100},
'users_default': 0,
'state_default': 50,
'events_default': 0,
'ban': 50, 'kick': 50, 'invite': 0, 'redact': 50,
},
);
// Raum beitreten (Admin kann invite-only-Räume per API beitreten):
await client.joinRoom(room.id);
// Space-Child-Relation setzen (Channel einem Space hinzufügen):
await client.setRoomStateWithKey(
spaceId,
EventTypes.SpaceChild,
channelRoomId,
{'via': [client.homeserver.host], 'suggested': false},
);
```
```bash
# Continuwuity Admin HTTP API
# Basis: https://server/_conduwuit/admin/v1/
# User zu Server-Admin machen:
curl -X POST "https://server/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": "@admin:server"}'
# Power Level in Raum setzen (Standard Matrix API, mit Admin-Token):
curl -X PUT "https://server/_matrix/client/v3/rooms/!roomId:server/state/m.room.power_levels/" \
-H "Authorization: Bearer ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"users": {"@admin:server": 100}, "users_default": 0, "state_default": 50}'
# Für Synapse (falls Server-Wechsel zu Synapse):
# curl .../server/_synapse/admin/v1/rooms/{roomId} ← dann wieder relevant
```
---
## Raum-Einstellungen komplett
```dart
// Name
await room.setName('Neuer Raumname');
// Thema
await room.setDescription('Neues Thema');
// Avatar
final mxcUri = await client.uploadContent(imageBytes, filename: 'avatar.png');
await room.setAvatar(mxcUri);
// Join-Rules
await room.setJoinRules(JoinRules.invite); // Privat
await room.setJoinRules(JoinRules.public); // Öffentlich
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
// Restricted (Nur Space-Mitglieder können beitreten)
await room.setJoinRules(
JoinRules.restricted,
allow: [
SpaceRoomJoinRulesAllowInner(
roomId: '!spaceId:server',
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
),
],
);
// History-Visibility
await room.setHistoryVisibility(HistoryVisibility.shared);
// .shared = alle sehen vergangene Nachrichten (nach Join)
// .invited = sieht Nachrichten ab dem Einladungs-Zeitpunkt
// .joined = sieht Nachrichten nur ab dem Beitritts-Zeitpunkt
// .worldReadable = öffentlich lesbar (auch ohne Account)
// Guest Access
await room.setGuestAccess(GuestAccess.canJoin); // Gäste erlaubt
await room.setGuestAccess(GuestAccess.forbidden); // Keine Gäste
// E2EE aktivieren (einmalig, nicht deaktivierbar!)
await room.enableEncryption();
// Notifications für @room: Welcher PL nötig?
// → über Power Levels 'notifications.room' setzen
```
---
## Raum-Alias verwalten
```dart
// Alias erstellen
await client.setRoomAlias('#neuer-alias:server', room.id);
// Kanonischen Alias setzen (sichtbarer Hauptalias)
await room.client.sendState(
room.id,
EventTypes.RoomCanonicalAlias,
{
'alias': '#hauptalias:server',
'alt_aliases': ['#alternativ:server'],
},
stateKey: '',
);
// Alias auflösen → Raum-ID
final resolved = await client.getRoomIdByAlias('#alias:server');
print(resolved.roomId); // !roomId:server
// Alias löschen
await client.deleteRoomAlias('#alter-alias:server');
// Alle Aliases eines Raums
final aliases = (room.getState(EventTypes.RoomCanonicalAlias)
?.content['alt_aliases'] as List?)
?.cast<String>() ?? [];
final canonical = room.canonicalAlias;
```
---
## Server ACL (IP-Sperren)
```dart
// Server ACL: Welche Server dürfen am Raum teilnehmen?
// Wichtig für öffentliche Räume um Spam-Server zu sperren.
await room.client.sendState(
room.id,
'm.room.server_acl',
{
'allow': ['*'], // Alle erlauben
'deny': ['spam.example.org', '*.badserver.org'],
'allow_ip_literals': false, // IP-Adressen als Server-Namen verboten
},
stateKey: '',
);
// ACL lesen
final acl = room.getState('m.room.server_acl')?.content;
final denied = (acl?['deny'] as List?)?.cast<String>() ?? [];
```
---
## Raum-Audit / State inspizieren
```dart
// Alle State-Events lesen
final states = room.states;
// Map<EventType, Map<StateKey, StrippedStateEvent>>
// Spezifischer State
final nameEvent = room.getState(EventTypes.RoomName);
final topicEvent = room.getState(EventTypes.RoomTopic);
final memberEvent = room.getState(EventTypes.RoomMember, '@alice:server');
final encEvent = room.getState(EventTypes.Encryption);
// Alle Mitglieder-States
final memberStates = states[EventTypes.RoomMember] ?? {};
for (final entry in memberStates.entries) {
final mxid = entry.key; // stateKey = MXID
final membership = entry.value.content['membership'];
print('$mxid: $membership');
}
// Raum-History vom Server laden (Admin)
final response = await client.request(
RequestType.GET,
'/rooms/${room.id}/state',
);
// Event-Timeline abrufen (Admin)
final timeline = await client.request(
RequestType.GET,
'/rooms/${room.id}/messages',
query: {'limit': '100', 'dir': 'b'},
);
```
---
## Widgets / Bots verwalten
```dart
// Widget hinzufügen (z.B. Jitsi, Etherpad)
await room.client.sendState(
room.id,
'm.widget',
{
'type': 'jitsi',
'url': 'https://jitsi.example.org/#!roomId',
'name': 'Jitsi Call',
'data': {'domain': 'jitsi.example.org'},
'waitForIframeLoad': true,
'creatorUserId': client.userID,
'id': 'jitsi-widget',
},
stateKey: 'jitsi-widget',
);
// Widget entfernen
await room.client.sendState(
room.id,
'm.widget',
{}, // Leeres Content = entfernen
stateKey: 'jitsi-widget',
);
// Alle Widgets lesen
final widgets = room.states['m.widget'] ?? {};
for (final widget in widgets.values) {
print(widget.content['name']);
print(widget.content['url']);
}
```
---
## Room Tombstone / Upgrade
```dart
// Raum-Upgrade (Nachfolger-Raum erstellen)
final newRoomId = await client.upgradeRoom(
room.id,
newVersion: '11', // Aktuelle empfohlene Version
);
// SDK sendet m.room.tombstone in alten Raum
// Und m.room.create mit predecessor in neuen Raum
// Alle Mitglieder werden in neuen Raum eingeladen
// Tombstone lesen
final tombstone = room.getState(EventTypes.RoomTombstone);
if (tombstone != null) {
final replacementRoomId = tombstone.content['replacement_room'] as String;
final serverMessage = tombstone.content['body'] as String;
// → Zum neuen Raum navigieren
}
// Predecessor lesen (im neuen Raum)
final create = room.getState(EventTypes.RoomCreate);
final predecessor = create?.content['predecessor'] as Map?;
if (predecessor != null) {
final oldRoomId = predecessor['room_id'] as String;
final lastEventId = predecessor['event_id'] as String;
}
```
---
## Reporting (Melden)
```dart
// Event melden (z.B. Spam, illegaler Inhalt)
await client.reportContent(
room.id,
event.eventId,
reason: 'Spam/Belästigung',
score: -100, // -100 = sehr schlimm, 0 = nicht schlimm
);
// User melden (via MSC4151, wenn vom Server unterstützt)
await client.httpClient.post(
Uri.parse('${client.homeserver}/_matrix/client/v3/report/${Uri.encodeComponent("@user:server")}'),
headers: {
'Authorization': 'Bearer ${client.accessToken}',
'Content-Type': 'application/json',
},
body: jsonEncode({'reason': 'Belästigung'}),
);
```
+238
View File
@@ -0,0 +1,238 @@
# 11 — Suche & Discovery
---
## Inhaltsverzeichnis
- [Nachrichten in einem Raum suchen](#nachrichten-in-einem-raum-suchen)
- [Globale Nachrichten-Suche](#globale-nachrichten-suche)
- [User-Suche](#user-suche)
- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis)
- [Matrix-URI auflösen](#matrix-uri-auflösen)
- [Raum per Alias finden](#raum-per-alias-finden)
---
## Nachrichten in einem Raum suchen
```dart
// In der lokalen Timeline suchen (gecacht)
final found = timeline.events
.where((e) =>
e.type == EventTypes.Message &&
e.body.toLowerCase().contains(query.toLowerCase()))
.toList();
// Auf dem Server suchen (Matrix-Suchendpoint)
final result = await client.searchEvents(
Categories(
roomEvents: RoomEventsCriteria(
searchTerm: 'suchbegriff',
roomId: room.id, // optional: nur in einem Raum
orderBy: SearchOrder.recent, // oder .rank
keys: [SearchKey.contentBody], // oder .contentName, .contentTopic
filter: RoomEventFilter(
limit: 20,
rooms: [room.id],
notRooms: [],
),
),
),
);
// Ergebnisse
for (final result in result.roomEvents?.results ?? []) {
print(result.result.body);
print(result.result.originServerTs);
print(result.result.senderId);
}
// Paginierung
final nextToken = result.roomEvents?.nextBatch;
if (nextToken != null) {
final more = await client.searchEvents(
/* gleiche Criteria */,
nextBatch: nextToken,
);
}
```
---
## Globale Nachrichten-Suche
```dart
// Über alle Räume suchen
final result = await client.searchEvents(
Categories(
roomEvents: RoomEventsCriteria(
searchTerm: 'flutter matrix',
orderBy: SearchOrder.recent,
includeState: false,
groupings: Groupings(
groupBy: [Group(key: GroupKey.roomId)], // Gruppiert nach Raum
),
),
),
);
// Gruppen-Ergebnisse
for (final entry in result.roomEvents?.groups?.entries ?? []) {
final groupKey = entry.key; // 'room_id'
for (final roomGroup in entry.value.entries) {
final roomId = roomGroup.key;
print('Treffer in $roomId: ${roomGroup.value.count}');
// roomGroup.value.results: List<String> (Event-IDs)
}
}
```
---
## User-Suche
```dart
// User-Verzeichnis durchsuchen
final response = await client.searchUserDirectory(
'alice', // Suchbegriff (Name oder MXID)
limit: 20,
);
for (final user in response.results) {
print(user.userId); // @alice:server
print(user.displayName); // Alice
print(user.avatarUrl); // mxc://...
// Avatar als HTTP-URL
final avatarHttp = user.avatarUrl?.getThumbnail(
client,
width: 56,
height: 56,
method: ThumbnailMethod.crop,
);
}
print(response.limited); // true wenn weitere Ergebnisse vorhanden
// MXID direkt laden (ohne Suche)
final profile = await client.getProfileFromUserId('@alice:server');
print(profile.displayname);
print(profile.avatarUrl);
```
---
## Öffentliches Raumverzeichnis
```dart
// Öffentliche Räume (des eigenen Servers)
final result = await client.queryPublicRooms(
limit: 50,
);
// Mit Suchbegriff
final searched = await client.queryPublicRooms(
limit: 20,
filter: PublicRoomQueryFilter(
genericSearchTerm: 'flutter',
),
);
// Anderen Server durchsuchen
final remote = await client.queryPublicRooms(
server: 'matrix.org',
limit: 20,
filter: PublicRoomQueryFilter(genericSearchTerm: 'linux'),
);
// Paginierung
var batch = await client.queryPublicRooms(limit: 50);
while (batch.nextBatch != null) {
batch = await client.queryPublicRooms(
limit: 50,
since: batch.nextBatch,
);
}
// Einzelnes Ergebnis
for (final room in result.chunk) {
print(room.roomId);
print(room.name);
print(room.topic);
print(room.numJoinedMembers);
print(room.worldReadable);
print(room.guestCanJoin);
print(room.avatarUrl);
print(room.canonicalAlias);
print(room.joinRule); // 'public' | 'knock' | etc.
print(room.roomType); // 'm.space' | null
}
```
---
## Matrix-URI auflösen
```dart
// Matrix-to URI parsen
// https://matrix.to/#/@alice:server
// https://matrix.to/#/!roomId:server
// https://matrix.to/#/#alias:server
// matrix:u/alice:server
// matrix:r/alias:server
// matrix:roomid/roomId:server
import 'package:matrix/matrix.dart';
final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org');
if (uri != null) {
switch (uri.primaryType) {
case MatrixUriType.room: // Raum-Alias
case MatrixUriType.roomId: // Raum-ID
case MatrixUriType.user: // User-MXID
case MatrixUriType.event: // Event-Link
}
final identifier = uri.primaryIdentifier;
// z.B. '#kanal:example.org' oder '!roomId:server'
// Beitreten (bei Raum)
if (uri.primaryType == MatrixUriType.room ||
uri.primaryType == MatrixUriType.roomId) {
await client.joinRoomOrAlias(
identifier,
serverName: uri.viaServers, // föderierte Server
);
}
}
// Matrix-URI generieren
final roomUri = MatrixUri(
primaryType: MatrixUriType.roomId,
primaryIdentifier: room.id,
viaServers: ['example.org'],
).toUri().toString();
// → matrix:roomid/roomId:server?via=example.org
// Matrix.to Link generieren
final matrixToLink = 'https://matrix.to/#/${Uri.encodeComponent(room.canonicalAlias ?? room.id)}';
```
---
## Raum per Alias finden
```dart
// Alias → Raum-ID auflösen
final result = await client.getRoomIdByAlias('#alias:server');
print(result.roomId);
print(result.servers); // Via-Server für Beitritt
// Direkt beitreten via Alias
await client.joinRoomOrAlias('#alias:server');
// Lokal prüfen (gecacht)
final localRoom = client.rooms.firstWhere(
(r) => r.canonicalAlias == '#alias:server',
orElse: () => throw Exception('Nicht gefunden'),
);
```
+366
View File
@@ -0,0 +1,366 @@
# 12 — Schnellreferenz: Typen, Konstanten & Patterns
---
## Wichtige Enum-Werte
```dart
// Membership
Membership.join // Mitglied
Membership.invite // Eingeladen
Membership.leave // Verlassen
Membership.ban // Gesperrt
Membership.knock // Klopf-Anfrage
// JoinRules
JoinRules.public // Offen
JoinRules.invite // Nur per Einladung
JoinRules.knock // Beitrittsanfragen erlaubt
JoinRules.restricted // Nur Mitglieder bestimmter Spaces/Räume
JoinRules.knockRestricted
// HistoryVisibility
HistoryVisibility.worldReadable // Ohne Login lesbar
HistoryVisibility.shared // Alle Mitglieder sehen History
HistoryVisibility.invited // Ab Einladungszeitpunkt
HistoryVisibility.joined // Ab Beitrittszeitpunkt
// GuestAccess
GuestAccess.canJoin // Gäste erlaubt
GuestAccess.forbidden // Keine Gäste
// Visibility
Visibility.public // Im Verzeichnis
Visibility.private // Nicht im Verzeichnis
// CreateRoomPreset
CreateRoomPreset.privateChat // invite + joined History
CreateRoomPreset.publicChat // public + shared History
CreateRoomPreset.trustedPrivateChat // wie private + alle Mitglieder sind Admins
// EventStatus
EventStatus.sending // Wird übertragen
EventStatus.sent // Server-ACK
EventStatus.synced // Im Sync empfangen
EventStatus.error // Fehlgeschlagen
EventStatus.removed // Gelöscht
// PresenceType
PresenceType.online
PresenceType.unavailable
PresenceType.offline
// ThumbnailMethod
ThumbnailMethod.crop // Ausschneiden auf exakte Größe
ThumbnailMethod.scale // Skalieren ohne Abschneiden
// LoginType
LoginType.mLoginPassword
LoginType.mLoginToken
LoginType.mLoginSso
// PushRuleKind
PushRuleKind.override
PushRuleKind.content
PushRuleKind.room
PushRuleKind.sender
PushRuleKind.underride
```
---
## EventTypes Konstanten
```dart
EventTypes.Message // m.room.message
EventTypes.Sticker // m.sticker
EventTypes.Reaction // m.reaction
EventTypes.RoomRedaction // m.room.redaction
EventTypes.Encrypted // m.room.encrypted
EventTypes.RoomCreate // m.room.create
EventTypes.RoomName // m.room.name
EventTypes.RoomTopic // m.room.topic
EventTypes.RoomAvatar // m.room.avatar
EventTypes.RoomMember // m.room.member
EventTypes.RoomPowerLevels // m.room.power_levels
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
EventTypes.RoomJoinRules // m.room.join_rules
EventTypes.RoomGuestAccess // m.room.guest_access
EventTypes.HistoryVisibility // m.room.history_visibility
EventTypes.RoomPinnedEvents // m.room.pinned_events
EventTypes.RoomTombstone // m.room.tombstone
EventTypes.Encryption // m.room.encryption
EventTypes.SpaceChild // m.space.child
EventTypes.SpaceParent // m.space.parent
EventTypes.CallInvite // m.call.invite
EventTypes.CallAnswer // m.call.answer
EventTypes.CallHangup // m.call.hangup
EventTypes.CallCandidates // m.call.candidates
EventTypes.CallReject // m.call.reject
EventTypes.CallSelectAnswer // m.call.select_answer
EventTypes.CallNegotiate // m.call.negotiate
```
---
## Häufige Content-Zugriffe
```dart
// Sicher auf Map-Inhalte zugreifen (Matrix SDK Extension)
final text = event.content.tryGet<String>('body');
final map = event.content.tryGetMap<String, dynamic>('info');
final list = event.content.tryGetList<String>('aliases');
// Event-Inhalt strukturiert
final msgtype = event.content['msgtype'] as String?;
final body = event.content['body'] as String?;
final formatted = event.content['formatted_body'] as String?;
final format = event.content['format'] as String?; // 'org.matrix.custom.html'
// Relation
final relatesTo = event.content
.tryGetMap<String, dynamic>('m.relates_to');
final relType = relatesTo?.tryGet<String>('rel_type');
final eventId = relatesTo?.tryGet<String>('event_id');
// Verschlüsselte Medien
final file = event.content.tryGetMap<String, dynamic>('file');
final url = file?.tryGet<String>('url'); // mxc://
final key = file?.tryGetMap<String, dynamic>('key');
// Mentions (MSC3952)
final mentions = event.content.tryGetMap<String, dynamic>('m.mentions');
final userIds = mentions?.tryGetList<String>('user_ids') ?? [];
final roomMention = mentions?.tryGet<bool>('room') ?? false;
```
---
## Timeline-Widget Pattern (Flutter)
```dart
class ChatView extends ConsumerStatefulWidget {
final String roomId;
const ChatView({required this.roomId, super.key});
@override
ConsumerState<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends ConsumerState<ChatView> {
Timeline? _timeline;
@override
void initState() {
super.initState();
_loadTimeline();
}
Future<void> _loadTimeline() async {
final client = await ref.read(matrixClientProvider.future);
final room = client.getRoomById(widget.roomId)!;
_timeline = await room.getTimeline(
onUpdate: () => setState(() {}),
);
setState(() {});
}
@override
void dispose() {
_timeline?.cancelSubscriptions(); // WICHTIG!
super.dispose();
}
@override
Widget build(BuildContext context) {
final events = _timeline?.events ?? [];
return ListView.builder(
reverse: true, // Neueste unten
itemCount: events.length,
itemBuilder: (ctx, i) {
final event = events[i];
if (event.type != EventTypes.Message) return const SizedBox();
return ListTile(
title: Text(event.body),
subtitle: Text(event.senderId),
);
},
);
}
}
```
---
## Raum-Liste Widget Pattern
```dart
// StreamProvider für reaktive Raumliste
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield _sortedRooms(client);
await for (final _ in client.onSync.stream) {
yield _sortedRooms(client);
}
});
List<Room> _sortedRooms(Client client) => client.rooms
.where((r) => r.membership == Membership.join)
.toList()
..sort((a, b) =>
(b.lastEvent?.originServerTs ?? DateTime(0))
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
// In Widget:
final rooms = ref.watch(roomsProvider);
rooms.when(
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (ctx, i) {
final room = list[i];
return ListTile(
leading: CircleAvatar(child: Text(room.name[0])),
title: Text(room.getLocalizedDisplayname()),
subtitle: Text(room.lastEvent?.body ?? ''),
trailing: room.notificationCount > 0
? Badge(label: Text('${room.notificationCount}'))
: null,
);
},
),
loading: () => const CircularProgressIndicator(),
error: (e, s) => Text('Fehler: $e'),
);
```
---
## Nachrichten senden mit optimistischem UI
```dart
// Das SDK fügt das Event sofort mit Status 'sending' in die Timeline ein.
// UI kann es sofort anzeigen, ohne auf Server zu warten.
class MessageInput extends ConsumerWidget {
final Room room;
const MessageInput({required this.room, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
return Row(
children: [
Expanded(child: TextField(controller: controller)),
IconButton(
icon: const Icon(Icons.send),
onPressed: () async {
final text = controller.text.trim();
if (text.isEmpty) return;
controller.clear();
try {
await room.sendTextEvent(text);
} catch (e) {
// Event bleibt mit Status 'error' in Timeline
// User kann auf "Erneut senden" tippen
}
},
),
],
);
}
}
```
---
## Nützliche Client-Extensions & Helpers
```dart
// User-Erwähnung formatieren
'@${userId.split(':').first.replaceFirst('@', '')}' // kurzform ohne Server
// Raum-Display-Name (FluffyChat-Stil)
// Für DMs: Name des Gegenübers
// Für Räume mit Name: Name
// Für Räume ohne Name: kommagetrennte Mitgliedernamen
room.getLocalizedDisplayname()
// Avatar-URL sicher holen
user.avatarUrl?.getThumbnail(client, width: 56, height: 56)
// Event-Zeitstempel formatieren
event.originServerTs.toLocal().toString()
// oder mit intl:
DateFormat.Hm().format(event.originServerTs.toLocal())
// Stable Notification-ID aus Raum-ID (deterministisch)
int stableId(String roomId) {
var h = 0;
for (final c in roomId.codeUnits) {
h = ((h * 31) + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
// Matrix-Größenangabe
// event.sizeString → '2.3 MB', '512 KB'
// mxc:// URI in HTTP umwandeln
mxcUri.getDownloadUri(client)
mxcUri.getThumbnail(client, width: 300, height: 300)
```
---
## Fehler-Handling
```dart
// MatrixException — Fehler vom Server
try {
await client.joinRoom('!room:server');
} on MatrixException catch (e) {
switch (e.error) {
case MatrixError.M_FORBIDDEN: // Kein Zugang
case MatrixError.M_NOT_FOUND: // Raum existiert nicht
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limit
final retryAfter = e.retryAfterMs; // Wann wieder versuchen
case MatrixError.M_UNKNOWN:
default:
print(e.errorMessage);
}
}
// TimeoutException — Keine Antwort vom Server
// SocketException — Keine Netzwerkverbindung
// FormatException — Ungültige Server-Antwort
// Verbindungsstatus überwachen
client.onConnectionStateChanged.stream.listen((state) {
switch (state) {
case ConnectionState.connected: // Sync läuft
case ConnectionState.connecting: // Verbindet
case ConnectionState.reconnecting: // Verbindung wiederhergestellt
}
});
```
---
## Wichtige Pakete für Pyramid
```yaml
dependencies:
matrix: ^6.x.x # Matrix SDK (Haupt-SDK)
flutter_vodozemac: ^x.x.x # E2EE (Rust-basiert, empfohlen)
firebase_messaging: ^x.x.x # FCM Push (Android)
firebase_core: ^x.x.x
flutter_local_notifications: ^x.x.x # Lokale Notifications
flutter_riverpod: ^x.x.x # State Management
sqflite: ^x.x.x # Datenbank
path_provider: ^x.x.x # App-Verzeichnisse
shared_preferences: ^x.x.x # Einfache KV-Speicherung
livekit_client: ^x.x.x # LiveKit für VoIP
flutter_webrtc: ^x.x.x # WebRTC
go_router: ^x.x.x # Navigation
local_notifier: ^x.x.x # Windows Notifications
windows_taskbar: ^x.x.x # Windows Badge
```
@@ -0,0 +1,437 @@
# 13 — Server-Administration: Continuwuity
> **Gilt für:** Continuwuity 0.5.x (Fork von Conduwuit, welches ein Fork von Conduit ist)
> **Homeserver:** `steggi-matrix.work`
> **WICHTIG:** Continuwuity hat KEINE Synapse Admin API (`/_synapse/admin/` existiert nicht!)
---
## Inhaltsverzeichnis
- [Server-Admin vs. Room Power Level](#server-admin-vs-room-power-level)
- [Admin Room — Befehle](#admin-room--befehle)
- [Continuwuity Admin HTTP API](#continuwuity-admin-http-api)
- [Power Level in Räumen erzwingen (als Server-Admin)](#power-level-in-räumen-erzwingen)
- [MSC3083: Restricted Join Rules auf Continuwuity](#msc3083-restricted-join-rules-auf-continuwuity)
- [Diagnose: Warum M_FORBIDDEN?](#diagnose-warum-m_forbidden)
- [User-Admin-Status prüfen/setzen](#user-admin-status-prüfensetzen)
- [Raum-Administration via Matrix API](#raum-administration-via-matrix-api)
- [Wichtige Unterschiede: Continuwuity vs. Synapse](#wichtige-unterschiede-continuwuity-vs-synapse)
- [Continuwuity `homeserver.yaml` — relevante Einstellungen](#continuwuity-homeserveryaml--relevante-einstellungen)
---
## Server-Admin vs. Room Power Level
```
Continuwuity Server-Admin
├── Ermöglicht: Admin-Room-Befehle, Server-API
├── Ermöglicht: User deaktivieren, Räume löschen (Server-Level)
└── NICHT: Automatisch PL 100 in jedem Matrix-Raum!
Matrix Room Power Level 100
├── Gilt pro Raum (in m.room.power_levels State-Event gespeichert)
├── Ermöglicht: Name/Topic/Avatar setzen, Join-Rules ändern, User kicken/bannen
└── Unabhängig vom Server-Admin-Status
```
**Beispiel-Problem:**
- Du bist Server-Admin auf `steggi-matrix.work`
- Du hast PL 100 im Space `!xyz:steggi-matrix.work`
- Du erstellst einen neuen Channel → Server gibt dir PL 100 als Ersteller ✅
- Du versuchst `join_rules: restricted` zu setzen → M_FORBIDDEN ❌
- **Grund:** Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig
---
## Admin Room — Befehle
Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Befehle eingeben:
```
# Admin-Raum beitreten (normalerweise automatisch eingeladen)
# Raum-Alias: #admins:steggi-matrix.work
# Befehle im Admin-Raum (als Nachricht senden):
!admin help # Alle Befehle anzeigen
!admin make_user_admin @user:server # User zu Server-Admin machen
!admin deactivate_user @user:server # User deaktivieren
!admin list_users # Alle User auflisten
!admin get_room_state !roomId:server # Room-State anzeigen
!admin delete_room !roomId:server # Raum löschen
!admin get_mem_usage # Server-Speicherverbrauch
!admin server_version # Server-Version
```
```bash
# Als curl (Admin-Token erforderlich):
# Admin-Room-ID herausfinden:
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
```
---
## Continuwuity Admin HTTP API
```bash
# Basis-URL: https://steggi-matrix.work/_conduwuit/admin/v1/
# (Nicht /_synapse/admin/ — das ist Synapse-only!)
# User zu Server-Admin machen
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
# Server-Status abfragen
curl "https://steggi-matrix.work/_conduwuit/server/version" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
```
---
## Power Level in Räumen erzwingen (als Server-Admin)
Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setzen,
**auch wenn du kein Mitglied bist** (Continuwuity erlaubt das für Server-Admins):
```bash
# Schritt 1: Aktuelles Power-Level-Event lesen
ROOM_ID="!raumId:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
SERVER="https://steggi-matrix.work"
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
-H "Authorization: Bearer ${TOKEN}"
# Schritt 2: Power Level setzen (users-Map ändern)
curl -X PUT "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"users": {
"@todesneutron:steggi-matrix.work": 100
},
"users_default": 0,
"events_default": 0,
"state_default": 50,
"ban": 50,
"kick": 50,
"invite": 0,
"redact": 50
}'
```
```dart
// In Flutter / Dart (mit Admin-Token):
// Zuerst aktuellen PL-State lesen:
final currentPl = await client.getRoomStateWithKey(
roomId,
EventTypes.RoomPowerLevels,
'',
);
// Users-Map erweitern und zurückschreiben:
final users = Map<String, dynamic>.from(
(currentPl['users'] as Map?)?.cast<String, dynamic>() ?? {},
);
users['@todesneutron:steggi-matrix.work'] = 100;
await client.setRoomStateWithKey(
roomId,
EventTypes.RoomPowerLevels,
'',
{
...currentPl,
'users': users,
},
);
```
---
## MSC3083: Restricted Join Rules auf Continuwuity
`join_rules: restricted` (MSC3083) erlaubt Räumen, nur Space-Mitglieder beizutreten.
**Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig.**
```bash
# Testen ob restricted join rules funktionieren:
ROOM_ID="!testRaum:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
SPACE_ID="!spaceId:steggi-matrix.work"
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"join_rule": "restricted",
"allow": [
{
"type": "m.room_membership",
"room_id": "'"${SPACE_ID}"'"
}
]
}'
# → 200 OK: Unterstützt!
# → M_FORBIDDEN oder M_UNRECOGNIZED: Nicht unterstützt oder PL zu niedrig
```
**Workaround wenn restricted nicht funktioniert:**
```bash
# Fallback: invite-only (explizit jeden User einladen)
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"join_rule": "invite"}'
```
```dart
// In space_admin_dialog.dart — Fallback wenn restricted nicht supported:
try {
await room.setJoinRules(
JoinRules.restricted,
allow: [
SpaceRoomJoinRulesAllowInner(
roomId: spaceId,
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
),
],
);
} on MatrixException catch (e) {
if (e.error == MatrixError.M_FORBIDDEN || e.error == MatrixError.M_UNKNOWN) {
// Continuwuity unterstützt restricted join rules nicht
// Fallback zu invite-only:
await room.setJoinRules(JoinRules.invite);
} else {
rethrow;
}
}
```
---
## Diagnose: Warum M_FORBIDDEN?
```bash
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
ROOM_ID="!raumId:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
-H "Authorization: Bearer ${TOKEN}"
# Antwort zeigt 'users' Map — prüfen ob '@todesneutron:steggi-matrix.work' drin ist
# 2. Prüfen: Was ist state_default?
# Im Antwort-JSON: "state_default": 50 bedeutet: PL 50 nötig für State-Events
# "m.room.join_rules": 100 in events-Map = PL 100 für Join-Rule-Änderung
# 3. Prüfen: Bin ich Mitglied des Raums?
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
-H "Authorization: Bearer ${TOKEN}"
# → {"membership": "join"} = Mitglied ✅
# → {"errcode": "M_NOT_FOUND"} = Nicht Mitglied ❌ (Server-Admin kann trotzdem State setzen)
# 4. Prüfen: Unterstützt der Server restricted join rules?
curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
-H "Authorization: Bearer ${TOKEN}"
# Antwort: capabilities-Objekt — MSC3083 ist nicht explizit gelistet (kein Standard-Capability)
```
**Häufige Ursachen und Lösungen:**
| Fehler | Ursache | Lösung |
|---|---|---|
| M_FORBIDDEN beim State-Event | PL zu niedrig im Raum | Power Level via curl erhöhen (s.o.) |
| M_FORBIDDEN bei join_rules: restricted | MSC3083 nicht unterstützt | Fallback zu `invite` nutzen |
| M_FORBIDDEN beim Channel erstellen | Bug in space_admin_dialog.dart | Raum manuell erstellen, dann Space-Child setzen |
| M_NOT_FOUND | Raum nicht bekannt | Raum beitreten oder Admin-Join |
| M_FORBIDDEN bei Power-Level-Änderung | PL darf nur gleich/höher gesetzt werden | Als Ersteller oder via Admin-API |
---
## User-Admin-Status prüfen/setzen
```bash
# Prüfen ob User Server-Admin ist:
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
# Alternativ: Über Admin-Room-Befehl
# (Im Admin-Room als Nachricht senden:)
# !admin make_user_admin @todesneutron:steggi-matrix.work
# Über HTTP (Conduwuit Admin-Endpoint):
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
```
---
## Raum-Administration via Matrix API
Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Admin-Token:
```bash
# Raum beitreten (als Admin, auch wenn invite-only):
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{}'
# User in Raum einladen:
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{"user_id": "@user:steggi-matrix.work"}'
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{
"via": ["steggi-matrix.work"],
"suggested": false
}'
# Space-Parent-Relation setzen (im Channel-Raum):
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{
"via": ["steggi-matrix.work"],
"canonical": true
}'
# Alle Mitglieder eines Raums auflisten:
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# Raum verlassen (als User, nicht löschen):
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Content-Type: application/json" \
-d '{}'
```
---
## Wichtige Unterschiede: Continuwuity vs. Synapse
| Feature | Synapse | Continuwuity 0.5.x |
|---|---|---|
| Admin API URL | `/_synapse/admin/v1/` | `/_conduwuit/admin/v1/` |
| Admin Room | Optional | Standard |
| MSC3083 (restricted join rules) | ✅ Vollständig | ⚠️ Teilweise / experimentell |
| Spaces (MSC1772) | ✅ | ✅ Grundfunktionen |
| MatrixRTC / MSC3401 | ✅ (Element Call) | ⚠️ Eingeschränkt |
| Push-Gateway | ✅ | ✅ |
| Server-side search | ✅ | ✅ Grundfunktionen |
| E2EE / Cross-Signing | ✅ | ✅ |
| Presence | ✅ | ✅ |
| Account Deactivation | Admin API | Admin Room / API |
| Room Deletion/Purge | Admin API | Admin Room / API |
| User Listing | Admin API | Admin Room / API |
| Federation | ✅ | ✅ |
| Application Services | ✅ | ⚠️ Eingeschränkt |
---
## Continuwuity `homeserver.yaml` — relevante Einstellungen
```yaml
# /etc/continuwuity/homeserver.yaml (oder /etc/conduwuit/conduwuit.toml je nach Version)
# Matrix-RTC / LiveKit
# MSC3266: Room Summary
# MSC3401: MatrixRTC
allow_unstable_room_versions: true
# Federation (für Multi-Server-Calls nötig)
allow_federation: true
allow_public_room_directory_without_auth: false
# Push (für Notifications)
# Kein spezieller Config — nutzt Standard-Matrix-Push-API
# TURN-Server für WebRTC
turn_uris:
- "turn:steggi-matrix.work:3478?transport=udp"
- "turn:steggi-matrix.work:3478?transport=tcp"
turn_secret: "dein-turn-secret"
turn_username: "" # leer = HMAC-basiert
turn_password: ""
turn_ttl: 86400
# Registrierung
allow_registration: false # Kein offener Signup
registration_token: "dein-token" # Oder token-basiert
# Log-Level
log: "warn,conduwuit=info"
# Performance
rocksdb_parallelism_threads: 4 # Pi 5 hat 4 Cores
# Unter Continuwuity: Admin-User beim ersten Start setzen
# Erster registrierter User wird automatisch Server-Admin
```
```bash
# Auf dem Pi: Config-Datei editieren
ssh steggi@192.168.178.71
sudo nano /etc/continuwuity/homeserver.yaml
# oder:
sudo nano /etc/conduwuit/conduwuit.toml # Falls TOML-Format
# Service neu starten:
sudo systemctl restart continuwuity
# oder:
sudo systemctl restart conduwuit
# Logs prüfen:
sudo journalctl -u continuwuity -f
```
---
## Schnell-Diagnose Checkliste
Wenn du M_FORBIDDEN bekommst:
```bash
# 1. Token gültig?
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# 2. PL im betroffenen Raum prüfen:
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# 3. Mitglied des Raums?
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
# M_FORBIDDEN + "Power level too low" → PL erhöhen
# M_FORBIDDEN + "Not a member" → Raum beitreten
# M_UNRECOGNIZED → Feature nicht unterstützt
# M_FORBIDDEN + "..." → join_rules restricted Problem
```
+433
View File
@@ -0,0 +1,433 @@
# Pyramid — Feature-Checklist & Implementierungsstand
> Stand: Mai 2026
> Legende: ✅ Fertig | ⚠️ Teilweise / Bug | ❌ Fehlt | 🔧 In Arbeit
---
## Inhaltsverzeichnis
- [Authentifizierung](#authentifizierung)
- [Raumliste](#raumliste)
- [Chat / Timeline](#chat--timeline)
- [Nachrichtentypen](#nachrichtentypen)
- [Nachrichtenfunktionen](#nachrichtenfunktionen)
- [Direct Messages](#direct-messages)
- [E2EE Verschlüsselung](#e2ee-verschlüsselung)
- [Push Notifications](#push-notifications)
- [VoIP & Calls](#voip--calls)
- [Settings / Einstellungen](#settings--einstellungen)
- [Benutzerprofil](#benutzerprofil)
- [Raum-Einstellungen](#raum-einstellungen)
- [Suche](#suche)
- [Medien](#medien)
- [Read Receipts & Typing](#read-receipts--typing)
- [Spaces](#spaces)
- [Windows / Desktop](#windows--desktop)
- [Sonstige Features](#sonstige-features)
- [Dokumentations-Vollständigkeit](#dokumentations-vollständigkeit)
---
## Authentifizierung
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Login mit Passwort | ✅ | `login_page.dart`, `login_notifier.dart` |
| Homeserver-Auswahl | ✅ | `server_page.dart` — HTTPS-Prefix fix, .well-known Autodiscovery |
| Session wiederherstellen (init) | ✅ | `matrix_client.dart``client.init()` beim App-Start |
| Logout | ✅ | Via Matrix SDK `client.logout()` |
| Login-Redirect nach Init | ✅ | `router.dart` — StreamProvider-basierter Guard |
| SSO / OAuth | ❌ | Nicht implementiert |
| Registrierung / Account erstellen | ❌ | Keine UI |
| Login mit Token | ❌ | Nur Passwort-Login |
| Multi-Account | ❌ | Nur ein Account gleichzeitig |
| UIAA Re-Auth (Passwort ändern) | ❌ | Kein UI |
---
## Raumliste
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Raumliste anzeigen | ✅ | `rooms_page.dart`, `room_list_item.dart` |
| Sortierung nach letzter Aktivität | ✅ | In `rooms_provider.dart` |
| Avatar-Anzeige (MXC) | ✅ | `mxc_image.dart` |
| Vorschautext (letztes Event) | ✅ | `room_list_item.dart` |
| Ungelesen-Badge | ✅ | `notificationCount` |
| Ungelesen-Punkt (kein Count) | ✅ | Bei `notificationCount == 0` aber ungelesen |
| Zeitstempel-Formatierung | ✅ | Heute: HH:MM, andere: DD.MM |
| DM-Erkennung (`isDirectChat`) | ✅ | Typ-Differenzierung in Liste |
| Space-Filterung | ✅ | `SpacesRail` filtert nach aktiver Space |
| Einladungen anzeigen | ⚠️ | Membership.invite vorhanden, gesonderte Ansicht fehlt |
| Raum suchen / filtern | ✅ | Via `search_modal.dart` |
| Archivierte Räume | ❌ | Kein UI für left-rooms |
| Raum erstellen (Knopf) | ✅ | `create_join_dialog.dart` |
| Raum beitreten (Alias) | ✅ | `create_join_dialog.dart` |
---
## Chat / Timeline
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Nachrichten anzeigen | ✅ | `message_bubble.dart`, `message_group.dart` |
| Gruppen-Bubbles (gleicher Sender) | ✅ | Continuation-Nachrichten (eingerückt, kein Avatar) |
| Datums-Trenner | ✅ | Tagstrennlinie |
| Ungelesen-Trenner ("Neue Nachrichten") | ✅ | `fullyRead`-Position |
| Scroll-to-Unread beim Öffnen | ✅ | `chat_view.dart` |
| History-Nachladen beim Scrollen | ✅ | `_onScroll()``requestHistory(60)` |
| Optimistische Nachrichtenanzeige | ✅ | SDK fügt Event mit Status `sending` ein |
| Nachrichtenstatus (senden/sent) | ✅ | Uhren-Icons |
| Timeline-Cleanup (`cancelSubscriptions`) | ✅ | In `dispose()` |
| Sprung zu bestimmtem Event | ⚠️ | Infrastruktur vorhanden, kein UI |
| Event-Suche in Timeline | ❌ | Nur globale Suche |
---
## Nachrichtentypen
| Typ | Status | Details |
|-----|--------|---------|
| Text (`m.text`) | ✅ | Vollständig |
| Bild (`m.image`) | ✅ | Thumbnail + Vollansicht mit Zoom |
| Video (`m.video`) | ✅ | Playback via `media_kit` |
| Audio (`m.audio`) | ✅ | Player mit Progress/Dauer |
| Datei (`m.file`) | ✅ | Download mit MIME-Icon |
| Sticker (`m.sticker`) | ✅ | Anzeige und Senden (Sticker-Picker) |
| Sprachnachricht (MSC3245) | ⚠️ | Audio-Aufnahme fehlt, Abspielen funktioniert |
| Standort (`m.location`) | ❌ | Nicht implementiert |
| Emote (`m.emote`) | ❌ | Kein gesondertes Rendering |
| Poll (MSC3381) | ❌ | Nicht implementiert |
| Verschlüsselte Nachrichten | ✅ | Automatisch via SDK, Fehler-UI vorhanden |
| Redacted-Events | ⚠️ | Wird angezeigt, kein "Nachricht gelöscht"-Platzhalter |
---
## Nachrichtenfunktionen
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Antworten (Reply) | ✅ | Swipe-Geste + Quote-Anzeige |
| Reaktionen (Emojis) | ✅ | Quick-Picker + vollständiger Emoji-Picker |
| Reaktionen entfernen | ⚠️ | UI zum Entfernen fehlt |
| Nachrichten bearbeiten (Edit) | ⚠️ | `getDisplayEvent()` vorhanden, Senden nicht im UI |
| Nachricht löschen (Redact) | ❌ | Kein Button / Dialog |
| Threads | ❌ | Nicht implementiert |
| Polls | ❌ | Nicht implementiert |
| Weitersenden (Forward) | ❌ | Nicht implementiert |
| Kopieren (Text) | ⚠️ | Nicht explizit sichtbar |
| Long-Press Auswahl-Modus | ✅ | `message_bubble.dart` — Long-Press aktiviert Selection |
| Bulk-Aktionen (Auswahl) | ⚠️ | Modus vorhanden, Aktionen begrenzt |
| Pinned Messages Panel | ✅ | `pinned_messages_panel.dart` |
| Pins setzen (UI-Aktion) | ❌ | Kein Pin-Button in Nachricht |
| Linkvorschau | ❌ | Keine URL-Preview |
| Mention-Links klickbar | ✅ | `@user`-Links via `url_launcher` |
---
## Direct Messages
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Neuen DM starten | ✅ | `new_dm_dialog.dart``startDirectChat()` |
| User-Verzeichnis-Suche | ✅ | `searchUserDirectory()` |
| DM-Erkennung in Raumliste | ✅ | `room.isDirectChat` |
| Partner-Avatar/Name | ✅ | `directChatMatrixID` → Profil laden |
| DM-Verschlüsselung | ✅ | Standard E2EE bei DM-Erstellung |
| Gruppen-DM | ⚠️ | Erstellbar, kein gesondertes UI |
| DM vergessen / verlassen | ⚠️ | Kein UI-Button |
| DM-spezifische Notification-Sounds | ❌ | Nicht implementiert |
---
## E2EE Verschlüsselung
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Vodozemac Rust-Crypto | ✅ | `flutter_vodozemac` initialisiert in `matrix_client.dart` |
| E2EE in verschlüsselten Räumen | ✅ | Automatisch via SDK |
| Bootstrap-Flow (SSSS + Cross-Signing) | ✅ | `bootstrap_dialog.dart` — vollständige State-Machine |
| Cross-Signing Setup | ✅ | Master-, Self- und User-Signing-Key Erstellung |
| Key Backup (online) | ✅ | SSSS-gesichertes Backup |
| Schlüssel-Verifikation (SAS/Emoji) | ✅ | Via Bootstrap-Flow |
| QR-Code-Verifikation | ⚠️ | SDK-Support vorhanden, kein UI |
| Device-Liste anzeigen | ❌ | Kein UI |
| Gerät sperren/blockieren | ❌ | Kein UI |
| Fehlende Schlüssel anfordern | ✅ | `requestMissingSessionKeys()` bei Bad-Encrypted |
| Entschlüsselungs-Fehler-UI | ✅ | "Nachricht konnte nicht entschlüsselt werden" + Schlüssel-Button |
| Schlüssel importieren/exportieren | ❌ | Kein UI (Infrastruktur vorhanden) |
---
## Push Notifications
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| FCM Token Registrierung | ✅ | `fcm_push_service.dart` |
| Pusher auf Homeserver | ✅ | `postPusher()` mit Sygnal-Gateway |
| Token-Refresh Handling | ✅ | `onTokenRefresh.listen()` |
| PushService.kt (native FCM) | ✅ | Läuft auch bei geschlossener App |
| Benachrichtigung bei geschlossener App | 🔧 | APK neu gebaut, noch nicht getestet |
| Absender-Name in Notification | ✅ | HTTP-Fetch von Matrix API in PushService.kt |
| Verschlüsselte Nachricht entschlüsseln | ⚠️ | "Neue Nachricht" bei geschlossener App; `decryptAndUpdateNotification` bei Hintergrund-App |
| Inline Reply in Notification | 🔧 | ReplyReceiver auf getActivity umgestellt — APK gebaut, ungetestet |
| Antwort wirklich abschicken | 🔧 | `replyFromNotification` MethodChannel — APK gebaut, ungetestet |
| Gelesen-Button in Notification | ✅ | `ACTION_DISMISS` über `ReplyReceiver.kt` |
| Notification bei Hintergrund-App | ✅ | Heartbeat-Check verhindert Duplikate |
| Notification-Kanal Android | ✅ | `pyramid_messages`, HIGH Importance |
| Stabile Notification-ID | ✅ | `stableId(roomId)` — gleicher Algo in Kotlin + Dart |
| Windows Notification | ✅ | `local_notifier` (Windows Toast) |
| Windows Taskbar Flash | ✅ | `WindowsTaskbar.setProgressMode()` |
| Ausstehende Reply beim App-Öffnen senden | ✅ | `_sendPendingReply()` bei Resume + Sync |
| Push Rules konfigurieren | ❌ | Kein UI |
| Raum stumm schalten | ❌ | Kein UI |
| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` — Preferences |
| iOS Push | ❌ | Kein iOS-Build |
| Web Push | ❌ | Kein Web-Build |
---
## VoIP & Calls
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Matrix WebRTC Legacy-Calls | ✅ | `voip_manager.dart` — CallSession |
| LiveKit SFU-Calls | ✅ | `livekit_call_manager.dart` |
| Unified Call-UI | ✅ | `voice_channel.dart` |
| Audio-Call | ✅ | Mikrofon + Speaker |
| Video-Call | ✅ | Kamera + Remote-Video |
| Screen-Sharing | ✅ | `screen_share_picker.dart` |
| Video-Qualitätsstufen | ✅ | SD/HD/FHD/4K Presets |
| Mute-Toggle | ✅ | `setMicrophoneEnabled()` |
| Deafen-Toggle | ✅ | Audio komplett stumm |
| Kamera-Toggle | ✅ | `setCameraEnabled()` |
| Kamera-Wechsel (Front/Rück) | ⚠️ | SDK-Support vorhanden |
| Teilnehmer-Grid | ✅ | Mehrere Participants anzeigen |
| Lokale Video-Vorschau | ✅ | Overlay-Widget |
| Klingel-System (to-device) | ⚠️ | Infrastruktur vorhanden, Klingelton fehlt |
| Incoming-Call-UI | ⚠️ | Kein dediziertes Klingel-Screen |
| Call-History | ❌ | Nicht implementiert |
| TURN-Server via Matrix | ⚠️ | `getTurnServer()` — ob konfiguriert unklar |
| Hintergrund-Foreground-Service | ✅ | `FlutterForegroundService` via webrtc |
| MatrixRTC Membership-Events | ✅ | `io.pyramid.voice.presence` State-Events |
| LiveKit-Token-Exchange | ✅ | `livekit_token.dart` |
---
## Settings / Einstellungen
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Settings-Seite (Hauptseite) | ⚠️ | `settings_page.dart` — NUR PLATZHALTER |
| Settings Modal | ✅ | `settings_modal.dart` — vollständiger Modal |
| Dark/Light Mode | ✅ | `themeModeProvider` + Toggle |
| Akzentfarbe (5 Presets) | ✅ | Amber/Coral/Mint/Violet/Cobalt/Lime |
| Eckenradius Slider | ✅ | `radiusProvider` |
| UI-Motion Toggle | ✅ | `motionProvider` |
| UI-Density Toggle | ✅ | `densityProvider` |
| Desktop Notifications | ✅ | `notifDesktopProvider` |
| Notification Sound | ✅ | `notifSoundProvider` |
| Nachrichtenvorschau | ✅ | `notifPreviewProvider` |
| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` |
| Presence anzeigen (Online) | ✅ | `privacyPresenceProvider` |
| Read Receipts senden | ✅ | `privacyReadReceiptsProvider` |
| Typing-Indikator senden | ✅ | `privacyTypingProvider` |
| Profil bearbeiten (Name/Avatar) | ❌ | Kein UI |
| Geräte-Verwaltung | ❌ | Kein UI |
| Session-Liste | ❌ | Kein UI |
| Passwort ändern | ❌ | Kein UI |
| Account löschen | ❌ | Kein UI |
| Raumspezifische Benachrichtigungen | ❌ | Kein UI |
| Sprache / Lokalisierung | ❌ | Nur Systemsprache |
| Barrierefreiheit | ❌ | Keine Einstellungen |
---
## Benutzerprofil
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Avatar anzeigen (MXC) | ✅ | `mxc_image.dart` überall |
| Displayname auflösen | ✅ | Fallback auf `@username` |
| Online-Präsenz senden | ✅ | `setPresence(online)` alle 10s in `app_shell.dart` |
| Präsenz-Anzeige anderer User | ⚠️ | Infrastruktur, kein UI |
| Profil-Popover (Hover-Karte) | ✅ | `profile_popover.dart` |
| Eigenes Profil bearbeiten | ❌ | Kein UI |
| Avatar hochladen | ❌ | Kein UI |
| Status-Nachricht | ❌ | Nicht implementiert |
| User ignorieren / blockieren | ❌ | Kein UI |
| User melden | ❌ | Kein UI |
---
## Raum-Einstellungen
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Space-Admin Panel (Name/Avatar/Banner) | ✅ | `space_admin_dialog.dart` — Overview-Tab |
| Power-Level-Editor (Mitglieder) | ✅ | `space_admin_dialog.dart` — Members-Tab |
| Permissions-Tab (Raum-PLs) | ✅ | `space_admin_dialog.dart` — Permissions-Tab |
| Kind-Räume verwalten | ✅ | `space_admin_dialog.dart` — Channels-Tab |
| Raum-Name ändern (normaler Raum) | ❌ | Kein UI |
| Raum-Thema ändern | ❌ | Kein UI |
| Raum-Avatar ändern | ❌ | Kein UI |
| Join-Rules ändern | ❌ | Kein UI |
| History-Visibility ändern | ❌ | Kein UI |
| E2EE aktivieren | ❌ | Kein UI |
| Raum-Alias verwalten | ❌ | Kein UI |
| Raum verlassen (Button) | ❌ | Kein UI |
| Raum vergessen (forget) | ❌ | Kein UI |
| User einladen (aus Chat) | ❌ | Kein UI |
| Kick / Ban (Moderation) | ❌ | Kein UI |
---
## Suche
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Server-seitige Nachrichtensuche | ✅ | `search_modal.dart``/search` API |
| Lokale Raum-/DM-Suche | ✅ | Filter auf `client.rooms` |
| Personen-Suche | ✅ | Lokale DM-Partner-Suche |
| Medien-Filter (Bilder/Videos/Audio) | ✅ | `search_modal.dart` |
| Paginierung (nextBatch) | ✅ | Infinite scroll |
| Suchergebnis-Highlighting | ✅ | Suchbegriff hervorheben |
| Debounced Query (400ms) | ✅ | `debounce` in Suchfeld |
| Öffentliches Raumverzeichnis | ❌ | Kein UI zum Entdecken |
| User-Verzeichnis-Suche (global) | ✅ | In `new_dm_dialog.dart` |
---
## Medien
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Bild senden | ✅ | `MatrixImageFile` + Upload |
| Bild anzeigen (Thumbnail + Vollbild) | ✅ | `MediaViewer` mit Zoom/Pan |
| Video senden | ✅ | |
| Video abspielen | ✅ | `media_kit` |
| Audio senden | ✅ | |
| Audio abspielen | ✅ | Player mit Progress |
| Datei senden | ✅ | `MatrixFile` |
| Datei herunterladen | ✅ | MIME-Icon + Browser-Öffner |
| Clipboard-Bild einfügen (Windows) | ✅ | FFI + `clipboard_image.dart` |
| Drag & Drop Upload (Desktop) | ✅ | `desktop_drop` |
| GIF-Picker (Giphy) | ✅ | Trending + Suche + Favoriten |
| Sticker-Picker | ✅ | Pack-Browser |
| Bild-Komprimierung | ✅ | `shrinkImageMaxDimension` |
| Verschlüsselte Medien | ✅ | Automatisch bei E2EE-Räumen |
| Sprachnachricht aufnehmen | ❌ | Kein Recorder |
| Kamera direkt (in App) | ❌ | Nur Gallery-Picker |
| Dokument-Vorschau | ❌ | Kein PDF-Viewer |
| Media-Galerie (Raum-Mediathek) | ❌ | Kein UI |
---
## Read Receipts & Typing
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Read Receipts senden | ✅ | `setReadMarker()` (privacy-gesteuert) |
| Eigene ✓✓ Bestätigung | ✅ | Anzeige auf eigenen Nachrichten |
| Read-Marker anderer anzeigen | ⚠️ | Position-Marker vorhanden, kein Avatar-Stack |
| Typing-Indikator senden | ✅ | 7s Auto-Timeout |
| Typing-Indikator empfangen | ✅ | Anzeige-Platzhalter |
| Typing-User-Namen anzeigen | ⚠️ | Platzhalter, keine Namen |
---
## Spaces
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Space-Erkennung (`isSpace`) | ✅ | |
| Spaces-Rail (Sidebar) | ✅ | `spaces_rail.dart` |
| Space-Filterung der Raumliste | ✅ | Nur Räume der aktiven Space |
| Space-Admin Panel | ✅ | `space_admin_dialog.dart` |
| Space erstellen | ✅ | `create_join_dialog.dart` |
| Kind-Räume hinzufügen | ✅ | In Space-Admin |
| Kind-Räume entfernen | ✅ | In Space-Admin |
| Voice-Präsenz in Space | ✅ | `io.pyramid.voice.presence` Events |
| Space-Banner | ✅ | Mit Crop-Dialog |
| Verschachtelte Spaces | ⚠️ | Anzeige, kein eigener Sub-Space-Rail |
| Space beitreten (öffentlich) | ⚠️ | Technisch möglich, kein Discovery-UI |
---
## Windows / Desktop
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Windows Build | ✅ | CMake + MSIX |
| Windows Notifications | ✅ | `local_notifier` |
| Taskbar Flash | ✅ | `WindowsTaskbar` |
| Update-Checker (Gitea API) | ✅ | `update_checker.dart` — 4h Intervall |
| Update-Banner | ✅ | `update_banner.dart` |
| Update-Download | ✅ | `update_download_dialog.dart` |
| APK Auto-Install (Android) | ✅ | Kotlin MethodChannel + FileProvider |
| PC Update (exe/msix) Download | ⚠️ | Release-Script pusht .zip statt .exe → öffnet Git-Seite |
| Drag & Drop | ✅ | `desktop_drop` |
| Clipboard Paste (Bild) | ✅ | FFI Windows API |
| Resizable Sidebar | ✅ | `panel_layout.dart` — Drag-Divider |
| Keyboard Shortcuts | ⚠️ | Enter zum Senden, Ctrl+V — kein vollständiges System |
| System Tray | ❌ | Nicht implementiert |
| Fenster-Badge (ungelesen) | ⚠️ | Nur Taskbar-Flash, kein Zähler |
| macOS / Linux | ❌ | Nicht gebaut/getestet |
---
## Sonstige Features
| Feature | Status | Details / Datei |
|---------|--------|-----------------|
| Emoji-Picker | ✅ | Kategorisiert + Suche |
| Quick-Reaktionen (6 Emojis) | ✅ | Hover-Aktionsleiste |
| Mitglieder-Panel (Raum) | ✅ | `members/` — Teilnehmerliste |
| Profil-Popover (Hover) | ✅ | `profile_popover.dart` |
| Neuer-DM Dialog | ✅ | `new_dm_dialog.dart` |
| Raum-erstellen Dialog | ✅ | `create_join_dialog.dart` |
| Nachricht-Auswahl-Modus | ✅ | Long-Press |
| Reply-Swipe-Geste | ✅ | Rechts-wischen zum Antworten |
| Immersive-Mode (Android) | ✅ | Status-Bar ausgeblendet |
| PyramidLoader (Ladeanimation) | ✅ | `pyramid_loader.dart` |
| Rich-Text-Editor | ❌ | Kein Markdown/Formatting-UI |
| HTML-Nachrichtenrendering | ❌ | Kein `flutter_html` |
| Message Forwarding | ❌ | |
| Nachrichtenübersetzung | ❌ | |
| Bot-Kommandos (`/`) | ❌ | |
| Matrix-URI Handler | ❌ | Kein Deep-Link-Handler |
| Barcode/QR-Scanner | ⚠️ | Nur in Verifikation |
---
## Dokumentations-Vollständigkeit
| Doku-Abschnitt | Vollständig | Anmerkungen |
|----------------|-------------|-------------|
| 01 Auth | ✅ | SSO, UIAA, alle Login-Flows |
| 02 Räume | ✅ | Create, Join, Spaces, Power Levels |
| 03 Nachrichten | ✅ | Text, Replies, Edits, Threads, Polls |
| 04 DMs | ✅ | Alle DM-Flows |
| 05 Medien | ✅ | Upload, Download, alle Dateitypen |
| 06 E2EE | ✅ | Setup, SAS, Cross-Signing, Key Backup |
| 07 Push | ✅ | FCM, Push Rules, Pyramid-Architektur |
| 08 VoIP | ✅ | Legacy m.call, MatrixRTC |
| 08b LiveKit | ✅ | **KOMPLETT** — Server-Setup bis Flutter-UI |
| 09 Profil | ✅ | Presence, Account-Data, Tags |
| 10 Room-Admin | ✅ | Power Levels, Moderation, ACL, Synapse Admin |
| 11 Suche | ✅ | Event-Suche, User-Suche, Raumverzeichnis |
| 12 Schnellreferenz | ✅ | Alle Enums, Widget-Patterns, Fehlerhandling |
---
## Prioritäts-Roadmap
### 🔴 Kritisch (bald testen/fixen)
1. **Notification-Reply testen** — Neue APK (getActivity-Fix) installieren und Reply-Flow verifizieren
2. **Benachrichtigung bei geschlossener App** — Mit neuer APK testen (PushService.kt Ansatz)
3. **PC Update-Script**`.zip` durch `.exe`-Installer ersetzen in `scripts/release.ps1`
### 🟠 Wichtig (nächste Features)
4. **Nachricht löschen (Redact)** — Long-Press-Menü → `event.redactEvent()`
5. **Nachricht bearbeiten** — Edit-Modus im Composer → `sendTextEvent(editEventId:...)`
6. **Raum-Einstellungen** — Name/Thema/Avatar für normale Räume (`room.setName()` etc.)
7. **Profil bearbeiten** — Displayname + Avatar in Settings
8. **Registrierung** — Account-Erstellung im Onboarding
### 🟡 Sinnvoll (mittelfristig)
9. **Einladungen-Screen** — Pending invites sichtbar darstellen
10. **Kick/Ban UI** — Moderation aus der Mitgliederliste
11. **Device-Verwaltung** — Eigene Sessions anzeigen + löschen
12. **Rich-Text-Editor** — Markdown/Formatierung in Nachrichten
13. **Klingelton bei eingehendem Call** — Audio + dedizierter Klingel-Screen
14. **Lautlos/Stumm per Raum** — Push-Rules im UI
### 🟢 Nice-to-have
15. Sprachnachrichten aufnehmen
16. HTML-Nachrichtenrendering (`flutter_html`)
17. Matrix-URI Deep-Links
18. Media-Galerie pro Raum
19. System Tray (Windows)
20. Polls / Abstimmungen
+122
View File
@@ -0,0 +1,122 @@
# Matrix SDK — Pyramid Entwicklungsreferenz
> **Basis:** [`package:matrix`](https://pub.dev/packages/matrix) (Dart/Flutter Matrix SDK)
> **Ziel:** Vollständige Landkarte aller Matrix-Funktionen mit Code-Snippets für den Pyramid-App-Bau.
> **Referenz-Implementierung:** FluffyChat (https://gitlab.com/famedly/fluffychat)
---
## Navigation
| # | Thema | Beschreibung |
|---|-------|--------------|
| [01](01-auth.md) | **Authentifizierung** | Login, Logout, Register, SSO, Token-Management |
| [02](02-rooms.md) | **Räume & Spaces** | Erstellen, Beitreten, Verlassen, Spaces, Suche |
| [03](03-messages.md) | **Nachrichten & Events** | Senden, Timeline, Reaktionen, Edits, Threads, Polls |
| [04](04-dm.md) | **Direct Messages** | DM-Räume finden, starten, verwalten |
| [05](05-media.md) | **Medien & Dateien** | Upload, Download, Thumbnails, verschlüsselte Medien |
| [06](06-encryption.md) | **E2EE Verschlüsselung** | Keys, Verifizierung, Cross-Signing, Key-Backup |
| [07](07-push.md) | **Push & Notifications** | Pusher, Push Rules, FCM, lokale Benachrichtigungen |
| [08](08-voip.md) | **VoIP & Calls** | MatrixRTC, Legacy m.call, Klingel-Flow |
| [08b](08b-livekit-streaming.md) | **LiveKit Streaming** ⭐ | **Vollständige Server-Anleitung + Flutter-Implementierung** |
| [09](09-profile.md) | **Profil & Präsenz** | Avatar, Displayname, Presence, Account-Daten |
| [10](10-room-admin.md) | **Raum-Administration** | Power Levels, Moderierung, Einstellungen, Bans |
| [11](11-search.md) | **Suche & Discovery** | Nachrichten-Suche, User-Suche, Raum-Verzeichnis |
| [12](12-quick-reference.md) | **Schnellreferenz** | Alle Enums, Widget-Patterns, Fehlerhandling |
| [13](13-server-admin-continuwuity.md) | **Server-Admin Continuwuity** ⭐ | Admin API, Power Levels erzwingen, MSC3083, Diagnose |
| [CHECKLIST](CHECKLIST.md) | **Feature-Checklist** ⭐ | Implementierungsstand + Roadmap |
---
## Schnellreferenz: Client-Initialisierung
```dart
import 'package:matrix/matrix.dart';
final client = Client(
'MeinApp', // App-Name (User-Agent)
database: MatrixSdkDatabase(...), // Persistenz (sqflite)
nativeImplementations: NativeImplementationsIsolate(compute), // E2EE
logLevel: Level.warning,
);
// Initialisieren (lädt gespeicherten Session-State)
await client.init(
waitForFirstSync: false, // UI nicht blockieren
waitUntilLoadCompletedLoaded: false,
);
```
---
## Architektur-Überblick
```
Client
├── rooms: List<Room> // alle bekannten Räume
├── userID: String? // eigene MXID (@user:server)
├── accessToken: String? // Bearer-Token
├── homeserver: Uri // https://matrix.example.org
├── Room
│ ├── id: String // !roomId:server
│ ├── getTimeline() // Event-Stream
│ ├── sendTextEvent()
│ ├── sendEvent()
│ ├── members // Map<String, User>
│ └── states // Map<String, Map<String, StrippedStateEvent>>
├── Event
│ ├── type: String // m.room.message, m.reaction, …
│ ├── content: Map<String, dynamic>
│ ├── body: String // text body (decrypted)
│ └── messageType: String? // m.text, m.image, m.file, …
└── User
├── id: String // MXID
├── displayName: String?
└── avatarUrl: Uri?
```
---
## Wichtige Streams / Provider-Hooks
```dart
// Auf Sync-Events reagieren
client.onSync.stream.listen((SyncUpdate update) { ... });
// Neue/geänderte Räume
client.onRoomUpdate.stream.listen((String roomId) { ... });
// Verbindungsstatus
client.onConnectionStateChanged.stream.listen((ConnectionState s) { ... });
// Eigener User geändert
client.onUserEvent.stream.listen((EventUpdate update) { ... });
// Benachrichtigungs-Events (gefiltert nach Push-Rules)
client.onNotification.stream.listen((Event event) { ... });
```
---
## Tipps für Riverpod-Integration
```dart
// Matrix-Client als Provider
final matrixClientProvider = FutureProvider<Client>((ref) async {
final client = Client('Pyramid', ...);
await client.init(waitForFirstSync: false);
return client;
});
// Raumliste reaktiv
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield client.rooms;
await for (final _ in client.onSync.stream) {
yield client.rooms;
}
});
```
+87
View File
@@ -0,0 +1,87 @@
# Server-Incident: Messaging-Ausfall — 2026-05-31
## Betroffene Nutzer
`@uta:steggi-matrix.work`, `@circuitslave:steggi-matrix.work`
## Symptome
- Uta und Circuitslave konnten in verschlüsselten DM-Räumen keine Nachrichten senden
- Nachrichten von `@todesneutron` kamen nicht an bzw. konnten nicht entschlüsselt werden
- `@jochen` (Test-Account, FluffyChat) war nicht betroffen
- Später: Element zeigt „Verbindung unterbrochen / Server offline"
- Nach Behebung: alle Chats zunächst leer (History nicht sichtbar)
---
## Ursachen (in Reihenfolge des Auftretens)
### 1. Continuwuity 0.5.8 — `room_keys/version` 500-Fehler
**Endpunkt:** `GET /_matrix/client/unstable/room_keys/version`
Continuwuity 0.5.8 crashte bei dieser Anfrage mit:
```
response error: JSON serialization failed: missing field `algorithm` at line 1 column 2
500 Internal Server Error
```
Element prüft diesen Endpunkt beim Start, bevor es verschlüsselte Nachrichten sendet.
Der 500-Fehler verhinderte den korrekten Aufbau des E2E-Zustands → Senden blockiert.
FluffyChat/Pyramid implementieren diesen Check nicht → waren nicht betroffen.
### 2. Zu viele unverifizierte Geräte bei `@todesneutron`
Durch die Pyramid-Entwicklung wurden 14 Geräte angesammelt (alte FluffyChat-,
Commet- und Pyramid-Debug-Sessions). Element von Uta/Circuitslave sah all diese
Geräte als unbekannt und konnte nicht für alle gleichzeitig verschlüsseln.
### 3. Continuwuity 0.5.9-latest — Axum Routing Panic (Folgeschaden)
Beim Update auf `latest` (0.5.9, Commit `0514491`) trat ein Panic auf:
```
thread 'conduwuit:worker' panicked at axum-0.8.9/src/routing/path_router.rs:406:22:
no route for id. This is a bug in axum.
```
Ausgelöst durch: `GET /_matrix/client/v3/user/@circuitslave:steggi-matrix.work/filter/ieOT`
Circuitslaves Element hatte nach dem ersten Neustart seinen Filter verloren und
sendete diese Anfrage in einer Schleife → Server-Worker crashte wiederholt →
Cloudflare bekam EOF → alle Clients offline.
### 4. Leere Chat-History nach Behebung
Continuwuity 0.5.9 hat die interne RocksDB-Struktur von 98 auf 100 Column Families
erweitert. Das invalidierte alle gespeicherten Sync-Token der Clients → Fresh Initial
Sync → Server schickt nur aktuelle Room-States ohne vollständige Timeline.
**Die Daten waren nicht verloren** — Pagination via `/messages` funktionierte korrekt.
Durch Hochscrollen in den Räumen wurde die History nachgeladen.
---
## Maßnahmen
| Schritt | Aktion |
|---|---|
| Diagnose | Geräteliste, Raumzustand, Server-Logs via SSH geprüft |
| Geräte aufräumen | 14 → 5 aktive Geräte bei `@todesneutron` |
| Server-Update | Wechsel auf `continuwuity:main` (Commit `1faa09b`) |
| Ergebnis | `room_keys/version` gibt gültige Antwort, Filter-Panic behoben |
| History | Nutzer scrollten in Räumen nach oben → Pagination lud History nach |
---
## Server-Infrastruktur
- **Homeserver:** Continuwuity (Conduit-Fork, Rust) via Docker
- **Compose-Datei:** `/home/steggi/matrix/docker-compose.yml`
- **Image (vorher):** `registry.gitlab.com/continuwuity/continuwuity:latest` (0.5.8 → 0.5.9)
- **Image (jetzt):** `registry.gitlab.com/continuwuity/continuwuity:main` (0.5.9, Commit `1faa09b`)
- **Tunnel:** Cloudflare (`cloudflared`)
- **Datenbank:** RocksDB unter `/home/steggi/matrix/db/`
---
## Lektionen
- `latest`-Tag vor produktivem Update testen (0.5.9-latest hatte Routing-Bug)
- Alte Geräte regelmäßig aus Element/Pyramid abmelden (verhindert E2E-Probleme)
- `continuwuity:main` ist stabiler als `continuwuity:latest`
- FluffyChat/Pyramid senden auch ohne Key-Backup-Check → können als Fallback dienen