Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 891f3489bd | |||
| f9979e4f32 | |||
| d254a36de7 | |||
| daf4cf3dad | |||
| a963262de2 | |||
| 31e4001fb0 | |||
| 0c7d359076 | |||
| a863fa3b9a | |||
| 5bf45a9366 | |||
| 5d772389d6 | |||
| 6400fb639c | |||
| f5b9ce9cd7 | |||
| f3fd451034 | |||
| db653ea7a3 | |||
| 254be1b8da | |||
| b5762ea08d | |||
| e8ab626c92 | |||
| fb7669a65b | |||
| 9dc83f7bcb | |||
| 63e491952d | |||
| 13512064a3 | |||
| 74d38fd267 | |||
| 401bd6a7f8 | |||
| 057ac1e4ea | |||
| 90593d2f81 | |||
| d2fcc59f7a | |||
| 2395db50be | |||
| 99f10c5210 | |||
| 133a8acbc8 | |||
| 9dc11757e2 | |||
| 594e6718be | |||
| f1fde9b45a | |||
| 336c82fb76 | |||
| 25ed765a03 |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-fable-5",
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python -c \"import json,sys,datetime; d=json.load(sys.stdin); f=d.get('tool_input',{}).get('file_path','unknown'); t=d.get('tool_name','Edit'); ts=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'); open('CHANGES.md','a').write(f'{ts} [{t}] {f}\\n')\" 2>/dev/null || true",
|
||||||
|
"statusMessage": "Dokumentation wird aktualisiert..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,5 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
autopilot.log
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.33+34] – 2026-06-11
|
||||||
|
|
||||||
|
### Anrufe & Sprache
|
||||||
|
- Anruf-Steuerung neu platziert: eigenes "Dock" über dem Konto-Balken (Discord-Stil) statt darin eingebettet; das doppelte Mini-Panel im Profilbereich wurde entfernt.
|
||||||
|
- Alle Anrufe – auch Sprachkanäle – nutzen jetzt das vollwertige Mini-Call-Widget mit Mikrofon, Deafen, Kamera und Bildschirmübertragung.
|
||||||
|
- Ein-/Ausblenden des Call-Docks animiert (220 ms).
|
||||||
|
- Querformat-Optimierung: Stream-Vorschau schrumpft bei wenig Bildschirmhöhe (140 -> 84 px), damit die Raumliste nutzbar bleibt.
|
||||||
|
- Ausgabelautstärke-Regler (0–150 %) in "Sprache & Video", wirkt sofort auf laufende Anrufe.
|
||||||
|
- Geräteauswahl verdrahtet: Mikrofon/Lautsprecher/Kamera aus den Einstellungen werden jetzt tatsächlich auf Anrufe angewendet (vorher wurden die Einstellungen nirgends gelesen).
|
||||||
|
- Ehrliche Fehlermeldung, wenn Windows-Audiotreiber keine Geräteliste an WebRTC melden (bekannter libwebrtc-Bug; Anrufe laufen trotzdem über das Standardgerät).
|
||||||
|
- Bildschirmübertragungs-Picker passt sich kleinen Bildschirmen an.
|
||||||
|
|
||||||
|
### Chat & Medien
|
||||||
|
- Bilder werden nicht mehr abgeschnitten: Anpassung an die Chatbreite, kein Verzerren oder Hochskalieren mehr.
|
||||||
|
- SVG-Inline-Vorschau im Chat (vorher als generische Datei oder fehlerhaft).
|
||||||
|
- Video-Ladeplatzhalter passen sich der Breite an (keine festen 360 px).
|
||||||
|
- Lesebestätigungen verbessert: goldener Doppelhaken, Zusammenführung von globalen und Thread-Bestätigungen, korrektes Verhalten wenn die andere Person antwortet/liest.
|
||||||
|
- Drag-&-Drop-Overlay animiert (Scale + Fade + wippendes Icon, Akzent-Glow).
|
||||||
|
- Anhang-Dialog öffnet mit Scale-Animation.
|
||||||
|
- Tastatur-Zwischenablage (Android): frisch kopierte Bilder erscheinen in der Tastatur-Vorschlagsleiste und landen im Anhang-Dialog.
|
||||||
|
- Banner-Wechsel: Absturz ("setState returned a Future") und Doppelanzeige behoben.
|
||||||
|
|
||||||
|
### Profile & DMs
|
||||||
|
- Aufklappbarer DM-Header: Klick auf Avatar/Name klappt das Profil auf – Banner als Hintergrund, Avatar skaliert mit (32 -> 96 px), Name wächst, Handle + Biographie blenden ein.
|
||||||
|
- Profilbild-Bug beim schnellen DM-Wechsel behoben (kein Übersprechen mehr zwischen Kanälen).
|
||||||
|
- Profil-Einstellungen laden sofort aus dem Cache, Server-Daten parallel im Hintergrund.
|
||||||
|
- Persistenter Bild-Cache: Avatare und Banner sind nach Neustart sofort da statt nachzuladen.
|
||||||
|
|
||||||
|
### Spaces & Berechtigungen
|
||||||
|
- Selbst-Aussperr-Schutz: Berechtigungsänderungen werden gegen den echten Server-Stand gemischt (nie mehr den lokalen Cache überschreiben). Zusätzlich bricht Pyramid jede Änderung ab, die den eigenen Account aussperren würde.
|
||||||
|
- Space-Banner überleben jetzt App-Neustarts (importantStateEvents).
|
||||||
|
- Aussagekräftige Fehlerdiagnose bei abgelehnten Space-Änderungen.
|
||||||
|
|
||||||
|
### Präsenz / Online-Status
|
||||||
|
- Online wird nur noch angezeigt, wenn die letzte Aktivität < 5 Min. her ist (behebt "alle wirken online").
|
||||||
|
- Präsenz-Heartbeat von 10 s auf 55 s reduziert (weniger Netzwerk/Akku).
|
||||||
|
|
||||||
|
### Teilen
|
||||||
|
- "Teilen nach Pyramid" (Android): Text und Dateien aus anderen Apps teilen, mit Raum-Picker (Suche + Vorschau).
|
||||||
|
|
||||||
|
### Performance & Build
|
||||||
|
- Inter-Schrift als Asset gebündelt (google_fonts entfernt) – offline-fähig, schnellerer Start.
|
||||||
|
- Bilder laden zuerst als Thumbnail (spart Bandbreite, v. a. mobil).
|
||||||
|
- 12 tote Quelldateien und ungenutzte Abhängigkeit dynamic_color entfernt.
|
||||||
|
- Android-Release-Build: eigener Release-Keystore, R8/Minify + Resource-Shrinking, ProGuard-Keep-Regeln für WebRTC/LiveKit/Notifications.
|
||||||
|
|
||||||
|
### Infrastruktur (Server)
|
||||||
|
- Defekter Space "Just us serious" repariert: sauber dupliziert (gleicher Name/Topic/Avatar/Banner, alle 6 Kanäle, Admin-Rechte wiederhergestellt).
|
||||||
|
|
||||||
|
### Hinweis zur Verteilung
|
||||||
|
- Wegen des neuen Release-Keystores müssen bestehende (Debug-signierte) Android-Installationen einmalig deinstalliert und neu installiert werden – danach laufen Updates normal.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Pyramid – Arbeitsregeln für Claude
|
||||||
|
|
||||||
|
Pyramid ist Bernds eigener Matrix-Client (Flutter, kein FluffyChat-Fork). Bernd ist
|
||||||
|
**kein Entwickler** – er gibt Ziele und Prioritäten vor, Claude trifft die technischen
|
||||||
|
Entscheidungen und erklärt sie in einfachem Deutsch. Die App ist bei echten Nutzern im
|
||||||
|
Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo.
|
||||||
|
|
||||||
|
**Dies ist das einzige aktuelle Repo:** `C:\Users\nordm\pyramid - Kopie\`.
|
||||||
|
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
||||||
|
April-Schnappschüsse – dort NIE arbeiten.)
|
||||||
|
|
||||||
|
## Infrastruktur
|
||||||
|
|
||||||
|
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
||||||
|
- LiveKit/TURN: Hetzner „Leuchtturm" → `wss://livekit.steggi-matrix.work` (API-Key LKMatrixPi), coturn 94.130.78.116:3478
|
||||||
|
- Git-Remote: Gitea auf dem Pi (`http://192.168.178.71:3000/steggi/pyramid.git`) – Push = Backup
|
||||||
|
- Push-Benachrichtigungen: FCM + Sygnal auf dem Pi (Memory „Pyramid Push" lesen, bevor daran gearbeitet wird)
|
||||||
|
- Vollbackup vom 2026-07-03: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`
|
||||||
|
|
||||||
|
## Arbeitszyklus (IMMER einhalten)
|
||||||
|
|
||||||
|
Jede Session – egal ob frisch gestartet oder fortgesetzt – läuft so:
|
||||||
|
|
||||||
|
1. **Einlesen:** `PROGRESS.md` (letzter Stand, Stolperfallen) und `ROADMAP.md`
|
||||||
|
(nächster offener Punkt) lesen. NIE Arbeit doppelt machen, die dort als erledigt steht.
|
||||||
|
2. **Einen Punkt nehmen:** den obersten nicht abgehakten Punkt des aktuellen Meilensteins
|
||||||
|
aus `ROADMAP.md` – nicht mehrere gleichzeitig, keine Sprünge in spätere Meilensteine.
|
||||||
|
3. **Umsetzen & prüfen:** Nach der Änderung mindestens `flutter analyze` sauber bekommen;
|
||||||
|
wo sinnvoll `flutter test` und `flutter run -d windows` (schnellster Praxistest).
|
||||||
|
4. **Protokollieren:** `PROGRESS.md` aktualisieren (Format siehe dort) und den Punkt in
|
||||||
|
`ROADMAP.md` abhaken.
|
||||||
|
5. **Commit + Push:** kleiner `git commit` (`feat:`/`fix:`/`refactor:`/`chore:`) und
|
||||||
|
`git push origin master` (Gitea = Backup). Lieber 5 kleine Commits als ein riesiger.
|
||||||
|
6. Weiter mit dem nächsten Punkt, solange Kontingent/Zeit da ist.
|
||||||
|
|
||||||
|
Wird eine Session mitten in einem Punkt abgebrochen (Limit erreicht), MUSS der letzte
|
||||||
|
Eintrag in `PROGRESS.md` den Zwischenstand beschreiben: was halb fertig ist, welche
|
||||||
|
Dateien angefasst wurden, was der nächste konkrete Handgriff ist.
|
||||||
|
|
||||||
|
## Qualitätsleitplanken
|
||||||
|
|
||||||
|
- **Kein Aussperren, kein Datenverlust:** Alles rund um Login, Sessions, Verschlüsselung
|
||||||
|
und Key-Backup ist heilig. Der Uta-Random-Logout-Bug zeigt: Fehlerpfade dürfen NIE in
|
||||||
|
einem stillen Logout enden. Änderungen daran immer mit Test: Login → Nachrichten →
|
||||||
|
Logout → erneuter Login → alte verschlüsselte Nachrichten noch lesbar?
|
||||||
|
- **Nach dem Refactoring (M2) gilt:** keine Verhaltensänderung ohne Not – Refactoring
|
||||||
|
heißt gleiche Funktion, bessere Struktur. Nach jedem Refactoring-Schritt App starten
|
||||||
|
und Kernflows prüfen (Login, Raum öffnen, Nachricht senden, Voice-Channel beitreten).
|
||||||
|
- **Keine Secrets ins Repo:** Tokens, Recovery-Keys, Passwörter, Keystore-Dateien
|
||||||
|
niemals committen (Signing-Keys liegen in `Documents\pyramid_keys_20260425.txt`).
|
||||||
|
- **Modularität (Bernd wichtig!):** Die Architektur muss aus austauschbaren Bausteinen
|
||||||
|
bestehen – jedes Feature (Calls, Streaming, Push, Chat-Timeline, …) hinter einer klaren,
|
||||||
|
schmalen Schnittstelle, sodass man einen alten Baustein durch einen neuen ersetzen kann,
|
||||||
|
ohne den Rest anzufassen. Beim Refactoring (M2) ist das DAS Leitprinzip; bei jedem neuen
|
||||||
|
Feature fragen: „Könnte man dieses Modul in einem Jahr komplett neu schreiben, ohne
|
||||||
|
andere Module zu ändern?"
|
||||||
|
- **Abhängigkeiten:** neue Pakete nur mit gutem Grund, etabliert und gepflegt.
|
||||||
|
- **Umgebung beachten:** Auf dem Pi (SSH/tmux-Sessions) ist evtl. kein Flutter-Toolchain
|
||||||
|
verfügbar. Dann trotzdem sauber arbeiten, aber jeden Schritt in PROGRESS.md als
|
||||||
|
„UNGETESTET (Pi)" markieren – der nächste PC-Lauf holt `flutter analyze` + Praxistest nach.
|
||||||
|
- **Bernd fragen** nur bei echten Richtungsentscheidungen (UX-Geschmack, Priorität) –
|
||||||
|
Fragen in PROGRESS.md unter „Fragen an Bernd" sammeln statt die Session zu blockieren.
|
||||||
|
|
||||||
|
## Nützliches Wissen
|
||||||
|
|
||||||
|
- Discord ist die UX-Referenz für Calls/Voice-Channels (Join/Leave, Nutzer-Lautstärke,
|
||||||
|
Geräteauswahl, Noise Suppression, Streaming-Qualitätswahl).
|
||||||
|
- `CHANGES.md` ist ein automatisches Änderungslog (Hook) – nicht von Hand pflegen.
|
||||||
|
- Windows-Build ist der schnellste Testweg; Android-Release über das Release-Skript
|
||||||
|
(siehe Commit d706ace), signiert – Play-Protect-Warnung siehe ROADMAP M6.
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
# Pyramid – Arbeitsprotokoll
|
||||||
|
|
||||||
|
Neueste Einträge OBEN. Jede Claude-Session schreibt hier nach jedem abgeschlossenen
|
||||||
|
Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## JJJJ-MM-TT – Kurztitel
|
||||||
|
**Erledigt:** was fertig ist (mit Commit-Hash)
|
||||||
|
**Offen/Nächster Schritt:** der konkrete nächste Handgriff
|
||||||
|
**Stolperfallen:** was schiefging und wie es gelöst wurde (damit es nie zweimal passiert)
|
||||||
|
**Fragen an Bernd:** (optional) gesammelte Richtungsfragen, die nicht blockieren
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `document_viewer.dart` aufgeteilt (5 Dateien)
|
||||||
|
|
||||||
|
**Erledigt:** Vor dem Weiterarbeiten wie immer Arbeitszyklus durchlaufen
|
||||||
|
(`PROGRESS.md`/`ROADMAP.md` gelesen, `flutter doctor` geprüft – GUI-Lücken
|
||||||
|
auf dem Pi unverändert). Die Ist-Analyse aus M2 hatte ursprünglich 8 große
|
||||||
|
Dateien gefunden, aber nur 5 wurden als „Gott-Dateien" tatsächlich
|
||||||
|
aufgeteilt (`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
|
||||||
|
`space_admin_dialog.dart`, `chat_view.dart`); `document_viewer.dart` (1773
|
||||||
|
Zeilen, 31 Klassen/Enums + 3 lose Top-Level-Helfer) war übrig, ist aber
|
||||||
|
inhaltlich genau derselbe Fall (viele unabhängige Widget-Klassen in einer
|
||||||
|
Datei) und – anders als `voice_channel.dart`/`app_shell.dart` – reines
|
||||||
|
Medien-/PDF-Anzeige-Feature ohne Berührung mit Login/Session/Verschlüsselung
|
||||||
|
oder der noch unentschiedenen Call-Fassade. Deshalb mit derselben
|
||||||
|
bewährten Technik aufgeteilt:
|
||||||
|
|
||||||
|
- `part`/`part of` wie bei den 5 vorigen Umbauten, per Skript extrahiert
|
||||||
|
(Klammer-Zählung für Blockgrenzen inkl. der 3 losen Top-Level-Funktionen
|
||||||
|
`_saveBytes`, `_extractPsdThumbnail`, `_detectType` – nicht nur
|
||||||
|
`class`/`enum`, das wurde diesmal zusätzlich automatisiert gesucht, um
|
||||||
|
keine lose Funktion zu übersehen).
|
||||||
|
- 5 neue Dateien unter `lib/features/chat/document/`:
|
||||||
|
`document_viewer_core.dart` (`DocumentViewer`-Dispatcher, `_DocHeader`,
|
||||||
|
`_ExtBadge`, `_DocContent`, `_DocType`-Enum + `_detectType`, `_ErrorView`,
|
||||||
|
`_UnsupportedViewer`, `_FileFallback`), `document_viewer_pdf.dart`
|
||||||
|
(größter Teil: `_RenderCache`, `PdfInlinePreview`+State für die
|
||||||
|
Chat-Inline-Vorschau, `_NavArrow`, `_saveBytes`, `_PdfFullViewer`+State,
|
||||||
|
`_PdfFullscreenViewer`+State, `_FsBtn`, `_ThumbnailStrip`+State),
|
||||||
|
`document_viewer_text.dart` (SVG/Markdown/Text/Office-Text-Extraktion),
|
||||||
|
`document_viewer_archive.dart` (ZIP/TAR-Archiv-Browser, PSD-Thumbnail-
|
||||||
|
Extraktion inkl. `_extractPsdThumbnail`, Sketch/XD-ZIP-Preview),
|
||||||
|
`document_viewer_misc.dart` (`_ProprietaryViewer`-Infokarte für nicht
|
||||||
|
darstellbare Formate). `document_viewer.dart` selbst: nur noch
|
||||||
|
Bibliothekskopf (Imports + 5 `part`-Direktiven, 20 Zeilen).
|
||||||
|
- Verifikation wie bei den 5 vorigen Umbauten: automatisierte
|
||||||
|
Klammer-Zählung fand alle 34 Top-Level-Blöcke (31 Klassen/Enums + 3 lose
|
||||||
|
Funktionen), alle wortwörtlich in den neuen Dateien wiedergefunden,
|
||||||
|
`class`/`enum`-Anzahl (31) vorher/nachher identisch, `flutter analyze`
|
||||||
|
unverändert bei 1 bekanntem Hinweis (`chat_provider.dart:42`). Einzige
|
||||||
|
externe Importstelle (`message_group.dart`) bleibt unverändert gültig, da
|
||||||
|
`document_viewer.dart` weiterhin der Bibliothekseinstieg ist.
|
||||||
|
- **Bewusst NICHT angefasst:** `voice_channel.dart` (1123 Zeilen) und
|
||||||
|
`app_shell.dart` (1073 Zeilen) – auch große Dateien mit vielen Klassen,
|
||||||
|
aber `voice_channel.dart` gehört zur noch unentschiedenen Call-Fassade
|
||||||
|
(siehe Fragen an Bernd unten) und `app_shell.dart` enthält den
|
||||||
|
Bootstrap-Trigger (`_triggerBootstrap`) für Cross-Signing/Key-Backup –
|
||||||
|
laut CLAUDE.md heiliger Bereich. Eine reine Datei-Aufteilung wäre zwar
|
||||||
|
vermutlich genauso risikoarm wie bei den anderen 6, aber ohne GUI-Test auf
|
||||||
|
dem Pi wollte ich das Risiko nicht zusätzlich zum ohnehin schon
|
||||||
|
aufgelaufenen UNGETESTET-Stapel erhöhen, bevor nicht wenigstens einmal ein
|
||||||
|
PC-Durchlauf stattgefunden hat.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** UNGETESTET (Pi) – siehe ROADMAP-Eintrag oben,
|
||||||
|
gehört zum selben ausstehenden Windows-Praxistest wie die 5 vorigen
|
||||||
|
Datei-Aufteilungen (PDF/Text/Archiv/Bild im Chat öffnen, Vollbild-PDF-Viewer
|
||||||
|
inkl. Thumbnail-Leiste und Speichern-Button prüfen). Danach bleibt exakt
|
||||||
|
dieselbe Blockade wie im Eintrag direkt darunter: kein M1/M2-Punkt mehr
|
||||||
|
ohne Bernds Antwort (Call-Pilot, SQLCipher-Priorität) oder PC/GUI-Zugriff
|
||||||
|
sicher bearbeitbar.
|
||||||
|
|
||||||
|
**Stolperfallen:** Anders als bei den 5 vorigen Umbauten gab es diesmal 3
|
||||||
|
lose Top-Level-Funktionen zusätzlich zu den Klassen/Enums
|
||||||
|
(`_saveBytes`/`_extractPsdThumbnail`/`_detectType`) – eine reine
|
||||||
|
`grep -E '^(class|enum) '`-Suche hätte sie übersehen und stillschweigend aus
|
||||||
|
der neuen Datei-Struktur fallen lassen. Deshalb diesmal zusätzlich per
|
||||||
|
Diff aller durch Klammer-Zählung abgedeckten vs. nicht abgedeckten
|
||||||
|
Zeilenbereiche geprüft, dass keine „Lücke" zwischen zwei Klassenblöcken
|
||||||
|
mehr als Kommentar/Leerzeile ist. Für künftige Datei-Aufteilungen (z. B.
|
||||||
|
`voice_channel.dart`/`app_shell.dart`, falls Bernd grünes Licht gibt): immer
|
||||||
|
zuerst auf lose Top-Level-Funktionen prüfen, nicht nur auf Klassen/Enums.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – Session-Check: weiterhin kein sicher bearbeitbarer Punkt auf dem Pi
|
||||||
|
|
||||||
|
**Erledigt:** Vor dem Weiterarbeiten Arbeitszyklus komplett durchlaufen: `PROGRESS.md`
|
||||||
|
+ `ROADMAP.md` gelesen, `flutter analyze` (weiterhin sauber, nur der eine bekannte
|
||||||
|
Hinweis in `chat_provider.dart:42`), `git status` (sauber, `master` = `origin/master`),
|
||||||
|
`flutter doctor` erneut geprüft (Android-SDK, Chrome, Linux-Desktop-Toolchain fehlen
|
||||||
|
weiterhin auf diesem Pi – keine GUI möglich). Damit alle offenen Punkte in M1/M2
|
||||||
|
gegengeprüft:
|
||||||
|
- M1 „SQLCipher" und „Härtetest dokumentieren": unverändert PC-only (siehe Einträge
|
||||||
|
weiter unten), keine neue Information seit letzter Session.
|
||||||
|
- M2 „Call-Schicht entwirren": zum Einstieg `core/voip_manager.dart` (538 Zeilen)
|
||||||
|
gelesen, um eine Fassade zu entwerfen. Ergebnis: Das ist **kein** Datei-Split wie die
|
||||||
|
letzten 5 Gott-Datei-Umbauten (dort: reines `part`/`part of`, null Logikänderung,
|
||||||
|
automatisiert byte-genau verifizierbar). `PyramidVoipManager` ist ein Singleton
|
||||||
|
(`_instance`), der gleichzeitig `ChangeNotifier` UND die vom matrix-SDK verlangte
|
||||||
|
`WebRTCDelegate`-Schnittstelle implementiert, mit Timer-basiertem Renderer-Polling,
|
||||||
|
impliziten Reihenfolgen bei Mute/Unmute/Screenshare und einem `MediaDevicesWrapper`,
|
||||||
|
der direkt auf private Felder des Managers zugreift. Eine Fassade davor UND die
|
||||||
|
Umstellung der 7 Importstellen lässt sich nicht mehr rein mechanisch verifizieren –
|
||||||
|
echtes Risiko für Verhaltensänderung an Calls, die echte Nutzer (Uta) im Einsatz
|
||||||
|
haben. Genau das hatte die vorige Session bereits zweimal als „erste Stelle mit
|
||||||
|
echtem Verhaltensrisiko in M2" geflaggt und Bernds Entscheidung dazu angefragt
|
||||||
|
(siehe „Fragen an Bernd" weiter unten) – hier bestätigt und bewusst NICHT
|
||||||
|
eigenmächtig angefasst, weil auf diesem Pi ohnehin kein GUI-Test möglich ist, um
|
||||||
|
eine Regression aufzufangen.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Es gibt aktuell **keinen** ROADMAP-Punkt in M1/M2, der auf
|
||||||
|
diesem Pi ohne (a) Bernds Antwort auf die Call-Pilot-Frage oder (b) echten PC/GUI-Zugriff
|
||||||
|
sicher bearbeitet werden kann. Nächster sinnvoller Schritt ist entweder ein PC-Termin
|
||||||
|
(Härtetest + SQLCipher-Migrationsplanung + Call-Fassade mit echtem Test) oder Bernds
|
||||||
|
kurze Antwort auf die unten stehende Frage, damit die nächste Pi-Session direkt starten
|
||||||
|
kann.
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine neuen – Bestätigung, dass die Vorsicht der letzten Session
|
||||||
|
richtig war: „sieht aus wie ein weiterer Datei-Split" ist bei der Call-Schicht trügerisch,
|
||||||
|
weil Singleton-Lifecycle + SDK-Delegate-Vertrag + Timer-Polling nicht mit einem
|
||||||
|
Textvergleich absicherbar sind wie bei reinen `part`/`part of`-Umbauten.
|
||||||
|
|
||||||
|
**Fragen an Bernd:** Unverändert offen (wiederholt aus den letzten beiden Einträgen,
|
||||||
|
damit sie nicht übersehen wird):
|
||||||
|
1. Call-Pilot (Fassade für `voip_manager.dart`/`livekit_call_manager.dart`) jetzt
|
||||||
|
angehen (nur auf einem PC mit echtem Test), oder erstmal zurückstellen?
|
||||||
|
2. SQLCipher-Verschlüsselung der lokalen DB: Priorität vor M2, oder reicht die
|
||||||
|
App-Sandbox als Schutz vorerst?
|
||||||
|
Ohne eine dieser beiden Antworten bleibt der Pi bei jedem weiteren Durchlauf an
|
||||||
|
derselben Stelle stehen – ein kurzer PC-Termin für Härtetest + Call-Pilot würde
|
||||||
|
gleich mehrere blockierte Punkte auf einmal lösen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `chat_view.dart` aufgeteilt (6 Dateien) – ROADMAP-Punkt abgehakt
|
||||||
|
|
||||||
|
**Erledigt:** Fünfter und letzter Durchgang derselben `part`/`part of`-Technik:
|
||||||
|
`features/chat/chat_view.dart` (2116 Zeilen, 22 Klassen, keine losen
|
||||||
|
Top-Level-Helfer) aufgeteilt in 6 Dateien unter `features/chat/view/`:
|
||||||
|
`chat_view_core.dart` (`ChatView`, `_ChatViewState` – der komplette
|
||||||
|
Nachrichten-Senden/Scroll/Attachment-Zustandsautomat, mit 521 Zeilen
|
||||||
|
weiterhin der größte Einzelblock, aber nicht weiter unterteilt, da es EINE
|
||||||
|
zusammenhängende State-Klasse ist, keine künstliche Aufspaltung mitten in
|
||||||
|
einer Klasse), `chat_header.dart` (`_ChatHeader`, DM-Header inkl.
|
||||||
|
`_DmExpandingHeader`+State, `_DmHeaderAvatar`, `_InitialCircle`,
|
||||||
|
`_PresenceLine`+State), `chat_connection.dart` (`_ConnectionBanner`+State,
|
||||||
|
Offline/Reconnect-Banner), `chat_message_list.dart` (`_MessageList`,
|
||||||
|
`_SelectionHeader`, `_SelBtn`, `_UnreadDivider`), `chat_typing.dart`
|
||||||
|
(`_TypingIndicator`+State, `_TypingDots`+State), `chat_misc.dart`
|
||||||
|
(`_DragOverlay`+State für Datei-Drag&Drop, `NoChatSelected` – bleibt
|
||||||
|
öffentlich, `app_shell.dart` importiert `chat_view.dart` und nutzt es).
|
||||||
|
`chat_view.dart` selbst: nur noch Bibliothekskopf (33 Zeilen).
|
||||||
|
|
||||||
|
Verifikation identisch zu den vier vorigen Umbauten: automatisierte
|
||||||
|
Klammer-Zählung, alle 22 Blöcke wortwörtlich wiedergefunden,
|
||||||
|
`class`/`enum`-Anzahl (22) und `Widget build(`-Methoden (15) vorher/nachher
|
||||||
|
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis.
|
||||||
|
|
||||||
|
**Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt**
|
||||||
|
(`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
|
||||||
|
`space_admin_dialog.dart`, `chat_view.dart` – siehe die vier Einträge
|
||||||
|
darunter). ROADMAP-Punkt „Toten Code & Duplikate entfernen, Ordnerstruktur
|
||||||
|
vereinheitlichen" jetzt abgehakt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** **Wichtig, bevor der nächste ROADMAP-Punkt
|
||||||
|
angefasst wird:** all diese Aufteilungen sind bisher NUR per `flutter
|
||||||
|
analyze` + automatisiertem Textvergleich geprüft, NICHT in echter UI
|
||||||
|
gesehen (kein GUI-Toolchain auf dem Pi verfügbar – kein Android-SDK, kein
|
||||||
|
Linux-Desktop-Build, siehe `flutter doctor`). Der nächste PC-/Windows-Lauf
|
||||||
|
MUSS vor dem nächsten inhaltlichen Schritt einmal durch alle 5 betroffenen
|
||||||
|
Bereiche klicken (Einstellungen komplett, ein Chat mit allen
|
||||||
|
Nachrichtentypen, die Raumliste, eine Space-Verwaltung, ein normaler
|
||||||
|
Chat-Screen mit Header/Tippanzeige/Drag&Drop) – das Risiko einer reinen
|
||||||
|
`part`/`part-of`-Verschiebung ist strukturell gering (Dart-Compiler hätte
|
||||||
|
jeden fehlenden Import/jede falsche Sichtbarkeit sofort als Analyzer-Fehler
|
||||||
|
gemeldet, nicht erst zur Laufzeit), aber „gering" ist nicht „null", und
|
||||||
|
CLAUDE.md verlangt nach jedem Refactoring-Schritt einen echten App-Start.
|
||||||
|
Danach nächster ROADMAP-Punkt in M2: „Call-Schicht entwirren" – dafür
|
||||||
|
weiterhin Bernds Entscheidung zum Call-Pilot aus „M2: Modul-Schnitt
|
||||||
|
definiert" ausstehend, UND dieser Punkt braucht ohnehin einen PC/Windows-Test
|
||||||
|
(Verhaltensänderung an State-Management, nicht nur Datei-Verschiebung).
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine neuen. Generelle Lehre aus allen 5 Durchgängen:
|
||||||
|
Skript-gestützte Klammer-Zählung + automatisierter Verbatim-Abgleich
|
||||||
|
(„jeder Originalblock muss wortwörtlich in den neuen Dateien auftauchen")
|
||||||
|
war der entscheidende Sicherheitsnetz-Schritt, um trotz fehlendem GUI-Test
|
||||||
|
auf dem Pi mit vertretbarem Risiko fortzufahren – sollte bei künftigen
|
||||||
|
reinen Datei-Umbauten (z. B. `voice_channel.dart` beim Call-Pilot) als
|
||||||
|
Vorgehen wiederverwendet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien)
|
||||||
|
|
||||||
|
**Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik:
|
||||||
|
`features/spaces/space_admin_dialog.dart` (2278 Zeilen, 32 Klassen/Enums +
|
||||||
|
6 lose Top-Level-Helfer) aufgeteilt in 6 Dateien unter
|
||||||
|
`features/spaces/admin/`: `space_admin_core.dart` (`SpaceAdminDialog`,
|
||||||
|
`_SpaceAdminDialogState`, `_DialogHeader` + die breit genutzten Helfer
|
||||||
|
`_kSpaceBannerEvent`, `updatePowerLevelsSafely` (öffentliche Funktion –
|
||||||
|
schützt vor Selbst-Aussperrung beim Ändern der Power-Levels, genutzt von
|
||||||
|
Members- UND Permissions-Tab), `_kPowerAdmin`/`_kPowerMod`/`_kPowerMember`,
|
||||||
|
`_powerLabel`, `_powerColor`), `space_admin_overview.dart` (`_OverviewTab`+State,
|
||||||
|
`_SpaceVisibilityToggle`+State, `_DangerZone`), `space_admin_members.dart`
|
||||||
|
(`_MembersTab`+State, `_InviteButton`+State, `_MemberTile`+State,
|
||||||
|
`_MemberActions`, `_MemberAction`), `space_admin_permissions.dart`
|
||||||
|
(`_PermissionsTab`+State, `_PermissionRow`), `space_admin_channels.dart`
|
||||||
|
(`_ChannelsTab`+State, `_ChannelItem`+State, `_CreateChannelDialog`+State,
|
||||||
|
`_AddExistingDialog`+State + die dort lokal genutzten Helfer `_kSpaceChild`/
|
||||||
|
`_kSpaceParent`/`_kVoiceChannelType`), `space_admin_shared.dart`
|
||||||
|
(`_SectionLabel`, `_Field`, `_SearchField`, `_AvatarEditor`, `_InitialAvatar`).
|
||||||
|
`space_admin_dialog.dart` selbst: nur noch Bibliothekskopf (19 Zeilen).
|
||||||
|
|
||||||
|
Auffällig beim Durchlesen (nicht angefasst, nur notiert): `updatePowerLevelsSafely`
|
||||||
|
ist als einzige Funktion in dieser Datei öffentlich (kein `_`-Präfix), wird
|
||||||
|
aber nirgends außerhalb von `space_admin_dialog.dart` importiert/aufgerufen –
|
||||||
|
vermutlich für einen mal geplanten, nie gebrauchten externen Aufrufer public
|
||||||
|
gemacht. Bewusst nicht auf privat zurückgestuft, um diesen Schritt nicht mit
|
||||||
|
einer inhaltlichen Änderung zu vermischen; wäre ein Kandidat für den nächsten
|
||||||
|
"toter Code"-Durchgang.
|
||||||
|
|
||||||
|
Verifikation wie bei den drei vorigen Umbauten: automatisierte
|
||||||
|
Klammer-Zählung für Blockgrenzen, alle 32 Blöcke + 4 Helfer-Gruppen
|
||||||
|
wortwörtlich wiedergefunden, `class`/`enum`-Anzahl (32) und
|
||||||
|
`Widget build(`-Methoden (20) vorher/nachher identisch, `flutter analyze`
|
||||||
|
unverändert bei 1 bekanntem Hinweis.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Space-
|
||||||
|
Verwaltung öffnen – Übersicht (Sichtbarkeit umschalten, Banner ändern,
|
||||||
|
Danger-Zone-Buttons NUR ansehen, nicht bestätigen), Mitglieder (einladen,
|
||||||
|
Rolle ändern, kicken/bannen – wenn möglich an einem Test-Space, nicht an
|
||||||
|
Utas echtem), Berechtigungen, Kanäle (erstellen, bestehenden Raum hinzufügen).
|
||||||
|
Danach `chat_view.dart` (2116 Zeilen) als letzte der 5 in der Ist-Analyse
|
||||||
|
gefundenen Gott-Dateien – damit wäre der komplette „Ordnerstruktur
|
||||||
|
vereinheitlichen"-Katalog aus M2 abgearbeitet (Häkchen dann setzen).
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine neuen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien)
|
||||||
|
|
||||||
|
**Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt:
|
||||||
|
`features/rooms/rooms_panel.dart` (2555 Zeilen, 37 Klassen/Enums + 4 lose
|
||||||
|
Top-Level-Helfer) aufgeteilt in 8 Dateien unter `features/rooms/panel/`:
|
||||||
|
`rooms_panel_core.dart` (`RoomsPanel`, `_RoomsPanelState` + die
|
||||||
|
Top-Level-Helfer `_kVoiceRoomType`/`_kSpaceBannerType`/`_isVoiceRoom`/
|
||||||
|
`_compareSpaceChildren`, die von mehreren anderen Teilen genutzt werden –
|
||||||
|
deshalb bewusst NICHT in ein eigenes „shared"-File, sondern beim Hauptwidget
|
||||||
|
belassen, wo sie ursprünglich standen), `rooms_header.dart` (`_Header`,
|
||||||
|
`_MenuRow`, `_NotificationsButton`, `_FilterInput`, `_SpaceMenuItem`),
|
||||||
|
`rooms_space_invite.dart` (`_SpaceInviteView`+State), `rooms_space_list.dart`
|
||||||
|
(`_SpaceRoomsList`+State, `_JoinableRoom`+Item+State, `_CategoryHeader`+State),
|
||||||
|
`rooms_list.dart` (`_RoomsList`, `_SectionHeader`+State, `_RoomItem`+State,
|
||||||
|
`_DraggableRoomItem`+State), `rooms_voice.dart` (`_VoiceParticipantList`,
|
||||||
|
`_Initials`, `_VoiceBarBtn`+State), `rooms_invite_item.dart`
|
||||||
|
(`_InviteItem`+State), `rooms_shared.dart` (`_DmAvatar`+State,
|
||||||
|
`_InitialCircle`, `_RoomMenuBtn`+State, `_HoverAction`+State, `_UnreadBadge`).
|
||||||
|
`rooms_panel.dart` selbst: nur noch Bibliothekskopf (Imports + 8
|
||||||
|
`part`-Direktiven, 31 Zeilen).
|
||||||
|
|
||||||
|
Verifikation wie bei den beiden vorigen Umbauten: automatisierte
|
||||||
|
Klammer-Zählung für Blockgrenzen, alle 37 Blöcke + 4 Helfer wortwörtlich in
|
||||||
|
den neuen Dateien wiedergefunden, Anzahl `class`/`enum` (37) und
|
||||||
|
`Widget build(`-Methoden (22) vorher/nachher identisch, `flutter analyze`
|
||||||
|
unverändert bei 1 bekanntem Hinweis. `dart format` wieder bewusst nicht
|
||||||
|
gelaufen.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Raumliste
|
||||||
|
prüfen – Space wechseln, Raum beitreten/verlassen, Voice-Channel-Teilnehmerliste
|
||||||
|
in der Raumliste, Einladung annehmen/ablehnen, Drag&Drop zum Umsortieren,
|
||||||
|
Ungelesen-Badge, DM-Avatare. Danach `space_admin_dialog.dart` (2278 Zeilen)
|
||||||
|
oder `chat_view.dart` (2116 Zeilen) nach demselben Muster – damit wären alle
|
||||||
|
in der Ist-Analyse gefundenen Gott-Dateien abgearbeitet.
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine neuen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `message_group.dart` aufgeteilt (7 Dateien)
|
||||||
|
|
||||||
|
**Erledigt:** Gleiche Technik wie beim `settings_modal.dart`-Umbau direkt
|
||||||
|
darunter angewandt: `features/chat/message_group.dart` (2775 Zeilen, 41
|
||||||
|
Klassen/Enums + 5 lose Top-Level-Hilfsfunktionen) per Dart `part`/`part of`
|
||||||
|
in 7 thematische Dateien unter `features/chat/message/` aufgeteilt:
|
||||||
|
`message_group_core.dart` (`MessageGroup`, `_MessageGroupState`,
|
||||||
|
`_SendFailedRow`, `_MessageBody`, `_ContinuationMessage`(+State), inkl. der in
|
||||||
|
der letzten Session vereinheitlichten `_formatMessageTime`), `message_text.dart`
|
||||||
|
(`_MessageContent`, `_MatrixHtmlText`, `_PlainLinkText`),
|
||||||
|
`message_link_preview.dart` (`_LinkPreview`(+State/Data), `_ReplyPreview`),
|
||||||
|
`message_media.dart` (alle Medientypen: Bild/Audio/Video/Doc/Datei/proprietäre
|
||||||
|
Inline-Karten + die zugehörigen Top-Level-Helfer `_reservedImageSize`/
|
||||||
|
`_classifyMediaError`/`_checkedDownload`/`_mediaFallback` – mit 1000 Zeilen
|
||||||
|
weiterhin der größte Teil, aber thematisch klar abgegrenzt),
|
||||||
|
`message_reactions.dart`, `message_actions.dart` (Hover-Aktionsleiste),
|
||||||
|
`message_shared.dart` (`_Avatar`, `DateDivider` – bleibt öffentlich, da
|
||||||
|
`chat_view.dart` es importiert; über `part`/`part of` weiterhin ohne
|
||||||
|
Umbenennung nutzbar). `message_group.dart` selbst ist jetzt nur noch der
|
||||||
|
Bibliothekskopf (Imports + 7 `part`-Direktiven, 30 Zeilen).
|
||||||
|
|
||||||
|
Verifikation identisch zum Settings-Umbau: automatisierte Klammer-Zählung
|
||||||
|
für die Blockgrenzen (kein manuelles Abtippen), alle 41 Original-Blöcke +
|
||||||
|
5 Helfer wortwörtlich in den neuen Dateien wiedergefunden, Anzahl
|
||||||
|
`class`/`enum` (45) und `Widget build(`-Methoden (27) vorher/nachher
|
||||||
|
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis. `dart format`
|
||||||
|
diesmal bewusst NICHT ausgeführt (siehe Stolperfalle im Eintrag darunter).
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: einen
|
||||||
|
Chat mit mehreren Nachrichtentypen öffnen (Text mit Link-Vorschau, Bild,
|
||||||
|
Video, Audio, Datei-Anhang, Reaktionen setzen, Hover-Aktionsleiste,
|
||||||
|
Datums-Trenner, gescheiterte Nachricht mit Retry) – rein mechanische
|
||||||
|
Verschiebung, aber `message_media.dart` ist der bisher am wenigsten oft
|
||||||
|
geprüfte Bereich (Download/Entschlüsselungs-Fehlerpfade), daher dort genauer
|
||||||
|
hinschauen. Danach ggf. `rooms_panel.dart` (2555 Zeilen) oder
|
||||||
|
`space_admin_dialog.dart` (2278 Zeilen) nach demselben Muster aufteilen.
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine neuen – siehe `dart format`-Hinweis im
|
||||||
|
`settings_modal.dart`-Eintrag darunter, hier von vornherein vermieden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `settings_modal.dart` aufgeteilt (12 Dateien)
|
||||||
|
|
||||||
|
**Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut
|
||||||
|
Ist-Analyse die größte „Gott-Datei". „Call-Schicht entwirren" (nächster
|
||||||
|
ROADMAP-Punkt) bleibt weiter blockiert, da es dabei um Verhaltens-/State-
|
||||||
|
Management-Änderungen an einem CLAUDE.md-„heiligen" Bereich (Calls) geht, die
|
||||||
|
laut Arbeitsregeln nach jedem Schritt einen echten App-Test brauchen –
|
||||||
|
auf dem Pi ohne GUI-Toolchain (kein Android-SDK, kein Linux-Desktop-Build,
|
||||||
|
siehe `flutter doctor`) unmöglich zu prüfen. Stattdessen den in
|
||||||
|
`docs/M2_MODULE_SCHNITT.md` als zweiten (risikoärmeren) Schritt vorgeschlagenen
|
||||||
|
Umbau vorgezogen: reine Datei-Aufteilung ohne Verhaltensänderung, komplett über
|
||||||
|
`flutter analyze` prüfbar.
|
||||||
|
|
||||||
|
- Technik: Dart `part`/`part of` statt eigenständiger Libraries – dadurch
|
||||||
|
bleiben alle privaten Klassen (`_ProfileSection`, `_SettingsGroup`, …) ohne
|
||||||
|
Umbenennung nutzbar, und alle Imports bleiben zentral in `settings_modal.dart`
|
||||||
|
(Part-Dateien brauchen keine eigenen Imports). Kein Klassenname geändert,
|
||||||
|
kein Zeichen Logik angefasst.
|
||||||
|
- 12 neue Dateien unter `lib/widgets/settings/`: `settings_shared.dart`
|
||||||
|
(gemeinsame Low-Level-Widgets wie `_SettingsGroup`/`_Toggle`/`_Divider`),
|
||||||
|
`settings_profile.dart`, `settings_notifications.dart`,
|
||||||
|
`settings_appearance.dart`, `settings_voice.dart`, `settings_keybinds.dart`
|
||||||
|
(Tastaturkürzel-Anzeige, NICHT zu verwechseln mit Verschlüsselungs-Keys),
|
||||||
|
`settings_privacy.dart`, `settings_sessions.dart`, `settings_verification.dart`
|
||||||
|
(SAS/QR-Dialog), `settings_encryption.dart` (inkl. der Megolm-Export/Import-
|
||||||
|
Kryptofunktionen `_runPbkdf2`/`_aesCtr`/`_encryptMegolmKeys`/`_decryptMegolmKeys`,
|
||||||
|
die bisher lose im Datei-Kopf lagen), `settings_account.dart` (Passwort ändern/
|
||||||
|
Account löschen), `settings_about.dart`.
|
||||||
|
- `settings_modal.dart` selbst: von 5541 auf 270 Zeilen geschrumpft – enthält
|
||||||
|
nur noch Imports, die 12 `part`-Direktiven, `SettingsModal` und
|
||||||
|
`_SettingsModalState` (Navigation/Layout).
|
||||||
|
- Verifikation (da kein GUI-Test möglich): Skript-gestützte Extraktion per
|
||||||
|
Klammer-Zählung (kein manuelles Copy-Paste, um Übertragungsfehler
|
||||||
|
auszuschließen); anschließend automatisiert geprüft, dass alle 61
|
||||||
|
ursprünglichen Klassen/Enums wortwörtlich (`in`-Substring-Check) in den neuen
|
||||||
|
Dateien wieder auftauchen; Anzahl `class`/`enum`-Deklarationen (60) und
|
||||||
|
`Widget build(`-Methoden (40) vorher/nachher identisch; alle Lücken zwischen
|
||||||
|
den Klassenblöcken im Original einzeln geprüft – ausschließlich Leerzeilen
|
||||||
|
und dekorative Trennkommentare, kein Code verloren.
|
||||||
|
- `flutter analyze`: weiterhin nur der eine bekannte, zurückgestellte Hinweis
|
||||||
|
(`chat_provider.dart:42`).
|
||||||
|
- **Stolperfalle vermieden:** `dart format` versuchte 40 neue Lint-Hinweise
|
||||||
|
einzuführen (`curly_braces_in_flow_control_structures`) – Ursache: die
|
||||||
|
Originaldatei war selbst nie komplett `dart format`-clean (bestätigt per
|
||||||
|
`dart format --set-exit-if-changed` auf dem Original-Git-Stand), und Reflow
|
||||||
|
einzelner langer Einzeiler-`if`s ohne Klammern erzeugte neue Zeilenumbrüche,
|
||||||
|
die der Linter anders bewertet als die Originalformatierung. Formatierung
|
||||||
|
komplett verworfen und die Dateien unformatiert aus dem Original übernommen,
|
||||||
|
um „keine Verhaltensänderung ohne Not" strikt einzuhalten und die
|
||||||
|
`flutter analyze`-Baseline (1 Hinweis) nicht zu verwässern.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI
|
||||||
|
gesehen". Nächster Windows-Lauf: Einstellungen öffnen, durch alle Reiter
|
||||||
|
klicken (Profil, Benachrichtigungen, Erscheinungsbild, Voice, Tastaturkürzel,
|
||||||
|
Privatsphäre, Sessions, Verschlüsselung inkl. SAS/QR-Verifizierung und
|
||||||
|
Recovery-Key-Anzeige/-Import, Passwort ändern, Account löschen-Dialog öffnen
|
||||||
|
(ohne zu bestätigen!), Über). Da rein mechanisch verschoben, ist das Risiko
|
||||||
|
gering, aber Verschlüsselung/Recovery-Key ist laut CLAUDE.md heilig – bitte
|
||||||
|
gezielt den Recovery-Key-Anzeige- und Passphrase-Export/Import-Flow einmal
|
||||||
|
durchklicken. Danach ROADMAP-Häkchen für „Ordnerstruktur vereinheitlichen"
|
||||||
|
erst setzen, wenn auch die übrigen Gott-Dateien (`message_group.dart`,
|
||||||
|
`rooms_panel.dart`, `space_admin_dialog.dart`, `chat_view.dart`,
|
||||||
|
`document_viewer.dart`) angegangen sind – aktuell nur `settings_modal.dart`
|
||||||
|
erledigt.
|
||||||
|
|
||||||
|
**Stolperfallen:** `dart format` NICHT blind nach einem Refactoring laufen
|
||||||
|
lassen, wenn die Baseline vorher nicht schon formatter-clean war – kann neue,
|
||||||
|
unrelated Lint-Hinweise einführen und die vermeintlich reine Verschiebung mit
|
||||||
|
Formatierungsrauschen vermischen (siehe oben).
|
||||||
|
|
||||||
|
**Fragen an Bernd:** Keine neuen – die offene Frage zum Call-Pilot aus dem
|
||||||
|
Eintrag „M2: Modul-Schnitt definiert" ist weiterhin unbeantwortet, blockiert
|
||||||
|
aber nicht (siehe dort).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Toten Code & Duplikate entfernt (Chat-Timeline)
|
||||||
|
|
||||||
|
**Erledigt:** Vorgriff auf den ROADMAP-Punkt „Toten Code & Duplikate entfernen"
|
||||||
|
(technisch der 5. Punkt in M2, aber risikofrei und ohne GUI testbar – im
|
||||||
|
Gegensatz zum aktuell blockierten „Call-Schicht entwirren", siehe Frage an
|
||||||
|
Bernd im Eintrag darunter):
|
||||||
|
- `lib/features/chat/message_bubble.dart` (178 Zeilen) gelöscht – vollständig
|
||||||
|
toter Code, wurde nirgends importiert/instanziiert (durch `message_group.dart`
|
||||||
|
abgelöst), per `grep` doppelt bestätigt.
|
||||||
|
- Die zwei identischen `_formatTime`-Methoden in `message_group.dart`
|
||||||
|
(`_MessageGroupState` Zeile 322, `_ContinuationMessageState` Zeile 475 –
|
||||||
|
Vorher-Zeilennummern) zu einer gemeinsamen Top-Level-Funktion
|
||||||
|
`_formatMessageTime()` zusammengeführt, beide Aufrufstellen umgestellt.
|
||||||
|
Reine Extraktion, keine Verhaltensänderung (identischer Code vorher/nachher).
|
||||||
|
`pinned_messages_panel.dart:_formatTime` bewusst NICHT angefasst – hat
|
||||||
|
andere Logik (relatives Datum „vor Xd"), ist kein echtes Duplikat.
|
||||||
|
- `flutter analyze` weiterhin sauber: nur der eine bekannte, bewusst
|
||||||
|
zurückgestellte Hinweis (`chat_provider.dart:42`, `Timeline.onUpdate`
|
||||||
|
deprecated, hängt am E2EE-Redecrypt-Pfad, siehe M0-Eintrag).
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI
|
||||||
|
gesehen" – kein GUI auf diesem Pi verfügbar. Da es sich aber um reine
|
||||||
|
Code-Verschiebung ohne Logikänderung handelt (identischer Funktionskörper),
|
||||||
|
ist das Risiko gering; trotzdem beim nächsten Windows-Lauf kurz einen
|
||||||
|
Chat mit mehreren aufeinanderfolgenden Nachrichten (Zeit-Anzeige) und eine
|
||||||
|
Fortsetzungsnachricht (Hover zeigt Uhrzeit) ansehen.
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Modul-Schnitt definiert (docs/M2_MODULE_SCHNITT.md)
|
||||||
|
|
||||||
|
**Erledigt:** Ausgehend von der Ist-Analyse (Eintrag darunter) die konkreten
|
||||||
|
Modul-Grenzen + öffentliche Schnittstellen entworfen, dokumentiert in
|
||||||
|
`docs/M2_MODULE_SCHNITT.md`. Reine Planung, kein Code geändert. Wichtigste
|
||||||
|
Erkenntnis beim genaueren Lesen von `voip_manager.dart` und
|
||||||
|
`livekit_call_manager.dart`: „Calls" ist architektonisch **kein einzelnes
|
||||||
|
Feature**, sondern zwei unabhängige Mechanismen, die getrennt bleiben sollten:
|
||||||
|
- `PyramidVoipManager` = natives Matrix-1:1-VoIP (`m.call.*`, direkte
|
||||||
|
WebRTC-Peer-Connection, kein SFU)
|
||||||
|
- `LiveKitCallManager` = SFU-basierte Voice-Channels (LiveKit-Raum, Presence
|
||||||
|
via Matrix-State-Event, Screensharing, Mehrpersonen) – das ist der
|
||||||
|
Discord-artige Kanal aus M3/M4
|
||||||
|
|
||||||
|
Modul-Liste im Dokument: `call_signaling`, `voice_channel`, `call_ui`,
|
||||||
|
`settings` (aufgesplittet in Sektionsdateien), `verification` (neu, aus
|
||||||
|
Settings herausgelöst – SAS/QR/Recovery-Key), `storage` (Ausbau der
|
||||||
|
bestehenden `settings_prefs.dart`-Abstraktion zur Pflicht), `auth` (schon
|
||||||
|
relativ sauber, kein akuter Bedarf), `push` (schon getrennt, eigener
|
||||||
|
`sqflite`-Zugriff in `background_push.dart` ist bewusst so, kein
|
||||||
|
Refactoring-Kandidat), `chat_timeline`/`rooms` (niedrigste Priorität, da
|
||||||
|
strukturell am wenigsten verwoben – Hauptproblem dort ist Dateigröße/Duplikate,
|
||||||
|
nicht Kopplung). Jeweils mit Dart-Interface-Skizze bzw. Datei-Zielliste.
|
||||||
|
|
||||||
|
Empfohlene Umsetzungsreihenfolge: 1) Call-Pilot (`call_signaling` +
|
||||||
|
`voice_channel` + `call_ui`, deckt gleich zwei ROADMAP-Punkte ab), 2)
|
||||||
|
`settings_modal.dart` aufsplitten (geringes Risiko, reine Datei-Teilung),
|
||||||
|
3) `verification` herauslösen, 4) `storage`-Fassade schrittweise erzwingen,
|
||||||
|
5) `chat_timeline`/`rooms` zuletzt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Wartet auf Bernds Entscheidung, ob der Call-Pilot
|
||||||
|
wie vorgeschlagen als Nächstes umgesetzt wird (siehe Frage unten). Erst danach
|
||||||
|
den ROADMAP-Punkt „Call-Schicht entwirren" wirklich anfassen – das ist die
|
||||||
|
erste Stelle mit echtem Verhaltensrisiko in M2 (CLAUDE.md: nach Refactoring-
|
||||||
|
Schritten immer App starten + Kernflows prüfen, hier zusätzlich brisant, weil
|
||||||
|
GUI-Test auf dem Pi nicht möglich ist).
|
||||||
|
|
||||||
|
**Stolperfallen:** Der erste Ist-Analyse-Durchgang (Subagent) hatte einen
|
||||||
|
falschen Befund (`rooms_provider.dart`-Import in `app_state.dart` sei toter
|
||||||
|
Code) – beim genaueren Lesen für den Modul-Schnitt widerlegt
|
||||||
|
(`voiceParticipantsProvider` nutzt `roomListProvider` daraus). Bereits im
|
||||||
|
Ist-Analyse-Eintrag korrigiert. Lehre: Subagent-Ist-Analysen sind ein guter
|
||||||
|
Startpunkt, aber vor jedem darauf aufbauenden Schritt einzelne Befunde
|
||||||
|
gegenlesen, nicht blind übernehmen.
|
||||||
|
|
||||||
|
**Fragen an Bernd:**
|
||||||
|
- Call-Pilot wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen (zuerst
|
||||||
|
`call_signaling`/`voice_channel`/`call_ui` entwirren) als nächster
|
||||||
|
Umsetzungsschritt in Ordnung, oder lieber zuerst `settings_modal.dart`
|
||||||
|
aufsplitten (geringeres Risiko, aber deckt keinen ROADMAP-Punkt aus M2
|
||||||
|
direkt ab)? Ohne GUI-Zugriff auf dem Pi kann der riskantere Call-Umbau
|
||||||
|
hier ohnehin nicht praktisch getestet werden – das spricht eher für
|
||||||
|
„auf dem PC anfangen", falls Zeitdruck besteht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Ist-Analyse der Architektur geschrieben
|
||||||
|
|
||||||
|
**Erledigt:** M1 hat aktuell keinen auf dem Pi bearbeitbaren Punkt mehr offen
|
||||||
|
(SQLCipher wartet auf Bernds Priorisierung, Härtetest braucht GUI/PC – siehe
|
||||||
|
Fragen an Bernd unten und Eintrag 2026-07-03 „3 schon vorhanden, 1 echte
|
||||||
|
Lücke"). Deshalb mit M2 weitergemacht, erster Punkt: Ist-Analyse. Per
|
||||||
|
Code-Lektüre (Zeilenzahlen, Import-Graph, grep) folgende Schwachstellen
|
||||||
|
gefunden:
|
||||||
|
|
||||||
|
- **Gott-Dateien:** `widgets/settings_modal.dart` mit 5541 Zeilen / 50+ Klassen
|
||||||
|
bündelt mindestens 8 unabhängige Bereiche (Profil, Benachrichtigungen,
|
||||||
|
Voice/TURN, E2EE-Verifizierung inkl. SAS/QR, Sessions, Account, Erscheinungsbild,
|
||||||
|
Updates). Weitere große Dateien: `message_group.dart` (2780), `rooms_panel.dart`
|
||||||
|
(2555), `space_admin_dialog.dart` (2278), `chat_view.dart` (2116),
|
||||||
|
`document_viewer.dart` (1773), `voice_channel.dart` (1123), `app_shell.dart` (1073).
|
||||||
|
- **Call-Schicht unsauber verteilt statt hinter einer Schnittstelle:**
|
||||||
|
`core/voip_manager.dart` (Matrix-VoIP-Signalisierung), `core/livekit_call_manager.dart`
|
||||||
|
(LiveKit-Transport), `features/call/voice_channel.dart` + `mini_call_widget.dart`
|
||||||
|
(UI) – aber `chat_view.dart`, `matrix_client.dart`, `settings_modal.dart` greifen
|
||||||
|
alle DIREKT auf die Manager zu statt über eine gemeinsame Call-Fassade. Vier
|
||||||
|
verschiedene Module importieren `voice_channel.dart` direkt.
|
||||||
|
Genau das Gegenteil des „austauschbare Bausteine"-Ziels aus CLAUDE.md.
|
||||||
|
- **State-Management gemischt:** 2 zentrale `ChangeNotifier`-Singletons
|
||||||
|
(`voip_manager.dart`, `livekit_call_manager.dart`) statt Riverpod-`Notifier`,
|
||||||
|
eingebunden über `app_state.dart` (`callStateProvider`, `voipStateProvider`).
|
||||||
|
Migration sinnvoll, aber riskant wegen breiter Call-Sites und enger
|
||||||
|
SDK-Callback-Kopplung (LiveKit-`Room`-Events, Matrix-`WebRTCDelegate`) – sollte
|
||||||
|
zusammen mit der Call-Fassade angegangen werden, nicht isoliert.
|
||||||
|
- **`core/` ist nicht rein generisch:** importiert Feature-Code direkt
|
||||||
|
(`app.dart`/`router.dart` → `features/auth/*`, `matrix_client.dart` instanziiert
|
||||||
|
`PyramidVoipManager` direkt). `app_state.dart` selbst ist ein Sammelbecken für
|
||||||
|
Theme-, Call-, Voice-, Share-Target- und Spaces-Provider aus praktisch allen
|
||||||
|
Features – zentraler Kopplungspunkt, der jedem Modulschnitt im Weg steht.
|
||||||
|
(Korrektur beim genaueren Hinsehen für den Modul-Schnitt-Punkt: der
|
||||||
|
`rooms_provider.dart`-Import dort ist entgegen der ersten Vermutung KEIN
|
||||||
|
toter Altlast-Import – `voiceParticipantsProvider` nutzt `roomListProvider`
|
||||||
|
daraus. Zeigt: Subagent-Befunde vor dem Weiterbauen selbst nachprüfen.)
|
||||||
|
- **Keine gemeinsame Storage-Schnittstelle:** `SharedPreferences.getInstance()`
|
||||||
|
wird an 12 Stellen direkt aufgerufen, obwohl `core/settings_prefs.dart` bereits
|
||||||
|
eine `StateNotifier`-Abstraktion (`BoolPref`, `StringSetPref`) anbietet – wird
|
||||||
|
meist umgangen. `sqflite` wird zusätzlich unabhängig in `background_push.dart`
|
||||||
|
nochmal geöffnet (eigene DB-Instanz fürs Background-Isolate). `media_cache.dart`
|
||||||
|
ist dagegen sauber gekapselt – gutes Vorbild für die anderen Speicherzugriffe.
|
||||||
|
- **Toter Code bestätigt:** `features/chat/message_bubble.dart` (178 Zeilen,
|
||||||
|
Klasse `MessageBubble`) wird nirgends instanziiert – von `message_group.dart`
|
||||||
|
abgelöst, kann beim Refactoring entfernt werden.
|
||||||
|
- **Duplikate:** `_formatTime`/`_formatDate` unabhängig implementiert in
|
||||||
|
`pinned_messages_panel.dart:206`, `message_group.dart:322` UND `:475` (zwei Mal
|
||||||
|
im selben File!), sowie im toten `message_bubble.dart:80/165`.
|
||||||
|
|
||||||
|
**Modul-Kandidaten für den nächsten Punkt („Modul-Schnitt definieren"):**
|
||||||
|
1. `call_signaling` (Matrix-VoIP) – `core/voip_manager.dart`
|
||||||
|
2. `call_transport` (LiveKit) – `core/livekit_call_manager.dart`
|
||||||
|
3. `call_ui` (nur UI, konsumiert 1+2 über Interface) – `features/call/*`
|
||||||
|
4. `settings/*` aufsplitten: profile, encryption_verification, sessions,
|
||||||
|
notifications, appearance, account – aus `widgets/settings_modal.dart`
|
||||||
|
5. `verification` als eigenständiges Feature (SAS/QR/Recovery-Key), nicht
|
||||||
|
Teil von Settings – wiederverwendbar (z. B. künftig beim Login)
|
||||||
|
6. `storage` – gemeinsame Fassade, `settings_prefs.dart` als Pflichtzugang
|
||||||
|
statt 12 Direktzugriffsstellen
|
||||||
|
7. `app_state.dart` auflösen in feature-lokale Provider-Dateien
|
||||||
|
8. `chat_timeline` – `message_group.dart` + `pinned_messages_panel.dart`,
|
||||||
|
dabei toten Code (`message_bubble.dart`) entfernen, `_formatTime`/`_formatDate`
|
||||||
|
in `chat/format_utils.dart` zusammenführen
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** M2, zweiter Punkt – „Modul-Schnitt definieren":
|
||||||
|
aus der obigen Kandidatenliste konkrete Modul-Interfaces (Dart-Abstract-Classes
|
||||||
|
o. ä.) entwerfen, dann EIN Modul zuerst umbauen (Vorschlag: `call_*`, da am
|
||||||
|
klarsten abgegrenzt und am dringendsten laut CLAUDE.md-Leitprinzip) als Pilot,
|
||||||
|
bevor der Rest folgt. Kein Codeumbau in diesem Schritt, nur Schnittstellen-Design.
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine – reine Lesearbeit, kein Risiko für Bestandsdaten.
|
||||||
|
Analyse wurde von einem Explore-Subagent erstellt und stichprobenartig
|
||||||
|
plausibilisiert (Zeilenzahlen/Imports selbst nachgezählt), nicht jede
|
||||||
|
Einzelbehauptung im Detail nachverifiziert.
|
||||||
|
|
||||||
|
**Fragen an Bernd:**
|
||||||
|
- SQLCipher-Priorität weiterhin offen (siehe Eintrag „3 schon vorhanden, 1 echte
|
||||||
|
Lücke" weiter unten) – blockiert nicht, aber ohne Antwort bleibt der M1-Punkt
|
||||||
|
bis zum nächsten PC-Praxistest liegen.
|
||||||
|
- Für M2: Soll ich die Call-Schicht als erstes Pilot-Modul umbauen (mein
|
||||||
|
Vorschlag oben), oder hast du eine andere Präferenz (z. B. erst die
|
||||||
|
`settings_modal.dart` aufsplitten, weil die am unübersichtlichsten ist)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M1: Restliche Punkte geprüft – 3 schon vorhanden, 1 echte Lücke gefunden
|
||||||
|
|
||||||
|
**Erledigt:**
|
||||||
|
- **Logout-Warnung bei fehlendem Key-Backup** (Commit b5762ea): neue Funktion
|
||||||
|
`isKeyBackupMissing(client)` in `bootstrap_dialog.dart` (prüft Cross-Signing +
|
||||||
|
`keyManager.isCached()`). Beide Logout-Dialoge in `settings_modal.dart` zeigen
|
||||||
|
jetzt eine rote Klartext-Warnung statt des neutralen Textes, wenn kein Backup
|
||||||
|
eingerichtet ist.
|
||||||
|
- **Drei ROADMAP-Punkte waren bereits umgesetzt** (nur nicht abgehakt) – durch
|
||||||
|
Code-Lektüre verifiziert, nicht neu gebaut:
|
||||||
|
- Key-Backup einrichten/wiederherstellen: `bootstrap_dialog.dart` deckt beides
|
||||||
|
über den `Bootstrap`-Zustandsautomaten des matrix-SDK ab (`askNewSsss` für
|
||||||
|
Neueinrichtung, `askUnlockSsss` fragt auf einem neuen Gerät nach dem
|
||||||
|
Recovery-Key). Wird automatisch getriggert, wenn Cross-Signing fehlt
|
||||||
|
(`app_shell.dart:_triggerBootstrap`).
|
||||||
|
- Geräte-/Sessionverwaltung: `_SessionsSection` in `settings_modal.dart` –
|
||||||
|
Liste, Umbenennen, SAS/QR-Geräteverifizierung, Einzel- und
|
||||||
|
Massen-Abmeldung (mit Passwort-Reauth bei UIA-Anforderung).
|
||||||
|
- **Echte Sicherheitslücke gefunden:** `sqlcipher_flutter_libs` ist eine
|
||||||
|
Abhängigkeit in `pubspec.yaml`, wird aber **nirgends im Code benutzt**.
|
||||||
|
`matrix_client.dart` (Haupt-App) und `background_push.dart` (Push-Hintergrund)
|
||||||
|
öffnen `pyramid.sqlite` beide über den normalen, unverschlüsselten
|
||||||
|
`sqflite`/`sqflite_common_ffi`-Factory. Die Krypto-DB (Access-Token,
|
||||||
|
gepickelter Olm-Account, Megolm-Sessions, komplette Nachrichtenhistorie)
|
||||||
|
liegt also **im Klartext** auf der Platte – nur durch Androids App-Sandbox
|
||||||
|
geschützt, nicht durch eine eigene Verschlüsselung.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** SQLCipher-Anbindung NICHT blind auf dem Pi
|
||||||
|
umgesetzt – siehe Stolperfalle unten. Braucht einen eigenen, vorsichtig
|
||||||
|
geplanten PC-Termin: Backup der Test-DB, Migrationscode (bestehende
|
||||||
|
Klartext-DB einmalig nach SQLCipher überführen), Test auf einem Gerät mit
|
||||||
|
echten Daten, erst dann Rollout.
|
||||||
|
|
||||||
|
**Fragen an Bernd:**
|
||||||
|
- Priorität der SQLCipher-Lücke: Soll das vor M2 (Refactoring) angegangen
|
||||||
|
werden, oder reicht die App-Sandbox als Schutz erstmal aus (Angreifer bräuchte
|
||||||
|
Root oder physischen Zugriff + ADB, um die Datei überhaupt zu lesen)? Die
|
||||||
|
Migration bestehender Installationen (Uta!) ist der aufwändige/riskante Teil,
|
||||||
|
nicht die Verschlüsselung selbst.
|
||||||
|
|
||||||
|
**Stolperfallen:**
|
||||||
|
- Eine DB-Verschlüsselungs-Migration ist genau die Art Änderung, die laut
|
||||||
|
CLAUDE.md nicht "mal eben" auf dem Pi ohne Testgerät gemacht werden darf:
|
||||||
|
scheitert die Migration (falscher Schlüssel, falsche PRAGMA-Reihenfolge,
|
||||||
|
Timing zwischen Haupt-App und Push-Hintergrund-Client), sind Utas
|
||||||
|
Nachrichten und Krypto-Schlüssel unwiederbringlich weg. Deshalb hier nur
|
||||||
|
dokumentiert, nicht implementiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M1: Push-Regression vermutlich dieselbe Ursache wie Random-Logout
|
||||||
|
|
||||||
|
**Erledigt:** Vor dem Blick in den Push-Code die Vermutung geprüft, ob die
|
||||||
|
Push-Entschlüsselungs-Regression und der Uta-Random-Logout-Bug (siehe Eintrag
|
||||||
|
weiter unten) zusammenhängen – Ergebnis: **sehr wahrscheinlich ja, exakt
|
||||||
|
dieselbe Ursache.**
|
||||||
|
|
||||||
|
- `client.getEventByPushNotification(...)` (aufgerufen in `_bgDecryptAndShow`,
|
||||||
|
`background_push.dart:84`) ruft als ALLERERSTES `ensureNotSoftLoggedOut()`
|
||||||
|
auf (matrix-Paket `client.dart:1878`). Diese Funktion prüft, ob der
|
||||||
|
Access-Token in < 1 Minute abläuft, und löst dann `_handleSoftLogout()` aus
|
||||||
|
— **dieselbe Funktion**, die ohne Refresh-Token (unser Zustand vor Commit
|
||||||
|
9dc83f7) sofort einen echten `client.logout()` auslöst (siehe Eintrag unten).
|
||||||
|
- Der Hintergrund-Client aus `_buildClient()` liest **dieselbe** `pyramid.sqlite`
|
||||||
|
wie der Haupt-Client. Ein `logout()`/`clear()` aus dem Hintergrund-Isolat
|
||||||
|
würde also die komplette Session (inkl. Krypto-Schlüssel) für die GESAMTE
|
||||||
|
App zerstören – nicht nur für den einen Push. Das erklärt, warum das Problem
|
||||||
|
als dauerhafte Regression erscheint ("seit dem letzten Update nie wieder"):
|
||||||
|
Nach dem ersten Auto-Logout-Ereignis (ausgelöst beim Sync ODER beim
|
||||||
|
Entschlüsseln eines Pushs) sind die Schlüssel weg, jede folgende
|
||||||
|
Entschlüsselung schlägt fehl → `BgEngine` zeigt nur noch den
|
||||||
|
„Neue Nachricht"-Platzhalter (Fallback nach 6 s), nie mehr den echten Text.
|
||||||
|
- Der bereits committete Fix (`refreshToken: true` beim Login, 9dc83f7) behebt
|
||||||
|
damit vermutlich BEIDE Symptome gleichzeitig: der Refresh-Token liegt in der
|
||||||
|
gemeinsamen DB, also kann auch der Hintergrund-Client ihn lesen und einen
|
||||||
|
drohenden Soft-Logout durch stillen Refresh abwenden, statt die Session zu
|
||||||
|
zerstören.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Gehört zum selben Praxistest wie der
|
||||||
|
Random-Logout-Punkt oben – zusätzlich beobachten, ob nach einer Weile
|
||||||
|
(mehrere Stunden/Tage) echte Push-Inhalte statt Platzhalter ankommen. Schlägt
|
||||||
|
das fehl, liegt eine zweite, unabhängige Ursache vor (dann `docs/NOTIFICATIONS.md`
|
||||||
|
+ Memory „Pyramid Push" erneut konsultieren, insbesondere die 3 dort gelisteten
|
||||||
|
Fallstricke der Hintergrund-Entschlüsselung).
|
||||||
|
|
||||||
|
**Stolperfallen:** Diese Verbindung wurde rein durch Lesen des matrix-SDK-
|
||||||
|
Quellcodes hergeleitet (`~/.pub-cache/hosted/pub.dev/matrix-6.2.0/lib/src/client.dart`),
|
||||||
|
nicht durch Beobachtung auf einem echten Gerät – bewusst als Vermutung
|
||||||
|
markiert, bis der PC-Praxistest sie bestätigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M1: Uta-Random-Logout – Ursache gefunden + Fix (UNGETESTET/Pi)
|
||||||
|
|
||||||
|
**Erledigt:**
|
||||||
|
- Alle Logout-Auslöser im Code kartiert:
|
||||||
|
- 3 bewusste Nutzer-Logout-Buttons in `settings_modal.dart` (alle hinter
|
||||||
|
Bestätigungsdialog) – kein Kandidat für "random"
|
||||||
|
- Der eigentliche Verdächtige: `matrix`-Paket (v6.2.0) `Client._innerSync()`
|
||||||
|
behandelt `M_UNKNOWN_TOKEN` vom `/sync`-Endpunkt so: bei `soft_logout: true`
|
||||||
|
→ `_handleSoftLogout()` → versucht `refreshAccessToken()` → **das SDK
|
||||||
|
hatte nie einen Refresh-Token angefordert** (`client.login(...)` in
|
||||||
|
`login_notifier.dart` rief ohne `refreshToken: true` auf) → `refreshAccessToken()`
|
||||||
|
wirft sofort "No refresh token available" → Catch-Block ruft `await logout()`
|
||||||
|
auf: ein **echter, destruktiver Logout**, ausgelöst rein durch einen normalen
|
||||||
|
Soft-Logout (z. B. abgelaufenes Access-Token), ganz ohne Nutzerinteraktion.
|
||||||
|
Quelle: `~/.pub-cache/hosted/pub.dev/matrix-6.2.0/lib/src/client.dart:2374-2384`
|
||||||
|
und `:2496-2509`.
|
||||||
|
- **Logging (Commit 63e4919):** neue `lib/core/auth_log.dart` schreibt jeden
|
||||||
|
Login-Status-Wechsel (`client.onLoginStateChanged`) sowie SDK-Warnungen/-Fehler
|
||||||
|
(`Logs().onLog`) in `auth_log.txt` im App-Support-Verzeichnis – übersteht
|
||||||
|
App-Neustart. Zusätzlich loggen alle 3 Nutzer-Logout-Buttons sich selbst, damit
|
||||||
|
sich künftig unterscheiden lässt: Nutzer-Logout vs. SDK-Auto-Logout. Reine
|
||||||
|
Zusatzfunktion, keine Verhaltensänderung.
|
||||||
|
- **Fix (Commit 9dc83f7):** `client.login(...)` fordert jetzt `refreshToken: true`
|
||||||
|
an. Damit kann das SDK einen Soft-Logout künftig durch stillen Token-Refresh
|
||||||
|
überstehen, statt die Sitzung zu zerstören. Unterstützt der Server keine
|
||||||
|
Refresh-Tokens, bleibt das Verhalten exakt wie vorher (`response.refreshToken`
|
||||||
|
ist dann `null`) – der Fix kann also nichts verschlimmern.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:**
|
||||||
|
1. **Praxistest auf dem PC (zwingend vor Haken in ROADMAP!):** Login → Nachrichten
|
||||||
|
senden/lesen → Logout → erneuter Login → alte verschlüsselte Nachrichten noch
|
||||||
|
lesbar? Dabei prüfen, ob `auth_log.txt` (App-Support-Verzeichnis) die
|
||||||
|
Login-Status-Wechsel sauber mitschreibt.
|
||||||
|
2. Danach: den nächsten Punkt in M1 angehen (Push-Entschlüsselungs-Regression).
|
||||||
|
3. **Wichtig für Bernd/Uta:** Der Fix wirkt nur für NEUE Logins. Utas bereits
|
||||||
|
bestehende Sitzung bekommt erst nach einem einmaligen manuellen Neu-Login
|
||||||
|
einen Refresh-Token und ist bis dahin weiterhin anfällig. Sobald der PC-Test
|
||||||
|
grün ist, sollte Uta sich einmal aus- und wieder einloggen.
|
||||||
|
|
||||||
|
**Stolperfallen:**
|
||||||
|
- Konnte auf dem Pi nicht mit echtem Login/Logout getestet werden (kein GUI,
|
||||||
|
kein Matrix-Testaccount zur Hand) – Fix beruht auf genauem Lesen des
|
||||||
|
matrix-SDK-Quellcodes, nicht auf Beobachtung im Betrieb. Deshalb zwingend
|
||||||
|
vor dem nächsten Haken in ROADMAP am PC nachprüfen.
|
||||||
|
- `chat_provider.dart:42` nutzt weiterhin das deprecated `Timeline.onUpdate`
|
||||||
|
(Re-Entschlüsseln nach Schlüssel-Empfang) – hängt mit demselben Sync-Bereich
|
||||||
|
zusammen, aber bewusst nicht angefasst, um nicht zwei riskante Änderungen im
|
||||||
|
selben Bereich gleichzeitig ungetestet zu committen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M0 abgeschlossen: Ordnung, flutter analyze sauber, README
|
||||||
|
|
||||||
|
**Erledigt:**
|
||||||
|
- Liegen gebliebene Deprecation-Fixes aus einer abgebrochenen Session committet
|
||||||
|
(`withOpacity`→`withValues`, `activeColor`→`activeThumbColor`, `cacheExtent`→
|
||||||
|
`ScrollCacheExtent`, `PublicRoomsChunk`→`PublishedRoomsChunk`) – Commit 057ac1e
|
||||||
|
- Restliche `flutter analyze`-Hinweise behoben – Commit 401bd6a:
|
||||||
|
- `document_viewer.dart`: `Matrix4.translate/scale` → `translateByDouble`/
|
||||||
|
`scaleByDouble` (PDF-Zoom, gleiches Verhalten)
|
||||||
|
- `rooms_panel.dart`: `onReorder` → `onReorderItem` (Index-Korrektur jetzt intern,
|
||||||
|
manuelles `newIdx--` entfernt); `implementation_imports`-Hinweis kommentiert
|
||||||
|
(SpaceChild wird vom `matrix`-Paket nicht öffentlich exportiert, kein Fix möglich)
|
||||||
|
- `voip_manager.dart`: `getSources()`-Override kommentiert (Pflicht-Override der
|
||||||
|
abstrakten `MediaDevices`-Klasse, die die Methode selbst deprecated hat)
|
||||||
|
- `settings_modal.dart`: zwei `BuildContext`-nach-`await`-Stellen mit
|
||||||
|
`context.mounted` abgesichert (Geräte umbenennen, Passwortabfrage bei Massenlogout)
|
||||||
|
- **Bewusst NICHT gefixt:** `chat_provider.dart:42` (`Timeline.onUpdate` deprecated,
|
||||||
|
Ersatz wäre `client.onSync` + Raum-Filter). Hängt am E2EE-Redecrypt-Pfad
|
||||||
|
(Re-Entschlüsseln nach Schlüssel-Empfang) – laut CLAUDE.md heiliger Bereich,
|
||||||
|
Umbau nur mit dediziertem Login→Nachrichten→Logout→Login-Test, nicht nebenbei.
|
||||||
|
- `lib/features/calls/` (Leiche) geprüft: existiert nicht mehr, nur `lib/features/call/`
|
||||||
|
mit den echten Dateien – Punkt war bereits erledigt, nur nicht abgehakt.
|
||||||
|
- README.md durch echte Projektbeschreibung ersetzt (Funktionsumfang, Build, Struktur) – Commit 74d38fd
|
||||||
|
- ROADMAP.md: M0 komplett abgehakt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** M1, erster Punkt – Uta-Random-Logout-Bug untersuchen
|
||||||
|
(Logging an allen Logout-Pfaden einbauen, dann Ursache finden). Direkt danach die
|
||||||
|
Push-Entschlüsselungs-Regression (`docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
|
||||||
|
vorher lesen).
|
||||||
|
|
||||||
|
**Stolperfallen:**
|
||||||
|
- Kein `test/`-Ordner vorhanden → `flutter test` läuft ins Leere, keine automatisierte
|
||||||
|
Absicherung. `flutter analyze` lief sauber (0 Fehler/Warnungen), aber die Lint-Fixes
|
||||||
|
in `document_viewer.dart` (PDF-Zoom) und `rooms_panel.dart` (Drag-Reorder der
|
||||||
|
Raumliste) sind **UNGETESTET** in echter UI – nächster Windows-Lauf sollte kurz
|
||||||
|
PDF-Zoom und Raum-Umsortieren per Drag prüfen.
|
||||||
|
- Der `ignore:`-Kommentar für einen deprecated-Aufruf muss auf der Zeile *unmittelbar*
|
||||||
|
über der betroffenen Codezeile stehen, nicht nur "in der Nähe" – sonst wirkt er nicht
|
||||||
|
(bei `voip_manager.dart` erst falsch über `@override` gesetzt, dann korrigiert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – Arbeitsstruktur angelegt + Vollbackup (Setup, noch kein Code)
|
||||||
|
|
||||||
|
**Erledigt:**
|
||||||
|
- Vollbackup inkl. Git-Historie: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz` (510 MB)
|
||||||
|
- `CLAUDE.md` (Arbeitsregeln), `ROADMAP.md` (M0–M7 nach Bernds Prioritäten), dieses Protokoll
|
||||||
|
- Klargestellt: `C:\Users\nordm\pyramid - Kopie` ist das EINZIGE aktuelle Repo;
|
||||||
|
`C:\Users\nordm\pyramid` und `MatrixPi\pyramid` sind veraltete April-Stände (Commit c9af913)
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** M0, Punkt 2 – die ~140 uncommitteten Dateien mit `git status`
|
||||||
|
sichten und in thematische Commits aufteilen (diese drei neuen Dateien mit committen),
|
||||||
|
dann `git push origin master`.
|
||||||
|
|
||||||
|
**Stolperfallen:**
|
||||||
|
- Letzter Commit ist vom 2026-04-28 (d706ace), gearbeitet wurde aber bis mindestens
|
||||||
|
2026-06-12 → zwischen Commits und Realität liegen 6 Wochen. Genau deshalb: ab jetzt
|
||||||
|
kleine Commits nach jedem Schritt.
|
||||||
|
- Git-Remote (Gitea auf dem Pi) enthält Zugangsdaten in der URL – funktioniert, aber
|
||||||
|
nur erreichbar, wenn der Pi läuft (unterwegs: WireGuard nötig).
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# Pyramid – Entwicklungsprotokoll
|
||||||
|
Stand: 24.04.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektübersicht
|
||||||
|
|
||||||
|
**Pyramid** ist ein Flutter-basierter Matrix-Client, modelliert nach einem FluffyChat-Fork,
|
||||||
|
mit zwei parallelen Call-Systemen:
|
||||||
|
|
||||||
|
| System | Einsatz | Datei |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| **LiveKit** (`livekit_client`) | Gruppen-Sprachräume | `lib/core/livekit_call_manager.dart` |
|
||||||
|
| **Matrix VoIP** (`matrix` SDK) | 1:1 Direktnachrichten | `lib/core/voip_manager.dart` |
|
||||||
|
|
||||||
|
Server-Infrastruktur läuft auf einem Raspberry Pi (`steggi-matrix.work`), LiveKit unter
|
||||||
|
`livekit.steggi-matrix.work`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erledigte Änderungen
|
||||||
|
|
||||||
|
### 1. LiveKit – Qualitäts-Bugs behoben (`livekit_call_manager.dart`)
|
||||||
|
|
||||||
|
**Problem:** Streamer und Zuschauer konnten die Übertragungsqualität nicht ändern.
|
||||||
|
|
||||||
|
**Ursachen:**
|
||||||
|
- `_screenShareEncoding()` hatte einen hardcodierten `switch ('balanced')` — gab immer
|
||||||
|
halbe Bitrate zurück, unabhängig vom eingestellten Qualitäts-Key.
|
||||||
|
- `setSubscribeQuality('auto')` sprang vorzeitig raus und setzte die Qualität nie.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```dart
|
||||||
|
VideoEncoding _screenShareEncoding() {
|
||||||
|
return _screenShareEncodings[_localQualityKey] ??
|
||||||
|
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`setSubscribeQuality` wendet jetzt immer die Qualität an, auch bei 'auto'.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. LiveKit – Track-Events lösen UI-Rebuild aus (`livekit_call_manager.dart`)
|
||||||
|
|
||||||
|
**Problem:** Kamera-Feed wurde nach dem Publishen nicht angezeigt, weil kein Widget-Rebuild ausgelöst wurde.
|
||||||
|
|
||||||
|
**Fix:** Handler für alle Track-Events hinzugefügt:
|
||||||
|
```dart
|
||||||
|
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
|
||||||
|
..on<TrackPublishedEvent>((_) => notifyListeners())
|
||||||
|
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||||
|
..on<TrackSubscribedEvent>((_) => notifyListeners())
|
||||||
|
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
|
||||||
|
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
||||||
|
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. LiveKit – Optimistic Mute (`livekit_call_manager.dart`)
|
||||||
|
|
||||||
|
**Problem:** Mute-Button hatte eine spürbare Verzögerung (async API-Call).
|
||||||
|
|
||||||
|
**Fix:** `notifyListeners()` wird jetzt vor dem `await` aufgerufen:
|
||||||
|
```dart
|
||||||
|
Future<void> toggleMute() async {
|
||||||
|
isMuted = !isMuted;
|
||||||
|
notifyListeners(); // Sofort
|
||||||
|
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. VoIP – Kamera-Fix (`voip_manager.dart`)
|
||||||
|
|
||||||
|
**Problem:** Kamera-Tracks wurden nicht korrekt zugewiesen, weil der Renderer bereits belegt
|
||||||
|
wurde bevor Video-Daten verfügbar waren.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Track-Zuweisung erfolgt jetzt erst wenn `getVideoTracks().isNotEmpty` — kein Renderer-Update
|
||||||
|
auf leere Streams mehr.
|
||||||
|
- `_isCameraMuted` wird bei eingehenden Calls korrekt aus dem Call-Typ gesetzt:
|
||||||
|
`_isCameraMuted = call.type == CallType.kVoice;`
|
||||||
|
- Bei `kConnected`-State wird `setLocalVideoMuted(false)` nochmals aufgerufen falls Kamera
|
||||||
|
aktiv sein soll.
|
||||||
|
- Poll-Intervall von 1500ms auf 1000ms reduziert für schnellere Feed-Erkennung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. VoIP – Optimistic Mic/Camera Toggle (`voip_manager.dart`)
|
||||||
|
|
||||||
|
**Problem:** `toggleMic()` und `toggleCamera()` nutzten `safeAction()` mit 1500ms Delay.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `toggleMic()` flippt State sofort, reverted bei Fehler automatisch.
|
||||||
|
- `toggleCamera()` flippt `_isCameraMuted` sofort (optimistic), ruft `_updateRenderers()`
|
||||||
|
mehrfach nach kurzen Delays auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. VoIP – Desktop Speakerphone Fix (`voip_manager.dart`)
|
||||||
|
|
||||||
|
**Problem:** `MissingPluginException: No implementation found for method enableSpeakerphone`
|
||||||
|
auf Windows/Desktop.
|
||||||
|
|
||||||
|
**Fix:** Speakerphone-Callback wird im Konstruktor unterdrückt:
|
||||||
|
```dart
|
||||||
|
try {
|
||||||
|
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||||
|
} catch (_) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. SQLite-Stabilität auf Windows
|
||||||
|
|
||||||
|
**Problem:** Sporadische "Database is locked"-Fehler durch parallele Zugriffe.
|
||||||
|
|
||||||
|
**Fix:** WAL-Modus aktiviert und `busy_timeout` gesetzt, um Locking-Konflikte zu verhindern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Einheitliches Call-UI (`voice_channel.dart`) – Kompletter Rewrite
|
||||||
|
|
||||||
|
Die gesamte `voice_channel.dart` wurde neu strukturiert mit einer einheitlichen Architektur
|
||||||
|
für LiveKit und VoIP.
|
||||||
|
|
||||||
|
#### 8a. Unified Participant Model
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _ParticipantData {
|
||||||
|
final String id, name;
|
||||||
|
final Widget? videoWidget;
|
||||||
|
final bool isSpeaking, isMuted, hasVideo, isScreenShare;
|
||||||
|
final Color avatarColor;
|
||||||
|
final dynamic originalParticipant;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8b. Adaptive Seitenverhältnisse (`voice_channel.dart`)
|
||||||
|
|
||||||
|
**Problem:** Video-Tiles waren auf `AspectRatio(16/9)` festgelegt — Hochkant-Videos vom
|
||||||
|
Handy wurden stark beschnitten und falsch dargestellt.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `_ParticipantData` bekommt ein `aspectRatio`-Feld (Default `16.0/9.0`).
|
||||||
|
- VoIP: echte Dimension aus `RTCVideoRenderer.videoWidth/videoHeight` ausgelesen.
|
||||||
|
- LiveKit: echte Dimension aus `(videoTrack as dynamic).currentDimensions` ausgelesen.
|
||||||
|
- Alle Renderer nutzen `VideoViewFit.contain` / `RTCVideoViewObjectFitContain` statt `cover`
|
||||||
|
— kein Beschnitt mehr.
|
||||||
|
- `_ParticipantGrid` wechselt von `GridView(childAspectRatio: 16/9)` zu `Wrap` mit
|
||||||
|
individuell berechneter `SizedBox(width, height)` pro Tile.
|
||||||
|
- `_ParticipantStrip` passt Tile-Breite an: `101 * p.aspectRatio` für Landscape-Tiles.
|
||||||
|
|
||||||
|
#### 8c. Hero/Spotlight-System
|
||||||
|
|
||||||
|
- **Auto-Spotlight:** Screenshares werden automatisch als Hero angezeigt
|
||||||
|
- **Manuelles Pinnen:** Klick auf Tile → wird Hero; erneuter Klick = unpin
|
||||||
|
- Gear-Icon im Hero-Overlay öffnet das Qualitätsmenü (hover-reveal)
|
||||||
|
- Fullscreen-Button öffnet eine eigene Fullscreen-Seite via `Navigator.push`
|
||||||
|
|
||||||
|
#### 8d. Aktive Sprecher
|
||||||
|
|
||||||
|
Pulsierende Rahmen-Animation (`AnimationController`) für Teilnehmer, die gerade sprechen.
|
||||||
|
|
||||||
|
#### 8e. Teilnehmer-Sortierung
|
||||||
|
|
||||||
|
```
|
||||||
|
Screensharer → Kamera-an → Sprechend → Audio-only
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8f. Responsive Layouts
|
||||||
|
|
||||||
|
**Portrait:** Hero oben (Expanded) + horizontaler Teilnehmer-Strip unten (120px)
|
||||||
|
**Landscape:** Hero links (Expanded) + vertikale Sidebar rechts (200px)
|
||||||
|
|
||||||
|
Beide Layouts sind **einklappbar** (Swipe-Geste + Pfeil-Button).
|
||||||
|
|
||||||
|
#### 8g. Zentrales Mirroring
|
||||||
|
|
||||||
|
`_MirrorWrapper` spiegelt nur die eigene Kamera (`isLocal && !isScreenShare`).
|
||||||
|
Screenshares und Remote-Teilnehmer bleiben ungespiegelt.
|
||||||
|
|
||||||
|
#### 8h. Grid-Layout mit einheitlichen Tiles
|
||||||
|
|
||||||
|
`SliverGridDelegateWithFixedCrossAxisCount`:
|
||||||
|
- < 600px → 2 Spalten
|
||||||
|
- 600–900px → 3 Spalten
|
||||||
|
- > 900px → 4 Spalten
|
||||||
|
|
||||||
|
#### 8i. Steuerungs-Reihenfolge (alle Views)
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Mic ] [ Deaf ] [ Kamera ] [ Screen ] | [ Auflegen ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Mini-Call-Widget – Steuerungs-Reihenfolge (`mini_call_widget.dart`)
|
||||||
|
|
||||||
|
Gleiche Reihenfolge wie Haupt-Call-Ansicht: Mic → Deaf → Kamera → Screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 4K Support
|
||||||
|
|
||||||
|
4K (2160p) als Qualitätsoption in LiveKit- und VoIP-Qualitätsmenüs hinzugefügt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. App-Icon – Pyramid-Logo (`flutter_launcher_icons.yaml`)
|
||||||
|
|
||||||
|
**Problem:** App zeigte das Flutter-Standard-Icon statt dem Pyramid-Logo.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `flutter_launcher_icons.yaml` auf `assets/logo.png` (goldene 3D-Pyramide, 1024px) umgestellt.
|
||||||
|
- macOS-Generierung explizit aktiviert (`macos: generate: true`).
|
||||||
|
- Windows ICO nach `dart run flutter_launcher_icons` mit Pillow neu generiert
|
||||||
|
(16/32/48/64/128/256px multi-size ICO, verhindert das 48px-Limit des Packages).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Lade-Animation – 3D Pyramiden-Painter (`pyramid_loader.dart`)
|
||||||
|
|
||||||
|
**Problem:** Lottie-JSON zeigte inkorrekte Farben, SVG wurde nur statisch gerendert
|
||||||
|
(flutter_svg führt kein JavaScript aus).
|
||||||
|
|
||||||
|
**Lösung:** JavaScript-Animations-Logik aus dem Original-SVG exakt nach Dart portiert
|
||||||
|
als `CustomPainter`:
|
||||||
|
|
||||||
|
- `_ease(t, exp)`: kubische Ease-In/Out wie im JS-Original
|
||||||
|
- `_yawAt(t)`: Yaw-Rotation mit Hold-Phase (80% Rotation, 20% Pause bei π/4)
|
||||||
|
- `_PyramidPainter`: 4 Flächen, Y-Rotation, Z-Clipping, orthografische Projektion,
|
||||||
|
20°-Tilt, Depth-Sorting (Painter's Algorithm)
|
||||||
|
- Fill: `darken(accent, 0.45)`, Stroke: `accent`, strokeWidth: `1.4 * (size/96)`
|
||||||
|
- `useLottie: false` ist nun der Standard — kein Lottie-Asset mehr nötig
|
||||||
|
|
||||||
|
**Loader Preview** in den Appearance Settings (zwei 120px-Boxen: Custom Painter + Lottie-Fallback).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Chat-Scroll-Stabilisierung (`chat_view.dart`)
|
||||||
|
|
||||||
|
**Problem:** Der Chat zuckte und zitterte je weiter man nach oben scrollte, besonders
|
||||||
|
wenn neue History nachgeladen wurde.
|
||||||
|
|
||||||
|
**Ursache:** `DateDivider` und `MessageGroup` waren in einem gemeinsamen `Column`-Widget
|
||||||
|
mit einem einzigen Key zusammengefasst. Wenn beim Nachladen von History die `showDate`-
|
||||||
|
Eigenschaft einer Gruppe sich änderte (z.B. weil sie nicht mehr die älteste Gruppe war),
|
||||||
|
änderte sich die Höhe des Items — direkt in dem Bereich, den der User gerade betrachtete.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `DateDivider` und `MessageGroup` sind jetzt separate List-Items mit unabhängigen Keys:
|
||||||
|
- Gruppen: `ValueKey('g:<firstEventId>')`
|
||||||
|
- Datumstrennlinien: `ValueKey('d:<YYYY-MM-DD>')`
|
||||||
|
- `findChildIndexCallback` nutzt eine vorberechnete `Map<String, int>` für O(1)-Lookup
|
||||||
|
statt O(n)-`indexWhere`.
|
||||||
|
- 200ms Cooldown nach `requestHistory` verhindert sofortige Re-Trigger durch veraltetes
|
||||||
|
`maxScrollExtent`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. Startup-Flash Fix (`app.dart`)
|
||||||
|
|
||||||
|
**Problem:** Beim App-Start flackerte kurz die Server-Eingabe-Seite auf, bevor der
|
||||||
|
korrekte Redirect zu `/app` stattfand.
|
||||||
|
|
||||||
|
**Ursache:** `isLoggedInProvider` (StreamProvider) gibt beim ersten Build `AsyncLoading`
|
||||||
|
zurück. `valueOrNull ?? false` ergibt `false` → Router leitet sofort zu `/server` weiter.
|
||||||
|
Kurz danach resolvet der Stream und der Router erstellt sich neu mit `isLoggedIn = true`.
|
||||||
|
|
||||||
|
**Fix:** `PyramidApp` prüft `isLoggedInProvider` zuerst. Solange noch `isLoading`:
|
||||||
|
vollbild `PyramidLoader` statt Router — die Pyramiden-Animation überbrückt den kurzen
|
||||||
|
Moment, bis der Auth-State bekannt ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. Eigenes Profilbild (`user_panel.dart`)
|
||||||
|
|
||||||
|
**Problem:** Das eigene Profilbild wurde unten links (UserPanel) nicht angezeigt —
|
||||||
|
nur ein Farbverlauf-Platzhalter mit Initial-Buchstabe.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `_ownProfileProvider` (FutureProvider): ruft einmalig `client.getProfileFromUserId(userID)`
|
||||||
|
auf und cached das Ergebnis.
|
||||||
|
- `UserPanel` zeigt nun `MxcAvatar` mit dem echten Profilbild.
|
||||||
|
- Anzeigename kommt aus `profile.displayName` statt nur aus der UserID.
|
||||||
|
- Farbverlauf-Initial-Avatar bleibt als Fallback (`_InitialAvatar`) während des Ladens
|
||||||
|
oder wenn kein Bild gesetzt ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. QR-Code-Verifikation (`app_shell.dart`, `settings_modal.dart`)
|
||||||
|
|
||||||
|
**Problem:** Geräteverifikation unterstützte nur Emoji-SAS — kein QR-Code-Flow.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `KeyVerificationMethod.qrShow` zu `verificationMethods` in `matrix_client.dart` hinzugefügt.
|
||||||
|
- `pubspec.yaml`: `pretty_qr_code: ^3.6.0` und `qr: ^3.0.2` ergänzt.
|
||||||
|
- Eingehender Verifizierungsdialog (`app_shell.dart`) und ausgehender Dialog (`settings_modal.dart`)
|
||||||
|
unterstützen jetzt beide 3 neue States:
|
||||||
|
- `askChoice` → Methodenwahl (Emoji oder QR)
|
||||||
|
- `confirmQRScan` → QR-Code anzeigen (`QrCode.fromUint8List` + `PrettyQrView`) + "Gescannt bestätigen"-Button
|
||||||
|
- `showQRSuccess` → Erfolgsanzeige
|
||||||
|
|
||||||
|
**Technischer Hinweis:** Matrix QR-Codes sind Binärdaten (`qrDataRawBytes: Uint8Buffer`).
|
||||||
|
Rendering über `QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17. Wiederherstellungsschlüssel ändern (`settings_modal.dart`)
|
||||||
|
|
||||||
|
**Problem:** Kein Feature zum Wechseln des SSSS-Wiederherstellungsschlüssels vorhanden.
|
||||||
|
|
||||||
|
**Implementierung:** `_ChangeRecoveryKeyDialog` mit Bootstrap-State-Machine:
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. **Laden** — Bootstrap startet, `wipeSsss(true)` → `newSsss()` generiert neuen Schlüssel
|
||||||
|
2. **Schlüssel anzeigen** — Schlüssel selektierbar + Kopier-Button; wartet auf Nutzer
|
||||||
|
3. **Bestätigen** — Nutzer tippt Schlüssel zur Bestätigung ein (`_ConfirmKeyBody`)
|
||||||
|
4. **Aktivieren** — wartet auf Bootstrap `done` (Cross-Signing + Key Backup im Hintergrund)
|
||||||
|
5. **Fertig** — Erfolgsmeldung
|
||||||
|
|
||||||
|
**Kritischer Fix (BootstrapBadStateException):**
|
||||||
|
- `newSsss()` ruft intern `checkCrossSigning()` auf → State geht sofort zu `askWipeCrossSigning`.
|
||||||
|
- `openExistingSsss()` ist nach `newSsss()` nicht mehr erreichbar — wurde entfernt.
|
||||||
|
- Alle Post-`newSsss`-States werden ohne `_step`-Guards auto-advanced.
|
||||||
|
- Bootstrap-`done` setzt `_step` nur wenn bereits in `applying` — verhindert vorzeitigen Dialog-Schluss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
### ⚠️ Screenshare auf Windows (LiveKit)
|
||||||
|
|
||||||
|
`startScreenShare()` wählt auf Windows automatisch die erste verfügbare Screen-Source.
|
||||||
|
Der `ScreenSharePicker` ist implementiert, muss noch verifiziert werden.
|
||||||
|
|
||||||
|
### ℹ️ Profilbilder in Chats – Lazy Loading
|
||||||
|
|
||||||
|
Profilbilder in der Raumliste und im Chat werden erst beim Öffnen des jeweiligen
|
||||||
|
Raums geladen (kein Preloading). Ist akzeptables Verhalten, kein Fehler.
|
||||||
|
|
||||||
|
### 📋 Noch nicht implementiert (Settings)
|
||||||
|
|
||||||
|
~~Alle erledigt in Sitzung 3 (26.04.2026): Gerät umbenennen, Alle anderen Sitzungen abmelden, Ed25519-Fingerabdruck, Blockierte Nutzer, Passwort ändern, Konto löschen, Logout-Button~~
|
||||||
|
|
||||||
|
### 💡 Nice-to-have
|
||||||
|
|
||||||
|
- **QR-Express-Login** (MSC4108): Desktop zeigt QR → Handy scannt → eingeloggt + verifiziert.
|
||||||
|
Voraussetzung: Rendezvous-Server + SDK-Unterstützung (matrix-dart-sdk ≥ 7.x erwartet).
|
||||||
|
Aktuell blockiert durch fehlende SDK-API in 6.2.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geänderte Dateien (vollständig)
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| `lib/core/livekit_call_manager.dart` | Quality-Bug-Fix, Track-Events, Optimistic Mute, 4K |
|
||||||
|
| `lib/core/voip_manager.dart` | Kamera-Fix, Optimistic Toggle, Speakerphone-Fix, 4K |
|
||||||
|
| `lib/features/call/voice_channel.dart` | Kompletter Rewrite: Unified Architecture, adaptive AR, Hero, Mirroring, Grid, Sortierung, Controls |
|
||||||
|
| `lib/features/call/mini_call_widget.dart` | Steuerungs-Reihenfolge |
|
||||||
|
| `lib/core/matrix_client.dart` | WAL-Modus, busy_timeout, qrShow in verificationMethods |
|
||||||
|
| `lib/widgets/pyramid_loader.dart` | 3D CustomPainter (JS-Port), useLottie=false Standard |
|
||||||
|
| `lib/core/app.dart` | Startup-Flash-Fix: PyramidLoader während isLoggedInProvider lädt |
|
||||||
|
| `lib/features/chat/chat_view.dart` | Scroll-Stabilisierung: flache Item-Liste, O(1)-Lookup, Cooldown |
|
||||||
|
| `lib/features/rooms/user_panel.dart` | Eigenes Profilbild via _ownProfileProvider + MxcAvatar |
|
||||||
|
| `lib/widgets/settings_modal.dart` | QR-Verifikation, Wiederherstellungsschlüssel ändern, Loader-Preview |
|
||||||
|
| `lib/layout/app_shell.dart` | QR-Verifikation im eingehenden Verifizierungsdialog |
|
||||||
|
| `pubspec.yaml` | pretty_qr_code, qr hinzugefügt |
|
||||||
|
| `flutter_launcher_icons.yaml` | Pyramid-Logo für alle Plattformen |
|
||||||
|
| `windows/runner/resources/app_icon.ico` | Multi-Size ICO (16/32/48/64/128/256px) via Pillow |
|
||||||
|
| `assets/logo.png` | Pyramid-Logo (1024px) |
|
||||||
|
| `assets/pyramid-loader.json` | Lottie-Fallback (aus Ordner mit Leerzeichen verschoben) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
| Datum | Pfad |
|
||||||
|
|-------|------|
|
||||||
|
| 24.04.2026 (Sitzung 1) | `E:\pyramid_backup_20260424` |
|
||||||
|
| 24.04.2026 (Sitzung 2) | `E:\pyramid_backup_20260424_2` |
|
||||||
|
| 26.04.2026 | `E:\pyramid_backup_20260426` |
|
||||||
@@ -1,17 +1,56 @@
|
|||||||
# pyramid
|
# Pyramid
|
||||||
|
|
||||||
A new Flutter project.
|
Pyramid ist ein eigenständiger Matrix-Client (Flutter), kein Fork von FluffyChat
|
||||||
|
oder Element. Er läuft gegen den selbst gehosteten Homeserver `steggi-matrix.work`
|
||||||
|
(Continuwuity) und ist bei echten Nutzern im Einsatz – Stabilität und
|
||||||
|
Datensicherheit haben Vorrang vor Feature-Tempo.
|
||||||
|
|
||||||
## Getting Started
|
## Funktionsumfang
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
- Matrix-Chat mit Timeline, Reply, Medien, Link-Vorschauen, lokaler Volltextsuche
|
||||||
|
über entschlüsselte Nachrichten (server-seitige Suche funktioniert bei E2EE nicht)
|
||||||
|
- Ende-zu-Ende-Verschlüsselung (vodozemac/Olm/Megolm) inkl. Key-Backup
|
||||||
|
- Sprach-/Video-Calls und Bildschirmübertragung über LiveKit
|
||||||
|
(`wss://livekit.steggi-matrix.work`)
|
||||||
|
- Push-Benachrichtigungen mit clientseitiger Entschlüsselung auch bei gekillter
|
||||||
|
App (FCM + Sygnal, siehe `docs/NOTIFICATIONS.md`)
|
||||||
|
- Größenverstellbares Panel-Layout (Rooms/Chat/Members) statt starrer Tabs
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
## Build
|
||||||
|
|
||||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
Voraussetzung: Flutter (stable channel, siehe `environment.sdk` in `pubspec.yaml`
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
für die unterstützte Dart-Version).
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
```
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
flutter pub get
|
||||||
samples, guidance on mobile development, and a full API reference.
|
flutter run -d windows # schnellster Praxistest während der Entwicklung
|
||||||
|
flutter analyze # muss sauber bleiben
|
||||||
|
```
|
||||||
|
|
||||||
|
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
||||||
|
`android/`, `windows/`, `linux/`, `ios/`, `macos/` sind Standard-Flutter-Projekte).
|
||||||
|
Release-Build (signiert, Windows + Android): `scripts/release.ps1`.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
core/ Matrix-Client, Auth, VoIP-Manager, Storage, App-State
|
||||||
|
features/
|
||||||
|
auth/ Login, Server-Auswahl
|
||||||
|
rooms/ Raumliste, Spaces
|
||||||
|
chat/ Timeline, Composer, Medien, Dokumentenvorschau
|
||||||
|
call/ LiveKit-Calls, Voice-Channel, Mini-Call-Widget
|
||||||
|
members/ Mitgliederliste/-panel
|
||||||
|
settings/ Einstellungen
|
||||||
|
layout/ Panel-Layout (resizable)
|
||||||
|
widgets/ geteilte UI-Bausteine
|
||||||
|
utils/ Hilfsfunktionen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Weiterführende Dokumentation
|
||||||
|
|
||||||
|
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
||||||
|
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
||||||
|
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
||||||
|
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Pyramid – Roadmap
|
||||||
|
|
||||||
|
Die lange Liste. Wird von oben nach unten abgearbeitet (Arbeitszyklus siehe `CLAUDE.md`).
|
||||||
|
Reihenfolge = Priorität: erst Ordnung & Sicherheit (Datenverlust-Risiken!), dann das
|
||||||
|
große Refactoring, dann Calls/Streaming/Chat-Features, dann Feinschliff.
|
||||||
|
Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||||
|
|
||||||
|
## M0 – Backup & Ordnung
|
||||||
|
|
||||||
|
- [x] Vollbackup vor dem Refactoring (2026-07-03 → `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`)
|
||||||
|
- [x] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
|
||||||
|
(Sicherungs-Commit 25ed765 + Folge-Commits bis 74d38fd; Arbeitsbaum ist jetzt sauber)
|
||||||
|
- [x] `flutter analyze` komplett sauber bekommen und sauber halten (nur noch 1 bewusst
|
||||||
|
zurückgestellter Hinweis, siehe PROGRESS.md 2026-07-03)
|
||||||
|
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
||||||
|
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
|
||||||
|
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
|
||||||
|
- [ ] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
||||||
|
Sämtliche Commits vom 2026-07-03 (25ed765..HEAD, gemacht von Sonnet 5) kritisch
|
||||||
|
und in der Tiefe nachprüfen – nicht nur formal: (a) die 6 Gott-Datei-Aufteilungen
|
||||||
|
stichprobenartig fachlich lesen (stimmen `part of`-Bezüge, Sichtbarkeiten,
|
||||||
|
keine verlorenen Blöcke – über den automatisierten Blockvergleich hinaus),
|
||||||
|
(b) den Uta-Logout-/Push-Fix (63e4919, 9dc83f7) fachlich gegen das matrix-SDK
|
||||||
|
verifizieren (ist die Ursachen-Analyse schlüssig? Nebenwirkungen?),
|
||||||
|
(c) die analyze-/Lint-Fixes auf versehentliche Verhaltensänderungen prüfen,
|
||||||
|
(d) abgehakte ROADMAP-Punkte gegen PROGRESS.md und den Code gegenprüfen.
|
||||||
|
Befunde direkt fixen, alles in PROGRESS.md protokollieren.
|
||||||
|
|
||||||
|
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
||||||
|
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
||||||
|
Nächster PC-Lauf MUSS den vollen Kreislauf praktisch prüfen (Login →
|
||||||
|
Nachrichten → Logout → erneuter Login → alte Nachrichten lesbar?), erst
|
||||||
|
dann gilt der Punkt als wirklich erledigt.
|
||||||
|
- [x] **REGRESSION: Push-Benachrichtigungen werden nicht mehr entschlüsselt** –
|
||||||
|
UNGETESTET (Pi), sehr wahrscheinlich DIESELBE Ursache wie der
|
||||||
|
Uta-Random-Logout-Bug (bereits mit 9dc83f7 mitgefixt), siehe PROGRESS.md
|
||||||
|
2026-07-03. Auf echtem Gerät prüfen: nach Neu-Login (für den Refresh-Token)
|
||||||
|
eine Weile laufen lassen und beobachten, ob Push-Nachrichten wieder mit
|
||||||
|
echtem Text statt nur „Neue Nachricht"-Platzhalter ankommen. Falls das
|
||||||
|
Problem weiterhin auftritt, liegt eine zweite, unabhängige Ursache vor
|
||||||
|
(z. B. fehlende Megolm-Schlüssel/Key-Sharing) – dann `docs/NOTIFICATIONS.md`
|
||||||
|
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
|
||||||
|
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
|
||||||
|
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
|
||||||
|
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
|
||||||
|
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
|
||||||
|
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
|
||||||
|
den normalen `sqflite`/`sqflite_common_ffi`-Factory, also **unverschlüsselt**
|
||||||
|
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
|
||||||
|
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
|
||||||
|
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
|
||||||
|
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd.
|
||||||
|
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||||
|
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
|
||||||
|
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||||
|
`BootstrapState.askUnlockSsss` fragt nach Recovery-Key auf neuem Gerät)
|
||||||
|
- [x] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt (b5762ea)
|
||||||
|
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
||||||
|
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
||||||
|
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
|
||||||
|
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
||||||
|
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben)
|
||||||
|
|
||||||
|
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||||
|
|
||||||
|
Ziel: gleiche Funktionen, saubere **modulare** Struktur – als Fundament für alles Folgende.
|
||||||
|
Leitprinzip (Bernd): austauschbare Bausteine – jedes Modul muss sich komplett neu schreiben
|
||||||
|
lassen, ohne dass andere Module angefasst werden müssen.
|
||||||
|
|
||||||
|
- [x] Ist-Analyse geschrieben (PROGRESS.md 2026-07-03): Gott-Datei `settings_modal.dart`
|
||||||
|
(5541 Zeilen/50+ Klassen), Call-Schicht ohne gemeinsame Fassade (4 Module
|
||||||
|
importieren `voice_channel.dart` direkt), `app_state.dart` als Kopplungspunkt
|
||||||
|
aller Features, keine gemeinsame Storage-Schnittstelle (12 direkte
|
||||||
|
`SharedPreferences`-Zugriffe), toter Code (`message_bubble.dart`) + Duplikate
|
||||||
|
(`_formatTime`/`_formatDate` 3-fach). 8 Modul-Kandidaten dokumentiert.
|
||||||
|
- [x] Modul-Schnitt definiert (`docs/M2_MODULE_SCHNITT.md`, PROGRESS.md 2026-07-03):
|
||||||
|
„Calls" ist architektonisch zwei getrennte Module (`call_signaling` =
|
||||||
|
Matrix-1:1-VoIP, `voice_channel` = LiveKit-SFU-Kanäle) + `call_ui`;
|
||||||
|
außerdem `settings` (aufgesplittet), `verification` (neu, aus Settings
|
||||||
|
herausgelöst), `storage` (Ausbau `settings_prefs.dart`), `auth`, `push`,
|
||||||
|
`chat_timeline`/`rooms`. Umsetzungsreihenfolge vorgeschlagen, wartet auf
|
||||||
|
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
||||||
|
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||||
|
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||||
|
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||||
|
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||||
|
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||||
|
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
||||||
|
Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in
|
||||||
|
thematische Dateien aufgeteilt – `widgets/settings_modal.dart`
|
||||||
|
(5541→270 Zeilen, 12 Teile unter `widgets/settings/`),
|
||||||
|
`features/chat/message_group.dart` (2775→30 Zeilen, 7 Teile unter
|
||||||
|
`features/chat/message/`), `features/rooms/rooms_panel.dart`
|
||||||
|
(2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`),
|
||||||
|
`features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile
|
||||||
|
unter `features/spaces/admin/`), `features/chat/chat_view.dart`
|
||||||
|
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`),
|
||||||
|
`features/chat/document_viewer.dart` (1773→20 Zeilen, 5 Teile unter
|
||||||
|
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
||||||
|
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
||||||
|
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
||||||
|
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst –
|
||||||
|
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
|
||||||
|
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
|
||||||
|
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
||||||
|
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
||||||
|
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
||||||
|
öffnen, Vollbild-PDF-Viewer inkl. Thumbnail-Leiste und Speichern-Button
|
||||||
|
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
||||||
|
PROGRESS.md-Einträgen)
|
||||||
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
|
||||||
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|
||||||
|
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||||
|
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||||
|
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||||
|
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||||
|
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||||
|
- [ ] Noise Suppression als optionale Einstellung (z. B. RNNoise/LiveKit-Audio-Filter)
|
||||||
|
- [ ] Lautstärke pro Teilnehmer einstellbar (Discord-Style, persistent pro Nutzer)
|
||||||
|
- [ ] Weitere Feinjustierung: Eingangsempfindlichkeit/Gate, Echo-Unterdrückung an/aus,
|
||||||
|
Push-to-Talk prüfen – von Discords Sprach-Einstellungen inspirieren lassen
|
||||||
|
- [ ] Anruf-Benachrichtigung auch bei gekillter App (Push-Architektur nutzen)
|
||||||
|
|
||||||
|
## M4 – Streaming (Bildschirmübertragung)
|
||||||
|
|
||||||
|
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||||
|
Software- statt Hardware-Encoding?), dann gezielt fixen
|
||||||
|
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||||
|
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||||
|
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||||
|
|
||||||
|
## M5 – Chat-Funktionen
|
||||||
|
|
||||||
|
- [ ] Sprachnachrichten aufnehmen & abspielen (MSC3245-kompatibel, wie Element)
|
||||||
|
- [ ] **Bilder speichern → richtige Galerie:** über MediaStore/Downloads statt in den
|
||||||
|
unzugänglichen App-Ordner (Android Scoped Storage korrekt nutzen)
|
||||||
|
- [ ] Medien senden/empfangen generell prüfen (Fortschritt, Vorschau, Videos)
|
||||||
|
|
||||||
|
## M6 – Release & Verteilung
|
||||||
|
|
||||||
|
- [ ] **Google-Warnung beim Installieren untersuchen:** Play-Protect-Meldung seit der
|
||||||
|
Signatur-Einführung analysieren (Signing-Konfig prüfen; unbekannte Signatur + Sideload
|
||||||
|
löst das oft aus; ggf. Play-Protect-Appeal oder saubere v2/v3-Signierung)
|
||||||
|
- [ ] Release-Skript prüfen/aktualisieren (Windows + Android), Versionierung sauber
|
||||||
|
- [ ] In-App-Updater testen (existiert laut Git-Historie)
|
||||||
|
|
||||||
|
## M7 – Design & Feinschliff
|
||||||
|
|
||||||
|
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
||||||
|
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
||||||
|
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
||||||
|
- [ ] Themes/Darkmode-Feinschliff, Schriftgrößen
|
||||||
|
|
||||||
|
## Ideen-Parkplatz (unsortiert, erst nach M7 bewerten)
|
||||||
|
|
||||||
|
- Threads, Sticker, Standort teilen, mehrere Konten
|
||||||
|
- Watch-Together (z. B. Jellyfin gemeinsam schauen)
|
||||||
|
|
||||||
|
> Bernd: „Das ist nur der Anfang meiner Liste" – neue Punkte einfach hier in den
|
||||||
|
> passenden Meilenstein eintragen (oder Claude sagen, er soll sie einsortieren).
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
# Design-Mockups, kein Produktionscode — nicht analysieren.
|
||||||
|
- mockup/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release signing is configured via android/key.properties (gitignored).
|
||||||
|
// The keystore lives outside the repo; see key.properties for the path.
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -11,6 +22,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -30,13 +42,42 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
signingConfigs {
|
||||||
release {
|
if (keystorePropertiesFile.exists()) {
|
||||||
// TODO: Add your own signing config for the release build.
|
create("release") {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// Falls back to debug signing on machines without key.properties
|
||||||
|
// so `flutter run --release` still works there.
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
// Required to compile PushService.kt which extends FirebaseMessagingService directly.
|
||||||
|
// Version must stay in sync with FirebaseSDKVersion in firebase_core's gradle.properties.
|
||||||
|
implementation(platform("com.google.firebase:firebase-bom:33.16.0"))
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "858748942228",
|
||||||
|
"project_id": "chat-pyramid",
|
||||||
|
"storage_bucket": "chat-pyramid.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:858748942228:android:661405508fbb2f40d30181",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "chat.pyramid.pyramid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDbqLHYFHqUdPOAexDFTpMvQVkQHY6chFE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Flutter's own keep rules are added automatically by the Flutter Gradle plugin.
|
||||||
|
|
||||||
|
# WebRTC / LiveKit: native code calls Java classes via JNI by name,
|
||||||
|
# so nothing under org.webrtc may be stripped or renamed.
|
||||||
|
-keep class org.webrtc.** { *; }
|
||||||
|
-dontwarn org.webrtc.**
|
||||||
|
-keep class livekit.org.webrtc.** { *; }
|
||||||
|
-dontwarn livekit.org.webrtc.**
|
||||||
|
|
||||||
|
# flutter_local_notifications deserializes scheduled notifications with Gson
|
||||||
|
# using reflection; stripped type adapters crash on the next reboot/schedule.
|
||||||
|
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
|
# Flutter references Play Core for deferred components, which this app
|
||||||
|
# doesn't ship — silence the missing-class errors from R8.
|
||||||
|
-dontwarn com.google.android.play.core.**
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
@@ -7,8 +9,13 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="pyramid"
|
android:label="pyramid"
|
||||||
@@ -40,6 +47,28 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Share-Target: "Teilen nach Pyramid" aus anderen Apps -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="image/*"/>
|
||||||
|
<data android:mimeType="video/*"/>
|
||||||
|
<data android:mimeType="audio/*"/>
|
||||||
|
<data android:mimeType="application/*"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="image/*"/>
|
||||||
|
<data android:mimeType="video/*"/>
|
||||||
|
<data android:mimeType="audio/*"/>
|
||||||
|
<data android:mimeType="application/*"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- FileProvider for APK installs (update downloader) -->
|
<!-- FileProvider for APK installs (update downloader) -->
|
||||||
<provider
|
<provider
|
||||||
@@ -51,6 +80,42 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"/>
|
android:resource="@xml/file_paths"/>
|
||||||
</provider>
|
</provider>
|
||||||
|
<!-- FCM: default channel + icon so system-generated notifications work -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="pyramid_messages"/>
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:value="@drawable/ic_notification"/>
|
||||||
|
<!-- Disable the Flutter Firebase plugin's FCM service — PushService.kt
|
||||||
|
handles FCM directly in native Kotlin, which is far more reliable on
|
||||||
|
Samsung/OEM devices where the Dart VM start is blocked by battery
|
||||||
|
optimisation when the app is killed. -->
|
||||||
|
<service
|
||||||
|
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||||
|
tools:node="remove"/>
|
||||||
|
<!-- Also remove the legacy C2DM broadcast receiver so Samsung cannot
|
||||||
|
start a second Dart background isolate via the old GCM/C2DM path. -->
|
||||||
|
<receiver
|
||||||
|
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
|
||||||
|
tools:node="remove"/>
|
||||||
|
<!-- Native FCM handler — receives messages even when the app is killed. -->
|
||||||
|
<service
|
||||||
|
android:name=".PushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<!-- Handles inline reply and dismiss actions without opening an Activity. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".ReplyReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="chat.pyramid.pyramid.REPLY"/>
|
||||||
|
<action android:name="chat.pyramid.pyramid.DISMISS"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
@@ -63,6 +128,15 @@
|
|||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
|
<!-- url_launcher: required on Android 11+ for canLaunchUrl to work -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="http"/>
|
||||||
|
</intent>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.FlutterInjector
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.view.FlutterCallbackInformation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps a headless Flutter background engine running the
|
||||||
|
* `notificationEngineMain` Dart entrypoint and forwards work items
|
||||||
|
* (decrypt-and-show, send-reply) to it via a MethodChannel.
|
||||||
|
*
|
||||||
|
* Only used when the app is KILLED (no main FlutterEngine). The Dart side builds
|
||||||
|
* a short-lived Matrix client against the same DB to decrypt / send. This is safe
|
||||||
|
* because callers gate on the main app being dead — never two clients at once.
|
||||||
|
*/
|
||||||
|
object BgEngine {
|
||||||
|
private const val TAG = "PYRAMID-BGENGINE"
|
||||||
|
private const val CHANNEL = "chat.pyramid.pyramid/bg_engine"
|
||||||
|
|
||||||
|
private var engine: FlutterEngine? = null
|
||||||
|
private var channel: MethodChannel? = null
|
||||||
|
private var ready = false
|
||||||
|
private var appContext: Context? = null
|
||||||
|
// Tasks queued until the Dart handler signals readiness.
|
||||||
|
private val pending = mutableListOf<Pair<String, Map<String, Any?>>>()
|
||||||
|
// Pending "Neue Nachricht" fallback placeholders, keyed by notifId. Shown
|
||||||
|
// only if decryption doesn't replace them in time; cancelled by showDecrypted.
|
||||||
|
private val fallbackHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val fallbacks = mutableMapOf<Int, Runnable>()
|
||||||
|
|
||||||
|
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
||||||
|
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) {
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
appContext = ctx
|
||||||
|
fallbackHandler.post {
|
||||||
|
cancelFallback(notifId)
|
||||||
|
val r = Runnable {
|
||||||
|
fallbacks.remove(notifId)
|
||||||
|
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId)
|
||||||
|
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
||||||
|
}
|
||||||
|
fallbacks[notifId] = r
|
||||||
|
fallbackHandler.postDelayed(r, delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelFallback(notifId: Int) {
|
||||||
|
fallbacks.remove(notifId)?.let { fallbackHandler.removeCallbacks(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enqueue a task; boots the engine on first use. Must run on the main thread. */
|
||||||
|
fun run(context: Context, method: String, args: Map<String, Any?>) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
try {
|
||||||
|
ensureEngine(context.applicationContext)
|
||||||
|
if (ready) {
|
||||||
|
channel?.invokeMethod(method, args)
|
||||||
|
} else {
|
||||||
|
pending.add(method to args)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "run failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureEngine(context: Context) {
|
||||||
|
if (engine != null) return
|
||||||
|
|
||||||
|
val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||||
|
val handle = try {
|
||||||
|
prefs.getLong("flutter.bg_engine_handle", 0L)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
if (handle == 0L) {
|
||||||
|
Log.w(TAG, "no bg_engine_handle stored — cannot start background engine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Flutter native library must be loaded BEFORE looking up the
|
||||||
|
// callback — nativeLookupCallbackInformation is a native method and
|
||||||
|
// throws UnsatisfiedLinkError otherwise.
|
||||||
|
val loader = FlutterInjector.instance().flutterLoader()
|
||||||
|
loader.startInitialization(context)
|
||||||
|
loader.ensureInitializationComplete(context, null)
|
||||||
|
|
||||||
|
val cbInfo = FlutterCallbackInformation.lookupCallbackInformation(handle)
|
||||||
|
if (cbInfo == null) {
|
||||||
|
Log.w(TAG, "callback info not found for handle $handle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val eng = FlutterEngine(context)
|
||||||
|
engine = eng
|
||||||
|
|
||||||
|
val ch = MethodChannel(eng.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
channel = ch
|
||||||
|
ch.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"bgEngineReady" -> {
|
||||||
|
ready = true
|
||||||
|
Log.d(TAG, "Dart background engine ready — flushing ${pending.size} task(s)")
|
||||||
|
pending.forEach { (m, a) -> ch.invokeMethod(m, a) }
|
||||||
|
pending.clear()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Dart finished decrypting — show the real content using the SAME
|
||||||
|
// native notification (keeps the reliable MainActivity reply action).
|
||||||
|
"showDecrypted" -> {
|
||||||
|
val title = call.argument<String>("title") ?: ""
|
||||||
|
val body = call.argument<String>("body") ?: "Neue Nachricht"
|
||||||
|
val roomId = call.argument<String>("room_id") ?: ""
|
||||||
|
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
|
||||||
|
val ctx = appContext
|
||||||
|
if (ctx != null && roomId.isNotEmpty()) {
|
||||||
|
// Beat the fallback placeholder to it — show decrypted directly.
|
||||||
|
cancelFallback(notifId)
|
||||||
|
NotificationHelper.show(ctx, title, body, roomId, notifId)
|
||||||
|
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eng.dartExecutor.executeDartCallback(
|
||||||
|
DartExecutor.DartCallback(context.assets, loader.findAppBundlePath(), cbInfo)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "background engine started")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,49 +3,270 @@ package chat.pyramid.pyramid
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val channel = "chat.pyramid.pyramid/install"
|
private val channel = "chat.pyramid.pyramid/install"
|
||||||
|
|
||||||
|
// ── Share-Target (Teilen nach Pyramid) ────────────────────────────────────
|
||||||
|
// Extracts shared text/streams from SEND/SEND_MULTIPLE intents. Streams are
|
||||||
|
// copied into cacheDir because Flutter cannot read content:// URIs directly.
|
||||||
|
private fun extractShare(intent: Intent?): Map<String, Any?>? {
|
||||||
|
if (intent == null) return null
|
||||||
|
val action = intent.action
|
||||||
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||||
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
val uris = mutableListOf<Uri>()
|
||||||
|
if (action == Intent.ACTION_SEND) {
|
||||||
|
val u: Uri? = if (Build.VERSION.SDK_INT >= 33)
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||||
|
if (u != null) uris.add(u)
|
||||||
|
} else {
|
||||||
|
val list: ArrayList<Uri>? = if (Build.VERSION.SDK_INT >= 33)
|
||||||
|
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else @Suppress("DEPRECATION") intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||||
|
if (list != null) uris.addAll(list)
|
||||||
|
}
|
||||||
|
if (text.isNullOrEmpty() && uris.isEmpty()) return null
|
||||||
|
val paths = uris.mapNotNull { copyShareToCache(it) }
|
||||||
|
return mapOf("text" to text, "paths" to paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyShareToCache(uri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val name = contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||||
|
val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (idx >= 0 && c.moveToFirst()) c.getString(idx) else null
|
||||||
|
} ?: "shared_${System.currentTimeMillis()}"
|
||||||
|
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||||
|
val outFile = File(cacheDir, "share_${System.currentTimeMillis()}_$safe")
|
||||||
|
val stream = contentResolver.openInputStream(uri) ?: return null
|
||||||
|
stream.use { input ->
|
||||||
|
outFile.outputStream().use { input.copyTo(it) }
|
||||||
|
}
|
||||||
|
outFile.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("PYRAMID-SHARE", "copyShareToCache failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract reply text from an intent using all known key patterns.
|
||||||
|
private fun extractReply(intent: Intent?): Pair<String?, String?> {
|
||||||
|
if (intent == null) return Pair(null, null)
|
||||||
|
// Only treat intents with our REPLY action as reply intents.
|
||||||
|
if (intent.action != ReplyReceiver.ACTION_REPLY) return Pair(null, null)
|
||||||
|
val remoteInputResults = RemoteInput.getResultsFromIntent(intent)
|
||||||
|
val replyText: String? =
|
||||||
|
remoteInputResults?.let { bundle ->
|
||||||
|
// Primary key must match RemoteInput.Builder key in NotificationHelper.
|
||||||
|
bundle.getCharSequence(ReplyReceiver.REMOTE_INPUT_KEY)?.toString()?.trim()
|
||||||
|
?: bundle.keySet()?.firstOrNull()?.let { bundle.getCharSequence(it)?.toString()?.trim() }
|
||||||
|
}
|
||||||
|
?: intent.getStringExtra(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||||
|
?: intent.getStringExtra("input")
|
||||||
|
val payload = intent.getStringExtra(ReplyReceiver.EXTRA_ROOM_ID)
|
||||||
|
?: intent.getStringExtra("payload")
|
||||||
|
return Pair(replyText, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// If this Activity was launched to handle a notification reply (cold start),
|
||||||
|
// apply a fully transparent theme BEFORE super.onCreate so no UI is ever
|
||||||
|
// drawn. The Flutter engine still initialises (needed for E2EE send via SDK)
|
||||||
|
// but the window remains invisible.
|
||||||
|
val (replyText, _) = extractReply(intent)
|
||||||
|
if (!replyText.isNullOrEmpty()) {
|
||||||
|
setTheme(R.style.ReplyHandlerTheme)
|
||||||
|
Log.e("PYRAMID-INTENT", "onCreate: reply launch detected — transparent theme applied")
|
||||||
|
}
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||||
|
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "installApk") {
|
when (call.method) {
|
||||||
val path = call.argument<String>("path")
|
// Flutter asks for the room_id embedded in the launch intent (cold-start tap).
|
||||||
if (path == null) {
|
"getInitialRoomId" -> {
|
||||||
result.error("INVALID_ARG", "path is null", null)
|
result.success(intent?.getStringExtra("room_id"))
|
||||||
return@setMethodCallHandler
|
|
||||||
}
|
}
|
||||||
try {
|
// Cold-start share: app was launched via "Teilen nach Pyramid".
|
||||||
val file = File(path)
|
"getInitialShare" -> {
|
||||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
val share = extractShare(intent)
|
||||||
FileProvider.getUriForFile(
|
if (share != null) {
|
||||||
this,
|
result.success(share)
|
||||||
"${applicationContext.packageName}.fileprovider",
|
intent = Intent() // clear so it's delivered only once
|
||||||
file
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Uri.fromFile(file)
|
result.success(null)
|
||||||
}
|
}
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
}
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
// Flutter asks for any inline reply that arrived on cold-start.
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
// When the app is killed and the user presses Reply, Android calls
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
// onCreate (not onNewIntent), so the reply text is in the launch intent.
|
||||||
|
"getInitialReply" -> {
|
||||||
|
val (replyText, payload) = extractReply(intent)
|
||||||
|
Log.e("PYRAMID-INTENT", "getInitialReply: replyText=${replyText?.take(20)} payload=$payload")
|
||||||
|
if (!replyText.isNullOrEmpty() && !payload.isNullOrEmpty()) {
|
||||||
|
result.success(mapOf("room_id" to payload, "text" to replyText))
|
||||||
|
// Clear the launch intent so a second getInitialReply call returns null.
|
||||||
|
intent = Intent()
|
||||||
|
} else {
|
||||||
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Supported CPU ABIs in preference order, so the updater can
|
||||||
|
// pick the matching split APK from a release.
|
||||||
|
"getAbis" -> {
|
||||||
|
result.success(Build.SUPPORTED_ABIS.toList())
|
||||||
|
}
|
||||||
|
"installApk" -> {
|
||||||
|
val path = call.argument<String>("path")
|
||||||
|
if (path == null) {
|
||||||
|
result.error("INVALID_ARG", "path is null", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
!packageManager.canRequestPackageInstalls()) {
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
result.error("PERMISSION_REQUIRED", "install_permission", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val file = File(path)
|
||||||
|
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"${applicationContext.packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Uri.fromFile(file)
|
||||||
|
}
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("INSTALL_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"openNotificationSettings" -> {
|
||||||
|
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", packageName, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("INSTALL_ERROR", e.message, null)
|
|
||||||
}
|
}
|
||||||
} else {
|
"openBatterySettings" -> {
|
||||||
result.notImplemented()
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", packageName, null)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(fallback)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"minimizeApp" -> {
|
||||||
|
moveTaskToBack(true)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"showNativeNotification" -> {
|
||||||
|
val title = call.argument<String>("title") ?: ""
|
||||||
|
val body = call.argument<String>("body") ?: ""
|
||||||
|
val roomId = call.argument<String>("roomId") ?: ""
|
||||||
|
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
|
||||||
|
if (roomId.isNotEmpty()) {
|
||||||
|
NotificationHelper.show(this, title, body, roomId, notifId)
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_ARG", "roomId missing", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called when the app is already running (background) and a notification action
|
||||||
|
// fires — reply and tap both arrive here via onNewIntent (singleTop + SINGLE_TOP flag).
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
// Zero-duration transition for all notification-driven intents.
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
|
||||||
|
Log.e("PYRAMID-INTENT", "onNewIntent action=${intent.action}")
|
||||||
|
|
||||||
|
// ── Reply action ──────────────────────────────────────────────────────
|
||||||
|
if (intent.action == ReplyReceiver.ACTION_REPLY) {
|
||||||
|
val (replyText, payload) = extractReply(intent)
|
||||||
|
Log.e("PYRAMID-INTENT", "reply: replyText=${replyText?.take(20)} payload=$payload")
|
||||||
|
if (!replyText.isNullOrEmpty() && !payload.isNullOrEmpty()) {
|
||||||
|
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
|
||||||
|
MethodChannel(messenger, channel).invokeMethod(
|
||||||
|
"replyFromNotification",
|
||||||
|
mapOf("room_id" to payload, "text" to replyText),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always send back immediately — never show the app UI for a reply.
|
||||||
|
moveTaskToBack(true)
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share action — app already running, user shared from another app ──
|
||||||
|
val share = extractShare(intent)
|
||||||
|
if (share != null) {
|
||||||
|
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
|
||||||
|
MethodChannel(messenger, channel).invokeMethod("sharedContent", share)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tap action — open the room ────────────────────────────────────────
|
||||||
|
val roomId = intent.getStringExtra("room_id") ?: return
|
||||||
|
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
|
||||||
|
MethodChannel(messenger, channel).invokeMethod(
|
||||||
|
"openRoom",
|
||||||
|
mapOf("room_id" to roomId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
const val CHANNEL_ID = "pyramid_messages"
|
||||||
|
|
||||||
|
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
||||||
|
fun stableId(roomId: String): Int {
|
||||||
|
var h = 0
|
||||||
|
for (c in roomId) h = ((h * 31) + c.code) and 0x7FFFFFFF
|
||||||
|
return if (h == 0) 1 else h
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "Nachrichten", NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Neue Nachrichten"
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
nm(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
PendingIntent.FLAG_MUTABLE else 0
|
||||||
|
|
||||||
|
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
||||||
|
// ReplyReceiver decrypts/sends via the background engine when killed.
|
||||||
|
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
||||||
|
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||||
|
.setLabel("Antworten…").build()
|
||||||
|
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||||
|
action = ReplyReceiver.ACTION_REPLY
|
||||||
|
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||||
|
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
|
}
|
||||||
|
val replyPi = PendingIntent.getBroadcast(
|
||||||
|
context, notifId,
|
||||||
|
replyIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
||||||
|
)
|
||||||
|
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
||||||
|
.addRemoteInput(replyInput)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Dismiss action — also points to ReplyReceiver so notification is cancelled.
|
||||||
|
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||||
|
action = ReplyReceiver.ACTION_DISMISS
|
||||||
|
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||||
|
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
|
}
|
||||||
|
val dismissPi = PendingIntent.getBroadcast(
|
||||||
|
context, notifId + 1_000_000,
|
||||||
|
dismissIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tap action — opens MainActivity to the right room.
|
||||||
|
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_MAIN
|
||||||
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
putExtra("room_id", roomId)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val tapPi = PendingIntent.getActivity(
|
||||||
|
context, notifId + 2_000_000,
|
||||||
|
tapIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
// Only buzz/heads-up on the first show — the decrypted update reuses
|
||||||
|
// the same notifId and replaces the text silently (no second alert).
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(tapPi)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.addAction(replyAction)
|
||||||
|
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
nm(context).notify(notifId, notif)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nm(context: Context) =
|
||||||
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class PushService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
val data = message.data
|
||||||
|
val roomId = data["room_id"] ?: run {
|
||||||
|
Log.d(TAG, "no room_id in payload, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||||
|
|
||||||
|
// Skip if the Dart app is actively syncing (wrote a heartbeat < 20 s ago).
|
||||||
|
val heartbeat = try {
|
||||||
|
prefs.getLong("flutter.notif_app_heartbeat", 0L)
|
||||||
|
.takeIf { it > 0L }
|
||||||
|
?: prefs.getInt("flutter.notif_app_heartbeat", 0).toLong()
|
||||||
|
} catch (_: Exception) { 0L }
|
||||||
|
if (System.currentTimeMillis() - heartbeat < 20_000L) {
|
||||||
|
Log.d(TAG, "app heartbeat fresh, skipping FCM notification")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventId = data["event_id"]
|
||||||
|
val notifId = NotificationHelper.stableId(roomId)
|
||||||
|
val hs = (prefs.getString("flutter.notif_homeserver", "") ?: "").trimEnd('/')
|
||||||
|
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
||||||
|
|
||||||
|
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
||||||
|
var title = "Pyramid"
|
||||||
|
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
Thread {
|
||||||
|
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
|
||||||
|
finally { latch.countDown() }
|
||||||
|
}.start()
|
||||||
|
latch.await(12L, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "handling push: title=$title roomId=$roomId")
|
||||||
|
|
||||||
|
val engine = FlutterEngineCache.getInstance().get("main")
|
||||||
|
if (engine != null) {
|
||||||
|
// App backgrounded but alive — show placeholder immediately, then the
|
||||||
|
// warm main client decrypts and updates it (fast, barely visible).
|
||||||
|
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
||||||
|
if (eventId != null) {
|
||||||
|
val capturedRoomId = roomId
|
||||||
|
val capturedEventId = eventId
|
||||||
|
val capturedNotifId = notifId
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
try {
|
||||||
|
MethodChannel(engine.dartExecutor.binaryMessenger,
|
||||||
|
"chat.pyramid.pyramid/install")
|
||||||
|
.invokeMethod("decryptAndUpdateNotification", mapOf(
|
||||||
|
"room_id" to capturedRoomId,
|
||||||
|
"event_id" to capturedEventId,
|
||||||
|
"notif_id" to capturedNotifId,
|
||||||
|
))
|
||||||
|
Log.d(TAG, "decryptAndUpdateNotification invoked (main engine) for $capturedRoomId")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "decryptAndUpdateNotification failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (eventId != null) {
|
||||||
|
// App killed — decrypt FIRST via the background engine, then show the
|
||||||
|
// real content directly. The "Neue Nachricht" placeholder is only a
|
||||||
|
// fallback if decryption takes too long or fails.
|
||||||
|
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
||||||
|
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L)
|
||||||
|
BgEngine.run(this, "decryptAndShow", mapOf(
|
||||||
|
"room_id" to roomId,
|
||||||
|
"event_id" to eventId,
|
||||||
|
"notif_id" to notifId,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
// No event id to decrypt — just show the placeholder.
|
||||||
|
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
Log.d(TAG, "FCM token refreshed — storing for Dart to re-register pusher")
|
||||||
|
getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString("flutter.fcm_pending_token", token)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun fetchTitle(
|
||||||
|
hs: String, token: String, roomId: String, eventId: String
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
||||||
|
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
||||||
|
|
||||||
|
val event = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent", token)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val senderId = event.optString("sender")
|
||||||
|
var senderName = senderId.split(":").firstOrNull()?.removePrefix("@") ?: ""
|
||||||
|
|
||||||
|
if (senderId.isNotEmpty()) {
|
||||||
|
val encSender = java.net.URLEncoder.encode(senderId, "UTF-8")
|
||||||
|
val member = httpGet(
|
||||||
|
"$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender", token)
|
||||||
|
val name = member?.optString("displayname")
|
||||||
|
if (!name.isNullOrEmpty()) senderName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
||||||
|
val roomName = nameState?.optString("name")
|
||||||
|
|
||||||
|
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
|
||||||
|
else senderName.ifEmpty { null }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun httpGet(url: String, token: String): JSONObject? {
|
||||||
|
var conn: HttpURLConnection? = null
|
||||||
|
return try {
|
||||||
|
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
connectTimeout = 8_000
|
||||||
|
readTimeout = 8_000
|
||||||
|
}
|
||||||
|
if (conn.responseCode != 200) return null
|
||||||
|
JSONObject(conn.inputStream.bufferedReader().readText())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
conn?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PYRAMID-PUSH"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
class ReplyReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_REPLY = "chat.pyramid.pyramid.REPLY"
|
||||||
|
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
||||||
|
const val EXTRA_ROOM_ID = "room_id"
|
||||||
|
const val EXTRA_NOTIF_ID = "notif_id"
|
||||||
|
const val REMOTE_INPUT_KEY = "reply_text"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
||||||
|
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
||||||
|
|
||||||
|
// Dismiss the notification immediately.
|
||||||
|
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||||
|
.cancel(notifId)
|
||||||
|
|
||||||
|
if (intent.action == ACTION_DISMISS) {
|
||||||
|
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract inline reply text. Some Samsung/MIUI/OEM devices drop the
|
||||||
|
// RemoteInput bundle or use a different key — try multiple fallbacks.
|
||||||
|
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
||||||
|
val replyText = remoteInput?.let { bundle ->
|
||||||
|
bundle.getCharSequence(REMOTE_INPUT_KEY)?.toString()?.trim()
|
||||||
|
?: bundle.keySet()?.firstOrNull()?.let { bundle.getCharSequence(it)?.toString()?.trim() }
|
||||||
|
} ?: intent.getStringExtra(REMOTE_INPUT_KEY)
|
||||||
|
?: intent.getStringExtra("input")
|
||||||
|
?: intent.getStringExtra("notification_action_input")
|
||||||
|
?: intent.getStringExtra("reply_text")
|
||||||
|
Log.d("PYRAMID-REPLY", "extracted replyText=${replyText?.take(20)} (remoteInput=${remoteInput != null})")
|
||||||
|
if (replyText.isNullOrEmpty()) {
|
||||||
|
Log.w("PYRAMID-REPLY", "reply text empty after all fallbacks, aborting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("PYRAMID-REPLY", "reply received: room=$roomId text=$replyText")
|
||||||
|
|
||||||
|
// Fast path: Flutter engine is alive — forward directly via MethodChannel.
|
||||||
|
val engine = FlutterEngineCache.getInstance().get("main")
|
||||||
|
if (engine != null) {
|
||||||
|
Log.d("PYRAMID-REPLY", "Flutter engine alive → MethodChannel")
|
||||||
|
try {
|
||||||
|
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
|
||||||
|
.invokeMethod("replyFromNotification",
|
||||||
|
mapOf("room_id" to roomId, "text" to replyText))
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("PYRAMID-REPLY", "MethodChannel failed: ${e.message}, falling back to prefs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: app killed — send the encrypted reply via the background
|
||||||
|
// engine (no Activity, fully silent). _bgSendReply falls back to the
|
||||||
|
// prefs queue itself if it can't send, so nothing is lost.
|
||||||
|
// goAsync() keeps this BroadcastReceiver's process alive while the engine
|
||||||
|
// boots + sends (otherwise it could be killed right after onReceive).
|
||||||
|
Log.d("PYRAMID-REPLY", "Flutter engine not running → background engine send")
|
||||||
|
val pending = goAsync()
|
||||||
|
BgEngine.run(context, "sendReply", mapOf(
|
||||||
|
"room_id" to roomId,
|
||||||
|
"text" to replyText,
|
||||||
|
))
|
||||||
|
// Release after a window long enough for sync + encrypted send (~4 s seen
|
||||||
|
// in logs; 15 s gives margin). The engine completes independently.
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||||
|
try { pending.finish() } catch (_: Exception) {}
|
||||||
|
}, 15_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Adaptive icon foreground — 108×108dp canvas.
|
||||||
|
Safe zone: inner 66dp (21–87). Pyramid fills ~56% of visible 72dp circle.
|
||||||
|
Derived from assets/logo.svg (48×48 viewBox, scale=1.1, centered at (54,54)).
|
||||||
|
Draw order matches SVG: right face behind, left face in front, then strokes on top.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<!-- Right face — pyr-side, diagonal gradient 85%→25% opacity -->
|
||||||
|
<path android:pathData="M54,35 L74,73 L54,66 Z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="54" android:startY="35"
|
||||||
|
android:endX="74" android:endY="73">
|
||||||
|
<item android:offset="0" android:color="#D9F5A524"/>
|
||||||
|
<item android:offset="1" android:color="#40F5A524"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- Left face — pyr-face, vertical gradient 100%→55% opacity -->
|
||||||
|
<path android:pathData="M54,35 L34,73 L54,66 Z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="54" android:startY="35"
|
||||||
|
android:endX="54" android:endY="73">
|
||||||
|
<item android:offset="0" android:color="#FFF5A524"/>
|
||||||
|
<item android:offset="1" android:color="#8CF5A524"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- Base outline — stroke 40% opacity, width 1.1dp, linejoin round -->
|
||||||
|
<path android:pathData="M34,73 L54,66 L74,73"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#66F5A524"
|
||||||
|
android:strokeWidth="1.1"
|
||||||
|
android:strokeLineJoin="round"/>
|
||||||
|
|
||||||
|
<!-- Edge highlight / center spine — stroke 90% opacity, width 0.88dp -->
|
||||||
|
<path android:pathData="M54,35 L54,66"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#E6F5A524"
|
||||||
|
android:strokeWidth="0.9"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<!-- Pyramid shape: left face -->
|
||||||
|
<path
|
||||||
|
android:pathData="M24,6 L6,40 L24,34 Z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<!-- Pyramid shape: right face -->
|
||||||
|
<path
|
||||||
|
android:pathData="M24,6 L42,40 L24,34 Z"
|
||||||
|
android:fillColor="#CCFFFFFF"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -15,4 +15,10 @@
|
|||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
<style name="ReplyHandlerTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowContentOverlay">@null</item>
|
||||||
|
<item name="android:backgroundDimEnabled">false</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1A1B1E</color>
|
||||||
|
</resources>
|
||||||
@@ -15,4 +15,12 @@
|
|||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Used when the activity is launched solely to handle a notification reply.
|
||||||
|
The window is fully transparent so the user never sees the UI open. -->
|
||||||
|
<style name="ReplyHandlerTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowContentOverlay">@null</item>
|
||||||
|
<item name="android:backgroundDimEnabled">false</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
|
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pyr-face" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F5A524" stop-opacity="1" />
|
||||||
|
<stop offset="1" stop-color="#F5A524" stop-opacity="0.55" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pyr-side" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F5A524" stop-opacity="0.85" />
|
||||||
|
<stop offset="1" stop-color="#F5A524" stop-opacity="0.25" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- right face -->
|
||||||
|
<path d="M24 6 L42 40 L24 34 Z" fill="url(#pyr-side)" />
|
||||||
|
<!-- left face (front) -->
|
||||||
|
<path d="M24 6 L6 40 L24 34 Z" fill="url(#pyr-face)" />
|
||||||
|
<!-- base outline -->
|
||||||
|
<path d="M6 40 L24 34 L42 40" stroke="#F5A524" stroke-opacity="0.4" stroke-width="1" fill="none" stroke-linejoin="round" />
|
||||||
|
<!-- edge highlight -->
|
||||||
|
<path d="M24 6 L24 34" stroke="#F5A524" stroke-opacity="0.9" stroke-width="0.8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 963 B |
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||||
|
<path d="M100 30 L175 160 L25 150 Z" fill="currentColor" />
|
||||||
|
<path d="M100 30 L110 160 L100 150 Z" fill="white" opacity="0.2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 202 B |
@@ -0,0 +1,25 @@
|
|||||||
|
# Pyramid-Autopilot: startet Claude-Sessions in Endlosschleife.
|
||||||
|
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten ein Neustart versucht,
|
||||||
|
# bis das Kontingent wieder da ist. Stoppen: Strg+C oder Fenster schließen.
|
||||||
|
#
|
||||||
|
# Start: Rechtsklick → "Mit PowerShell ausführen"
|
||||||
|
# oder: pwsh -File autopilot.ps1
|
||||||
|
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
$prompt = "Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. " +
|
||||||
|
"Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). " +
|
||||||
|
"Arbeite so viele Punkte ab wie möglich."
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
Add-Content autopilot.log "`n===== [$ts] Neue Claude-Session ====="
|
||||||
|
Write-Host "`n===== [$ts] Neue Claude-Session (Log: autopilot.log) =====" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
claude -p $prompt --dangerously-skip-permissions 2>&1 | Tee-Object -FilePath autopilot.log -Append
|
||||||
|
|
||||||
|
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
Add-Content autopilot.log "===== [$ts] Session beendet - naechster Versuch in 20 Minuten ====="
|
||||||
|
Write-Host "===== [$ts] Session beendet - naechster Versuch in 20 Minuten (Strg+C zum Stoppen) =====" -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 1200
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||||
|
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
||||||
|
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||||
|
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||||
|
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
|
||||||
|
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||||
|
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||||
|
|
||||||
|
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
||||||
|
MATRIX_URL="http://127.0.0.1:6167"
|
||||||
|
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
|
||||||
|
MATRIX_TOKEN="$(grep -s '^MATRIX_TOKEN=' "$HOME/gatus/.env" | cut -d= -f2-)"
|
||||||
|
|
||||||
|
notify_matrix() {
|
||||||
|
[ -z "$MATRIX_TOKEN" ] && return 0
|
||||||
|
local body
|
||||||
|
body=$(python3 -c 'import json,sys; print(json.dumps({"msgtype":"m.text","body":sys.stdin.read().strip()}))' <<< "$1")
|
||||||
|
curl -s -m 10 -X PUT \
|
||||||
|
"$MATRIX_URL/_matrix/client/v3/rooms/$MATRIX_ROOM/send/m.room.message/autopilot$(date +%s%N)" \
|
||||||
|
-H "Authorization: Bearer $MATRIX_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "$body" > /dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
|
||||||
|
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
|
||||||
|
Arbeite so viele Punkte ab wie möglich."
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if [ -f PAUSE ]; then
|
||||||
|
echo "===== $(date '+%F %T') PAUSE aktiv (rm PAUSE zum Fortsetzen) - pruefe wieder in 10 Minuten ====="
|
||||||
|
sleep 600
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
||||||
|
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
||||||
|
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
|
||||||
|
AFTER=$(git rev-parse HEAD 2>/dev/null)
|
||||||
|
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
||||||
|
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
||||||
|
NEXT=$(grep -m1 -A3 "Offen/Nächster Schritt" PROGRESS.md)
|
||||||
|
notify_matrix "🔺 Pyramid-Autopilot $(date '+%H:%M') – Session fertig.
|
||||||
|
|
||||||
|
Neue Commits:
|
||||||
|
$COMMITS
|
||||||
|
|
||||||
|
$NEXT"
|
||||||
|
fi
|
||||||
|
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||||
|
sleep 1200
|
||||||
|
done
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# M2 – Modul-Schnitt (Entwurf)
|
||||||
|
|
||||||
|
Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage:
|
||||||
|
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
|
||||||
|
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
|
||||||
|
einem Jahr komplett neu schreiben lassen, ohne andere Module anzufassen.
|
||||||
|
Andere Module dürfen daher nur die hier genannte öffentliche Schnittstelle
|
||||||
|
importieren, nie interne Dateien.
|
||||||
|
|
||||||
|
Korrektur zur Ist-Analyse: der Import von `rooms_provider.dart` in
|
||||||
|
`app_state.dart` ist **kein** toter Altlast-Import, wie dort vermutet –
|
||||||
|
`voiceParticipantsProvider` (Zeile 160) nutzt `roomListProvider` daraus, um
|
||||||
|
bei jedem Sync neu zu bauen. Der Eintrag in PROGRESS.md wird beim nächsten
|
||||||
|
Durchgang korrigiert.
|
||||||
|
|
||||||
|
## Wichtige Erkenntnis vor dem Schnitt: „Calls" ist eigentlich ZWEI Features
|
||||||
|
|
||||||
|
Beim genaueren Lesen von `voip_manager.dart` und `livekit_call_manager.dart`
|
||||||
|
zeigt sich: das sind keine zwei Hälften derselben Sache, sondern zwei
|
||||||
|
unabhängige Call-Mechanismen, die zufällig beide „Call" heißen:
|
||||||
|
|
||||||
|
- **`PyramidVoipManager`** – natives Matrix-VoIP (`m.call.*`-Events über das
|
||||||
|
matrix-SDK, direkte 1:1-WebRTC-Peer-Connection, kein SFU). Vermutlich für
|
||||||
|
spontane 1:1-Anrufe gedacht.
|
||||||
|
- **`LiveKitCallManager`** – SFU-basierte, dauerhafte Voice-Channels (LiveKit-
|
||||||
|
Server-Raum, Presence über Matrix-State-Events, Screensharing, Mehrpersonen).
|
||||||
|
Das ist der Discord-artige Voice-Channel aus M3/M4.
|
||||||
|
|
||||||
|
Beide Module sollten **getrennt** bleiben, nicht zu einem „calls"-Modul
|
||||||
|
verschmolzen werden. Nur die UI (`call_ui`) darf beide kennen, falls sie
|
||||||
|
gemeinsam dargestellt werden müssen (z. B. „du bist in einem Voice-Channel,
|
||||||
|
jemand ruft dich per 1:1-VoIP an").
|
||||||
|
|
||||||
|
## Modul-Liste mit öffentlicher Schnittstelle (Entwurf)
|
||||||
|
|
||||||
|
### 1. `call_signaling` (Matrix-1:1-VoIP)
|
||||||
|
**Ersetzt:** `core/voip_manager.dart`
|
||||||
|
**Öffentliche Schnittstelle (Ausschnitt):**
|
||||||
|
```dart
|
||||||
|
abstract class CallSignalingService {
|
||||||
|
Stream<CallSession?> get currentCallStream;
|
||||||
|
bool get isMicMuted;
|
||||||
|
bool get isCameraMuted;
|
||||||
|
Future<void> startCall(String roomId, {bool video});
|
||||||
|
Future<void> hangup();
|
||||||
|
Future<void> toggleMic();
|
||||||
|
Future<void> toggleCamera();
|
||||||
|
Future<void> toggleScreenSharing(BuildContext? context);
|
||||||
|
RTCVideoRenderer get localRenderer; // bleibt vorerst WebRTC-typisiert,
|
||||||
|
RTCVideoRenderer get remoteRenderer; // da UI direkt rendert
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Interna (ICE-Server-Merge, `MediaDevicesWrapper`, Geräte-Prefs aus
|
||||||
|
`SharedPreferences`) bleiben privat im Modul. Geräte-Prefs sollten künftig
|
||||||
|
über die `storage`-Fassade laufen statt direktem `SharedPreferences`-Zugriff
|
||||||
|
(aktuell Zeile 260, 447).
|
||||||
|
|
||||||
|
### 2. `voice_channel` (LiveKit-SFU, Discord-artige Kanäle)
|
||||||
|
**Ersetzt:** `core/livekit_call_manager.dart`
|
||||||
|
**Öffentliche Schnittstelle (Ausschnitt):**
|
||||||
|
```dart
|
||||||
|
abstract class VoiceChannelService {
|
||||||
|
Stream<VoiceChannelState> get stateStream; // isActive, isMuted, participants…
|
||||||
|
Future<void> join({required String roomName, required String matrixRoomId, ...});
|
||||||
|
Future<void> leave();
|
||||||
|
Future<void> toggleMute();
|
||||||
|
Future<void> toggleDeafen();
|
||||||
|
Future<void> toggleCamera();
|
||||||
|
Future<void> toggleScreenShare();
|
||||||
|
Future<void> setPublishQuality(String key); // Streamer-Seite (M4)
|
||||||
|
Future<void> setSubscribeQuality(String key); // Empfänger-Seite (M4)
|
||||||
|
Future<void> setOutputVolume(double v);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Presence-Writing (`_writePresence`, Matrix-State-Event
|
||||||
|
`io.pyramid.voice.presence`) bleibt intern, aber nutzt eine injizierte
|
||||||
|
`Client`-Referenz statt globalem Singleton-Zugriff – macht das Modul für
|
||||||
|
Tests austauschbar.
|
||||||
|
|
||||||
|
### 3. `call_ui`
|
||||||
|
**Ersetzt:** `features/call/voice_channel.dart`, `features/call/mini_call_widget.dart`
|
||||||
|
Konsumiert ausschließlich `CallSignalingService` + `VoiceChannelService` über
|
||||||
|
Riverpod-Provider (`callSignalingProvider`, `voiceChannelProvider`). Keine
|
||||||
|
direkten Importe von `core/voip_manager.dart`/`core/livekit_call_manager.dart`
|
||||||
|
mehr – heute importieren `chat_view.dart`, `matrix_client.dart` und
|
||||||
|
`settings_modal.dart` diese Dateien direkt, das entfällt.
|
||||||
|
|
||||||
|
### 4. `settings` (aufgesplittet)
|
||||||
|
Kein eigenes fachliches Modul, sondern reine Aggregations-UI. Jede Sektion
|
||||||
|
wird eine eigene Datei/Klasse mit einer schmalen `Widget build(...)`-Fläche,
|
||||||
|
die ihrerseits nur die Provider ihres Fachmoduls konsumiert (z. B.
|
||||||
|
`_VoiceSection` nutzt künftig `voiceChannelProvider`/`callSignalingProvider`
|
||||||
|
statt der Manager direkt). Vorschlag für Dateien:
|
||||||
|
`settings/profile_section.dart`, `settings/notifications_section.dart`,
|
||||||
|
`settings/appearance_section.dart`, `settings/sessions_section.dart`,
|
||||||
|
`settings/account_section.dart`, `settings/voice_section.dart`,
|
||||||
|
`settings/about_section.dart`.
|
||||||
|
|
||||||
|
### 5. `verification` (neu, aus Settings herausgelöst)
|
||||||
|
**Quelle:** `widgets/settings_modal.dart` Zeile 2921–4545 (SAS-Dialog,
|
||||||
|
QR-Anzeige, Emoji-Vergleich, Recovery-Key-Flow).
|
||||||
|
```dart
|
||||||
|
abstract class VerificationService {
|
||||||
|
Future<void> startSasVerification(DeviceKeys device);
|
||||||
|
Future<void> confirmSas();
|
||||||
|
Future<void> declineSas();
|
||||||
|
Stream<VerificationStep> get stepStream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Eigenständig, weil SAS/QR-Verifikation künftig auch außerhalb der Settings
|
||||||
|
gebraucht werden könnte (z. B. beim Login auf einem neuen Gerät –
|
||||||
|
`bootstrap_dialog.dart` macht heute schon etwas Ähnliches separat).
|
||||||
|
|
||||||
|
### 6. `storage` (Fassade, kein Rewrite)
|
||||||
|
**Ausbau von:** `core/settings_prefs.dart`
|
||||||
|
```dart
|
||||||
|
abstract class Prefs {
|
||||||
|
Future<bool> getBool(String key, {bool fallback});
|
||||||
|
Future<void> setBool(String key, bool value);
|
||||||
|
// … String, double, StringSet analog zu BoolPref/StringSetPref
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Ziel ist NICHT, `SharedPreferences` zu ersetzen, sondern die bereits
|
||||||
|
vorhandene `BoolPref`/`StringSetPref`-Abstraktion zur Pflicht zu machen.
|
||||||
|
Die 12 identifizierten Direktzugriffsstellen (u. a. `voip_manager.dart:260`,
|
||||||
|
`livekit_call_manager.dart:246`, `settings_modal.dart:1804/1860/1870`) werden
|
||||||
|
schrittweise umgestellt – **nicht** in diesem Schritt, sondern sobald das
|
||||||
|
jeweilige Modul (z. B. `call_signaling`) ohnehin angefasst wird.
|
||||||
|
Krypto-/Access-Token-Speicherung (`flutter_secure_storage`,
|
||||||
|
`matrix_client.dart`) bleibt bewusst außen vor – hängt an der noch offenen
|
||||||
|
SQLCipher-Frage (M1) und wird dort separat behandelt, nicht hier.
|
||||||
|
|
||||||
|
### 7. `auth`
|
||||||
|
**Dateien:** `features/auth/login_notifier.dart`, `login_page.dart`,
|
||||||
|
`server_page.dart`, `bootstrap_dialog.dart` (Bootstrap-Teil, Verifikations-Teil
|
||||||
|
wandert ggf. zu `verification`, s. o.). Schnittstelle bleibt der bereits
|
||||||
|
vorhandene `matrixClientProvider`/`login_notifier`-Zustandsautomat – heute
|
||||||
|
schon relativ sauber gekapselt, kein akuter Handlungsbedarf.
|
||||||
|
|
||||||
|
### 8. `push`
|
||||||
|
**Dateien:** `core/background_push.dart`, `core/fcm_push_service.dart`,
|
||||||
|
`core/notification_service.dart`. Bereits über eigene Dateien getrennt
|
||||||
|
(nativ vs. Flutter-Engine, siehe Memory „Pyramid Push"). Einzige Lücke:
|
||||||
|
eigener `sqflite`-Zugriff in `background_push.dart` statt gemeinsamer
|
||||||
|
DB-Schicht – bewusst so gewollt (Background-Isolate-Zwang), daher **kein**
|
||||||
|
Refactoring-Kandidat, nur dokumentieren.
|
||||||
|
|
||||||
|
### 9. `chat_timeline` / `rooms`
|
||||||
|
Niedrigste Priorität für den Pilot-Umbau, da laut Ist-Analyse strukturell
|
||||||
|
am wenigsten verwoben (Hauptproblem dort ist Dateigröße + Duplikate, nicht
|
||||||
|
Modul-Kopplung). Wird nach dem Call-Pilot angegangen.
|
||||||
|
|
||||||
|
## Reihenfolge-Vorschlag für die Umsetzung
|
||||||
|
|
||||||
|
1. **Pilot: `call_signaling` + `voice_channel` + `call_ui`** (Punkt „Call-Schicht
|
||||||
|
entwirren" in ROADMAP.md) – kleinster klar abgegrenzter Schnitt, deckt
|
||||||
|
gleich zwei ROADMAP-Punkte ab (Modul-Schnitt beweisen + Call-Entwirrung).
|
||||||
|
2. `settings_modal.dart` aufsplitten (reine Datei-Teilung, geringes Risiko,
|
||||||
|
keine Verhaltensänderung).
|
||||||
|
3. `verification` aus Settings herauslösen.
|
||||||
|
4. `storage`-Fassade schrittweise erzwingen, immer im Zuge anderer Umbauten.
|
||||||
|
5. `chat_timeline`/`rooms` zuletzt.
|
||||||
|
|
||||||
|
**Bernd-Entscheidung nötig:** siehe Frage in PROGRESS.md – ob Punkt 1
|
||||||
|
(Call-Pilot) so wie hier vorgeschlagen als Nächstes umgesetzt werden soll.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
# Matrix / Element Feature Checklist für Pyramid
|
# Matrix / Element Feature Checklist für Pyramid
|
||||||
|
|
||||||
*Stand: April 2026 — Pyramid v0.1.x (Early Development)*
|
*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] Verschlüsselte Nachrichten anzeigen (Entschlüsselungsstatus)
|
||||||
- [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden)
|
- [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden)
|
||||||
- [x] Ungelesene-Nachrichten-Tracking
|
- [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
|
### Nachrichtenaktionen
|
||||||
- [x] Nachrichtentext kopieren
|
- [x] Nachrichtentext kopieren
|
||||||
- [x] Eigene Nachrichten löschen (redact)
|
- [x] Eigene Nachrichten löschen (redact)
|
||||||
- [x] Nachrichten anpinnen / Pinboard anzeigen
|
- [x] Nachrichten anpinnen / Pinboard anzeigen
|
||||||
- [~] Auf Nachrichten antworten (in-reply-to vorhanden, kein visuelles Zitatfeld)
|
- [x] Auf Nachrichten antworten (in-reply-to)
|
||||||
- [~] Nachrichten bearbeiten (m.edit im SDK erkannt, kein vollständiges UI)
|
|
||||||
- [ ] Nachrichten weiterleiten
|
- [ ] Nachrichten weiterleiten
|
||||||
- [ ] Zitat-Styling (visuelle Blockquote-Anzeige)
|
- [ ] 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**)
|
- [ ] Fett (**text**)
|
||||||
- [ ] Kursiv (*text*)
|
- [ ] Kursiv (*text*)
|
||||||
- [ ] Durchgestrichen (~~text~~)
|
- [ ] Durchgestrichen (~~text~~)
|
||||||
@@ -33,7 +48,7 @@
|
|||||||
- [ ] Code-Block (```code```)
|
- [ ] Code-Block (```code```)
|
||||||
- [ ] Aufzählungslisten
|
- [ ] Aufzählungslisten
|
||||||
- [ ] Klickbare Links
|
- [ ] Klickbare Links
|
||||||
- [ ] @Erwähnungen (User)
|
- [~] @Erwähnungen — Button vorhanden, `onTap: null` 🟡 P2
|
||||||
- [ ] #Kanal-Erwähnungen
|
- [ ] #Kanal-Erwähnungen
|
||||||
- [ ] Spoiler-Tags (||text||)
|
- [ ] Spoiler-Tags (||text||)
|
||||||
|
|
||||||
@@ -62,14 +77,118 @@
|
|||||||
- [x] Mehrere Reaktionen pro Nachricht
|
- [x] Mehrere Reaktionen pro Nachricht
|
||||||
- [x] Kategoriebasierte Emoji-Organisation
|
- [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
|
### Threads
|
||||||
- [ ] Thread-Antworten (m.in_reply_to vorhanden, kein Thread-View)
|
- [~] Thread-Button vorhanden (`onPressed: () {}` — leer) 🔵 P3
|
||||||
- [ ] Thread aufklappen / eigene Ansicht
|
- [ ] Thread aufklappen / eigene Ansicht
|
||||||
- [ ] Thread-Benachrichtigungen
|
- [ ] Thread-Benachrichtigungen
|
||||||
- [ ] Thread als gelöst markieren
|
- [ ] 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
|
## Raumverwaltung
|
||||||
|
|
||||||
### Grundfunktionen
|
### Grundfunktionen
|
||||||
@@ -79,14 +198,12 @@
|
|||||||
- [x] Raumname & Avatar anzeigen
|
- [x] Raumname & Avatar anzeigen
|
||||||
- [x] Verschlüsselungsstatus (Schloss-Icon)
|
- [x] Verschlüsselungsstatus (Schloss-Icon)
|
||||||
- [x] Einladungen annehmen / ablehnen
|
- [x] Einladungen annehmen / ablehnen
|
||||||
- [ ] Neuen Raum erstellen
|
- [ ] Neuen Raum erstellen (UI-Einstiegspunkt)
|
||||||
- [ ] Raumname bearbeiten
|
- [ ] Raumname bearbeiten
|
||||||
- [ ] Raumthema/Beschreibung bearbeiten
|
- [ ] Raumthema/Beschreibung bearbeiten
|
||||||
- [ ] Raum-Avatar ändern
|
- [ ] Raum-Avatar ändern
|
||||||
- [ ] Raumvisibilität (öffentlich / privat)
|
- [ ] Raumvisibilität (öffentlich / privat)
|
||||||
- [ ] Zugriffsregeln (invite/knock/public)
|
|
||||||
- [ ] Verlaufsvisibilität konfigurieren
|
- [ ] Verlaufsvisibilität konfigurieren
|
||||||
- [ ] Raum-Aliases verwalten
|
|
||||||
|
|
||||||
### Direktnachrichten (DMs)
|
### Direktnachrichten (DMs)
|
||||||
- [x] 1:1 DM-Räume anzeigen & öffnen
|
- [x] 1:1 DM-Räume anzeigen & öffnen
|
||||||
@@ -94,261 +211,214 @@
|
|||||||
- [x] Echtzeit-Presence-Updates
|
- [x] Echtzeit-Presence-Updates
|
||||||
- [x] Sprachanruf aus DM starten
|
- [x] Sprachanruf aus DM starten
|
||||||
- [x] Videoanruf aus DM starten
|
- [x] Videoanruf aus DM starten
|
||||||
- [ ] DM starten (neuen Chat beginnen, nur über Suchfeld)
|
- [ ] DM starten (neuen Chat beginnen)
|
||||||
|
|
||||||
### Mitglieder & Rollen
|
### Mitglieder & Rollen
|
||||||
- [x] Mitgliederliste anzeigen
|
- [x] Mitgliederliste anzeigen
|
||||||
- [x] Mitgliederanzahl anzeigen
|
- [x] Mitgliederanzahl anzeigen
|
||||||
- [x] Avatar & Anzeigename der Mitglieder
|
- [x] Avatar & Anzeigename der Mitglieder
|
||||||
- [x] Nutzer in Raum einladen (Raum-Kontextmenü)
|
- [x] Nutzer in Raum einladen
|
||||||
- [ ] Nutzer aus Raum entfernen (Kick)
|
- [ ] Nutzer aus Raum entfernen (Kick)
|
||||||
- [ ] Nutzer sperren (Ban)
|
- [ ] Nutzer sperren (Ban)
|
||||||
- [ ] Power Levels bearbeiten
|
- [ ] 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 & Gemeinschaften
|
||||||
|
|
||||||
### Spaces
|
|
||||||
- [x] Spaces in der Seitenleiste anzeigen
|
- [x] Spaces in der Seitenleiste anzeigen
|
||||||
- [x] Zwischen Spaces navigieren
|
- [x] Zwischen Spaces navigieren
|
||||||
- [x] Space-Avatar anzeigen
|
- [x] Space-Avatar & Banner anzeigen
|
||||||
- [x] Space-Banner hochladen & anzeigen (Discord-Style)
|
|
||||||
- [x] Räume nach Space filtern
|
- [x] Räume nach Space filtern
|
||||||
- [x] Virtuelle "Rooms"- und "DMs"-Spaces
|
- [x] Unterkategorien (Sub-Spaces)
|
||||||
- [x] Unterkategorien (Sub-Spaces) als Kategorien in der Kanalleiste
|
|
||||||
- [x] Drag-and-drop Kanalreihenfolge für Admins
|
- [x] Drag-and-drop Kanalreihenfolge für Admins
|
||||||
- [x] Space-Verwaltungsdialog (Name, Avatar, Banner, Einladungen, Mitglieder, Power Levels)
|
- [x] Space-Verwaltungsdialog
|
||||||
- [ ] Neuen Space erstellen
|
- [ ] Neuen Space erstellen
|
||||||
- [ ] Räume zum Space hinzufügen / entfernen
|
- [ ] Räume zum Space hinzufügen / entfernen
|
||||||
- [ ] Space-Regeln / Beschreibung
|
- [ ] Space-Entdeckung (öffentliche Spaces)
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sprach- & Videoanrufe
|
## Sprach- & Videoanrufe (LiveKit)
|
||||||
|
|
||||||
### Sprachanrufe (LiveKit)
|
- [x] Sprachkanal betreten / verlassen
|
||||||
- [x] Sprachkanal betreten / verlassen (Toggle)
|
- [x] Mikrofon stummschalten / Deafen
|
||||||
- [x] Teilnehmerliste im Sprachkanal
|
- [x] Teilnehmerliste im Sprachkanal
|
||||||
- [x] Mikrofon stummschalten
|
- [x] Anruf-UI (Vollansicht + Mini-Widget)
|
||||||
- [x] Audio-Ausgabe deaktivieren (Deafen)
|
- [x] Videoanruf mit Kamera-Toggle
|
||||||
- [x] Anruf-UI im Profil-Panel (aktiver Anruf)
|
|
||||||
- [x] Anruf-Ansicht öffnen (Vollansicht)
|
|
||||||
- [x] Mini-Call-Widget bei VOIP-Anrufen
|
|
||||||
- [ ] Klingelton bei eingehenden Anrufen
|
- [ ] Klingelton bei eingehenden Anrufen
|
||||||
- [ ] Anruf annehmen / ablehnen (UI)
|
- [ ] Anruf annehmen / ablehnen (UI)
|
||||||
- [ ] Anrufdauer-Timer
|
- [ ] Anrufdauer-Timer
|
||||||
- [ ] Anrufverlauf
|
- [ ] Bildschirmfreigabe
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Nutzereinstellungen & Profil
|
## Nutzereinstellungen & Profil
|
||||||
|
|
||||||
### Profilverwaltung
|
- [x] Hell / Dunkel-Theme, Akzentfarbe, Eckradius, Dichte
|
||||||
- [x] Eigene User-ID anzeigen
|
- [x] Eigene User-ID, Avatar (Initialen-Fallback), Anzeigename anzeigen
|
||||||
- [x] Eigenen Avatar anzeigen (Initialen-Fallback)
|
- [ ] Anzeigenamen & Avatar bearbeiten
|
||||||
- [x] Anzeigenamen anzeigen
|
|
||||||
- [ ] Anzeigenamen bearbeiten
|
|
||||||
- [ ] Eigenen Avatar hochladen / entfernen
|
|
||||||
- [ ] Status-Nachricht setzen
|
|
||||||
|
|
||||||
### Kontosicherheit
|
|
||||||
- [ ] Passwort ändern
|
- [ ] Passwort ändern
|
||||||
- [ ] E-Mail-Adresse verwalten
|
- [ ] Sitzungsverwaltung
|
||||||
- [ ] Sitzungsverwaltung (alle Sitzungen anzeigen)
|
- [ ] Konto deaktivieren
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Auto-Update
|
## Auto-Update
|
||||||
|
|
||||||
- [x] GitHub-Releases als Update-Quelle
|
- [x] GitHub-Releases als Update-Quelle
|
||||||
- [x] Versions-Vergleich (SemVer)
|
- [x] Versions-Vergleich (SemVer), Rate-Limiting, Update-Banner
|
||||||
- [x] Rate-Limiting (max. alle 4 Stunden prüfen)
|
|
||||||
- [x] Update-Banner im App-Header (Windows)
|
|
||||||
- [x] "Diese Version überspringen"-Option
|
- [x] "Diese Version überspringen"-Option
|
||||||
- [x] Manueller Update-Check in den Einstellungen
|
- [ ] Automatischer Download & Installation
|
||||||
- [ ] Automatischer Download & Installation (Squirrel/NSIS)
|
|
||||||
- [ ] Delta-Updates (nur geänderte Dateien)
|
|
||||||
- [ ] Update-Kanal wählen (Stable/Beta/Nightly)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Plattform-spezifisch
|
## Plattform-spezifisch
|
||||||
|
|
||||||
### Desktop (Windows / macOS / Linux)
|
### Desktop
|
||||||
- [x] Native Desktop-App (Flutter Desktop)
|
- [x] Native Desktop-App, Tastenkürzel, Drag-and-Drop
|
||||||
- [x] Tastenkürzel (Enter = Senden, Shift+Enter = Zeile)
|
|
||||||
- [x] Drag-and-Drop für Dateianhänge
|
|
||||||
- [ ] System-Tray-Integration
|
- [ ] System-Tray-Integration
|
||||||
- [ ] Desktop-Benachrichtigungen
|
|
||||||
- [ ] Minimieren in System-Tray
|
|
||||||
- [ ] Fenster-State speichern (Größe, Position)
|
- [ ] Fenster-State speichern (Größe, Position)
|
||||||
- [ ] MSIX / Installer-Paketierung
|
|
||||||
|
|
||||||
### Mobile (Android / iOS)
|
### Mobile
|
||||||
- [x] Mobile App (Flutter)
|
- [x] Mobile App (Flutter), Drawer-Navigation
|
||||||
- [x] Drawer-Navigation (Hamburger-Menü)
|
- [ ] Touch-optimiertes UI
|
||||||
- [ ] Touch-optimiertes UI (derzeit Desktop-first)
|
|
||||||
- [ ] Mobile Push-Benachrichtigungen
|
- [ ] 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 145–168
|
||||||
|
**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 ~408–421
|
||||||
|
**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
|
## Statistik
|
||||||
|
|
||||||
| Bereich | Implementiert | Teilweise | Fehlend |
|
| Bereich | Implementiert | Fehlerhaft | Fehlend |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Messaging | 14 | 2 | 14 |
|
| Chat-Mechanik | Solide Basis | Edits, Typing, `requestFuture` | Jump-to-quote, Threads, Edited-Badge |
|
||||||
| Raumverwaltung | 8 | 0 | 14 |
|
| Read Receipts | DM-Logik ✓ | Gruppen, Off-by-one, Notif-Cancel | Seen-by-Avatare, Tooltip |
|
||||||
| Spaces | 10 | 0 | 6 |
|
| Presence | Anzeige ✓ | Broadcasting, Lifecycle | Heartbeat, Dot in Raumliste |
|
||||||
| Anrufe | 8 | 1 | 10 |
|
| Benachrichtigungen | Local ✓ | Prefs ignoriert, Grouping | Remote Push, iOS, macOS |
|
||||||
| Benachrichtigungen | 4 | 0 | 8 |
|
| Unread-Divider | Konzept ✓ | Position, Auto-Scroll | — |
|
||||||
| Einstellungen/Profil | 5 | 0 | 11 |
|
| E2EE | Stärkster Bereich | Incoming-Verif., Placeholder | Device-Trust-Indicator, Warn beim Senden |
|
||||||
| Sicherheit/E2EE | 9 | 0 | 7 |
|
| Suche | API-Basis ✓ | — | Jump-to-message, Filter |
|
||||||
| Suche | 1 | 0 | 10 |
|
|
||||||
| Auto-Update | 6 | 0 | 3 |
|
|
||||||
| Plattform | 5 | 0 | 10 |
|
|
||||||
| **Gesamt** | **~70** | **~3** | **~93** |
|
|
||||||
|
|
||||||
**Implementierungsgrad: ~42 %** (inkl. Teilimplementierungen)
|
**Implementierungsgrad gesamt: ~42 %** (inkl. Teilimplementierungen)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Empfohlene Feature-Prioritäten
|
## Implementierungs-Fortschritt
|
||||||
|
|
||||||
1. **Textformatierung** — häufig genutzt, von Nutzern erwartet
|
| ID | Beschreibung | Status |
|
||||||
2. **Push-Benachrichtigungen** — Desktop-Benachrichtigungen für Windows
|
|---|---|---|
|
||||||
3. **Tipp-Indikatoren** — verbessert Gesprächsgefühl deutlich
|
| P0-1 | Incoming Key-Verification-Request-Handler | ✅ War bereits implementiert (`app_shell.dart`) |
|
||||||
4. **Nachrichtenbearbeitung** — SDK-Grundlage vorhanden, nur UI fehlt
|
| P1-1 | Notification-Prefs auswerten | ✅ Implementiert (`notification_service.dart`) |
|
||||||
5. **Profilbearbeitung** — Avatar & Anzeigename ändern
|
| P1-2 | Edits rendern (`getDisplayEvent`) | ✅ Implementiert (`message_group.dart`) |
|
||||||
6. **Raum erstellen** — Basis-Workflow, derzeit nicht möglich
|
| P1-3 | Encrypted-Placeholder + Recovery-Button | ✅ Implementiert (`message_group.dart`) |
|
||||||
7. **Sprachnachricht abspielen** — Struktur vorhanden
|
| P2-1 | Typing: `room.setTyping()` senden | ✅ Implementiert (`chat_composer.dart`) |
|
||||||
8. **Suche** — Dialog vorhanden, Backend-Abfrage fehlt
|
| P2-2 | Typing: Display-Widget | ✅ Implementiert (`chat_view.dart` — `_TypingIndicator`) |
|
||||||
9. **Nutzer blockieren** — Sicherheits-Grundfunktion
|
| P2-3 | Unread-Divider: Position + leere fullyRead-Guard | ✅ Neu implementiert (`chat_view.dart`) |
|
||||||
10. **System-Tray** — Desktop-Erlebnis verbessern
|
| 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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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?;
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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'
|
||||||
|
```
|
||||||
@@ -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?
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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'},
|
||||||
|
];
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -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'}),
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
macos:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/logo.png"
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/logo.png"
|
||||||
|
image_path: "assets/logo.png"
|
||||||
|
adaptive_icon_background: "#1A1B1E"
|
||||||
|
adaptive_icon_foreground: "android/app/src/main/res/drawable/ic_launcher_foreground.xml"
|
||||||
@@ -431,7 +431,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 971 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,29 +1,107 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
|
||||||
import 'package:pyramid/core/router.dart';
|
import 'package:pyramid/core/router.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/auth/login_notifier.dart';
|
||||||
|
import 'package:pyramid/widgets/pyramid_loader.dart';
|
||||||
|
|
||||||
class PyramidApp extends ConsumerWidget {
|
class PyramidApp extends ConsumerStatefulWidget {
|
||||||
const PyramidApp({super.key});
|
const PyramidApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PyramidApp> createState() => _PyramidAppState();
|
||||||
final router = ref.watch(routerProvider);
|
}
|
||||||
final colorScheme = ref.watch(colorSchemeProvider);
|
|
||||||
|
|
||||||
return MaterialApp.router(
|
class _PyramidAppState extends ConsumerState<PyramidApp>
|
||||||
title: 'Pyramid',
|
with WidgetsBindingObserver {
|
||||||
theme: buildTheme(colorScheme, Brightness.light),
|
bool _prefsLoaded = false;
|
||||||
darkTheme: buildTheme(colorScheme, Brightness.dark),
|
|
||||||
themeMode: ThemeMode.system,
|
@override
|
||||||
routerConfig: router,
|
void initState() {
|
||||||
localizationsDelegates: const [
|
super.initState();
|
||||||
GlobalMaterialLocalizations.delegate,
|
WidgetsBinding.instance.addObserver(this);
|
||||||
GlobalWidgetsLocalizations.delegate,
|
_loadPersistedPrefs();
|
||||||
GlobalCupertinoLocalizations.delegate,
|
}
|
||||||
],
|
|
||||||
supportedLocales: const [Locale('de'), Locale('en')],
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
switch (state) {
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
setAppForeground(true);
|
||||||
|
checkPendingReply(ref);
|
||||||
|
case AppLifecycleState.paused:
|
||||||
|
case AppLifecycleState.detached:
|
||||||
|
// Clears heartbeat so PushService.kt shows FCM notifications immediately.
|
||||||
|
// Background Matrix syncs must not re-set the heartbeat (see notification_service.dart).
|
||||||
|
setAppForeground(false);
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPersistedPrefs() async {
|
||||||
|
final saved = await loadThemePrefs();
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(themeModeProvider.notifier).state = saved.isDark;
|
||||||
|
ref.read(accentProvider.notifier).state = saved.accentIdx;
|
||||||
|
ref.read(radiusProvider.notifier).state = saved.radius;
|
||||||
|
ref.read(motionProvider.notifier).state = saved.motion;
|
||||||
|
setState(() => _prefsLoaded = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Block the router from rendering until auth state is known so the
|
||||||
|
// /server page never flashes before the redirect to /app fires.
|
||||||
|
final authAsync = ref.watch(isLoggedInProvider);
|
||||||
|
if (authAsync.isLoading || !_prefsLoaded) {
|
||||||
|
return const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Scaffold(
|
||||||
|
backgroundColor: Color(0xFF1A1B1E),
|
||||||
|
body: Center(child: PyramidLoader(size: 80)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
final isDark = ref.watch(themeModeProvider);
|
||||||
|
final accentIdx = ref.watch(accentProvider);
|
||||||
|
final radius = ref.watch(radiusProvider);
|
||||||
|
final density = ref.watch(densityProvider);
|
||||||
|
final motion = ref.watch(motionProvider);
|
||||||
|
final accent = accentPresets[accentIdx].color;
|
||||||
|
|
||||||
|
return PyramidTheme(
|
||||||
|
isDark: isDark,
|
||||||
|
accent: accent,
|
||||||
|
radius: radius,
|
||||||
|
density: density,
|
||||||
|
motion: motion,
|
||||||
|
child: MaterialApp.router(
|
||||||
|
title: 'Pyramid',
|
||||||
|
theme: buildPyramidThemeData(false),
|
||||||
|
darkTheme: buildPyramidThemeData(true),
|
||||||
|
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
||||||
|
routerConfig: router,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [Locale('de'), Locale('en')],
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/livekit_call_manager.dart';
|
||||||
|
import 'package:pyramid/core/voip_manager.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
|
||||||
|
// ─── Theme state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
|
||||||
|
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
||||||
|
|
||||||
|
const accentPresets = [
|
||||||
|
(name: 'amber', color: PyramidColors.accentAmber),
|
||||||
|
(name: 'coral', color: PyramidColors.accentCoral),
|
||||||
|
(name: 'mint', color: PyramidColors.accentMint),
|
||||||
|
(name: 'violet', color: PyramidColors.accentViolet),
|
||||||
|
(name: 'cobalt', color: PyramidColors.accentCobalt),
|
||||||
|
(name: 'lime', color: PyramidColors.accentLime),
|
||||||
|
];
|
||||||
|
|
||||||
|
final radiusProvider = StateProvider<double>((ref) => 12);
|
||||||
|
final densityProvider = StateProvider<double>((ref) => 1);
|
||||||
|
final motionProvider = StateProvider<double>((ref) => 1);
|
||||||
|
|
||||||
|
// ─── Shell layout state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final railCollapsedProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
|
// True while the app window is the foreground / focused window.
|
||||||
|
// Set to false when the lifecycle transitions to inactive/paused/detached.
|
||||||
|
final windowFocusedProvider = StateProvider<bool>((ref) => true);
|
||||||
|
final membersPanelOpenProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
|
// Set to an eventId to make the open chat scroll to + briefly highlight that
|
||||||
|
// message (e.g. when tapping a search result). ChatView consumes and clears it.
|
||||||
|
final pendingJumpEventProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
// Currently selected room ID (null = no room selected)
|
||||||
|
final activeRoomIdProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
// ─── Share-Target ("Teilen nach Pyramid", Android) ───────────────────────────
|
||||||
|
// Von MainActivity.kt via MethodChannel geliefert; AppShell zeigt dann den
|
||||||
|
// Raum-Picker. paths = bereits in den App-Cache kopierte Dateien.
|
||||||
|
class PendingShare {
|
||||||
|
final String? text;
|
||||||
|
final List<String> paths;
|
||||||
|
const PendingShare({this.text, this.paths = const []});
|
||||||
|
|
||||||
|
bool get isEmpty => (text == null || text!.isEmpty) && paths.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingShareProvider = StateProvider<PendingShare?>((ref) => null);
|
||||||
|
|
||||||
|
// Space filter: null = all rooms, otherwise space ID
|
||||||
|
final activeSpaceIdProvider = StateProvider<String?>((ref) => 'dms');
|
||||||
|
|
||||||
|
// ─── Modal state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum ModalKind { none, settings, search }
|
||||||
|
|
||||||
|
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||||
|
|
||||||
|
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
|
||||||
|
return LiveKitCallManager.instance;
|
||||||
|
});
|
||||||
|
|
||||||
|
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
|
||||||
|
return PyramidVoipManager.instance;
|
||||||
|
});
|
||||||
|
|
||||||
|
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
|
||||||
|
|
||||||
|
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum ViewMode { chat, voice }
|
||||||
|
|
||||||
|
final viewModeProvider = StateProvider<ViewMode>((ref) => ViewMode.chat);
|
||||||
|
|
||||||
|
// ─── Spaces from Matrix ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final spacesProvider = StreamProvider<List<Room>>((ref) async* {
|
||||||
|
final client = await ref.watch(matrixClientProvider.future);
|
||||||
|
yield _getSpaces(client);
|
||||||
|
await for (final _ in client.onSync.stream) {
|
||||||
|
yield _getSpaces(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Room> _getSpaces(Client client) {
|
||||||
|
return client.rooms.where((r) => r.isSpace).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toast notifications ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ToastMessage {
|
||||||
|
final String text;
|
||||||
|
final String icon;
|
||||||
|
ToastMessage(this.text, this.icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
final toastProvider = StateProvider<ToastMessage?>((ref) => null);
|
||||||
|
|
||||||
|
// Remembers the last active room per space section (key = spaceId or null)
|
||||||
|
final lastRoomPerSpaceProvider = StateProvider<Map<String?, String?>>((ref) => {});
|
||||||
|
|
||||||
|
// Space IDs the user just accepted an invite for. The server confirms the join
|
||||||
|
// immediately, but Continuwuity's incremental /sync sometimes does not flip the
|
||||||
|
// local membership from invite → join. We treat these as joined optimistically
|
||||||
|
// so the UI switches to the channel list (channels load via the space hierarchy).
|
||||||
|
// Persisted so the user does not have to re-accept on every app start.
|
||||||
|
final forceJoinedSpacesProvider =
|
||||||
|
StateNotifierProvider<StringSetPref, Set<String>>(
|
||||||
|
(ref) => StringSetPref('force_joined_spaces'));
|
||||||
|
|
||||||
|
// ─── Voice channel state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ID of the Matrix room the user is currently in as a voice channel (null = none)
|
||||||
|
final activeVoiceRoomIdProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
const kVoicePresenceType = 'io.pyramid.voice.presence';
|
||||||
|
|
||||||
|
// Local override cache: spaceId → bannerMxcUri (set immediately on upload,
|
||||||
|
// before the server sync delivers the state event back)
|
||||||
|
final spaceBannerOverrideProvider =
|
||||||
|
StateProvider<Map<String, String?>>((ref) => {});
|
||||||
|
|
||||||
|
// ─── Server-level banner ──────────────────────────────────────────────────────
|
||||||
|
// Persisted to SharedPreferences keyed by homeserver URL.
|
||||||
|
// Key in store: homeserverUrl → mxcUri
|
||||||
|
|
||||||
|
class ServerBannerNotifier extends AsyncNotifier<Map<String, String>> {
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> build() => loadAllServerBanners();
|
||||||
|
|
||||||
|
Future<void> set(String homeserverUrl, String? mxcUri) async {
|
||||||
|
final current = Map<String, String>.from(state.valueOrNull ?? {});
|
||||||
|
if (mxcUri == null || mxcUri.isEmpty) {
|
||||||
|
current.remove(homeserverUrl);
|
||||||
|
} else {
|
||||||
|
current[homeserverUrl] = mxcUri;
|
||||||
|
}
|
||||||
|
await saveServerBanner(homeserverUrl, mxcUri);
|
||||||
|
state = AsyncData(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final serverBannerProvider =
|
||||||
|
AsyncNotifierProvider<ServerBannerNotifier, Map<String, String>>(
|
||||||
|
ServerBannerNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Active voice participants for a room, read from Matrix state events
|
||||||
|
final voiceParticipantsProvider =
|
||||||
|
Provider.family<List<Map<String, dynamic>>, String>((ref, roomId) {
|
||||||
|
ref.watch(roomListProvider); // Rebuild on every sync so presence updates show
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
if (client == null) return [];
|
||||||
|
final room = client.getRoomById(roomId);
|
||||||
|
if (room == null) return [];
|
||||||
|
final states = room.states[kVoicePresenceType] ?? {};
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final result = <Map<String, dynamic>>[];
|
||||||
|
for (final entry in states.entries) {
|
||||||
|
final content = entry.value.content;
|
||||||
|
if (content['active'] != true) continue;
|
||||||
|
final joinedAt = content['joined_at'] as int? ?? 0;
|
||||||
|
if (now - joinedAt > 10 * 60 * 1000) continue;
|
||||||
|
result.add({
|
||||||
|
'userId': entry.key,
|
||||||
|
'displayName': content['display_name'] as String? ?? entry.key,
|
||||||
|
'avatarUrl': content['avatar_url'] as String?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistically insert the local user immediately (state event may not
|
||||||
|
// have synced back from the server yet after joining)
|
||||||
|
final activeVoiceRoomId = ref.watch(activeVoiceRoomIdProvider);
|
||||||
|
if (activeVoiceRoomId == roomId && client.userID != null) {
|
||||||
|
final userId = client.userID!;
|
||||||
|
if (!result.any((p) => p['userId'] == userId)) {
|
||||||
|
String displayName = userId.split(':').first.replaceAll('@', '');
|
||||||
|
String? avatarUrl;
|
||||||
|
for (final r in client.rooms) {
|
||||||
|
try {
|
||||||
|
final m = r.getParticipants().firstWhere((u) => u.id == userId);
|
||||||
|
if (m.displayName != null) {
|
||||||
|
displayName = m.displayName!;
|
||||||
|
avatarUrl = m.avatarUrl?.toString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
result.insert(0, {
|
||||||
|
'userId': userId,
|
||||||
|
'displayName': displayName,
|
||||||
|
'avatarUrl': avatarUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
|
||||||
|
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
|
||||||
|
/// können, auch wenn die App zwischen dem Logout und dem nächsten Blick ins
|
||||||
|
/// Log neu gestartet wurde (siehe ROADMAP M1).
|
||||||
|
class AuthLog {
|
||||||
|
static const _maxLines = 500;
|
||||||
|
static File? _file;
|
||||||
|
|
||||||
|
static Future<File> _ensureFile() async {
|
||||||
|
final existing = _file;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final file = File(p.join(dir.path, 'auth_log.txt'));
|
||||||
|
return _file = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> write(String message) async {
|
||||||
|
try {
|
||||||
|
final file = await _ensureFile();
|
||||||
|
await file.writeAsString(
|
||||||
|
'${DateTime.now().toIso8601String()} $message\n',
|
||||||
|
mode: FileMode.append,
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
final lines = await file.readAsLines();
|
||||||
|
if (lines.length > _maxLines) {
|
||||||
|
await file.writeAsString(
|
||||||
|
'${lines.sublist(lines.length - _maxLines).join('\n')}\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Logging darf selbst nie einen Fehlerpfad (oder gar einen Logout) auslösen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> read() async {
|
||||||
|
try {
|
||||||
|
final file = await _ensureFile();
|
||||||
|
if (!await file.exists()) return '(noch keine Einträge)';
|
||||||
|
return await file.readAsString();
|
||||||
|
} catch (e) {
|
||||||
|
return 'Log konnte nicht gelesen werden: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||||
|
|
||||||
|
/// Port name used to locate the main isolate from background isolates.
|
||||||
|
/// Registered by notification_service.dart when the app starts.
|
||||||
|
const kMainIsolateName = 'pyramid_main';
|
||||||
|
|
||||||
|
int _stableId(String roomId) {
|
||||||
|
var h = 0;
|
||||||
|
for (final c in roomId.codeUnits) {
|
||||||
|
h = ((h * 31) + c) & 0x7FFFFFFF;
|
||||||
|
}
|
||||||
|
return h == 0 ? 1 : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FCM background handler ────────────────────────────────────────────────────
|
||||||
|
// Runs in its own Dart isolate (or fresh process) when the app is killed or
|
||||||
|
// backgrounded. Firebase calls this after Firebase.initializeApp() is done.
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> handleBackgroundMessage(RemoteMessage message) async {
|
||||||
|
// PushService.kt handles all FCM-triggered notifications natively.
|
||||||
|
// This callback is registered for API completeness but never fires because
|
||||||
|
// FlutterFirebaseMessagingService is removed from the manifest.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background engine entrypoint (killed-app E2EE work) ───────────────────────
|
||||||
|
// PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this
|
||||||
|
// entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel
|
||||||
|
// so native can ask us to (a) decrypt a push event and update its notification,
|
||||||
|
// or (b) send an encrypted reply. Building a Matrix client here is safe ONLY
|
||||||
|
// because native gates this on the main app being dead — never two clients on
|
||||||
|
// the same olm DB at once.
|
||||||
|
|
||||||
|
const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine';
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
void notificationEngineMain() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const channel = MethodChannel(_kBgEngineChannel);
|
||||||
|
|
||||||
|
channel.setMethodCallHandler((call) async {
|
||||||
|
try {
|
||||||
|
final args = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
|
||||||
|
switch (call.method) {
|
||||||
|
case 'decryptAndShow':
|
||||||
|
return await _bgDecryptAndShow(
|
||||||
|
args['room_id'] as String?,
|
||||||
|
args['event_id'] as String?,
|
||||||
|
(args['notif_id'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
case 'sendReply':
|
||||||
|
return await _bgSendReply(
|
||||||
|
args['room_id'] as String?,
|
||||||
|
args['text'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal native that the channel handler is installed and tasks can be sent.
|
||||||
|
channel.invokeMethod('bgEngineReady');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async {
|
||||||
|
if (roomId == null || eventId == null) return false;
|
||||||
|
final client = await _buildClient();
|
||||||
|
if (client == null) return false;
|
||||||
|
try {
|
||||||
|
final event = await client
|
||||||
|
.getEventByPushNotification(
|
||||||
|
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
|
||||||
|
storeInDatabase: false,
|
||||||
|
returnNullIfSeen: false,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
if (event == null) return false;
|
||||||
|
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
||||||
|
if (event.body.startsWith('**')) return false;
|
||||||
|
|
||||||
|
final room = client.getRoomById(roomId);
|
||||||
|
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
||||||
|
event.senderId.split(':').first.replaceFirst('@', '');
|
||||||
|
final title = (room?.isDirectChat ?? true)
|
||||||
|
? sender
|
||||||
|
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
||||||
|
|
||||||
|
// Hand the decrypted content back to native so it re-shows the SAME
|
||||||
|
// notification via NotificationHelper — that keeps the reliable
|
||||||
|
// MainActivity-based reply action (the flutter_local_notifications reply
|
||||||
|
// path doesn't fire dependably when the app is killed).
|
||||||
|
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
||||||
|
'title': title,
|
||||||
|
'body': _buildBody(event),
|
||||||
|
'room_id': roomId,
|
||||||
|
'notif_id': notifId ?? _stableId(roomId),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
// Let the async key-backup upload settle before closing the DB — otherwise
|
||||||
|
// it logs a harmless "database_closed" error mid-flight.
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
await client.dispose().catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _bgSendReply(String? roomId, String? text) async {
|
||||||
|
if (roomId == null || text == null || text.trim().isEmpty) return false;
|
||||||
|
final client = await _buildClient();
|
||||||
|
if (client == null) return false;
|
||||||
|
try {
|
||||||
|
// A one-shot sync ensures the room and recipient device keys are present so
|
||||||
|
// the message can be encrypted. Safe here: main app is dead (native-gated).
|
||||||
|
await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null);
|
||||||
|
final room = client.getRoomById(roomId);
|
||||||
|
if (room == null) {
|
||||||
|
// Fall back to the prefs queue so the main app sends it on next launch.
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await room.sendTextEvent(text.trim());
|
||||||
|
debugPrint('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] sendReply error: $e');
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
// Allow the encrypted send + any key upload to flush before closing the DB.
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
await client.dispose().catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
||||||
|
/// bootstrap the background engine. Call once at app startup.
|
||||||
|
Future<void> registerBgEngineHandle() async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
try {
|
||||||
|
final handle = PluginUtilities.getCallbackHandle(notificationEngineMain);
|
||||||
|
if (handle == null) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build a short-lived Matrix client for background work ────────────────────
|
||||||
|
// Uses NativeImplementationsDummy so Vodozemac runs synchronously in this
|
||||||
|
// isolate — NativeImplementationsIsolate(compute) would try to spawn a
|
||||||
|
// sub-isolate, which fails inside a Firebase background isolate.
|
||||||
|
|
||||||
|
Future<Client?> _buildClient() async {
|
||||||
|
try {
|
||||||
|
// vodozemac may already be initialised in this isolate (e.g. a decrypt task
|
||||||
|
// ran before this reply task reused the same engine). Calling init() twice
|
||||||
|
// throws — swallow it so it doesn't fail the whole client build.
|
||||||
|
try {
|
||||||
|
await vod.init();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final appDir = await getApplicationSupportDirectory();
|
||||||
|
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||||
|
|
||||||
|
final db = await sqflite_native.databaseFactory
|
||||||
|
.openDatabase(dbPath)
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
// Prevent "database is locked" if the main process still has the DB open.
|
||||||
|
try {
|
||||||
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
await db.execute('PRAGMA journal_mode = WAL');
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
|
|
||||||
|
final client = Client(
|
||||||
|
'Pyramid',
|
||||||
|
database: sdkDb,
|
||||||
|
nativeImplementations: NativeImplementationsDummy(),
|
||||||
|
logLevel: Level.error,
|
||||||
|
// Kritisch: getEventByPushNotification() ruft als Erstes
|
||||||
|
// ensureNotSoftLoggedOut() auf. Ohne eigenen Handler würde ein
|
||||||
|
// fehlgeschlagener Token-Refresh AUS DEM HINTERGRUND-ISOLATE heraus
|
||||||
|
// logout()+clear() auf der geteilten pyramid.sqlite auslösen und die
|
||||||
|
// Session der gesamten App zerstören. Siehe soft_logout_guard.dart.
|
||||||
|
onSoftLogout: guardedSoftLogoutRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client
|
||||||
|
.init(
|
||||||
|
waitForFirstSync: false,
|
||||||
|
waitUntilLoadCompletedLoaded: false,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
|
||||||
|
return client;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] _buildClient failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildBody(Event event) {
|
||||||
|
final msgType = event.messageType;
|
||||||
|
final rawBody = event.body;
|
||||||
|
return switch (msgType) {
|
||||||
|
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
|
||||||
|
'm.image' => '📷 Bild',
|
||||||
|
'm.video' => '🎥 Video',
|
||||||
|
'm.audio' => '🎵 Audio',
|
||||||
|
'm.file' => '📄 Datei',
|
||||||
|
'm.sticker' => '🏷️ Sticker',
|
||||||
|
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification action handler ───────────────────────────────────────────────
|
||||||
|
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
||||||
|
// Must return quickly — Android keeps the notification's "sending" spinner
|
||||||
|
// visible until this function returns.
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> handleBackgroundNotificationResponse(
|
||||||
|
NotificationResponse details) async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
debugPrint('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
||||||
|
debugPrint('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
||||||
|
|
||||||
|
// ── Fast path: main isolate is running ───────────────────────────────────
|
||||||
|
// If the Pyramid UI process is alive (app is backgrounded but not killed),
|
||||||
|
// forward the action directly via IsolateNameServer. This avoids starting
|
||||||
|
// any new engine and works silently without bringing the app to foreground.
|
||||||
|
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
|
||||||
|
if (sendPort != null) {
|
||||||
|
debugPrint('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
|
||||||
|
sendPort.send(<String, dynamic>{
|
||||||
|
'actionId': details.actionId,
|
||||||
|
'payload': details.payload,
|
||||||
|
'input': details.input,
|
||||||
|
'id': details.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slow path: app was killed ─────────────────────────────────────────────
|
||||||
|
// Main isolate is gone. Dismiss the notification and, for reply actions,
|
||||||
|
// persist the text in SharedPreferences so _sendPendingReply picks it up
|
||||||
|
// the next time the app opens (via getInitialReply cold-start flow).
|
||||||
|
debugPrint('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
|
||||||
|
|
||||||
|
final plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
await plugin.initialize(
|
||||||
|
const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('@drawable/ic_notification')),
|
||||||
|
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
final roomId = details.payload;
|
||||||
|
if (roomId != null) {
|
||||||
|
await plugin.cancel(_stableId(roomId));
|
||||||
|
debugPrint('[NOTIF-BG] cancelled notification for room $roomId');
|
||||||
|
} else if (details.id != null) {
|
||||||
|
await plugin.cancel(details.id!);
|
||||||
|
debugPrint('[NOTIF-BG] cancelled notification id ${details.id}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.actionId != 'reply') {
|
||||||
|
debugPrint('[NOTIF-BG] not a reply action, done');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = details.input?.trim() ?? '';
|
||||||
|
if (text.isEmpty || roomId == null) {
|
||||||
|
debugPrint('[NOTIF-BG] text empty or roomId null, aborting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App killed — send the encrypted reply directly from this background isolate.
|
||||||
|
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
|
||||||
|
// delivers it on next launch).
|
||||||
|
final sent = await _bgSendReply(roomId, text);
|
||||||
|
debugPrint('[NOTIF-BG] background reply sent=$sent room=$roomId');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class E2eeDiagEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String roomId;
|
||||||
|
final String eventId;
|
||||||
|
final String sessionId;
|
||||||
|
final String error;
|
||||||
|
|
||||||
|
const E2eeDiagEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.roomId,
|
||||||
|
required this.eventId,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'ts': timestamp.toIso8601String(),
|
||||||
|
'room': roomId,
|
||||||
|
'event': eventId,
|
||||||
|
'session': sessionId,
|
||||||
|
'error': error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class E2eeDiagnosticsNotifier extends Notifier<List<E2eeDiagEntry>> {
|
||||||
|
static const int _maxEntries = 500;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<E2eeDiagEntry> build() => [];
|
||||||
|
|
||||||
|
void add(E2eeDiagEntry entry) {
|
||||||
|
final next = [...state, entry];
|
||||||
|
state = next.length > _maxEntries ? next.sublist(next.length - _maxEntries) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() => state = [];
|
||||||
|
|
||||||
|
Future<String> upload({required String serverUrl, required String token}) async {
|
||||||
|
if (state.isEmpty) return 'Keine Diagnosedaten vorhanden.';
|
||||||
|
|
||||||
|
final payload = utf8.encode(jsonEncode({
|
||||||
|
'client': 'Pyramid',
|
||||||
|
'uploaded_at': DateTime.now().toIso8601String(),
|
||||||
|
'entry_count': state.length,
|
||||||
|
'entries': state.map((e) => e.toJson()).toList(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
final client = HttpClient();
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('$serverUrl/api/e2ee-diagnostics');
|
||||||
|
final request = await client.postUrl(uri);
|
||||||
|
request.headers.set('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
request.headers.set('Authorization', 'Bearer $token');
|
||||||
|
request.headers.contentLength = payload.length;
|
||||||
|
request.add(payload);
|
||||||
|
final response = await request.close();
|
||||||
|
await response.drain<void>();
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final count = state.length;
|
||||||
|
clear();
|
||||||
|
return '$count Einträge hochgeladen und lokal gelöscht.';
|
||||||
|
}
|
||||||
|
return 'Server-Fehler: HTTP ${response.statusCode}';
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
return 'Netzwerkfehler: ${e.message}';
|
||||||
|
} catch (e) {
|
||||||
|
return 'Fehler: $e';
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final e2eeDiagnosticsProvider =
|
||||||
|
NotifierProvider<E2eeDiagnosticsNotifier, List<E2eeDiagEntry>>(
|
||||||
|
E2eeDiagnosticsNotifier.new,
|
||||||
|
);
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
// Self-hosted Sygnal instance on the Pi — must be configured with the
|
||||||
|
// Firebase service account for the 'chat-pyramid' Firebase project.
|
||||||
|
// push.element.io only supports Element's own apps and will actively reject
|
||||||
|
// chat.pyramid.pyramid pushkeys, causing the homeserver to auto-remove the pusher.
|
||||||
|
const _kGatewayUrl = 'https://push.steggi-matrix.work/_matrix/push/v1/notify';
|
||||||
|
const _kAppId = 'chat.pyramid.pyramid';
|
||||||
|
|
||||||
|
// ── Foreground-Setup & Pusher-Registrierung ───────────────────────────────────
|
||||||
|
|
||||||
|
Future<String> fcmDiagnostics(Client matrixClient) async {
|
||||||
|
if (!Platform.isAndroid) return 'Kein Android';
|
||||||
|
final buf = StringBuffer();
|
||||||
|
try {
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
buf.writeln('✓ Firebase initialisiert');
|
||||||
|
} catch (e) {
|
||||||
|
buf.writeln('✗ Firebase-Init: $e');
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final settings = await FirebaseMessaging.instance.requestPermission();
|
||||||
|
buf.writeln('✓ Berechtigung: ${settings.authorizationStatus.name}');
|
||||||
|
} catch (e) {
|
||||||
|
buf.writeln('✗ Berechtigung: $e');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final token = await FirebaseMessaging.instance.getToken();
|
||||||
|
if (token != null) {
|
||||||
|
buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}…');
|
||||||
|
} else {
|
||||||
|
buf.writeln('✗ FCM-Token: null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
buf.writeln('✗ FCM-Token: $e');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final pushers = await matrixClient.getPushers() ?? [];
|
||||||
|
final fcmPushers = pushers.where((p) => p.appId == _kAppId).toList();
|
||||||
|
if (fcmPushers.isEmpty) {
|
||||||
|
buf.writeln('✗ Pusher: keiner bei Synapse registriert!');
|
||||||
|
} else {
|
||||||
|
buf.writeln('✓ Pusher: ${fcmPushers.length} registriert');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
buf.writeln('✗ Pusher-Abfrage: $e');
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initFcm(Client matrixClient) async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
} catch (_) {
|
||||||
|
// Bereits initialisiert (main.dart) oder google-services.json fehlt – weiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background messages are handled by handleBackgroundMessage (background_push.dart)
|
||||||
|
// via FlutterFirebaseMessagingService. It decrypts E2EE events using the Matrix
|
||||||
|
// SDK and shows notifications via flutter_local_notifications with
|
||||||
|
// showsUserInterface:false so no Activity opens when the user replies.
|
||||||
|
|
||||||
|
// Notification-Berechtigung anfordern (Android 13+)
|
||||||
|
await FirebaseMessaging.instance.requestPermission();
|
||||||
|
|
||||||
|
// Foreground-Notifications unterdrücken — App zeigt selbst Nachrichten
|
||||||
|
await FirebaseMessaging.instance
|
||||||
|
.setForegroundNotificationPresentationOptions(
|
||||||
|
alert: false,
|
||||||
|
badge: false,
|
||||||
|
sound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-register pusher if token was refreshed while the app was killed.
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final pendingToken = prefs.getString('flutter.fcm_pending_token');
|
||||||
|
if (pendingToken != null && matrixClient.isLogged()) {
|
||||||
|
await _registerPusher(matrixClient, pendingToken);
|
||||||
|
await prefs.remove('flutter.fcm_pending_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = pendingToken ?? await _getToken();
|
||||||
|
if (token != null && matrixClient.isLogged()) {
|
||||||
|
await _registerPusher(matrixClient, token);
|
||||||
|
// Save homeserver + token for PushService.kt (Kotlin native notification handler)
|
||||||
|
await _saveNotifCredentials(matrixClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei Token-Refresh: Pusher + gespeicherte Credentials aktualisieren
|
||||||
|
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
|
||||||
|
if (matrixClient.isLogged()) {
|
||||||
|
await _registerPusher(matrixClient, newToken);
|
||||||
|
await _saveNotifCredentials(matrixClient);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveNotifCredentials(Client client) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('notif_homeserver', client.homeserver.toString());
|
||||||
|
await prefs.setString('notif_access_token', client.accessToken ?? '');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getToken() async {
|
||||||
|
try {
|
||||||
|
return await FirebaseMessaging.instance.getToken();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _registerPusher(Client client, String fcmToken) async {
|
||||||
|
try {
|
||||||
|
await client.postPusher(
|
||||||
|
Pusher(
|
||||||
|
pushkey: fcmToken,
|
||||||
|
kind: 'http',
|
||||||
|
appId: _kAppId,
|
||||||
|
appDisplayName: 'Pyramid',
|
||||||
|
deviceDisplayName: client.deviceName ?? 'Android',
|
||||||
|
lang: 'de',
|
||||||
|
data: PusherData(
|
||||||
|
url: Uri.parse(_kGatewayUrl),
|
||||||
|
format: 'event_id_only',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
append: true,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FCM] Pusher-Registrierung fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to register the FCM pusher and returns a human-readable result string.
|
||||||
|
Future<String> retryPusherRegistration(Client client) async {
|
||||||
|
if (!Platform.isAndroid) return 'Kein Android';
|
||||||
|
final buf = StringBuffer();
|
||||||
|
try {
|
||||||
|
String? token;
|
||||||
|
try {
|
||||||
|
token = await FirebaseMessaging.instance.getToken();
|
||||||
|
} catch (e) {
|
||||||
|
return '✗ FCM-Token: $e';
|
||||||
|
}
|
||||||
|
if (token == null) {
|
||||||
|
return '✗ FCM-Token: null (Google Play Services Problem?)';
|
||||||
|
}
|
||||||
|
buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}…');
|
||||||
|
buf.writeln(' Gateway: $_kGatewayUrl');
|
||||||
|
await client.postPusher(
|
||||||
|
Pusher(
|
||||||
|
pushkey: token,
|
||||||
|
kind: 'http',
|
||||||
|
appId: _kAppId,
|
||||||
|
appDisplayName: 'Pyramid',
|
||||||
|
deviceDisplayName: client.deviceName ?? 'Android',
|
||||||
|
lang: 'de',
|
||||||
|
data: PusherData(
|
||||||
|
url: Uri.parse(_kGatewayUrl),
|
||||||
|
format: 'event_id_only',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
append: true,
|
||||||
|
);
|
||||||
|
buf.writeln('✓ Pusher registriert');
|
||||||
|
await _saveNotifCredentials(client);
|
||||||
|
buf.writeln('✓ Credentials gespeichert');
|
||||||
|
} catch (e) {
|
||||||
|
buf.writeln('✗ Fehler: $e');
|
||||||
|
}
|
||||||
|
return buf.toString().trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Zentrale Quelle für ICE-Server (STUN/TURN), die in ALLE WebRTC-Verbindungen
|
||||||
|
/// injiziert werden (Matrix-1:1-Calls und LiveKit).
|
||||||
|
///
|
||||||
|
/// Hintergrund: Der Heimserver hängt hinter CGNAT (keine Portfreigabe möglich).
|
||||||
|
/// Direkte Verbindungen brauchen deshalb ICE-Hole-Punching mit STUN auf beiden
|
||||||
|
/// Seiten; für harte Fälle (symmetrische Mobilfunk-NAT) können später
|
||||||
|
/// TURN-Server (z. B. Cloudflare TURN, 1 TB/Monat gratis) ergänzt werden —
|
||||||
|
/// dafür muss nur die ice.json auf dem Pi aktualisiert werden, ohne App-Update.
|
||||||
|
class IceServers {
|
||||||
|
/// Wird vom Pi ausgeliefert und kann dort per Cron mit frischen
|
||||||
|
/// TURN-Credentials befüllt werden (siehe /home/steggi/matrix/ice.json).
|
||||||
|
static const _remoteUrl =
|
||||||
|
'https://steggi-matrix.work/.well-known/pyramid/ice.json';
|
||||||
|
|
||||||
|
/// Fallback, falls die ice.json nicht erreichbar ist.
|
||||||
|
static const List<Map<String, dynamic>> _fallback = [
|
||||||
|
{
|
||||||
|
'urls': [
|
||||||
|
'stun:stun.l.google.com:19302',
|
||||||
|
'stun:stun.cloudflare.com:3478',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<Map<String, dynamic>>? _cached;
|
||||||
|
static DateTime? _fetchedAt;
|
||||||
|
|
||||||
|
/// Liefert die ICE-Server als WebRTC-`iceServers`-Maps
|
||||||
|
/// (`{'urls': [...], 'username': ..., 'credential': ...}`).
|
||||||
|
/// Ergebnis wird 30 Minuten gecached; TURN-Credentials in der ice.json
|
||||||
|
/// sollten deutlich länger gültig sein.
|
||||||
|
static Future<List<Map<String, dynamic>>> get() async {
|
||||||
|
final cached = _cached;
|
||||||
|
if (cached != null &&
|
||||||
|
_fetchedAt != null &&
|
||||||
|
DateTime.now().difference(_fetchedAt!) <
|
||||||
|
const Duration(minutes: 30)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final resp = await http
|
||||||
|
.get(Uri.parse(_remoteUrl))
|
||||||
|
.timeout(const Duration(seconds: 4));
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
final body = jsonDecode(resp.body);
|
||||||
|
final list = (body['ice_servers'] as List)
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => e.cast<String, dynamic>())
|
||||||
|
.toList();
|
||||||
|
if (list.isNotEmpty) {
|
||||||
|
_cached = list;
|
||||||
|
_fetchedAt = DateTime.now();
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Offline/Endpoint fehlt — Fallback unten.
|
||||||
|
}
|
||||||
|
return _cached ?? _fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:matrix/matrix.dart' hide Room;
|
||||||
|
import 'package:pyramid/core/ice_servers.dart';
|
||||||
|
import 'package:pyramid/core/livekit_token.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const _kVoicePresenceType = 'io.pyramid.voice.presence';
|
||||||
|
|
||||||
|
class LiveKitCallManager with ChangeNotifier {
|
||||||
|
static final LiveKitCallManager instance = LiveKitCallManager._();
|
||||||
|
LiveKitCallManager._();
|
||||||
|
|
||||||
|
// ── Publish quality presets (Streamer → Server) ──────────────────────────
|
||||||
|
static final Map<String, VideoParameters> qualityPresets = {
|
||||||
|
'sd': VideoParametersPresets.h360_169,
|
||||||
|
'hd': VideoParametersPresets.h720_169,
|
||||||
|
'fhd': VideoParametersPresets.h1080_169,
|
||||||
|
'4k': VideoParametersPresets.h2160_169,
|
||||||
|
};
|
||||||
|
|
||||||
|
static const Map<String, VideoEncoding> _screenShareEncodings = {
|
||||||
|
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
|
||||||
|
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
||||||
|
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
||||||
|
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
|
||||||
|
};
|
||||||
|
|
||||||
|
Room? _room;
|
||||||
|
EventsListener<RoomEvent>? _roomEventListener;
|
||||||
|
String _displayName = '';
|
||||||
|
bool isConnecting = false;
|
||||||
|
String? error;
|
||||||
|
bool isMuted = false;
|
||||||
|
bool isCameraOff = false;
|
||||||
|
bool isScreenSharing = false;
|
||||||
|
String _localQualityKey = 'hd';
|
||||||
|
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
|
||||||
|
String? _currentScreenShareSourceId;
|
||||||
|
LocalVideoTrack? _screenShareTrack;
|
||||||
|
bool isDeafened = false;
|
||||||
|
bool _mutedBeforeDeafen = false;
|
||||||
|
bool isVoiceChannel = false;
|
||||||
|
String? _matrixRoomId;
|
||||||
|
Client? _matrixClient;
|
||||||
|
Timer? _presenceHeartbeat;
|
||||||
|
|
||||||
|
Timer? _statsTimer;
|
||||||
|
|
||||||
|
bool get isActive => _room != null || isConnecting;
|
||||||
|
Room? get room => _room;
|
||||||
|
String? get currentRoomId => _matrixRoomId;
|
||||||
|
String get displayName => _displayName;
|
||||||
|
bool get noiseSuppression => false;
|
||||||
|
String get currentQualityKey => _localQualityKey;
|
||||||
|
String get subscribeQualityKey => _subscribeQualityKey;
|
||||||
|
|
||||||
|
VideoParameters get _currentQuality =>
|
||||||
|
qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169;
|
||||||
|
|
||||||
|
void _onRoomChanged() => notifyListeners();
|
||||||
|
|
||||||
|
void _cleanUpAfterDisconnect() {
|
||||||
|
_stopStatsLogging();
|
||||||
|
_presenceHeartbeat?.cancel();
|
||||||
|
_presenceHeartbeat = null;
|
||||||
|
if (_matrixClient != null && _matrixRoomId != null) {
|
||||||
|
_writePresence(false).catchError((_) {});
|
||||||
|
}
|
||||||
|
_roomEventListener?.dispose();
|
||||||
|
_roomEventListener = null;
|
||||||
|
_room?.removeListener(_onRoomChanged);
|
||||||
|
_room = null;
|
||||||
|
_displayName = '';
|
||||||
|
isConnecting = false;
|
||||||
|
error = null;
|
||||||
|
isMuted = false;
|
||||||
|
isCameraOff = false;
|
||||||
|
isScreenSharing = false;
|
||||||
|
isDeafened = false;
|
||||||
|
isVoiceChannel = false;
|
||||||
|
_mutedBeforeDeafen = false;
|
||||||
|
_screenShareTrack = null;
|
||||||
|
_currentScreenShareSourceId = null;
|
||||||
|
_matrixRoomId = null;
|
||||||
|
_matrixClient = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writePresence(bool active) async {
|
||||||
|
final client = _matrixClient;
|
||||||
|
final roomId = _matrixRoomId;
|
||||||
|
final userId = client?.userID;
|
||||||
|
if (client == null || roomId == null || userId == null) return;
|
||||||
|
try {
|
||||||
|
Map<String, dynamic> content;
|
||||||
|
if (active) {
|
||||||
|
// Resolve display name + avatar from any joined room's member state
|
||||||
|
User? member;
|
||||||
|
for (final r in client.rooms) {
|
||||||
|
final participants = r.getParticipants();
|
||||||
|
try {
|
||||||
|
member = participants.firstWhere((u) => u.id == userId);
|
||||||
|
if (member.displayName != null || member.avatarUrl != null) break;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
content = {
|
||||||
|
'active': true,
|
||||||
|
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'display_name': member?.displayName ?? userId,
|
||||||
|
if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
content = {'active': false};
|
||||||
|
}
|
||||||
|
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VoicePresence] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startCall({
|
||||||
|
required String roomName,
|
||||||
|
required String roomDisplayName,
|
||||||
|
required String identity,
|
||||||
|
bool audioOnly = false,
|
||||||
|
bool voiceChannel = false,
|
||||||
|
Client? matrixClient,
|
||||||
|
String? matrixRoomId,
|
||||||
|
}) async {
|
||||||
|
if (isActive) return;
|
||||||
|
|
||||||
|
final url = 'wss://livekit.steggi-matrix.work';
|
||||||
|
|
||||||
|
isConnecting = true;
|
||||||
|
isVoiceChannel = voiceChannel;
|
||||||
|
error = null;
|
||||||
|
_displayName = roomDisplayName;
|
||||||
|
isCameraOff = audioOnly;
|
||||||
|
isMuted = false;
|
||||||
|
isScreenSharing = false;
|
||||||
|
isDeafened = false;
|
||||||
|
_mutedBeforeDeafen = false;
|
||||||
|
_localQualityKey = 'hd';
|
||||||
|
_subscribeQualityKey = 'auto';
|
||||||
|
_matrixClient = matrixClient;
|
||||||
|
_matrixRoomId = matrixRoomId;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final token = LiveKitTokenGenerator.generate(
|
||||||
|
roomName: roomName,
|
||||||
|
identity: identity,
|
||||||
|
displayName: identity,
|
||||||
|
ttlSeconds: 21600,
|
||||||
|
);
|
||||||
|
|
||||||
|
final room = Room(
|
||||||
|
roomOptions: const RoomOptions(
|
||||||
|
adaptiveStream: false, // Matches FluffyChat fix
|
||||||
|
dynacast: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
room.addListener(_onRoomChanged);
|
||||||
|
|
||||||
|
// Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige
|
||||||
|
// iceServers ERSETZEN die vom LiveKit-Server gelieferten — die zeigten
|
||||||
|
// bisher auf einen hinter CGNAT unerreichbaren coturn.
|
||||||
|
RTCConfiguration rtcConfig = const RTCConfiguration();
|
||||||
|
try {
|
||||||
|
final ice = await IceServers.get();
|
||||||
|
rtcConfig = RTCConfiguration(
|
||||||
|
iceServers: ice
|
||||||
|
.map((m) => RTCIceServer(
|
||||||
|
urls: m['urls'] is List
|
||||||
|
? List<String>.from(m['urls'] as List)
|
||||||
|
: [m['urls'] as String],
|
||||||
|
username: m['username'] as String?,
|
||||||
|
credential: m['credential'] as String?,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] ICE server fetch error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
await room.connect(
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
|
||||||
|
);
|
||||||
|
|
||||||
|
_room = room;
|
||||||
|
isConnecting = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
||||||
|
await _writePresence(true);
|
||||||
|
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
|
_writePresence(true).catchError((_) {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_roomEventListener = room.createListener()
|
||||||
|
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
|
||||||
|
..on<LocalTrackUnpublishedEvent>((event) {
|
||||||
|
if (event.publication.source == TrackSource.screenShareVideo) {
|
||||||
|
isScreenSharing = false;
|
||||||
|
_currentScreenShareSourceId = null;
|
||||||
|
_screenShareTrack = null;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
})
|
||||||
|
..on<TrackSubscribedEvent>((event) {
|
||||||
|
final pub = event.publication;
|
||||||
|
if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) {
|
||||||
|
pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey));
|
||||||
|
}
|
||||||
|
// Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks.
|
||||||
|
if (pub.kind == TrackType.AUDIO) {
|
||||||
|
try {
|
||||||
|
rtc.Helper.setVolume(
|
||||||
|
_outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
})
|
||||||
|
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
|
||||||
|
..on<TrackPublishedEvent>((_) => notifyListeners())
|
||||||
|
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||||
|
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
||||||
|
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
|
||||||
|
..on<RoomDisconnectedEvent>((_) {
|
||||||
|
_cleanUpAfterDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs).
|
||||||
|
// Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell
|
||||||
|
// vorhandenen Geräte validiert und sonst ignoriert.
|
||||||
|
String? prefMic, prefSpeaker, prefCam;
|
||||||
|
var deviceIds = <String>{};
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0;
|
||||||
|
prefMic = prefs.getString('voice_mic_device');
|
||||||
|
prefSpeaker = prefs.getString('voice_speaker_device');
|
||||||
|
prefCam = prefs.getString('voice_camera_device');
|
||||||
|
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
||||||
|
deviceIds = devices.map((d) => d.deviceId).toSet();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] Device prefs/enumerate error: $e');
|
||||||
|
}
|
||||||
|
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
|
||||||
|
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
|
||||||
|
if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null;
|
||||||
|
|
||||||
|
await room.localParticipant?.setMicrophoneEnabled(
|
||||||
|
true,
|
||||||
|
audioCaptureOptions:
|
||||||
|
prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prefSpeaker != null) {
|
||||||
|
try {
|
||||||
|
await rtc.Helper.selectAudioOutput(prefSpeaker);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] selectAudioOutput error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioOnly) {
|
||||||
|
try {
|
||||||
|
await room.localParticipant?.setCameraEnabled(
|
||||||
|
true,
|
||||||
|
cameraCaptureOptions: CameraCaptureOptions(
|
||||||
|
deviceId: prefCam,
|
||||||
|
params: _currentQuality,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] Failed to enable camera: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsLogging();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Start call error: $e');
|
||||||
|
isConnecting = false;
|
||||||
|
error = e.toString();
|
||||||
|
_cleanUpAfterDisconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoQuality _videoQualityFromKey(String key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'low': return VideoQuality.LOW;
|
||||||
|
case 'medium': return VideoQuality.MEDIUM;
|
||||||
|
case 'high': return VideoQuality.HIGH;
|
||||||
|
default: return VideoQuality.HIGH; // auto defaults to high
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
||||||
|
double _outputVolume = 1.0;
|
||||||
|
|
||||||
|
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
|
||||||
|
Future<void> setOutputVolume(double v) async {
|
||||||
|
_outputVolume = v;
|
||||||
|
final room = _room;
|
||||||
|
if (room == null) return;
|
||||||
|
for (final p in room.remoteParticipants.values) {
|
||||||
|
for (final pub in p.audioTrackPublications) {
|
||||||
|
final t = pub.track;
|
||||||
|
if (t != null) {
|
||||||
|
try {
|
||||||
|
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSubscribeQuality(String key) async {
|
||||||
|
debugPrint('[LiveKit] Viewer changing subscribe quality to: $key');
|
||||||
|
_subscribeQualityKey = key;
|
||||||
|
if (_room == null) { notifyListeners(); return; }
|
||||||
|
|
||||||
|
final quality = _videoQualityFromKey(key);
|
||||||
|
for (final p in _room!.remoteParticipants.values) {
|
||||||
|
for (final pub in p.videoTrackPublications) {
|
||||||
|
if (pub.kind == TrackType.VIDEO) {
|
||||||
|
debugPrint('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
|
||||||
|
pub.setVideoQuality(quality);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleMute() async {
|
||||||
|
isMuted = !isMuted;
|
||||||
|
notifyListeners();
|
||||||
|
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleDeafen() async {
|
||||||
|
if (!isDeafened) {
|
||||||
|
_mutedBeforeDeafen = isMuted;
|
||||||
|
isDeafened = true;
|
||||||
|
if (!isMuted) {
|
||||||
|
await _room?.localParticipant?.setMicrophoneEnabled(false);
|
||||||
|
isMuted = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isDeafened = false;
|
||||||
|
if (!_mutedBeforeDeafen) {
|
||||||
|
await _room?.localParticipant?.setMicrophoneEnabled(true);
|
||||||
|
isMuted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_applyDeafen();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyDeafen() {
|
||||||
|
if (_room == null) return;
|
||||||
|
for (final p in _room!.remoteParticipants.values) {
|
||||||
|
for (final trackPub in p.audioTrackPublications) {
|
||||||
|
final track = trackPub.track;
|
||||||
|
if (track != null) {
|
||||||
|
track.mediaStreamTrack.enabled = !isDeafened;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleCamera() async {
|
||||||
|
if (isCameraOff) {
|
||||||
|
try {
|
||||||
|
await _room?.localParticipant?.setCameraEnabled(
|
||||||
|
true,
|
||||||
|
cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality),
|
||||||
|
);
|
||||||
|
isCameraOff = false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] toggleCamera ON error: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await _room?.localParticipant?.setCameraEnabled(false);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] toggleCamera OFF error: $e');
|
||||||
|
}
|
||||||
|
isCameraOff = true;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleScreenShare() async {
|
||||||
|
if (isScreenSharing) {
|
||||||
|
await stopScreenShare();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) {
|
||||||
|
final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]);
|
||||||
|
if (sources.isNotEmpty) {
|
||||||
|
await startScreenShare(sources.first.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _room?.localParticipant?.setScreenShareEnabled(true);
|
||||||
|
isScreenSharing = true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ScreenShare] Toggle error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoEncoding _screenShareEncoding() {
|
||||||
|
return _screenShareEncodings[_localQualityKey] ??
|
||||||
|
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startScreenShare(String sourceId) async {
|
||||||
|
if (isScreenSharing) return;
|
||||||
|
try {
|
||||||
|
final encoding = _screenShareEncoding();
|
||||||
|
final track = await LocalVideoTrack.createScreenShareTrack(
|
||||||
|
ScreenShareCaptureOptions(
|
||||||
|
sourceId: sourceId,
|
||||||
|
maxFrameRate: encoding.maxFramerate.toDouble(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_room == null || _room!.localParticipant == null) return;
|
||||||
|
|
||||||
|
await _room!.localParticipant!.publishVideoTrack(
|
||||||
|
track,
|
||||||
|
publishOptions: VideoPublishOptions(
|
||||||
|
simulcast: true,
|
||||||
|
degradationPreference: DegradationPreference.balanced,
|
||||||
|
screenShareEncoding: encoding,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
isScreenSharing = true;
|
||||||
|
_currentScreenShareSourceId = sourceId;
|
||||||
|
_screenShareTrack = track;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(_applyFpsToAllSimulcastLayers(track));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ScreenShare] ERROR: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async {
|
||||||
|
final targetFps = _screenShareEncoding().maxFramerate;
|
||||||
|
for (var attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
|
if (!isScreenSharing || _screenShareTrack != track) return;
|
||||||
|
final sender = track.transceiver?.sender;
|
||||||
|
if (sender == null) continue;
|
||||||
|
final params = sender.parameters;
|
||||||
|
final encodings = params.encodings;
|
||||||
|
if (encodings == null || encodings.isEmpty) continue;
|
||||||
|
var anyUpdated = false;
|
||||||
|
for (final enc in encodings) {
|
||||||
|
if ((enc.maxFramerate ?? 0) < targetFps) {
|
||||||
|
enc.maxFramerate = targetFps;
|
||||||
|
anyUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyUpdated) await sender.setParameters(params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopScreenShare() async {
|
||||||
|
final local = _room?.localParticipant;
|
||||||
|
if (local != null) {
|
||||||
|
final toStop = <String>[];
|
||||||
|
for (final pub in local.videoTrackPublications) {
|
||||||
|
if (pub.source == TrackSource.screenShareVideo) {
|
||||||
|
toStop.add(pub.sid);
|
||||||
|
await pub.track?.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final sid in toStop) {
|
||||||
|
try { await local.removePublishedTrack(sid); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
||||||
|
isScreenSharing = false;
|
||||||
|
_currentScreenShareSourceId = null;
|
||||||
|
_screenShareTrack = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> hangUp() async {
|
||||||
|
_stopStatsLogging();
|
||||||
|
_presenceHeartbeat?.cancel();
|
||||||
|
_presenceHeartbeat = null;
|
||||||
|
if (_matrixClient != null && _matrixRoomId != null) {
|
||||||
|
await _writePresence(false).catchError((_) {});
|
||||||
|
}
|
||||||
|
_roomEventListener?.dispose();
|
||||||
|
_roomEventListener = null;
|
||||||
|
_room?.removeListener(_onRoomChanged);
|
||||||
|
await _room?.disconnect();
|
||||||
|
_room = null;
|
||||||
|
_displayName = '';
|
||||||
|
isConnecting = false;
|
||||||
|
error = null;
|
||||||
|
isMuted = false;
|
||||||
|
isCameraOff = false;
|
||||||
|
isScreenSharing = false;
|
||||||
|
isDeafened = false;
|
||||||
|
isVoiceChannel = false;
|
||||||
|
_mutedBeforeDeafen = false;
|
||||||
|
_screenShareTrack = null;
|
||||||
|
_currentScreenShareSourceId = null;
|
||||||
|
_matrixRoomId = null;
|
||||||
|
_matrixClient = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changeQuality(String qualityKey) async {
|
||||||
|
debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
||||||
|
_localQualityKey = qualityKey;
|
||||||
|
final params = qualityPresets[qualityKey];
|
||||||
|
if (params == null) { notifyListeners(); return; }
|
||||||
|
if (_room == null) { notifyListeners(); return; }
|
||||||
|
|
||||||
|
final local = _room!.localParticipant;
|
||||||
|
if (local == null) { notifyListeners(); return; }
|
||||||
|
|
||||||
|
if (!isCameraOff) {
|
||||||
|
await local.setCameraEnabled(false);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
try {
|
||||||
|
await local.setCameraEnabled(
|
||||||
|
true,
|
||||||
|
cameraCaptureOptions: CameraCaptureOptions(params: params),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] Camera restart error: $e');
|
||||||
|
isCameraOff = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScreenSharing && _currentScreenShareSourceId != null) {
|
||||||
|
final sid = _currentScreenShareSourceId!;
|
||||||
|
await stopScreenShare();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
await startScreenShare(sid);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changeAudioInput(String deviceId) async {
|
||||||
|
final participant = _room?.localParticipant;
|
||||||
|
if (participant == null) return;
|
||||||
|
try {
|
||||||
|
final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId));
|
||||||
|
for (final pub in List.of(participant.audioTrackPublications)) {
|
||||||
|
await participant.removePublishedTrack(pub.sid);
|
||||||
|
}
|
||||||
|
await participant.publishAudioTrack(track);
|
||||||
|
if (isMuted) await participant.setMicrophoneEnabled(false);
|
||||||
|
} catch (e) { debugPrint('[Audio] error: $e'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRemoteVolume(double volume) {
|
||||||
|
if (_room == null) return;
|
||||||
|
final mute = volume < 0.05;
|
||||||
|
for (final p in _room!.remoteParticipants.values) {
|
||||||
|
for (final pub in p.audioTrackPublications) {
|
||||||
|
pub.track?.mediaStreamTrack.enabled = !mute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startStatsLogging() {
|
||||||
|
_stopStatsLogging();
|
||||||
|
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopStatsLogging() {
|
||||||
|
_statsTimer?.cancel();
|
||||||
|
_statsTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logStats() async {
|
||||||
|
if (_room == null) return;
|
||||||
|
// Basic stats logging...
|
||||||
|
debugPrint('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||||
|
|
||||||
|
class LiveKitTokenGenerator {
|
||||||
|
// Your LiveKit Credentials
|
||||||
|
static const String apiKey = 'LKMatrixPi';
|
||||||
|
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
|
||||||
|
|
||||||
|
static String generate({
|
||||||
|
required String roomName,
|
||||||
|
required String identity,
|
||||||
|
String? displayName,
|
||||||
|
int ttlSeconds = 3600, // Valid for 1 hour by default
|
||||||
|
}) {
|
||||||
|
final jwt = JWT(
|
||||||
|
{
|
||||||
|
'video': {
|
||||||
|
'roomJoin': true,
|
||||||
|
'room': roomName,
|
||||||
|
'canPublish': true,
|
||||||
|
'canSubscribe': true,
|
||||||
|
'canPublishData': true,
|
||||||
|
},
|
||||||
|
'metadata': displayName ?? identity,
|
||||||
|
},
|
||||||
|
issuer: apiKey,
|
||||||
|
subject: identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign the token with your secret
|
||||||
|
final token = jwt.sign(
|
||||||
|
SecretKey(apiSecret),
|
||||||
|
expiresIn: Duration(seconds: ttlSeconds),
|
||||||
|
);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,99 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||||
|
import 'package:matrix/encryption/utils/key_verification.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
|
import 'package:pyramid/core/voip_manager.dart';
|
||||||
|
|
||||||
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||||
if (!kIsWeb && (Platform.isWindows || Platform.isLinux)) {
|
final DatabaseFactory factory;
|
||||||
|
|
||||||
|
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||||
|
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
|
||||||
|
// no .so loading required.
|
||||||
|
factory = sqflite_native.databaseFactory;
|
||||||
|
} else if (!kIsWeb) {
|
||||||
sqfliteFfiInit();
|
sqfliteFfiInit();
|
||||||
databaseFactory = databaseFactoryFfi;
|
factory = databaseFactoryFfi;
|
||||||
|
} else {
|
||||||
|
throw UnsupportedError('Web not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
final appDir = await getApplicationSupportDirectory();
|
final appDir = await getApplicationSupportDirectory();
|
||||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||||
|
|
||||||
final db = await databaseFactoryFfi.openDatabase(dbPath);
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
|
||||||
|
// Optimization for Windows/FFI to prevent "database is locked" errors
|
||||||
|
try {
|
||||||
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
|
||||||
|
}
|
||||||
|
|
||||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
|
|
||||||
final client = Client('Pyramid', database: sdkDb);
|
final client = Client(
|
||||||
await client.init();
|
'Pyramid',
|
||||||
|
database: sdkDb,
|
||||||
|
// Custom State-Events, die beim App-Start mit aus der DB geladen werden
|
||||||
|
// müssen — sonst sind Space-Banner & Voice-Presence nach Neustart leer,
|
||||||
|
// bis der jeweilige Raum vollständig nachgeladen wird.
|
||||||
|
importantStateEvents: {
|
||||||
|
'io.pyramid.space.banner',
|
||||||
|
'io.pyramid.voice.presence',
|
||||||
|
},
|
||||||
|
verificationMethods: {
|
||||||
|
KeyVerificationMethod.numbers,
|
||||||
|
KeyVerificationMethod.emoji,
|
||||||
|
KeyVerificationMethod.qrShow,
|
||||||
|
},
|
||||||
|
logLevel: kReleaseMode ? Level.warning : Level.verbose,
|
||||||
|
// Ohne eigenen Handler eskaliert das SDK jeden fehlgeschlagenen
|
||||||
|
// Token-Refresh (auch bloße Netzwerkfehler!) zu logout()+clear() —
|
||||||
|
// Session und Krypto-Schlüssel wären weg. Siehe soft_logout_guard.dart.
|
||||||
|
onSoftLogout: guardedSoftLogoutRefresh,
|
||||||
|
nativeImplementations: NativeImplementationsIsolate(
|
||||||
|
compute,
|
||||||
|
vodozemacInit: () => vod.init(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persistiert Warnungen/Fehler des SDK sowie jeden Login-Status-Wechsel,
|
||||||
|
// damit ein unerwarteter Logout (z. B. Soft-Logout vom Server) auch nach
|
||||||
|
// einem App-Neustart noch nachvollziehbar ist (ROADMAP M1, Uta-Logout-Bug).
|
||||||
|
Logs().onLog = (event) {
|
||||||
|
if (event.level.index <= Level.warning.index) {
|
||||||
|
unawaited(AuthLog.write('[SDK ${event.level.name}] ${event.toFormattedString()}'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.onLoginStateChanged.stream.listen((state) {
|
||||||
|
unawaited(AuthLog.write('LoginState -> ${state.name}'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.init(
|
||||||
|
waitForFirstSync: false,
|
||||||
|
waitUntilLoadCompletedLoaded: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-use or create singleton instance
|
||||||
|
try {
|
||||||
|
PyramidVoipManager.instance.client;
|
||||||
|
} catch (_) {
|
||||||
|
PyramidVoipManager(client);
|
||||||
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
|
||||||
|
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
|
||||||
|
/// so the most-recently-viewed media (i.e. your recent messages) stays instantly
|
||||||
|
/// available while older media is dropped and re-fetched on demand.
|
||||||
|
///
|
||||||
|
/// In addition to the in-memory layer there is a small disk layer for
|
||||||
|
/// persistent entries (avatars, banners), so they appear instantly after an
|
||||||
|
/// app restart instead of popping in once re-downloaded.
|
||||||
|
class MediaCache {
|
||||||
|
MediaCache._();
|
||||||
|
static final MediaCache instance = MediaCache._();
|
||||||
|
|
||||||
|
// ~256 MB in memory, capped at 300 entries — comfortably covers the last
|
||||||
|
// ~100 messages of media per active chat without unbounded growth.
|
||||||
|
static const int _maxBytes = 256 * 1024 * 1024;
|
||||||
|
static const int _maxEntries = 300;
|
||||||
|
// Don't cache very large single files (videos etc.) — they'd evict everything.
|
||||||
|
static const int _maxEntryBytes = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Disk layer: only small entries (avatars, banners), bounded total size.
|
||||||
|
static const int _maxDiskEntryBytes = 1024 * 1024; // 1 MB pro Eintrag
|
||||||
|
static const int _maxDiskTotalBytes = 64 * 1024 * 1024; // 64 MB gesamt
|
||||||
|
|
||||||
|
final LinkedHashMap<String, Uint8List> _entries = LinkedHashMap();
|
||||||
|
int _totalBytes = 0;
|
||||||
|
|
||||||
|
Directory? _diskDir;
|
||||||
|
bool _pruneScheduled = false;
|
||||||
|
|
||||||
|
Uint8List? get(String key) {
|
||||||
|
final v = _entries.remove(key);
|
||||||
|
if (v != null) _entries[key] = v; // move to most-recently-used position
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void put(String key, Uint8List bytes) {
|
||||||
|
if (bytes.lengthInBytes > _maxEntryBytes) return;
|
||||||
|
|
||||||
|
final existing = _entries.remove(key);
|
||||||
|
if (existing != null) _totalBytes -= existing.lengthInBytes;
|
||||||
|
|
||||||
|
_entries[key] = bytes;
|
||||||
|
_totalBytes += bytes.lengthInBytes;
|
||||||
|
|
||||||
|
_evict();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory lookup first, then disk. Disk hits are promoted back into memory.
|
||||||
|
Future<Uint8List?> getPersistent(String key) async {
|
||||||
|
final mem = get(key);
|
||||||
|
if (mem != null) return mem;
|
||||||
|
try {
|
||||||
|
final file = await _diskFile(key);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
put(key, bytes);
|
||||||
|
// Touch für LRU-Pruning nach mtime.
|
||||||
|
file.setLastModified(DateTime.now()).catchError((_) {});
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores in memory and (for small entries) on disk, surviving restarts.
|
||||||
|
Future<void> putPersistent(String key, Uint8List bytes) async {
|
||||||
|
put(key, bytes);
|
||||||
|
if (bytes.lengthInBytes > _maxDiskEntryBytes) return;
|
||||||
|
try {
|
||||||
|
final file = await _diskFile(key);
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _diskFile(String key) async {
|
||||||
|
final dir = _diskDir ??= await _initDiskDir();
|
||||||
|
final name = sha1.convert(utf8.encode(key)).toString();
|
||||||
|
return File('${dir.path}${Platform.pathSeparator}$name.bin');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _initDiskDir() async {
|
||||||
|
final support = await getApplicationSupportDirectory();
|
||||||
|
final dir = Directory(
|
||||||
|
'${support.path}${Platform.pathSeparator}media_cache');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
_schedulePrune(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once per session: drop the oldest disk entries when over budget.
|
||||||
|
void _schedulePrune(Directory dir) {
|
||||||
|
if (_pruneScheduled) return;
|
||||||
|
_pruneScheduled = true;
|
||||||
|
Future(() async {
|
||||||
|
try {
|
||||||
|
final files = <File, FileStat>{};
|
||||||
|
var total = 0;
|
||||||
|
await for (final e in dir.list()) {
|
||||||
|
if (e is! File) continue;
|
||||||
|
final stat = await e.stat();
|
||||||
|
files[e] = stat;
|
||||||
|
total += stat.size;
|
||||||
|
}
|
||||||
|
if (total <= _maxDiskTotalBytes) return;
|
||||||
|
final sorted = files.entries.toList()
|
||||||
|
..sort((a, b) => a.value.modified.compareTo(b.value.modified));
|
||||||
|
for (final entry in sorted) {
|
||||||
|
if (total <= _maxDiskTotalBytes) break;
|
||||||
|
total -= entry.value.size;
|
||||||
|
await entry.key.delete().catchError((_) => entry.key);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _evict() {
|
||||||
|
while ((_totalBytes > _maxBytes || _entries.length > _maxEntries) &&
|
||||||
|
_entries.isNotEmpty) {
|
||||||
|
final oldestKey = _entries.keys.first;
|
||||||
|
final removed = _entries.remove(oldestKey);
|
||||||
|
if (removed != null) _totalBytes -= removed.lengthInBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_entries.clear();
|
||||||
|
_totalBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||