Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2413ef627 | |||
| a4f9fd70e6 | |||
| bf552ee194 | |||
| e0179ad907 | |||
| 8b36d16cdd | |||
| f8f5332d90 | |||
| ffd70caffd | |||
| 9f797279eb | |||
| dba4b64df8 | |||
| fdc16afa70 | |||
| 9950e1eb29 | |||
| 17b3798dc0 | |||
| f2b9afbfea | |||
| 9693174de9 | |||
| bca7abb206 | |||
| 3698048b26 | |||
| 3300a44c4a | |||
| 6b0ae21f2b | |||
| 41bcaf2a88 | |||
| 52b0ae975a | |||
| 6128953e28 | |||
| c8cc1480a8 | |||
| 643ba662dd | |||
| 95d9b07dbd | |||
| b131f56b72 | |||
| d22f9c2961 | |||
| b2335da2fb | |||
| b84d31ca87 | |||
| f87db183ea | |||
| 3c7eb9ddca | |||
| f64398d3c6 | |||
| cead62ccb2 | |||
| 4d16d49012 | |||
| 725f03abb0 | |||
| 15001ae80a | |||
| 1ef32c0022 | |||
| 66bf54bdff | |||
| 81ec06398f | |||
| 4477ed9101 | |||
| 825481e6fa | |||
| db4d001ef1 | |||
| af7c538e18 | |||
| 4c6efa2a7a | |||
| d3e75c36a1 | |||
| 326cc598c7 | |||
| 13b333cbec | |||
| 4d4044b888 | |||
| 5ada0e5038 | |||
| c8ff850c98 | |||
| d42beaf7ab | |||
| 343545b59e | |||
| 455742d1bf | |||
| e97a748b35 | |||
| ba97f942d6 | |||
| 88af6c016c | |||
| 78e4174658 | |||
| 925aafb2fe | |||
| fe6427bfba | |||
| a5a5c4baf5 | |||
| 7b90f20e36 | |||
| 03b84e0e0e | |||
| 7dfdbf1159 | |||
| e0ac5cb9fd | |||
| eebf8ae7dd | |||
| 566938f004 | |||
| 1630d0e389 | |||
| 99cde9a1e2 | |||
| bfe58760ee | |||
| 87ff15d760 | |||
| 16561bbdeb | |||
| 87b29b5912 | |||
| 73420fe245 | |||
| 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,9 @@ 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/
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
|
||||||
|
*.bak-*
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Bernds Entscheidungen – Richtungsfragen aus PROGRESS.md beantwortet
|
||||||
|
|
||||||
|
Diese Datei beantwortet die über mehrere Sessions in PROGRESS.md gesammelten
|
||||||
|
„Fragen an Bernd". Sie ist ab jetzt **maßgeblich** – die betroffenen Punkte in
|
||||||
|
ROADMAP.md/PROGRESS.md gelten als entschieden.
|
||||||
|
Zuletzt aktualisiert: **2026-07-05** (Bernd + Claude am PC).
|
||||||
|
|
||||||
|
## 1. SQLCipher (lokale DB + Access-Token verschlüsseln) → VOR M2 priorisieren
|
||||||
|
- **Entscheidung:** Ja. Die Verschlüsselung der lokalen `pyramid.sqlite` hat Vorrang
|
||||||
|
vor dem M2-Refactoring. Betrifft den M1-Punkt „Sichere Speicherung von Access-Token
|
||||||
|
& Krypto-DB".
|
||||||
|
- Umsetzung nach `docs/SQLCIPHER_MIGRATION.md`. **Wichtig:** Die Migration bestehender
|
||||||
|
Klartext-DBs (Utas Gerät!) ist der riskante Teil und braucht einen echten
|
||||||
|
PC-/Gerätetest. Auf dem Pi (kein GUI) nur so weit vorbereiten, wie es OHNE GUI sicher
|
||||||
|
testbar ist; alles Verhaltensrelevante klar als UNGETESTET markieren und erst nach
|
||||||
|
`docs/PC_TESTPLAN.md` als erledigt abhaken. Grundsatz „Kein Datenverlust" – im Zweifel
|
||||||
|
für den PC-Termin liegen lassen.
|
||||||
|
|
||||||
|
## 2. M2 – erstes Umbau-Modul → Call-Pilot zuerst
|
||||||
|
- **Entscheidung:** Wenn M2 startet (nach SQLCipher), zuerst die Call-Schicht entwirren
|
||||||
|
(Call-Pilot / gemeinsame Fassade über `voip_manager.dart` + `livekit_call_manager.dart`),
|
||||||
|
wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen. **Nicht** zuerst `settings_modal.dart`
|
||||||
|
aufsplitten.
|
||||||
|
- **Wichtig:** heiliger Call-Pfad → braucht zwingend echten Call-Test auf einem Gerät.
|
||||||
|
Auf dem Pi nicht blind umbauen; Vorbereitung/Analyse ok, Umsetzung + Test am PC.
|
||||||
|
|
||||||
|
## 3. Dashboard-Fernzugriff (stats.html) → Heimnetz-only lassen
|
||||||
|
- **Entscheidung:** KEIN Fernzugriff. Das Interims-Gate (nur Heimnetz/localhost) bleibt so.
|
||||||
|
Die offene A/B-Frage (Cloudflare Access vs. DASH_TOKEN) aus
|
||||||
|
`docs/DASHBOARD_AUTH_HARDENING.md` ist damit **geschlossen** – nicht weiter daran
|
||||||
|
arbeiten. Unterwegs per WireGuard erreichbar.
|
||||||
|
|
||||||
|
## 4. Git-History / Admin-Token → vorerst NICHTS tun
|
||||||
|
- **Entscheidung:** Kein Git-History-Rewrite und aktuell keine Token-Rotation durch den
|
||||||
|
Autopilot. Der Admin-Token ist bereits aus dem App-Code heraus (eigener `DIAG_TOKEN`).
|
||||||
|
Eine spätere Rotation bleibt Bernds manuelle Sache – nicht eigenmächtig anstoßen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Kurzfassung fürs Arbeiten:** SQLCipher vor M2, dann Call-Pilot als erstes M2-Modul.
|
||||||
|
Dashboard bleibt Heimnetz-only. History unangetastet. Riskante/GUI-relevante Teile
|
||||||
|
warten auf den PC-Termin (`docs/PC_TESTPLAN.md`); auf dem Pi nur sicher Vorbereitbares.
|
||||||
@@ -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,73 @@
|
|||||||
|
# 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.)
|
||||||
|
|
||||||
|
> **Bernds Entscheidungen zu den offenen Richtungsfragen stehen in `ANTWORTEN_BERND.md`
|
||||||
|
> (zuletzt 2026-07-05) – vor dem Abarbeiten von ROADMAP-Punkten lesen, sie sind maßgeblich.**
|
||||||
|
|
||||||
|
## 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,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,330 @@
|
|||||||
|
# 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)
|
||||||
|
- [x] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
||||||
|
ERLEDIGT – siehe Abschnitt „Fable-5-Review" oben in PROGRESS.md.
|
||||||
|
(a) alle 6 Gott-Datei-Aufteilungen unabhängig verifiziert (Zeilen-Multiset,
|
||||||
|
part-Bezüge): korrekt, verlorene Erklärkommentare waren mit f9979e4 schon
|
||||||
|
wiederhergestellt; (b) Uta-Logout-/Push-Fix gegen matrix-SDK 6.2.0
|
||||||
|
verifiziert: Analyse richtig, Lücke (SDK eskaliert Refresh-Fehler zu
|
||||||
|
logout()+clear()) war mit 891f348 (onSoftLogout-Guard) geschlossen, Guard
|
||||||
|
heute gegenverifiziert; (c) Lint-Fixes verhaltensäquivalent (onReorderItem
|
||||||
|
gegen Flutter-SDK geprüft, kein Off-by-One); (d) abgehakte Punkte stimmen
|
||||||
|
mit dem Code überein. Alles weiterhin UNGETESTET (Pi) → PC-Praxistest bleibt.
|
||||||
|
|
||||||
|
- [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.**
|
||||||
|
**Stand 2026-07-06: UMGESETZT + auf Windows praxisgetestet** – neue
|
||||||
|
Storage-Fassade `lib/core/app_database.dart` (SQLCipher auf
|
||||||
|
Android/Windows/Linux, Schlüssel im Keystore, Klartext-Migration mit
|
||||||
|
Backup/Verifikation/atomarem Tausch, Push-Isolate-Marker). Bernds echte
|
||||||
|
Windows-DB wurde erfolgreich migriert (PROGRESS.md 2026-07-06), Tests in
|
||||||
|
`test/app_database_test.dart` fahren die Migration mit echtem SQLCipher.
|
||||||
|
**Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün**
|
||||||
|
(API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start,
|
||||||
|
Neustart mit Keystore-Schlüssel ohne Re-Migration – Details im
|
||||||
|
Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`).
|
||||||
|
**Nachtrag 2026-07-06 (Nacht-Session): LIVE-E2E-Test mit ECHTEM Login grün**
|
||||||
|
(`test/live_encryption_e2e_test.dart`, Test-Account pyramidtest1 auf
|
||||||
|
steggi-matrix.work, Gate `PYRAMID_LIVE_TEST=1`): Platte nach echtem
|
||||||
|
Login/Nachricht byte-geprüft (kein SQLite-Header, Access-Token und
|
||||||
|
Nachrichtentext NICHT im Klartext auffindbar, Positiv-Kontrolle in
|
||||||
|
Klartext-DB findet beides), Neustart restauriert Session aus der
|
||||||
|
verschlüsselten DB, Logout→Re-Login→alte Nachricht lesbar, und die
|
||||||
|
Klartext-Migration mit einer ECHTEN eingeloggten Session erhält
|
||||||
|
Session+Daten. **Bleibt offen:**
|
||||||
|
echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test
|
||||||
|
(FCM liest verschlüsselte DB, secure_storage im Background-Isolate) –
|
||||||
|
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`;
|
||||||
|
erst nach grünem Gerätetest Release + Utas Gerät (vorher ihr Key-Backup
|
||||||
|
prüfen!), dann abhaken.
|
||||||
|
`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. **Umsetzungsplan liegt
|
||||||
|
fertig in `docs/SQLCIPHER_MIGRATION.md`** (2026-07-03, gegen Code und
|
||||||
|
SDK verifiziert) – PC-Termin kann direkt damit starten.
|
||||||
|
- [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)
|
||||||
|
- [x] **SICHERHEIT: Dashboard-Admin-Endpoints – ERLEDIGT: Loch geschlossen,
|
||||||
|
Heimnetz-only ist laut Bernd der Endzustand** (Fable-5-Review (o);
|
||||||
|
Entscheidung `ANTWORTEN_BERND.md` Nr. 3, 2026-07-05).
|
||||||
|
`server.py` nimmt `/api/ban`, `/api/unban`, `/api/toggle-registration`,
|
||||||
|
`/api/create-invite`, `/api/run-stats` jetzt NUR noch aus Heimnetz/localhost an
|
||||||
|
(Socket-IP + Cloudflare-Header-Check); Fremde aus dem Internet bekommen 403.
|
||||||
|
Headless verifiziert (öffentlich 403, Heimnetz ok, App-Endpoints unverändert),
|
||||||
|
siehe PROGRESS.md 2026-07-04. **Bernds Dashboard läuft im Heimnetz unverändert:
|
||||||
|
`http://192.168.178.71:8080/stats.html`.** Offen bleibt: Will Bernd die
|
||||||
|
Admin-Buttons auch von unterwegs? Dann Weg A (Cloudflare Access, empfohlen –
|
||||||
|
schließt auch die weiterhin öffentliche Nutzerlisten-Ansicht) oder Weg B
|
||||||
|
(`DASH_TOKEN`), Plan + Restrisiken in `docs/DASHBOARD_AUTH_HARDENING.md`.
|
||||||
|
Zusatz (Fable-5-Review (p), 2026-07-04): `server.py` läuft jetzt multithreaded
|
||||||
|
mit 30-s-Socket-Timeout – ein einzelner langsamer Client kann den Server
|
||||||
|
(und damit `/api/livekit-token`) nicht mehr blockieren; headless verifiziert.
|
||||||
|
Zusatz (Fable-5-Review (q), 2026-07-04): Der durch (p) eingeführte
|
||||||
|
Nebenläufigkeits-Race in den Admin-Routen (Read-Modify-Write auf `conduit.toml`,
|
||||||
|
`send_admin`-Readback) ist mit `ADMIN_LOCK` geschlossen, das NUR die
|
||||||
|
Admin-Mutationen serialisiert – die öffentlichen Routen bleiben nebenläufig;
|
||||||
|
headless verifiziert (2× parallel = 2× Laufzeit, öffentlicher Pfad 11 ms).
|
||||||
|
Zusatz (Fable-5-Review (r), 2026-07-04): drei Restbefunde gefixt –
|
||||||
|
`registration-status`-Leser jetzt unter ADMIN_LOCK (Truncate-Write-Race),
|
||||||
|
negative/übergroße `Content-Length` überall abgelehnt (kein Thread-Hänger,
|
||||||
|
ban/unban 4-KB-Deckel), Einladungs-Token per `secrets` statt `random`.
|
||||||
|
Zusatz (Fable-5-Review (s), 2026-07-04): ban/unban-Body wird jetzt VOR dem
|
||||||
|
ADMIN_LOCK gelesen (hängender Body-Read konnte den Lock 30 s festhalten),
|
||||||
|
und die öffentlichen Routen antworten bei internen Fehlern generisch statt
|
||||||
|
mit Exception-Text (Info-Leak); Details ins Journal. Headless verifiziert.
|
||||||
|
Zusatz (Fable-5-Review (t), 2026-07-04): auch der öffentliche GET-Leser
|
||||||
|
`registration-status` antwortet bei internen Fehlern jetzt generisch
|
||||||
|
statt mit `str(e)` (hätte den `conduit.toml`-Pfad geleakt); headless
|
||||||
|
verifiziert inkl. 500-Beweis.
|
||||||
|
Zusatz (Fable-5-Review (u), 2026-07-04): (t)-Fix gegengelesen – korrekt,
|
||||||
|
kein Befund; die server.py-Härtungskette ist damit KONVERGIERT (alle
|
||||||
|
verbliebenen `str(e)`-Antworten LAN-gegated, absichtlich).
|
||||||
|
**Abschluss (2026-07-05, `ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff
|
||||||
|
gewünscht – die A/B-Frage ist GESCHLOSSEN, das Heimnetz-Gate ist der
|
||||||
|
Endzustand (unterwegs nutzt Bernd WireGuard). Die Restrisiken des Gates
|
||||||
|
(öffentliche Nutzerlisten-Ansicht, theoretisches LAN-CSRF) sind bewusst
|
||||||
|
akzeptiert und in `docs/DASHBOARD_AUTH_HARDENING.md` dokumentiert.
|
||||||
|
Nicht weiter daran arbeiten.**
|
||||||
|
- [x] **LiveKit-Token-Route server-seitig gebaut + verifiziert** (Fable-5-Review (n),
|
||||||
|
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
|
||||||
|
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
|
||||||
|
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
|
||||||
|
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
|
||||||
|
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
|
||||||
|
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||||
|
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
|
||||||
|
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
|
||||||
|
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
|
||||||
|
- **Rotieren (Pflicht, sie sind bereits geleakt):** Matrix-Server-Admin-Token
|
||||||
|
`J5lax…` (noch gültig, voller Admin!), Gitea-Release-Token, LiveKit-`apiKey`/
|
||||||
|
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
|
||||||
|
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
|
||||||
|
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
|
||||||
|
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
|
||||||
|
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
|
||||||
|
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
|
||||||
|
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
|
||||||
|
String wie den Matrix-Admin-Token – deshalb steckte er im Client. Jetzt hat das
|
||||||
|
Diagnose-Endpoint einen eigenen, eng begrenzten Token (`DIAG_TOKEN`, hängt nur an
|
||||||
|
den Log an, keine Admin-Rechte); `settings_encryption.dart` benutzt diesen. Der
|
||||||
|
**Admin-Token ist damit nicht mehr im App-Code** (per `grep` bestätigt). Server +
|
||||||
|
Client umgestellt und verifiziert (neuer Token 200, alter Admin-Token 403). Der
|
||||||
|
Admin-Token selbst muss trotzdem noch rotiert werden (steht oben, Bernds Sache).
|
||||||
|
- Optional: Git-History-Rewrite (Bernds Entscheidung, Repo ist auch Backup).
|
||||||
|
- **Stand 2026-07-05 (`ANTWORTEN_BERND.md` Nr. 4): Rotation und
|
||||||
|
History-Rewrite ruhen BEWUSST – beides bleibt Bernds manuelle Sache,
|
||||||
|
NICHT vom Autopilot anstoßen oder nachfragen. Der Punkt bleibt nur als
|
||||||
|
Merkposten offen; Autopilot-bearbeitbar ist hier nichts mehr.**
|
||||||
|
- **ACHTUNG bei „Repo einfach privat schalten":** Der In-App-Updater
|
||||||
|
(`lib/core/update_checker.dart`) holt Releases **anonym** über die öffentliche
|
||||||
|
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
|
||||||
|
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
|
||||||
|
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
|
||||||
|
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
||||||
|
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
|
||||||
|
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
|
||||||
|
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
||||||
|
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
|
||||||
|
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
|
||||||
|
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
|
||||||
|
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
|
||||||
|
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
**Teilschritt erledigt (2026-07-06, M2-Pilot laut Entscheidung 2):**
|
||||||
|
Module `call_signaling` und `voice_channel` mit Fassaden-Interfaces +
|
||||||
|
Providern existieren (`lib/features/call_signaling/`,
|
||||||
|
`lib/features/voice_channel/`), alle Konsumenten gehen nur noch über
|
||||||
|
`callSignalingProvider`/`voiceChannelProvider` – kein UI-Import der
|
||||||
|
Implementierungen mehr (Details/Abweichungen: Status-Kopf in
|
||||||
|
`docs/M2_MODULE_SCHNITT.md`). App startet, 29 Tests grün, Sync/E2EE
|
||||||
|
laufen. **UNGETESTET:** echter 1:1-Call und Voice-Channel-Join per
|
||||||
|
Klick (braucht Gerät bzw. interaktiven Test) – deshalb nicht abgehakt.
|
||||||
|
**Weitere Teilschritte erledigt (2026-07-06, zweite PC-Session):**
|
||||||
|
`call_ui` ist jetzt ein eigenes Modul (`lib/features/call_ui/`,
|
||||||
|
b2335da) und die voice_*-Prefs laufen über die `VoicePrefs`-Fassade
|
||||||
|
in `core/settings_prefs.dart` statt direktem SharedPreferences-Zugriff
|
||||||
|
(d22f9c2) – beides verhaltensgleich, analyze/Tests grün, App-Start
|
||||||
|
geprüft. **Bewusst zurückgestellt bis Bernds Klick-Test des Piloten
|
||||||
|
(PC_TESTPLAN Abschnitt 8) grün ist:** UI-State aus den Managern
|
||||||
|
herauslösen – kein zweiter ungetesteter Logik-Umbau auf dem heiligen
|
||||||
|
Call-Pfad.
|
||||||
|
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||||
|
**Ist-Analyse + Umbauplan liegen in `docs/M2_STATE_MANAGEMENT.md`**
|
||||||
|
(2026-07-06): Riverpod ist schon dominant (~50 Provider); 4
|
||||||
|
Misch-Patterns identifiziert, Schrittfolge definiert. Schritte 1–2
|
||||||
|
(Call-Manager-UI-State, Voice-Provider-Umzug) GESPERRT bis Bernds
|
||||||
|
Klick-Test (PC_TESTPLAN Abschnitt 8); MediaCache-Umstellung
|
||||||
|
zurückgestellt (hängt an der Chat-Timeline, Korrektur im Doc).
|
||||||
|
Push bleibt bewusst wie er ist (Isolate-Zwang). Toter
|
||||||
|
`incomingCallProvider` bereits entfernt (41bcaf2).
|
||||||
|
- [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) waren zunächst bewusst NICHT
|
||||||
|
mit angefasst – beide hängen eng mit der damals unentschiedenen
|
||||||
|
Call-Fassade bzw. dem Bootstrap/Login-Pfad zusammen.
|
||||||
|
**Nachtrag 2026-07-06:** Nach dem Call-Piloten ist
|
||||||
|
`call_ui/voice_channel.dart` jetzt ebenfalls geteilt (5 part-Dateien
|
||||||
|
unter `call_ui/view/`, zeichenidentisch verifiziert, 95d9b07);
|
||||||
|
`app_shell.dart` bleibt wegen des Bootstrap/Login-Pfads (heilig)
|
||||||
|
weiter unangetastet. Ebenso `settings/settings_encryption.dart`
|
||||||
|
(1470 Zeilen, größte verbliebene Teildatei) in 4 part-Dateien unter
|
||||||
|
`settings/encryption/` geteilt (zeichenidentisch verifiziert,
|
||||||
|
6128953) – Vorbereitung für das spätere verification-Modul
|
||||||
|
(Schnitt-Doc Schritt 3), Code selbst unangetastet.
|
||||||
|
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
|
||||||
|
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||||
|
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
|
||||||
|
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
|
||||||
|
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
|
||||||
|
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
|
||||||
|
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
|
||||||
|
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
|
||||||
|
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
|
||||||
|
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
|
||||||
|
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
|
||||||
|
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
|
||||||
|
Code abgesichert). Nicht abgehakt.
|
||||||
|
- [ ] 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
|
||||||
|
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
|
||||||
|
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
|
||||||
|
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
|
||||||
|
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
|
||||||
|
durchgereicht), degradationPreference (60-FPS-Modi
|
||||||
|
maintainFramerate, kein Simulcast). contentHint='motion' in
|
||||||
|
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
|
||||||
|
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
|
||||||
|
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
|
||||||
|
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
|
||||||
|
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||||
|
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
|
||||||
|
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
|
||||||
|
- [ ] 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,15 +42,44 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Falls back to debug signing on machines without key.properties
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// so `flutter run --release` still works there.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
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 {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +9,16 @@
|
|||||||
<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"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<service
|
<service
|
||||||
@@ -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,25 +3,152 @@ 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) {
|
||||||
|
// Flutter asks for the room_id embedded in the launch intent (cold-start tap).
|
||||||
|
"getInitialRoomId" -> {
|
||||||
|
result.success(intent?.getStringExtra("room_id"))
|
||||||
|
}
|
||||||
|
// Cold-start share: app was launched via "Teilen nach Pyramid".
|
||||||
|
"getInitialShare" -> {
|
||||||
|
val share = extractShare(intent)
|
||||||
|
if (share != null) {
|
||||||
|
result.success(share)
|
||||||
|
intent = Intent() // clear so it's delivered only once
|
||||||
|
} else {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flutter asks for any inline reply that arrived on cold-start.
|
||||||
|
// When the app is killed and the user presses Reply, Android calls
|
||||||
|
// 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")
|
val path = call.argument<String>("path")
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
result.error("INVALID_ARG", "path is null", null)
|
result.error("INVALID_ARG", "path is null", null)
|
||||||
return@setMethodCallHandler
|
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 {
|
try {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
@@ -43,9 +170,103 @@ class MainActivity : FlutterActivity() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("INSTALL_ERROR", e.message, null)
|
result.error("INSTALL_ERROR", e.message, null)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
result.notImplemented()
|
|
||||||
}
|
}
|
||||||
|
"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)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"openBatterySettings" -> {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@@ -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,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||||
|
# Laeuft eine Session ins 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 naechsten Session)
|
||||||
|
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||||
|
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||||
|
|
||||||
|
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
|
||||||
|
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
|
||||||
|
# ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
|
||||||
|
# ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
|
||||||
|
# die Config faelschlich unter ~/.claude/.claude.json).
|
||||||
|
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
|
||||||
|
# ist oeffentlich, PII gehoert nicht hinein.)
|
||||||
|
apply_account() { unset CLAUDE_CONFIG_DIR; }
|
||||||
|
ACCOUNT_NAME="nordmann"
|
||||||
|
|
||||||
|
# --- 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. Arbeite in dieser Reihenfolge: (1) Review-Pass: Gehe die in ROADMAP.md/PROGRESS.md als erledigt markierten Punkte durch, prüfe den zugehörigen Code kritisch und überarbeite/verbessere ihn, wo nötig. Führe in PROGRESS.md einen Abschnitt 'Fable-5-Review' und hake dort jeden geprüften Punkt ab, damit bereits reviewte Punkte nicht erneut geprüft werden. (2) Wenn alle erledigten Punkte reviewt sind, arbeite ROADMAP.md Punkt für Punkt weiter ab. Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). Arbeite so viel 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
|
||||||
|
apply_account
|
||||||
|
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $ACCOUNT_NAME) =====" | tee -a autopilot.log
|
||||||
|
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
||||||
|
SESSION_OUT=$(mktemp)
|
||||||
|
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
|
||||||
|
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
|
||||||
|
# Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
|
||||||
|
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
|
||||||
|
rm -f "$SESSION_OUT"
|
||||||
|
echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
|
||||||
|
sleep 1200
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$SESSION_OUT"
|
||||||
|
|
||||||
|
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||||
|
sleep 1200
|
||||||
|
done
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
|
||||||
|
|
||||||
|
**Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate
|
||||||
|
(„Weg C", siehe unten) geschlossen, und Bernd hat entschieden
|
||||||
|
(`ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff – das Gate ist der ENDZUSTAND.**
|
||||||
|
Die fünf Admin-Endpoints akzeptieren nur noch Anfragen aus dem
|
||||||
|
Heimnetz/localhost; alles, was über den Cloudflare-Tunnel kommt, bekommt 403 mit
|
||||||
|
einer Erklärung (die das Dashboard per `alert()` anzeigt). Bernds Dashboard
|
||||||
|
funktioniert im Heimnetz unverändert unter
|
||||||
|
`http://192.168.178.71:8080/stats.html`; unterwegs kommt Bernd per WireGuard ins
|
||||||
|
Heimnetz. Die A/B-Frage (Cloudflare Access vs. `DASH_TOKEN`) ist damit
|
||||||
|
GESCHLOSSEN – die Optionen unten bleiben nur als Archiv stehen, falls sich der
|
||||||
|
Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst
|
||||||
|
akzeptiert. **Nicht weiter an diesem Thema arbeiten.**
|
||||||
|
|
||||||
|
## Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)
|
||||||
|
|
||||||
|
In `server.py` (Konstante `ADMIN_PATHS` + Helfer `lan_denied_reason`): eine
|
||||||
|
Admin-POST-Anfrage wird nur angenommen, wenn (1) **kein** `Cf-Connecting-Ip`/
|
||||||
|
`Cf-Ray`-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
|
||||||
|
(2) die **Socket-Absender-IP** (nicht spoofbar) `127.0.0.1`/`::1`/`192.168.*`
|
||||||
|
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
|
||||||
|
`security_alerts.log` und damit in der Dashboard-Anzeige. Headless verifiziert:
|
||||||
|
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
|
||||||
|
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
|
||||||
|
`GET /` öffentlich → 200, `/api/livekit-token` (401 bei ungültigem Token) und
|
||||||
|
`/api/e2ee-diagnostics` (403 bei falschem Token) unverändert öffentlich
|
||||||
|
erreichbar (die App braucht sie, eigene Auth vorhanden).
|
||||||
|
|
||||||
|
**Bekannte Restrisiken des Gates (Bernd bekannt und bewusst akzeptiert,
|
||||||
|
Entscheidung 2026-07-05):**
|
||||||
|
- Die **öffentliche Ansicht bleibt offen** und zeigt Nutzerliste + Präsenz
|
||||||
|
(Informations-Preisgabe). Weg A würde auch das schließen.
|
||||||
|
- **CSRF aus dem Heimnetz:** Eine bösartige Webseite in Bernds Browser könnte
|
||||||
|
theoretisch POSTs an `http://192.168.178.71:8080` schicken (moderne
|
||||||
|
Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig).
|
||||||
|
Erst ein Token/Access-Login (Weg A/B) schließt das sauber.
|
||||||
|
- Wer **im Heimnetz** ist, kann weiterhin ohne Anmeldung administrieren.
|
||||||
|
|
||||||
|
## Problem (belegt, nicht vermutet)
|
||||||
|
|
||||||
|
Der Dashboard-Server `/home/steggi/matrix/server.py` (läuft als
|
||||||
|
`matrix-stats.service` auf `0.0.0.0:8080`) ist über Cloudflare unter
|
||||||
|
`https://dashboard.steggi-matrix.work` **öffentlich aus dem Internet erreichbar**.
|
||||||
|
Getestet: `GET /` liefert 200 (stats.html), und die **zustandsändernden POST-Endpoints
|
||||||
|
antworten mit der App-eigenen Validierung – ohne jede Authentifizierung**:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
|
||||||
|
{"error": "Kein Nutzer angegeben"} # HTTP 400 – App-Antwort, KEIN Login davor
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit kann **jeder Fremde im Internet**, der den Hostnamen kennt, ohne Anmeldung:
|
||||||
|
|
||||||
|
- `/api/ban` / `/api/unban` – **jeden Matrix-Nutzer sperren/entsperren** (auch Uta!)
|
||||||
|
→ Aussperrung / Denial-of-Service gegen echte Nutzer.
|
||||||
|
- `/api/toggle-registration` und `/api/create-invite` – **offene Registrierung am
|
||||||
|
Homeserver aktivieren** (setzt `allow_registration = true` +
|
||||||
|
`yes_i_am_very_very_sure…`) → der Homeserver wird zur Spam-/Missbrauchsschleuder.
|
||||||
|
- `/api/run-stats` – Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
|
||||||
|
|
||||||
|
**Der Hostname ist nicht wirklich geheim:** Cloudflare stellt TLS-Zertifikate aus,
|
||||||
|
die in den öffentlichen Certificate-Transparency-Logs landen – jede Subdomain von
|
||||||
|
`steggi-matrix.work` ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
|
||||||
|
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
|
||||||
|
|
||||||
|
Bereits **korrekt abgesichert** (zum Vergleich, nicht betroffen):
|
||||||
|
- `/api/e2ee-diagnostics` – eigener `DIAG_TOKEN` (Bearer).
|
||||||
|
- `/api/livekit-token` – Matrix-Access-Token per `whoami` gegen Continuwuity.
|
||||||
|
|
||||||
|
Nur die vier Admin-/Stats-Endpoints oben sind offen.
|
||||||
|
|
||||||
|
## Wie das Dashboard heute aufruft
|
||||||
|
|
||||||
|
`stats.html` ruft alle Aktionen als **Same-Origin-`fetch` ohne Auth-Header** auf
|
||||||
|
(`toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction` →
|
||||||
|
`/api/ban` /`/api/unban`). Es gibt keine Session, kein Cookie, keinen Token –
|
||||||
|
deshalb bricht **jede** hinzugefügte Authentifizierung das Dashboard so lange,
|
||||||
|
bis `stats.html` den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
|
||||||
|
und kein stiller Fix.
|
||||||
|
|
||||||
|
## Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
|
||||||
|
|
||||||
|
Am saubersten wird das Dashboard **komplett** durch Cloudflare Access geschützt
|
||||||
|
(Zero-Trust-Login vor der Subdomain), statt in `server.py` Auth nachzubauen:
|
||||||
|
|
||||||
|
1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für
|
||||||
|
`dashboard.steggi-matrix.work` anlegen.
|
||||||
|
2. Policy: nur Bernds E-Mail (`bernd.steckmeister@gmail.com`) per One-Time-PIN
|
||||||
|
oder Google-Login zulassen.
|
||||||
|
3. Fertig – `server.py` und `stats.html` bleiben **unverändert**, das Dashboard
|
||||||
|
funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen
|
||||||
|
gar nicht erst an die Endpoints.
|
||||||
|
|
||||||
|
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko
|
||||||
|
durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im
|
||||||
|
Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). **Das ist der
|
||||||
|
Königsweg – Option B nur, falls Access nicht gewünscht ist.**
|
||||||
|
|
||||||
|
## Option B (Fallback, rein in server.py): eigener Dashboard-Token
|
||||||
|
|
||||||
|
Wenn Access nicht gewünscht ist, ein **neuer, zufälliger `DASH_TOKEN`** (getrennt
|
||||||
|
vom geleakten Admin-Token `J5lax…` und vom `DIAG_TOKEN`!), den die vier
|
||||||
|
Admin-/Stats-Endpoints als `Authorization: Bearer <DASH_TOKEN>` verlangen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server.py, zu den anderen Token-Konstanten:
|
||||||
|
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
|
||||||
|
|
||||||
|
def _dash_authed(self):
|
||||||
|
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
In `do_POST` **vor** der Behandlung von `/api/ban`, `/api/unban`,
|
||||||
|
`/api/toggle-registration`, `/api/create-invite`, `/api/run-stats` einfügen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
|
||||||
|
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /` und `/api/registration-status` bleiben öffentlich (nur Lesen), damit die
|
||||||
|
Stats-Seite ohne Token sichtbar bleibt.
|
||||||
|
|
||||||
|
**`stats.html`** darf den Token **NIEMALS fest eingebettet** ausliefern (die Seite
|
||||||
|
ist öffentlich – der Token läge sonst für jeden offen). Stattdessen einmal im
|
||||||
|
Browser abfragen und in `localStorage` halten:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function dashToken() {
|
||||||
|
var t = localStorage.getItem('dashToken');
|
||||||
|
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
|
||||||
|
return t || '';
|
||||||
|
}
|
||||||
|
// und bei JEDEM Aktions-fetch:
|
||||||
|
fetch('/api/ban', { method:'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
|
||||||
|
body: JSON.stringify({user:u}) })
|
||||||
|
```
|
||||||
|
|
||||||
|
(betrifft `toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`).
|
||||||
|
Bei 403 den gecachten Token verwerfen (`localStorage.removeItem('dashToken')`) und
|
||||||
|
neu fragen. Bernd gibt den Token so genau **einmal pro Browser** ein.
|
||||||
|
|
||||||
|
## Rollout / Testplan
|
||||||
|
|
||||||
|
**Option A:** Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
|
||||||
|
`https://dashboard.steggi-matrix.work/` zum Access-Login umleitet statt die Seite
|
||||||
|
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
|
||||||
|
|
||||||
|
**Option B (headless auf dem Pi verifizierbar):**
|
||||||
|
1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen,
|
||||||
|
`Restart=always` lädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle).
|
||||||
|
2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet.
|
||||||
|
3. Mit `Authorization: Bearer <DASH_TOKEN>` + leerem Nutzer → 400 (App-Validierung
|
||||||
|
erreicht) → beweist: Token akzeptiert, Auth greift.
|
||||||
|
4. Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen
|
||||||
|
(Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an
|
||||||
|
einem **Test-Nutzer**, nicht an Uta).
|
||||||
|
|
||||||
|
## Warum erst nur der Plan kam – und dann doch das Interims-Gate
|
||||||
|
|
||||||
|
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
|
||||||
|
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
|
||||||
|
einrichten) – das mitten in einer autonomen Session zu deployen verletzt die
|
||||||
|
CLAUDE.md-Leitplanke „kein Aussperren". Das *Wie* der Anmeldung bleibt deshalb
|
||||||
|
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
|
||||||
|
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
|
||||||
|
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
|
||||||
|
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
|
||||||
|
die öffentliche URL gehen nicht – die 403-Meldung im Dashboard erklärt das und
|
||||||
|
nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only,
|
||||||
|
unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau
|
||||||
|
(falls je nötig) = die Gate-Zeilen in `server.py` entfernen
|
||||||
|
(Backup: `server.py.bak-20260704-dashgate`).
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
|
||||||
|
|
||||||
|
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
|
||||||
|
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
|
||||||
|
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
|
||||||
|
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`lib/core/livekit_token.dart` mintet die LiveKit-JWTs **im Client**. Dafür muss das
|
||||||
|
LiveKit-`apiSecret` im App-Code liegen (`livekit_token.dart:6`) – d. h. jeder
|
||||||
|
App-Nutzer (auch Uta) trägt das SFU-Secret im Gerät. Wer es hat, kann sich für
|
||||||
|
**jeden** Raum und **jede** Identität ein Beitritts-Token ausstellen. Das ist ein
|
||||||
|
Architekturproblem, kein bloßer Repo-Leak: Selbst nach einem History-Rewrite bliebe
|
||||||
|
das Secret im ausgelieferten Binary.
|
||||||
|
|
||||||
|
Aktueller Fluss (`lib/core/livekit_call_manager.dart:154`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final token = LiveKitTokenGenerator.generate(
|
||||||
|
roomName: roomName,
|
||||||
|
identity: identity,
|
||||||
|
displayName: identity,
|
||||||
|
ttlSeconds: 21600, // 6 h
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugtes JWT (HS256, `livekit_token.dart:14-33`): Grants `video` mit
|
||||||
|
`roomJoin/room/canPublish/canSubscribe/canPublishData`, `metadata` = Anzeigename,
|
||||||
|
`iss` = apiKey (`LKMatrixPi`), `sub` = identity, `exp` = jetzt+ttl.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Das `apiSecret` liegt **nur noch auf dem Pi**. Der Client holt sich für einen
|
||||||
|
konkreten Raum ein fertiges JWT von einem kleinen Token-Endpoint und übergibt es
|
||||||
|
unverändert an `Room.connect`.
|
||||||
|
|
||||||
|
## Server-Seite (Pi)
|
||||||
|
|
||||||
|
Es gibt bereits einen schlanken Dashboard-Server ohne Fremd-Abhängigkeiten:
|
||||||
|
`/home/steggi/matrix/server.py` (stdlib `http.server`, läuft als
|
||||||
|
`matrix-stats.service` auf `0.0.0.0:8080`, nach außen `dashboard.steggi-matrix.work`).
|
||||||
|
Der Token-Endpoint kann dort als weitere Route `POST /api/livekit-token` andocken –
|
||||||
|
kein neuer Dienst nötig. Das LiveKit-`apiSecret` steht schon auf dem Pi in
|
||||||
|
`/home/steggi/matrix/livekit.yaml` (`keys: LKMatrixPi: <secret>`); der Server liest
|
||||||
|
es von dort, statt es erneut zu hinterlegen (eine Quelle der Wahrheit).
|
||||||
|
|
||||||
|
**Wichtig – Authentifizierung:** Der Endpoint darf NICHT anonym Tokens ausstellen
|
||||||
|
(sonst ist nichts gewonnen). Der Client schickt seinen **Matrix-Access-Token** mit;
|
||||||
|
der Server prüft ihn gegen Continuwuity und leitet daraus die Identität ab:
|
||||||
|
|
||||||
|
1. `POST /api/livekit-token`, Body `{ "room": "<roomName>", "matrix_token": "<access_token>" }`.
|
||||||
|
2. Server ruft `GET http://127.0.0.1:6167/_matrix/client/v3/account/whoami`
|
||||||
|
mit `Authorization: Bearer <matrix_token>` auf → liefert `user_id`
|
||||||
|
(401 → Endpoint gibt 401 zurück, kein Token).
|
||||||
|
3. `identity` = `user_id` (NICHT vom Client wählbar – verhindert Identitäts-Spoofing).
|
||||||
|
4. Optional, aber empfohlen: Raummitgliedschaft prüfen (`/_matrix/client/v3/rooms/
|
||||||
|
{roomId}/joined_members` oder der Matrix-Raum, zu dem der Voice-Channel gehört) –
|
||||||
|
nur ausstellen, wenn der Nutzer wirklich Mitglied ist. Für Phase 1 reicht die
|
||||||
|
whoami-Authentifizierung; Mitgliedschaftsprüfung als Härtung nachziehen.
|
||||||
|
5. Server mintet das LiveKit-JWT **mit denselben Grants wie heute** und gibt
|
||||||
|
`{ "token": "<jwt>", "url": "wss://livekit.steggi-matrix.work" }` zurück.
|
||||||
|
|
||||||
|
**JWT-Minting ohne Fremd-Paket (stdlib genügt):** LiveKit-Tokens sind HS256-JWTs.
|
||||||
|
Mit `hmac`/`hashlib`/`base64`/`json` aus der stdlib:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64, hmac, hashlib, json, time
|
||||||
|
|
||||||
|
def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
|
||||||
|
def mint_livekit(api_key, api_secret, identity, room, name, ttl=21600):
|
||||||
|
now = int(time.time())
|
||||||
|
header = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
payload = {
|
||||||
|
"iss": api_key, "sub": identity, "name": name,
|
||||||
|
"nbf": now, "exp": now + ttl, "metadata": name,
|
||||||
|
"video": {"roomJoin": True, "room": room,
|
||||||
|
"canPublish": True, "canSubscribe": True,
|
||||||
|
"canPublishData": True},
|
||||||
|
}
|
||||||
|
segs = [_b64url(json.dumps(header, separators=(",", ":")).encode()),
|
||||||
|
_b64url(json.dumps(payload, separators=(",", ":")).encode())]
|
||||||
|
signing_input = b".".join(segs)
|
||||||
|
sig = hmac.new(api_secret.encode(), signing_input, hashlib.sha256).digest()
|
||||||
|
return (signing_input + b"." + _b64url(sig)).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifikation lokal auf dem Pi (kein Gerät nötig): geminetes Token gegen den
|
||||||
|
laufenden LiveKit prüfen, z. B. per LiveKit-CLI `lk` oder indem man das JWT auf
|
||||||
|
jwt.io-Art dekodiert und Claims/Signatur vergleicht. Zusätzlich Gegentest mit dem
|
||||||
|
**alten** Client-Pfad: dasselbe Token muss `Room.connect` akzeptieren.
|
||||||
|
|
||||||
|
## Client-Seite
|
||||||
|
|
||||||
|
- `lib/core/livekit_token.dart`: `apiKey`/`apiSecret` **entfernen**. Die Klasse wird
|
||||||
|
entweder gelöscht oder zu einem dünnen HTTP-Client
|
||||||
|
`Future<String> fetchLiveKitToken({required String room, required String matrixToken})`,
|
||||||
|
der `POST https://dashboard.steggi-matrix.work/api/livekit-token` aufruft und das
|
||||||
|
`token`-Feld zurückgibt. `http`-Paket ist schon Abhängigkeit (siehe
|
||||||
|
`update_checker.dart`).
|
||||||
|
- `lib/core/livekit_call_manager.dart:154`: `LiveKitTokenGenerator.generate(...)`
|
||||||
|
→ `await fetchLiveKitToken(room: roomName, matrixToken: matrixClient.accessToken)`.
|
||||||
|
Der Manager hat den Matrix-Client bereits (`_matrixClient`, Zeile 149) – der
|
||||||
|
Access-Token ist also verfügbar. `identity`/`displayName` werden nicht mehr vom
|
||||||
|
Client bestimmt (Server leitet Identität aus whoami ab); den bisherigen
|
||||||
|
`identity`-Parameter entsprechend zurückbauen.
|
||||||
|
- Fehlerpfade: Netzfehler / 401 / 403 sauber als Call-Fehler anzeigen
|
||||||
|
(`error = ...; notifyListeners()`), NICHT still schlucken – sonst „Call verbindet
|
||||||
|
nicht" ohne Hinweis. TTL bleibt 21600 s (6 h), Erneuerung wie bisher beim
|
||||||
|
Neu-Beitreten.
|
||||||
|
|
||||||
|
## Rotation (Pflicht, danach)
|
||||||
|
|
||||||
|
Das alte `apiSecret` `rYUT2PRa…` ist geleakt (Client + Git-History). Nach der
|
||||||
|
Umstellung:
|
||||||
|
|
||||||
|
1. Neues `apiSecret` in `/home/steggi/matrix/livekit.yaml` (`keys:`) setzen.
|
||||||
|
2. LiveKit-Container neu starten
|
||||||
|
(`docker compose -f /home/steggi/matrix/docker-compose.yml restart livekit`).
|
||||||
|
3. `server.py` liest das Secret aus `livekit.yaml` – kein zweiter Ort zu pflegen.
|
||||||
|
4. Alte Clients (Utas installierte APK) minten mit dem alten Secret → deren
|
||||||
|
selbst-gemintete Tokens werden nach der Rotation abgelehnt. Deshalb Rotation
|
||||||
|
**zusammen mit einem neuen Release** ausrollen, das den neuen Fetch-Pfad nutzt.
|
||||||
|
|
||||||
|
## Reihenfolge / Testplan (PC + Gerät)
|
||||||
|
|
||||||
|
1. `server.py`-Route bauen, lokal auf dem Pi verifizieren (whoami-Auth greift,
|
||||||
|
geminetes Token dekodiert korrekt, `Room.connect` akzeptiert es).
|
||||||
|
2. Client umstellen, `flutter analyze` sauber, `flutter test` grün.
|
||||||
|
3. **Auf echtem Gerät** (Calls heilig, kein Headless-Test möglich): Voice-Channel
|
||||||
|
beitreten, Audio/Video/Screenshare, Verlassen/Wieder-Beitreten, zweiter
|
||||||
|
Teilnehmer. Erst wenn das steht: Secret rotieren + Release.
|
||||||
|
4. Danach ROADMAP-M0-Punkt „LiveKit-Token server-seitig minten" abhaken und den
|
||||||
|
Rotations-Teil des Secret-Punkts als erledigt markieren.
|
||||||
|
|
||||||
|
## Warum nicht jetzt (auf dem Pi) gemacht
|
||||||
|
|
||||||
|
Die Server-Route ließe sich hier bauen und headless testen – aber der zugehörige
|
||||||
|
Client-Umbau tauscht den **heiligen Call-Pfad** aus und ist auf diesem Pi ohne
|
||||||
|
GUI/Gerät nicht praktisch prüfbar. Einen halben, nicht end-to-end verifizierbaren
|
||||||
|
Umbau am Call-Pfad zu stapeln verbietet die CLAUDE.md-Leitplanke („keine
|
||||||
|
Verhaltensänderung ohne Not", Calls heilig). Deshalb hier nur der fertige Plan.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# M2 – Modul-Schnitt (Entwurf)
|
||||||
|
|
||||||
|
> **STATUS 2026-07-06: Pilot (Schritt 1) UMGESETZT.** Die Module
|
||||||
|
> `call_signaling` (`lib/features/call_signaling/`: `CallSignalingService`
|
||||||
|
> + `callSignalingProvider` + Implementierung `voip_manager.dart`) und
|
||||||
|
> `voice_channel` (`lib/features/voice_channel/`: `VoiceChannelService` +
|
||||||
|
> `voiceChannelProvider` + Implementierung `livekit_call_manager.dart`)
|
||||||
|
> existieren; ALLE Konsumenten (Shell, Call-UI, Rooms-Panel, Chat-Header,
|
||||||
|
> Settings) gehen nur noch über die Provider/Interfaces, kein direkter
|
||||||
|
> Implementierungs-Import mehr (einzige dokumentierte Ausnahme:
|
||||||
|
> Kompositions-Punkt `matrix_client.dart`). Bewusste Abweichungen vom
|
||||||
|
> Entwurf unten (Verhaltensgleichheit vor Schönheit, heiliger Call-Pfad):
|
||||||
|
> ChangeNotifier statt Streams, Methodennamen wie im Bestand
|
||||||
|
> (`startCall`/`hangUp` statt `join`/`leave`), `CallSession`/LiveKit-`Room`
|
||||||
|
> bleiben in den Schnittstellen sichtbar, weil die UI direkt rendert.
|
||||||
|
> Echter Call-/Voice-Channel-Test steht aus (Gerät bzw. Klick-Test) –
|
||||||
|
> siehe PROGRESS.md 2026-07-06. Schritte 2–5 unten sind weiter offen.
|
||||||
|
>
|
||||||
|
> **Nachtrag 2026-07-06 (zweite PC-Session):** `call_ui` ist jetzt ein
|
||||||
|
> eigenes Modul (`lib/features/call_ui/`, reine Umbenennung von
|
||||||
|
> `features/call/`; konsumiert weiterhin nur die zwei Service-Interfaces).
|
||||||
|
> Außerdem ist der storage-Teilschritt für die Call-Module erledigt: die
|
||||||
|
> voice_*-Prefs (Geräte + Ausgabelautstärke) laufen über die neue
|
||||||
|
> `VoicePrefs`-Kapsel in `core/settings_prefs.dart` (Punkt 6 unten) –
|
||||||
|
> kein direkter SharedPreferences-Zugriff mehr in `voip_manager.dart`/
|
||||||
|
> `livekit_call_manager.dart`/`settings_voice.dart`, Keys unverändert.
|
||||||
|
> „UI-State aus den Managern herauslösen" wartet bewusst auf Bernds
|
||||||
|
> Klick-Test des Piloten (heiliger Call-Pfad).
|
||||||
|
|
||||||
|
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,95 @@
|
|||||||
|
# M2 – Einheitliches State-Management: Ist-Analyse + Umbauplan
|
||||||
|
|
||||||
|
> **STATUS 2026-07-06: reine Analyse, noch KEIN Umbau.** Grundlage für den
|
||||||
|
> ROADMAP-M2-Punkt „Einheitliches State-Management (Riverpod konsequent,
|
||||||
|
> keine Misch-Patterns)". Die Schritte 1–2 unten betreffen den heiligen
|
||||||
|
> Call-Pfad und sind GESPERRT, bis Bernds Klick-Test des Call-Piloten
|
||||||
|
> (`docs/PC_TESTPLAN.md` Abschnitt 8) grün ist – vorher nur Schritt 3.
|
||||||
|
|
||||||
|
## Ist-Stand (repo-weit erhoben, 2026-07-06)
|
||||||
|
|
||||||
|
Riverpod ist bereits das dominante Muster: rund 50 Provider –
|
||||||
|
`StateProvider` für UI-Zustand, `StreamProvider` auf `client.onSync`,
|
||||||
|
`FutureProvider` (Client, Update-Info), `StateNotifierProvider` für die
|
||||||
|
Prefs-Notifier (`BoolPref`/`StringSetPref` in `core/settings_prefs.dart`).
|
||||||
|
`ValueNotifier`/eigene `StreamController` gibt es NICHT. Lokales
|
||||||
|
`setState` in Widgets für rein ephemeren Zustand (Hover, Dialog-Schritte,
|
||||||
|
Textfelder) ist normales Flutter und zählt hier bewusst NICHT als
|
||||||
|
Misch-Pattern.
|
||||||
|
|
||||||
|
Gefundene Misch-Patterns, nach Gewicht:
|
||||||
|
|
||||||
|
1. **Call-Schicht: Singletons + ChangeNotifier hinter den Fassaden.**
|
||||||
|
`LiveKitCallManager.instance` (static final Singleton,
|
||||||
|
`features/voice_channel/livekit_call_manager.dart:17`) und
|
||||||
|
`PyramidVoipManager._instance` (nullable static,
|
||||||
|
`features/call_signaling/voip_manager.dart:17`; konstruiert im
|
||||||
|
Kompositions-Punkt `core/matrix_client.dart:66–71`). Die Modul-Provider
|
||||||
|
(`voiceChannelProvider`/`callSignalingProvider`) geben nur die
|
||||||
|
Singletons zurück. Das ist die im Pilot BEWUSST gewählte Zwischenform
|
||||||
|
(Verhaltensgleichheit vor Schönheit). Eigentliches Problem: UI-Zustand
|
||||||
|
(Mute-Flags, Teilnehmer, Renderer, Qualitätswahl) lebt mutierbar IN den
|
||||||
|
Managern und wird per `notifyListeners()` grob-granular publiziert.
|
||||||
|
2. **Voice-UI-Zustand außerhalb des Moduls:** `activeVoiceRoomIdProvider`
|
||||||
|
und `voiceParticipantsProvider` liegen in `core/app_state.dart`
|
||||||
|
(Kopplungspunkt laut Ist-Analyse), werden aber von Rooms-Panel und
|
||||||
|
Mini-Call-Widget gesetzt/gelesen und gehören fachlich ins
|
||||||
|
`voice_channel`-Modul (Join-Zustand + Presence sind Modulsache).
|
||||||
|
3. **`MediaCache.instance`** (`core/media_cache.dart:19`): reines
|
||||||
|
Singleton, kein UI-Zustand, nur Cache. Nutzer sind `widgets/mxc_image.dart`
|
||||||
|
(7 Stellen, Widgets – leicht umstellbar) UND
|
||||||
|
`features/chat/message/message_media.dart` (7 Stellen, darunter der
|
||||||
|
statische Wrapper `_DocBytesCache` für Dokument-Vorschauen, dessen
|
||||||
|
Aufrufer keinen `ref`-Zugang haben). Eine Umstellung müsste `ref`/die
|
||||||
|
Cache-Instanz durch die statischen Helfer der Chat-Timeline fädeln –
|
||||||
|
und genau dieser Code (Download-/Entschlüsselungs-Fehlerpfade) ist
|
||||||
|
laut PC_TESTPLAN Abschnitt 3 der am wenigsten geprüfte Teil der App.
|
||||||
|
4. **Push/Notifications:** modul-globale Mutable-Zustände in
|
||||||
|
`core/notification_service.dart` (`_windowsNotifs`-Map, Plugin-Globals)
|
||||||
|
und direkte `SharedPreferences`-Zugriffe in `background_push.dart`/
|
||||||
|
`fcm_push_service.dart`. Das ist dem Background-Isolate-Zwang
|
||||||
|
geschuldet (siehe Schnitt-Doc Punkt 8 + Memory „Pyramid Push") –
|
||||||
|
**bewusst KEIN Umbau-Kandidat**, nur hier dokumentiert.
|
||||||
|
|
||||||
|
Bereits erledigt im Zuge dieser Analyse: der seit dem Call-Piloten tote
|
||||||
|
`incomingCallProvider` ist aus `app_state.dart` entfernt (41bcaf2).
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
- `core/app_state.dart` enthält nur noch ECHTEN App-weiten UI-Zustand
|
||||||
|
(Theme, Layout/Panels, Navigation, Toast, Space-Auswahl) – kein
|
||||||
|
Feature-Fachzustand.
|
||||||
|
- Feature-Zustand lebt im jeweiligen Modul hinter dessen Provider;
|
||||||
|
andere Module lesen ihn NUR über die öffentliche Schnittstelle.
|
||||||
|
- Kein `.instance`-Zugriff über Modulgrenzen; Konstruktion ausschließlich
|
||||||
|
im Kompositions-Punkt bzw. im Provider des Moduls.
|
||||||
|
- ChangeNotifier ist als Übergangsform ok; Endzustand der Call-Module ist
|
||||||
|
ein immutables Zustandsobjekt (z. B. `VoiceChannelState`) pro Modul,
|
||||||
|
das der Provider publiziert, plus Kommando-Methoden auf dem Service
|
||||||
|
(entspricht dem Schnitt-Doc-Entwurf, dort als Stream skizziert).
|
||||||
|
|
||||||
|
## Schrittfolge (Reihenfolge = Priorität)
|
||||||
|
|
||||||
|
1. **[GESPERRT bis Klick-Test] UI-State aus den Managern herauslösen:**
|
||||||
|
je Call-Modul ein immutables Zustandsobjekt + `Notifier`/
|
||||||
|
`StateNotifier`, Manager wird interne Implementierung ohne
|
||||||
|
UI-Zustand. Das ist der bereits in PROGRESS.md zurückgestellte
|
||||||
|
Logik-Umbau – NICHT vorher anfangen (heiliger Call-Pfad, ein
|
||||||
|
ungetesteter Umbau liegt schon auf dem Stapel).
|
||||||
|
2. **[GESPERRT bis Klick-Test] `activeVoiceRoomIdProvider` +
|
||||||
|
`voiceParticipantsProvider` ins `voice_channel`-Modul umziehen**
|
||||||
|
(zusammen mit `kVoicePresenceType`). Mechanisch klein, aber
|
||||||
|
Call-Pfad-nah – sinnvoll im selben Zug wie Schritt 1.
|
||||||
|
3. **[ZURÜCKGESTELLT] `MediaCache` hinter `Provider<MediaCache>`:**
|
||||||
|
Erst als „risikoarm, jederzeit" eingestuft – das stimmte NICHT
|
||||||
|
(Korrektur 2026-07-06, siehe Ist-Stand Punkt 3: auch die Chat-Timeline
|
||||||
|
hängt dran, inkl. statischer Helfer ohne `ref`). Gehört in den Zug des
|
||||||
|
`chat_timeline`-Moduls (Schnitt-Doc Punkt 9) bzw. frühestens nach dem
|
||||||
|
Klick-Test von PC_TESTPLAN Abschnitt 3 – nicht als Lückenfüller.
|
||||||
|
4. **[kein Umbau] Push/Notifications:** bleibt wie es ist
|
||||||
|
(Isolate-Zwang); nur bei ohnehin anstehenden Push-Arbeiten die
|
||||||
|
Prefs-Zugriffe auf die `Prefs`-Fassade ziehen (Schnitt-Doc Punkt 6).
|
||||||
|
|
||||||
|
Abhak-Kriterium für den ROADMAP-Punkt: Schritte 1–3 umgesetzt und die
|
||||||
|
Kernflows (Login, Raum, Nachricht, Voice-Join) nach jedem Schritt
|
||||||
|
praktisch geprüft; Punkt 4 braucht nur den Doku-Vermerk (steht hiermit).
|
||||||
@@ -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,174 @@
|
|||||||
|
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||||
|
|
||||||
|
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
|
||||||
|
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||||
|
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
||||||
|
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
||||||
|
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
||||||
|
Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
|
||||||
|
|
||||||
|
Schnellster Weg: `flutter run -d windows`.
|
||||||
|
|
||||||
|
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
|
||||||
|
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
|
||||||
|
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
|
||||||
|
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
|
||||||
|
|
||||||
|
## 0. Vorab (1 Minute)
|
||||||
|
|
||||||
|
- [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
|
||||||
|
SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
|
||||||
|
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
|
||||||
|
mehrfach gelaufen (vor/nach jedem Umbau des Tages).
|
||||||
|
|
||||||
|
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
||||||
|
|
||||||
|
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
||||||
|
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
|
||||||
|
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
|
||||||
|
|
||||||
|
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
||||||
|
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
|
||||||
|
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
|
||||||
|
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
|
||||||
|
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
|
||||||
|
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
|
||||||
|
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
|
||||||
|
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
|
||||||
|
Soft-Logout-Eintrag.
|
||||||
|
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
|
||||||
|
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
|
||||||
|
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||||
|
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
||||||
|
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
||||||
|
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
|
||||||
|
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
|
||||||
|
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
|
||||||
|
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
|
||||||
|
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
|
||||||
|
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
|
||||||
|
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
|
||||||
|
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
|
||||||
|
Migration). Danach zwei geplante Neustarts für die Refactoring-
|
||||||
|
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
|
||||||
|
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
|
||||||
|
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
|
||||||
|
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
|
||||||
|
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
|
||||||
|
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
|
||||||
|
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
|
||||||
|
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
|
||||||
|
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
|
||||||
|
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
|
||||||
|
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
|
||||||
|
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
|
||||||
|
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
|
||||||
|
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
|
||||||
|
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
|
||||||
|
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
|
||||||
|
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
|
||||||
|
echten Mehr-Stunden-Fenster abhaken.
|
||||||
|
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
|
||||||
|
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
|
||||||
|
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
|
||||||
|
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
|
||||||
|
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
|
||||||
|
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
|
||||||
|
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
|
||||||
|
läuft weiter, nächstes Fenster ab 23:12.
|
||||||
|
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
|
||||||
|
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
|
||||||
|
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
|
||||||
|
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
|
||||||
|
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
|
||||||
|
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
|
||||||
|
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
|
||||||
|
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
|
||||||
|
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
|
||||||
|
Fenster ab 03:06.
|
||||||
|
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
|
||||||
|
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
|
||||||
|
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
|
||||||
|
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
|
||||||
|
Stück.)
|
||||||
|
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
||||||
|
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
||||||
|
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
||||||
|
entschlüsselt ankommen (statt „Neue Nachricht"-Platzhalter). Falls nicht:
|
||||||
|
zweite Ursache, siehe `docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
|
||||||
|
- [ ] Danach Uta: einmal aus- und wieder einloggen (erst dann hat ihre
|
||||||
|
Session einen Refresh-Token!)
|
||||||
|
|
||||||
|
## 2. Settings-Split (5d77238, 12 Teildateien)
|
||||||
|
|
||||||
|
Alle Reiter öffnen und benutzen: Profil, Benachrichtigungen, Erscheinungsbild,
|
||||||
|
Voice, Tastaturkürzel, Privatsphäre, Sessions, Verschlüsselung, Account, Über.
|
||||||
|
|
||||||
|
- [ ] Verschlüsselung (HEILIG): Recovery-Key anzeigen, Megolm-Export UND
|
||||||
|
-Import (Passphrase), SAS/QR-Verifizierung starten
|
||||||
|
- [ ] Sessions: Gerät umbenennen, Einzel-Logout, Massen-Logout-Dialog
|
||||||
|
(Passwort-Reauth) nur bis zur Abfrage
|
||||||
|
- [ ] Account: Passwort-ändern-Dialog öffnen, Account-löschen-Dialog öffnen
|
||||||
|
(NICHT bestätigen)
|
||||||
|
- [ ] Logout-Warnungs-Text bei fehlendem Backup sichtbar (settings_about/shared)
|
||||||
|
|
||||||
|
## 3. Chat-Timeline-Split (5bf45a9) + tote-Code-Bereinigung (6400fb6)
|
||||||
|
|
||||||
|
Einen Chat mit allen Nachrichtentypen durchgehen:
|
||||||
|
|
||||||
|
- [ ] Text mit Link-Vorschau, Bild, Video, Audio, Datei-Anhang
|
||||||
|
- [ ] Download-/Entschlüsselungs-Fehlerpfade in `message_media.dart` gezielt
|
||||||
|
ansehen (am wenigsten geprüfter Teil)
|
||||||
|
- [ ] Reaktionen setzen/entfernen, Hover-Aktionsleiste, Antworten/Zitat
|
||||||
|
- [ ] Datums-Trenner, „Neue Nachrichten"-Divider
|
||||||
|
- [ ] Gescheiterte Nachricht: bleibt mit Retry/Löschen stehen, Retry geht
|
||||||
|
- [ ] Zeitanzeige bei aufeinanderfolgenden Nachrichten + Hover-Uhrzeit bei
|
||||||
|
Fortsetzungsnachrichten (`_formatMessageTime`-Zusammenführung)
|
||||||
|
|
||||||
|
## 4. Raumlisten-Split (a863fa3) + Reorder-Fix (401bd6a)
|
||||||
|
|
||||||
|
- [ ] Space wechseln, Raum beitreten/verlassen
|
||||||
|
- [ ] **Drag&Drop-Umsortieren: landet der Raum EXAKT an der Zielposition?**
|
||||||
|
(onReorder→onReorderItem, Index-Korrektur jetzt im Framework – Off-by-One
|
||||||
|
wäre hier sichtbar; nach oben UND nach unten ziehen testen)
|
||||||
|
- [ ] Voice-Channel-Teilnehmerliste in der Raumliste
|
||||||
|
- [ ] Einladung annehmen/ablehnen, Ungelesen-Badge, DM-Avatare
|
||||||
|
|
||||||
|
## 5. Space-Admin-Split (0c7d359)
|
||||||
|
|
||||||
|
An einem TEST-Space, nicht an Utas echtem:
|
||||||
|
|
||||||
|
- [ ] Übersicht: Sichtbarkeit umschalten, Banner ändern (Danger-Zone nur ansehen)
|
||||||
|
- [ ] Mitglieder: einladen, Rolle ändern (Selbst-Aussperr-Schutz
|
||||||
|
`updatePowerLevelsSafely` greift?), kicken
|
||||||
|
- [ ] Berechtigungen ändern, Kanäle: erstellen + bestehenden Raum hinzufügen
|
||||||
|
|
||||||
|
## 6. Chat-View-Split (31e4001)
|
||||||
|
|
||||||
|
- [ ] Chat-Header (normal + DM mit ausklappendem Header/Presence)
|
||||||
|
- [ ] Tippanzeige, Offline-/Reconnect-Banner (Netz kurz trennen)
|
||||||
|
- [ ] Datei per Drag&Drop in den Chat ziehen (Overlay + Senden)
|
||||||
|
|
||||||
|
## 7. Dokument-Viewer-Split (daf4cf3) + Matrix4-Fix (401bd6a)
|
||||||
|
|
||||||
|
- [ ] PDF inline im Chat, Vollbild-Viewer: **Zoom per Buttons/Strg+Mausrad**
|
||||||
|
(translateByDouble/scaleByDouble-Umbau – zoomt es zentriert wie vorher?),
|
||||||
|
Seiten-Navigation, Thumbnail-Leiste, Speichern-Button
|
||||||
|
- [ ] Text/Markdown/SVG-Vorschau, Office-Text-Extraktion
|
||||||
|
- [ ] ZIP/TAR-Archiv-Browser, PSD-Thumbnail
|
||||||
|
- [ ] Bild im Chat öffnen (Fallback-Pfade)
|
||||||
|
|
||||||
|
## 8. Kleinkram aus den Lint-Commits
|
||||||
|
|
||||||
|
- [ ] Mini-Call-Widget: Buttons zeigen jetzt Hover-Farbe (9dc1175, gewollt) –
|
||||||
|
sieht das ok aus?
|
||||||
|
- [ ] Anhang-Dialog + Raum-erstellen-Dialog: Switches (activeThumbColor) sehen
|
||||||
|
aus wie vorher
|
||||||
|
- [ ] Voice-Channel beitreten/verlassen (Kernflow laut CLAUDE.md, von den
|
||||||
|
Splits nicht berührt, aber Pflicht nach jedem Refactoring)
|
||||||
|
|
||||||
|
## Danach
|
||||||
|
|
||||||
|
- [ ] Alle ✅ hier eintragen, PROGRESS.md-Eintrag „PC-Praxistest" schreiben,
|
||||||
|
erst DANN neue riskante Punkte anfangen (Call-Fassade, SQLCipher –
|
||||||
|
siehe `docs/SQLCIPHER_MIGRATION.md`)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Pyramid – Fortschritt & offene Punkte (ARCHIV, Stand 2026-06-04)
|
||||||
|
|
||||||
|
> **ACHTUNG:** Dies ist ein eingefrorener Alt-Stand von vor der neuen
|
||||||
|
> Arbeitsstruktur. Das aktuelle Arbeitsprotokoll liegt im Projekt-Root:
|
||||||
|
> `PROGRESS.md`. Diese Datei nur als historische Referenz lesen, nie pflegen.
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
|
||||||
|
|
||||||
|
> **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
|
||||||
|
> Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
|
||||||
|
> 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die
|
||||||
|
> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem
|
||||||
|
> SQLCipher ab).
|
||||||
|
> **Android-Emulator-Test 2026-07-06 (API 36, x86_64, Debug-APK) GRÜN:**
|
||||||
|
> (a) frische Installation legt die DB direkt verschlüsselt an
|
||||||
|
> (Header = Zufallsbytes, per run-as verifiziert), kein Cipher-Fallback im
|
||||||
|
> auth_log; (b) Update-Szenario nach `pm clear` + untergeschobener
|
||||||
|
> Klartext-DB (synthetisch, 2 Tabellen/550 Zeilen): Migration lief beim
|
||||||
|
> ersten Start („2 Tabellen verifiziert" im auth_log), `.premigration`
|
||||||
|
> liegt da, DB danach verschlüsselt; (c) Neustart öffnet mit dem
|
||||||
|
> Keystore-Schlüssel OHNE Re-Migration, App rendert normal den
|
||||||
|
> Login-Screen. Damit sind openCipherOnAndroid, sqlcipher_export und
|
||||||
|
> flutter_secure_storage auf Android (Haupt-Isolate) real belegt.
|
||||||
|
> **Offen bleibt (deshalb Punkt 4 NICHT voll abgehakt):** echtes
|
||||||
|
> ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
|
||||||
|
> der Push-Test (FCM-Isolate liest verschlüsselte DB, secure_storage im
|
||||||
|
> Background-Isolate) – braucht ein Testgerät mit eingeloggtem Account.
|
||||||
|
> Erst danach Release und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries"
|
||||||
|
> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher
|
||||||
|
> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output
|
||||||
|
> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux
|
||||||
|
> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben
|
||||||
|
> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel).
|
||||||
|
|
||||||
|
## Für Bernd in einfach
|
||||||
|
|
||||||
|
Die Datenbank der App (Nachrichten, Zugangs-Token, Verschlüsselungs-Schlüssel)
|
||||||
|
liegt heute **unverschlüsselt** auf dem Gerät – geschützt nur durch die
|
||||||
|
Android-App-Sandbox. Ein Angreifer bräuchte Root oder physischen Zugriff mit
|
||||||
|
Entwickler-Tools, um sie zu lesen. Dieser Plan verschlüsselt die Datei selbst.
|
||||||
|
Der riskante Teil ist nicht die Verschlüsselung, sondern die **einmalige
|
||||||
|
Umwandlung bestehender Installationen** (Utas Handy!) – deshalb: erst am PC
|
||||||
|
mit Testdaten, dann eigenes Gerät, dann erst Release.
|
||||||
|
|
||||||
|
**Offene Entscheidung (Frage in PROGRESS.md):** Priorität vor/nach M2?
|
||||||
|
|
||||||
|
## Ist-Zustand (verifiziert im Code)
|
||||||
|
|
||||||
|
| Stelle | Datei | Factory heute |
|
||||||
|
|---|---|---|
|
||||||
|
| Haupt-App Android/iOS | `lib/core/matrix_client.dart:24` | `sqflite_native.databaseFactory` (System-SQLite, **kann kein SQLCipher**) |
|
||||||
|
| Haupt-App Desktop | `lib/core/matrix_client.dart:27` | `databaseFactoryFfi` (gebündeltes sqlite3) |
|
||||||
|
| Push-Isolate (nur Android) | `lib/core/background_push.dart` `_buildClient()` | `sqflite_native.databaseFactory` |
|
||||||
|
|
||||||
|
- `sqlcipher_flutter_libs ^0.6.8` ist bereits in `pubspec.yaml`, wird nirgends benutzt.
|
||||||
|
- `flutter_secure_storage ^10` ist bereits da (→ Schlüsselablage im Android Keystore).
|
||||||
|
- Beide Prozesse öffnen **dieselbe Datei** (`pyramid.sqlite`, WAL-Modus,
|
||||||
|
`busy_timeout 5000`).
|
||||||
|
|
||||||
|
## Zielarchitektur
|
||||||
|
|
||||||
|
1. **Ein gemeinsamer DB-Öffner** `lib/core/app_database.dart` (neues Storage-Modul
|
||||||
|
im Sinne von M2): kapselt Factory-Wahl, PRAGMAs, Schlüsselbeschaffung und
|
||||||
|
Migration. `matrix_client.dart` und `background_push.dart` rufen NUR noch
|
||||||
|
diese Fassade – damit verschwindet auch die heutige Code-Duplikation der
|
||||||
|
PRAGMA-Blöcke.
|
||||||
|
2. **Android:** nicht mehr `sqflite_native`, sondern `databaseFactoryFfi` mit
|
||||||
|
SQLCipher-Lib:
|
||||||
|
```dart
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||||
|
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||||
|
```
|
||||||
|
**Achtung Push-Isolate:** `databaseFactoryFfi` spawnt ein eigenes Isolate –
|
||||||
|
im Firebase-Background-Isolate ist das genau die Fallenklasse, an der schon
|
||||||
|
`NativeImplementationsIsolate` scheiterte (Kommentar in
|
||||||
|
`background_push.dart`). Dort deshalb `databaseFactoryFfiNoIsolate` benutzen.
|
||||||
|
3. **Schlüssel:** 32 Byte kryptografisch zufällig (`Random.secure()`), hex-kodiert
|
||||||
|
in `flutter_secure_storage` unter `db_cipher_key`. Beide Prozesse lesen
|
||||||
|
denselben Eintrag. Schlüssel wird bei ERSTER Nutzung erzeugt; existiert schon
|
||||||
|
eine verschlüsselte DB, aber kein Schlüssel im Storage → harter Fehler mit
|
||||||
|
Klartext-Meldung, NIEMALS still neue DB anlegen (Datenverlust-Regel!).
|
||||||
|
4. **Öffnen:** `PRAGMA key = "x'<hex>'"` als allererstes Statement
|
||||||
|
(`OpenDatabaseOptions(onConfigure: ...)`), danach wie bisher WAL + busy_timeout.
|
||||||
|
5. **Windows/Linux-Desktop:** `sqlcipher_flutter_libs` liefert dort KEINE
|
||||||
|
Binaries. Phase 1 = nur Android verschlüsseln (dort liegt das echte Risiko,
|
||||||
|
dort sind die echten Nutzer); Desktop bleibt vorerst Klartext, klar im Code
|
||||||
|
kommentiert. Desktop-SQLCipher später als eigener Punkt (eigene DLL bündeln).
|
||||||
|
|
||||||
|
## Migration bestehender Klartext-DBs (der heikle Teil)
|
||||||
|
|
||||||
|
Reihenfolge beim App-Start, VOR `MatrixSdkDatabase.init`, VOR jedem Push-Zugriff:
|
||||||
|
|
||||||
|
1. Erkennung: Datei beginnt mit `SQLite format 3\0` → Klartext → Migration nötig.
|
||||||
|
(Verschlüsselte SQLCipher-Dateien haben keinen lesbaren Header.)
|
||||||
|
2. WAL eindampfen: Klartext-DB kurz normal öffnen,
|
||||||
|
`PRAGMA wal_checkpoint(TRUNCATE)`, schließen – sonst verlieren wir Daten,
|
||||||
|
die noch in `pyramid.sqlite-wal` liegen.
|
||||||
|
3. **Backup:** `pyramid.sqlite` → `pyramid.sqlite.premigration` kopieren
|
||||||
|
(Bytes, nicht rename). Bleibt bis zum verifizierten Erfolg liegen.
|
||||||
|
4. Export in NEUE Datei (Original bleibt unangetastet):
|
||||||
|
```sql
|
||||||
|
ATTACH DATABASE 'pyramid.enc.sqlite' AS enc KEY "x'<hex>'";
|
||||||
|
SELECT sqlcipher_export('enc');
|
||||||
|
DETACH DATABASE enc;
|
||||||
|
```
|
||||||
|
(läuft über die SQLCipher-FFI-Verbindung auf der Klartext-DB)
|
||||||
|
5. Verifikation: `pyramid.enc.sqlite` mit Schlüssel öffnen,
|
||||||
|
`PRAGMA integrity_check` == ok UND Zeilenzahl einer Kerntabelle
|
||||||
|
(z. B. `box_inbound_group_session`, Name gegen matrix-6.2.0
|
||||||
|
`matrix_sdk_database.dart:139` verifiziert) identisch mit Original. Schlägt IRGENDWAS
|
||||||
|
fehl → `pyramid.enc.sqlite` löschen, App startet normal mit Klartext-DB
|
||||||
|
weiter, Fehler in `auth_log.txt`. **Kein Zustand darf die Klartext-DB
|
||||||
|
beschädigt zurücklassen.**
|
||||||
|
6. Tausch: Original + `-wal`/`-shm` löschen, `pyramid.enc.sqlite` →
|
||||||
|
`pyramid.sqlite` (rename, gleiche Partition = atomar).
|
||||||
|
7. `.premigration`-Backup erst nach N erfolgreichen Tagen / manuell löschen
|
||||||
|
(Entscheidung Bernd: sonst liegt weiter eine Klartext-Kopie herum –
|
||||||
|
Kompromiss: nach 7 Tagen beim Start automatisch löschen).
|
||||||
|
|
||||||
|
### Race mit dem Push-Isolate
|
||||||
|
|
||||||
|
Kommt ein FCM-Push, während die Haupt-App migriert, würde das Isolate die DB
|
||||||
|
mittendrin öffnen. Lösung: Marker-Datei `pyramid.sqlite.migrating` vor Schritt 2
|
||||||
|
anlegen, nach Schritt 6 löschen; `_buildClient()` im Push-Pfad bricht bei
|
||||||
|
vorhandenem Marker sofort ab (Notification zeigt dann einmalig den
|
||||||
|
„Neue Nachricht"-Platzhalter – akzeptabel). Zusätzlich erkennt das Isolate
|
||||||
|
selbst Klartext vs. verschlüsselt am Datei-Header und wählt PRAGMA key
|
||||||
|
entsprechend – es migriert aber NIE selbst.
|
||||||
|
|
||||||
|
## Testplan (PC-Termin, in dieser Reihenfolge)
|
||||||
|
|
||||||
|
1. Windows-Build mit Test-Account: Migration Klartext→SQLCipher durchlaufen
|
||||||
|
lassen (Desktop kriegt fürs TESTEN die FFI-SQLCipher-Variante per lokalem
|
||||||
|
Override, auch wenn Phase 1 sie im Release nicht aktiviert).
|
||||||
|
2. Kernflow danach: Login erhalten? Alte verschlüsselte Nachrichten lesbar?
|
||||||
|
Logout → Neu-Login → immer noch lesbar? (`docs/PC_TESTPLAN.md` Abschnitt 1)
|
||||||
|
3. Fehlerpfade erzwingen: Migration mit vollem Datenträger / Kill mitten in
|
||||||
|
Schritt 4 → App muss mit unversehrter Klartext-DB weiterlaufen.
|
||||||
|
4. Android-Testgerät (eigenes, NICHT Uta): frische Installation (Neuanlage
|
||||||
|
verschlüsselt), dann Update-Szenario (bestehende Klartext-DB migriert),
|
||||||
|
dann Push-Test (Isolate liest verschlüsselte DB; secure_storage-Zugriff
|
||||||
|
aus dem Background-Isolate explizit verifizieren – bekanntes Risiko).
|
||||||
|
5. Erst wenn 1–4 grün: Release, Uta informieren (ihr Gerät migriert beim
|
||||||
|
ersten Start automatisch; vorher ihr Key-Backup verifizieren!).
|
||||||
|
|
||||||
|
## Bewusst NICHT im Scope
|
||||||
|
|
||||||
|
- `auth_log.txt`, `SharedPreferences`, Media-Cache bleiben Klartext (keine
|
||||||
|
Geheimnisse bzw. nur Cache; einzeln bewerten, falls Bernd es wünscht).
|
||||||
|
- Passphrase-basierter Schlüssel (Nutzer-Eingabe): bewusst nein – Schlüssel
|
||||||
|
liegt im Keystore, gleiche Vertrauensstufe wie der Rest der App-Daten,
|
||||||
|
aber Datei-Exfiltration allein reicht dann nicht mehr.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Streaming: hohe Auflösung + 60 FPS (M4)
|
||||||
|
|
||||||
|
**Ziel (Bernd):** Bildschirm-Streams ruckeln aktuell. Es soll eine hohe Streaming-Einstellung
|
||||||
|
geben – hohe Auflösung UND hohe FPS, idealerweise **60 FPS**, flüssig.
|
||||||
|
|
||||||
|
## Ist-Zustand (im Code gefunden, 2026-07-06)
|
||||||
|
|
||||||
|
Datei `lib/features/voice_channel/livekit_call_manager.dart`:
|
||||||
|
- `qualityPresets` (~Z. 21): Auflösungs-Presets `sd`(360p) / `hd`(720p) / `fhd`(1080p) / `4k`(2160p).
|
||||||
|
- `_screenShareEncodings` (~Z. 28): **FPS hart gedeckelt** – `sd`=10, `hd`=15, `fhd`=20, `4k`=30.
|
||||||
|
**Es gibt KEIN 60-FPS-Preset.** Bitraten für hohe FPS zu niedrig (fhd nur 2,5 Mbit/s @20,
|
||||||
|
4k nur 4 Mbit/s @30).
|
||||||
|
- `startScreenShare` (~Z. 453): erstellt `ScreenShareCaptureOptions(maxFrameRate: …)` und
|
||||||
|
published mit `simulcast: true`, `degradationPreference: DegradationPreference.balanced`.
|
||||||
|
- **`contentHint` wird NICHT gesetzt** → der Encoder weiß nicht, dass es bewegtes Video ist,
|
||||||
|
und priorisiert Detail/Auflösung statt Framerate → Ruckeln.
|
||||||
|
- `_applyFpsToAllSimulcastLayers` (~Z. 485) hebt die FPS nachträglich auf allen Simulcast-Layern an.
|
||||||
|
|
||||||
|
UI `lib/features/call_ui/view/quality_menu.dart` (~Z. 21): „SENDEN"-Menü listet sd/hd/fhd/4k.
|
||||||
|
|
||||||
|
## Wahrscheinliche Ruckel-Ursachen
|
||||||
|
1. `maxFramerate` hart auf 20–30 begrenzt (nie 60).
|
||||||
|
2. Zu niedrige Bitrate für hohe FPS → Encoder verwirft/verwischt Frames.
|
||||||
|
3. `contentHint` nicht auf `motion` → Encoder opfert FPS für Detail.
|
||||||
|
4. `degradationPreference: balanced` → unter Last sinkt eher die FPS als die Auflösung.
|
||||||
|
|
||||||
|
## Aufgabe
|
||||||
|
1. **Neue High-FPS-Presets** für die Send-Qualität, z. B.:
|
||||||
|
- `hd60` → 720p, `VideoEncoding(maxBitrate ~4–5 Mbit/s, maxFramerate 60)`
|
||||||
|
- `fhd60` → 1080p, `VideoEncoding(maxBitrate ~6–8 Mbit/s, maxFramerate 60)`
|
||||||
|
Sowohl in `qualityPresets` (Auflösung) als auch `_screenShareEncodings` (Bitrate/FPS) eintragen
|
||||||
|
und im „SENDEN"-Menü in `quality_menu.dart` als „HD 60fps" / „Full HD 60fps" ergänzen.
|
||||||
|
2. **`contentHint = 'motion'`** auf dem Screenshare-Track setzen (flutter_webrtc/LiveKit-Feld) –
|
||||||
|
wichtigster Einzelhebel gegen Ruckeln bei Bewegung.
|
||||||
|
3. Für die 60-FPS-Modi **`degradationPreference: DegradationPreference.maintainFramerate`**
|
||||||
|
statt `balanced` (unter Last lieber Auflösung senken als FPS).
|
||||||
|
4. Sicherstellen, dass 60 wirklich durchgereicht wird – auch in `ScreenShareCaptureOptions.maxFrameRate`
|
||||||
|
und `_applyFpsToAllSimulcastLayers`.
|
||||||
|
5. Für den flüssigen 60-FPS-Modus **`simulcast: false` erwägen** (volle Bitrate auf einen Layer),
|
||||||
|
A/B gegen simulcast=true testen.
|
||||||
|
|
||||||
|
## Testen (mit den Test-Accounts)
|
||||||
|
- Streamen als pyramidtest1, zuschauen als pyramidtest2 (zwei Logins/Fenster).
|
||||||
|
- Prüfen, dass `sender.parameters.encodings` real `maxFramerate == 60` und die hohe `maxBitrate` tragen.
|
||||||
|
- Subjektiv: flüssiger? Bei Bewegung kein Ruckeln?
|
||||||
|
- EHRLICH protokollieren, was wirklich getestet wurde. Echtes flüssiges 60-FPS-Empfinden ist ggf.
|
||||||
|
nur mit echtem Gerät/Netz voll beurteilbar → dann klar als UNGETESTET markieren.
|
||||||
|
|
||||||
|
## Nicht vergessen
|
||||||
|
`flutter analyze` sauber, kleiner Commit + Push, PROGRESS.md + ROADMAP (M4) pflegen.
|
||||||
@@ -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 <ADMIN_TOKEN>"
|
||||||
|
# → {"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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||||
|
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||||
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
# → {"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 <ADMIN_TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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="<ADMIN_TOKEN>"
|
||||||
|
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="<ADMIN_TOKEN>"
|
||||||
|
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="<ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
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 <ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
# 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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
# 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 <ADMIN_TOKEN>" \
|
||||||
|
-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 <ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
# 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 <ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
# 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 <ADMIN_TOKEN>"
|
||||||
|
|
||||||
|
# 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,22 +1,98 @@
|
|||||||
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>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
bool _prefsLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_loadPersistedPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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',
|
title: 'Pyramid',
|
||||||
theme: buildTheme(colorScheme, Brightness.light),
|
theme: buildPyramidThemeData(false),
|
||||||
darkTheme: buildTheme(colorScheme, Brightness.dark),
|
darkTheme: buildPyramidThemeData(true),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
@@ -24,6 +100,8 @@ class PyramidApp extends ConsumerWidget {
|
|||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
supportedLocales: const [Locale('de'), Locale('en')],
|
supportedLocales: const [Locale('de'), Locale('en')],
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
import 'package:pyramid/core/app_dirs.dart';
|
||||||
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
|
|
||||||
|
/// Läuft im DB-Isolate der FFI-Factory (muss deshalb top-level sein):
|
||||||
|
/// Auf Android liefert das sqlcipher_flutter_libs-Plugin die Bibliothek als
|
||||||
|
/// `libsqlcipher.so` – dem sqlite3-Paket muss man das dort sagen. Auf
|
||||||
|
/// Windows/Linux bündelt dasselbe Plugin SQLCipher bereits UNTER DEM
|
||||||
|
/// STANDARDNAMEN (`sqlite3.dll` neben der exe, verifiziert im Build-Output),
|
||||||
|
/// der Default-Loader findet sie also ohne Override.
|
||||||
|
void _sqlcipherFfiInit() {
|
||||||
|
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemeinsamer Öffner für die `pyramid.sqlite` – der eine Storage-Baustein,
|
||||||
|
/// hinter dem Factory-Wahl, Verschlüsselung und Migration stecken (Plan:
|
||||||
|
/// `docs/SQLCIPHER_MIGRATION.md`, Modularitäts-Leitsatz aus CLAUDE.md).
|
||||||
|
///
|
||||||
|
/// Kapselt an EINER Stelle, was vorher in `matrix_client.dart` und
|
||||||
|
/// `background_push.dart` dupliziert war:
|
||||||
|
/// * Factory-Wahl pro Plattform (FFI + SQLCipher wo verfügbar),
|
||||||
|
/// * Schlüsselbeschaffung: 32 zufällige Bytes, hex-kodiert im
|
||||||
|
/// Plattform-Keystore (`flutter_secure_storage`, Eintrag `db_cipher_key`),
|
||||||
|
/// * einmalige Migration bestehender Klartext-DBs nach SQLCipher,
|
||||||
|
/// * die PRAGMAs (WAL, busy_timeout).
|
||||||
|
///
|
||||||
|
/// Oberste Regel (CLAUDE.md „Kein Datenverlust"): KEIN Codepfad darf eine
|
||||||
|
/// bestehende DB beschädigen oder still eine leere neue anlegen. Im Zweifel
|
||||||
|
/// läuft die App unverschlüsselt weiter und der Fehler steht im auth_log.
|
||||||
|
class AppDatabase {
|
||||||
|
AppDatabase._();
|
||||||
|
|
||||||
|
static const _cipherKeyStorageKey = 'db_cipher_key';
|
||||||
|
|
||||||
|
/// Klartext-Backup `*.premigration` nach so vielen Tagen automatisch
|
||||||
|
/// aufräumen (Kompromiss aus dem Migrationsplan: Rollback-Fenster vs.
|
||||||
|
/// dauerhaft herumliegende Klartext-Kopie).
|
||||||
|
static const _premigrationMaxAge = Duration(days: 7);
|
||||||
|
|
||||||
|
/// Plattformen, auf denen sqlcipher_flutter_libs die Cipher-Bibliothek
|
||||||
|
/// mitliefert. iOS/macOS bleiben bewusst beim nativen sqflite-Plugin
|
||||||
|
/// (Klartext): der SQLCipher-Pod kollidiert dort mit Paketen, die das
|
||||||
|
/// System-sqlite3 linken (z. B. firebase_messaging, siehe Paket-README),
|
||||||
|
/// und iOS ist kein Release-Ziel von Pyramid.
|
||||||
|
static bool get _isCipherPlatform =>
|
||||||
|
!kIsWeb && (Platform.isAndroid || Platform.isWindows || Platform.isLinux);
|
||||||
|
|
||||||
|
static Future<String> _dbPath() async {
|
||||||
|
final appDir = await appSupportDir();
|
||||||
|
return p.join(appDir.path, 'pyramid.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Öffnen: Haupt-App ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Öffnet die Datenbank für die Haupt-App. Nur dieser Pfad migriert eine
|
||||||
|
/// bestehende Klartext-DB (das Push-Isolate migriert NIE selbst).
|
||||||
|
static Future<Database> open() async {
|
||||||
|
final dbPath = await _dbPath();
|
||||||
|
|
||||||
|
if (!_isCipherPlatform) {
|
||||||
|
// iOS/macOS: unverändert das native sqflite-Plugin, Klartext.
|
||||||
|
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
// Lade-Workaround für Android 6 (Paket-README) – im Main-Isolate
|
||||||
|
// ausführen, BEVOR irgendein Isolate sqlite benutzt.
|
||||||
|
try {
|
||||||
|
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final factory = createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit);
|
||||||
|
|
||||||
|
await _recoverFromInterruptedSwap(dbPath);
|
||||||
|
await _clearStaleMarker(dbPath);
|
||||||
|
|
||||||
|
final dbFile = File(dbPath);
|
||||||
|
final exists = await dbFile.exists();
|
||||||
|
final isPlaintext = exists && await isPlaintextSqlite(dbFile);
|
||||||
|
|
||||||
|
if (!await _cipherAvailable(factory)) {
|
||||||
|
// Sicherheitsnetz: sollte auf Cipher-Plattformen nie passieren
|
||||||
|
// (Bibliothek wird als Plugin mitgebaut). Eine VERSCHLÜSSELTE DB ohne
|
||||||
|
// Cipher-Lib ist ein harter Fehler – niemals still neu anlegen.
|
||||||
|
if (exists && !isPlaintext) {
|
||||||
|
throw StateError(
|
||||||
|
'pyramid.sqlite ist verschlüsselt, aber die SQLCipher-Bibliothek '
|
||||||
|
'fehlt in diesem Build. Kein Zugriff möglich – Daten bleiben '
|
||||||
|
'unangetastet. Bitte App-Build prüfen (sqlcipher_flutter_libs).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] SQLCipher nicht verfügbar – Datenbank bleibt unverschlüsselt.',
|
||||||
|
);
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists && !isPlaintext) {
|
||||||
|
// Bereits verschlüsselt: der Schlüssel MUSS im Keystore liegen.
|
||||||
|
final key = await _readStoredKey();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
'pyramid.sqlite ist verschlüsselt, aber im Schlüsselspeicher fehlt '
|
||||||
|
'"$_cipherKeyStorageKey". Es wird KEINE neue Datenbank angelegt, '
|
||||||
|
'damit keine Daten verloren gehen. Schlüsselspeicher prüfen '
|
||||||
|
'(flutter_secure_storage) oder Backup einspielen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final db = await _openWithKey(factory, dbPath, key);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
await _cleanupOldBackup(dbPath);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = await _getOrCreateKey();
|
||||||
|
|
||||||
|
if (exists && isPlaintext) {
|
||||||
|
final migrated = await migratePlaintextToEncrypted(
|
||||||
|
factory: factory,
|
||||||
|
dbPath: dbPath,
|
||||||
|
hexKey: key,
|
||||||
|
);
|
||||||
|
if (!migrated) {
|
||||||
|
// Migration fehlgeschlagen → Klartext-DB ist unversehrt, App läuft
|
||||||
|
// wie bisher weiter (Fehlerdetails stehen im auth_log).
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frische Installation (legt verschlüsselt neu an) oder frisch migriert.
|
||||||
|
final db = await _openWithKey(factory, dbPath, key);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Öffnen: Android-Push-Isolate ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Öffnet die Datenbank für das Push-/Hintergrund-Isolate. Gibt `null`
|
||||||
|
/// zurück, wenn gerade kein sicherer Zugriff möglich ist (Migration läuft,
|
||||||
|
/// DB fehlt, Schlüssel fehlt) – die Notification zeigt dann einmalig den
|
||||||
|
/// nativen „Neue Nachricht"-Platzhalter, das ist der akzeptierte Preis.
|
||||||
|
/// Dieser Pfad migriert NIE und legt NIE eine neue Datei an.
|
||||||
|
static Future<Database?> openForPush() async {
|
||||||
|
final dbPath = await _dbPath();
|
||||||
|
|
||||||
|
if (await File('$dbPath.migrating').exists()) {
|
||||||
|
debugPrint('[DB] Migration läuft – Push-Zugriff übersprungen.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbFile = File(dbPath);
|
||||||
|
if (!await dbFile.exists()) return null;
|
||||||
|
|
||||||
|
if (!_isCipherPlatform) {
|
||||||
|
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// noIsolate: databaseFactoryFfi würde ein Unter-Isolate spawnen – genau
|
||||||
|
// die Fallenklasse, an der im Firebase-Background-Isolate schon
|
||||||
|
// NativeImplementationsIsolate scheiterte (siehe background_push.dart).
|
||||||
|
final factory =
|
||||||
|
createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit, noIsolate: true);
|
||||||
|
|
||||||
|
if (await isPlaintextSqlite(dbFile)) {
|
||||||
|
// Noch nicht migriert (macht beim nächsten Start die Haupt-App).
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = await _readStoredKey();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
// Bekanntes Risiko aus dem Plan: secure_storage aus dem
|
||||||
|
// Background-Isolate. Niemals raten oder neu anlegen – nur aussteigen.
|
||||||
|
debugPrint('[DB] Kein Cipher-Schlüssel im Push-Isolate lesbar.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final db = await _openWithKey(factory, dbPath, key);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration Klartext → SQLCipher ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wandelt eine bestehende Klartext-DB in eine SQLCipher-DB um – der heikle
|
||||||
|
/// Teil des Plans. Reihenfolge exakt wie in `docs/SQLCIPHER_MIGRATION.md`:
|
||||||
|
/// Marker → WAL-Checkpoint → Byte-Backup → Export in NEUE Datei →
|
||||||
|
/// Verifikation (integrity_check + Zeilenzahlen ALLER Tabellen) → atomarer
|
||||||
|
/// Tausch. Schlägt irgendetwas fehl, bleibt die Klartext-DB unangetastet
|
||||||
|
/// und die Funktion gibt `false` zurück.
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> migratePlaintextToEncrypted({
|
||||||
|
required DatabaseFactory factory,
|
||||||
|
required String dbPath,
|
||||||
|
required String hexKey,
|
||||||
|
}) async {
|
||||||
|
final marker = File('$dbPath.migrating');
|
||||||
|
final encPath = '$dbPath.enc';
|
||||||
|
final backupPath = '$dbPath.premigration';
|
||||||
|
try {
|
||||||
|
// Marker zuerst: hält das Push-Isolate fern (Race aus dem Plan).
|
||||||
|
await marker.writeAsString(DateTime.now().toIso8601String());
|
||||||
|
|
||||||
|
// 1. WAL eindampfen – sonst fehlen im Export Daten, die noch in
|
||||||
|
// pyramid.sqlite-wal liegen.
|
||||||
|
var db = await factory.openDatabase(dbPath);
|
||||||
|
try {
|
||||||
|
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Byte-Backup (Kopie, kein rename) – bleibt bis zum verifizierten
|
||||||
|
// Erfolg + Aufräumfrist liegen.
|
||||||
|
await File(dbPath).copy(backupPath);
|
||||||
|
|
||||||
|
// 3. Export in NEUE Datei; das Original wird nie beschrieben.
|
||||||
|
final encFile = File(encPath);
|
||||||
|
if (await encFile.exists()) await encFile.delete();
|
||||||
|
final Map<String, int> sourceCounts;
|
||||||
|
db = await factory.openDatabase(dbPath);
|
||||||
|
try {
|
||||||
|
sourceCounts = await _tableRowCounts(db);
|
||||||
|
await db.rawQuery(
|
||||||
|
'ATTACH DATABASE \'${_escapeSqlString(encPath)}\' AS enc '
|
||||||
|
'KEY "x\'$hexKey\'"',
|
||||||
|
);
|
||||||
|
await db.rawQuery("SELECT sqlcipher_export('enc')");
|
||||||
|
await db.rawQuery('DETACH DATABASE enc');
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verifikation der NEUEN Datei, bevor irgendetwas getauscht wird.
|
||||||
|
final encDb = await _openWithKey(factory, encPath, hexKey);
|
||||||
|
try {
|
||||||
|
final integrity = await encDb.rawQuery('PRAGMA integrity_check');
|
||||||
|
final integrityOk = integrity.length == 1 &&
|
||||||
|
integrity.first.values.first.toString() == 'ok';
|
||||||
|
if (!integrityOk) {
|
||||||
|
throw StateError('integrity_check fehlgeschlagen: $integrity');
|
||||||
|
}
|
||||||
|
final encCounts = await _tableRowCounts(encDb);
|
||||||
|
for (final entry in sourceCounts.entries) {
|
||||||
|
if (encCounts[entry.key] != entry.value) {
|
||||||
|
throw StateError(
|
||||||
|
'Zeilenzahl weicht ab in "${entry.key}": '
|
||||||
|
'${entry.value} (Original) != ${encCounts[entry.key]} (Export)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await encDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Tausch. rename auf derselben Partition ist atomar; für das
|
||||||
|
// Zeitfenster zwischen delete und rename existiert das Byte-Backup
|
||||||
|
// (Wiederanlauf repariert das, siehe _recoverFromInterruptedSwap).
|
||||||
|
for (final suffix in ['-wal', '-shm']) {
|
||||||
|
final sidecar = File('$dbPath$suffix');
|
||||||
|
if (await sidecar.exists()) await sidecar.delete();
|
||||||
|
}
|
||||||
|
await File(dbPath).delete();
|
||||||
|
await File(encPath).rename(dbPath);
|
||||||
|
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] Klartext-DB erfolgreich nach SQLCipher migriert '
|
||||||
|
'(${sourceCounts.length} Tabellen verifiziert).',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// Aufräumen: halbfertigen Export löschen, Original bleibt maßgeblich.
|
||||||
|
try {
|
||||||
|
final encFile = File(encPath);
|
||||||
|
if (await encFile.exists()) await encFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] SQLCipher-Migration fehlgeschlagen – Klartext-DB bleibt '
|
||||||
|
'unangetastet: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (await marker.exists()) await marker.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absturz exakt zwischen „Original gelöscht" und „Export umbenannt":
|
||||||
|
/// dann fehlt `pyramid.sqlite`, aber das Byte-Backup existiert. Backup
|
||||||
|
/// zurückkopieren (die Migration läuft danach einfach erneut). Ein evtl.
|
||||||
|
/// liegengebliebener Export ist nicht als vollständig beweisbar → löschen.
|
||||||
|
static Future<void> _recoverFromInterruptedSwap(String dbPath) async {
|
||||||
|
final backup = File('$dbPath.premigration');
|
||||||
|
if (await File(dbPath).exists() || !await backup.exists()) return;
|
||||||
|
try {
|
||||||
|
final enc = File('$dbPath.enc');
|
||||||
|
if (await enc.exists()) await enc.delete();
|
||||||
|
await backup.copy(dbPath);
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] Unterbrochene Migration erkannt – Klartext-Backup '
|
||||||
|
'wiederhergestellt, Migration läuft erneut.',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
await AuthLog.write('[DB] Wiederherstellung aus .premigration fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Marker ohne laufende Migration ist ein Absturz-Überbleibsel – beim
|
||||||
|
/// Start der Haupt-App (es gibt nur eine) gefahrlos entfernbar.
|
||||||
|
static Future<void> _clearStaleMarker(String dbPath) async {
|
||||||
|
try {
|
||||||
|
final marker = File('$dbPath.migrating');
|
||||||
|
if (await marker.exists()) {
|
||||||
|
await marker.delete();
|
||||||
|
await AuthLog.write('[DB] Verwaisten Migrations-Marker entfernt.');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _cleanupOldBackup(String dbPath) async {
|
||||||
|
try {
|
||||||
|
final backup = File('$dbPath.premigration');
|
||||||
|
if (!await backup.exists()) return;
|
||||||
|
final age = DateTime.now().difference((await backup.stat()).modified);
|
||||||
|
if (age > _premigrationMaxAge) {
|
||||||
|
await backup.delete();
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] Klartext-Backup .premigration nach '
|
||||||
|
'${_premigrationMaxAge.inDays} Tagen gelöscht.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bausteine ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Klartext-SQLite-Dateien beginnen mit dem 16-Byte-Header
|
||||||
|
/// `SQLite format 3\0`; SQLCipher-Dateien haben keinen lesbaren Header.
|
||||||
|
/// Leere/winzige Dateien zählen als Klartext (die Migration exportiert sie
|
||||||
|
/// dann schlicht leer).
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> isPlaintextSqlite(File file) async {
|
||||||
|
final raf = await file.open();
|
||||||
|
try {
|
||||||
|
final header = await raf.read(16);
|
||||||
|
if (header.length < 16) return true;
|
||||||
|
// 16-Byte-Magic: "SQLite format 3" gefolgt von einem NUL-Byte.
|
||||||
|
const magic = 'SQLite format 3';
|
||||||
|
for (var i = 0; i < magic.length; i++) {
|
||||||
|
if (header[i] != magic.codeUnitAt(i)) return false;
|
||||||
|
}
|
||||||
|
return header[15] == 0x00;
|
||||||
|
} finally {
|
||||||
|
await raf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet eine Datei mit Schlüssel. `PRAGMA key` MUSS das allererste
|
||||||
|
/// Statement der Verbindung sein → onConfigure. Der sqlite_master-Zugriff
|
||||||
|
/// danach lässt einen falschen/fehlenden Schlüssel sofort hier scheitern
|
||||||
|
/// statt irgendwann später mitten im Betrieb.
|
||||||
|
static Future<Database> _openWithKey(
|
||||||
|
DatabaseFactory factory,
|
||||||
|
String path,
|
||||||
|
String hexKey,
|
||||||
|
) async {
|
||||||
|
final db = await factory.openDatabase(
|
||||||
|
path,
|
||||||
|
options: OpenDatabaseOptions(
|
||||||
|
onConfigure: (db) async {
|
||||||
|
await db.rawQuery('PRAGMA key = "x\'$hexKey\'"');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await db.rawQuery('SELECT count(*) FROM sqlite_master');
|
||||||
|
} catch (e) {
|
||||||
|
await db.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<Database> openEncrypted(
|
||||||
|
DatabaseFactory factory,
|
||||||
|
String path,
|
||||||
|
String hexKey,
|
||||||
|
) =>
|
||||||
|
_openWithKey(factory, path, hexKey);
|
||||||
|
|
||||||
|
/// `PRAGMA cipher_version` liefert bei normalem sqlite3 KEINE Zeile – der
|
||||||
|
/// vom Paket-README empfohlene Wächter dagegen, versehentlich unverschlüsselt
|
||||||
|
/// weiterzulaufen, weil die falsche Bibliothek geladen wurde.
|
||||||
|
static Future<bool> _cipherAvailable(DatabaseFactory factory) async {
|
||||||
|
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||||
|
try {
|
||||||
|
final rows = await db.rawQuery('PRAGMA cipher_version');
|
||||||
|
if (rows.isEmpty) return false;
|
||||||
|
final value = rows.first.values.first;
|
||||||
|
return value != null && value.toString().isNotEmpty;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie bisher in matrix_client.dart/background_push.dart: WAL gegen
|
||||||
|
/// "database is locked" zwischen Haupt-App und Push-Isolate.
|
||||||
|
static Future<void> _applyPragmas(Database db) async {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _readStoredKey() =>
|
||||||
|
const FlutterSecureStorage().read(key: _cipherKeyStorageKey);
|
||||||
|
|
||||||
|
/// Erzeugt beim allerersten Mal 32 kryptografisch zufällige Bytes und legt
|
||||||
|
/// sie hex-kodiert im Keystore ab. Der Read-Back danach ist Pflicht: würde
|
||||||
|
/// das Schreiben still scheitern und wir trotzdem damit verschlüsseln,
|
||||||
|
/// wäre die DB beim nächsten Start unlesbar (Totalverlust).
|
||||||
|
static Future<String> _getOrCreateKey() async {
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
final existing = await storage.read(key: _cipherKeyStorageKey);
|
||||||
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
|
||||||
|
final rnd = Random.secure();
|
||||||
|
final key = List.generate(
|
||||||
|
32,
|
||||||
|
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
).join();
|
||||||
|
await storage.write(key: _cipherKeyStorageKey, value: key);
|
||||||
|
final readBack = await storage.read(key: _cipherKeyStorageKey);
|
||||||
|
if (readBack != key) {
|
||||||
|
throw StateError(
|
||||||
|
'Schlüsselspeicher-Verifikation fehlgeschlagen: '
|
||||||
|
'"$_cipherKeyStorageKey" konnte nicht zuverlässig abgelegt werden. '
|
||||||
|
'Es wird NICHT verschlüsselt (sonst wäre die DB später unlesbar).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, int>> _tableRowCounts(Database db) async {
|
||||||
|
final tables = await db.rawQuery(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||||
|
);
|
||||||
|
final result = <String, int>{};
|
||||||
|
for (final row in tables) {
|
||||||
|
final name = row['name'] as String;
|
||||||
|
final count = await db.rawQuery('SELECT count(*) AS c FROM "$name"');
|
||||||
|
result[name] = (count.first['c'] as num).toInt();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQL-String-Literal: einfache Anführungszeichen verdoppeln (der Pfad
|
||||||
|
/// kommt aus dem App-Verzeichnis, aber sicher ist sicher).
|
||||||
|
static String _escapeSqlString(String value) => value.replaceAll("'", "''");
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Basisverzeichnis für alle lokalen App-Daten (pyramid.sqlite, auth_log.txt,
|
||||||
|
/// Media-Cache).
|
||||||
|
///
|
||||||
|
/// Normalfall: das Application-Support-Verzeichnis der Plattform – exakt wie
|
||||||
|
/// bisher, es ändert sich NICHTS.
|
||||||
|
///
|
||||||
|
/// Nur auf dem Desktop kann die Umgebungsvariable `PYRAMID_PROFILE_DIR` ein
|
||||||
|
/// abweichendes Verzeichnis erzwingen. Zweck: Testläufe mit den
|
||||||
|
/// Autopilot-Testkonten in einem eigenen Profil, parallel zur echten
|
||||||
|
/// Session desselben Windows-Nutzers (die echte Session sieht die Variable
|
||||||
|
/// nie und bleibt unberührt). Bewusst NICHT umgeleitet werden
|
||||||
|
/// shared_preferences und flutter_secure_storage – deren Plugins lösen ihre
|
||||||
|
/// Pfade selbst auf; ein Testprofil teilt sich also den DB-Schlüssel
|
||||||
|
/// (`db_cipher_key`) und die UI-Prefs mit dem Hauptprofil. Für Tests ist das
|
||||||
|
/// unkritisch (der Schlüssel wird nur GELESEN, nie überschrieben), echte
|
||||||
|
/// Zweitkonten bräuchten mehr.
|
||||||
|
Future<Directory> appSupportDir() async {
|
||||||
|
final override = Platform.isWindows || Platform.isLinux || Platform.isMacOS
|
||||||
|
? Platform.environment['PYRAMID_PROFILE_DIR']
|
||||||
|
: null;
|
||||||
|
if (override != null && override.trim().isNotEmpty) {
|
||||||
|
final dir = Directory(override.trim());
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
return getApplicationSupportDirectory();
|
||||||
|
}
|
||||||