wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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,4 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
autopilot.log
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.33+34] – 2026-06-11
|
||||
|
||||
### Anrufe & Sprache
|
||||
- Anruf-Steuerung neu platziert: eigenes "Dock" über dem Konto-Balken (Discord-Stil) statt darin eingebettet; das doppelte Mini-Panel im Profilbereich wurde entfernt.
|
||||
- Alle Anrufe – auch Sprachkanäle – nutzen jetzt das vollwertige Mini-Call-Widget mit Mikrofon, Deafen, Kamera und Bildschirmübertragung.
|
||||
- Ein-/Ausblenden des Call-Docks animiert (220 ms).
|
||||
- Querformat-Optimierung: Stream-Vorschau schrumpft bei wenig Bildschirmhöhe (140 -> 84 px), damit die Raumliste nutzbar bleibt.
|
||||
- Ausgabelautstärke-Regler (0–150 %) in "Sprache & Video", wirkt sofort auf laufende Anrufe.
|
||||
- Geräteauswahl verdrahtet: Mikrofon/Lautsprecher/Kamera aus den Einstellungen werden jetzt tatsächlich auf Anrufe angewendet (vorher wurden die Einstellungen nirgends gelesen).
|
||||
- Ehrliche Fehlermeldung, wenn Windows-Audiotreiber keine Geräteliste an WebRTC melden (bekannter libwebrtc-Bug; Anrufe laufen trotzdem über das Standardgerät).
|
||||
- Bildschirmübertragungs-Picker passt sich kleinen Bildschirmen an.
|
||||
|
||||
### Chat & Medien
|
||||
- Bilder werden nicht mehr abgeschnitten: Anpassung an die Chatbreite, kein Verzerren oder Hochskalieren mehr.
|
||||
- SVG-Inline-Vorschau im Chat (vorher als generische Datei oder fehlerhaft).
|
||||
- Video-Ladeplatzhalter passen sich der Breite an (keine festen 360 px).
|
||||
- Lesebestätigungen verbessert: goldener Doppelhaken, Zusammenführung von globalen und Thread-Bestätigungen, korrektes Verhalten wenn die andere Person antwortet/liest.
|
||||
- Drag-&-Drop-Overlay animiert (Scale + Fade + wippendes Icon, Akzent-Glow).
|
||||
- Anhang-Dialog öffnet mit Scale-Animation.
|
||||
- Tastatur-Zwischenablage (Android): frisch kopierte Bilder erscheinen in der Tastatur-Vorschlagsleiste und landen im Anhang-Dialog.
|
||||
- Banner-Wechsel: Absturz ("setState returned a Future") und Doppelanzeige behoben.
|
||||
|
||||
### Profile & DMs
|
||||
- Aufklappbarer DM-Header: Klick auf Avatar/Name klappt das Profil auf – Banner als Hintergrund, Avatar skaliert mit (32 -> 96 px), Name wächst, Handle + Biographie blenden ein.
|
||||
- Profilbild-Bug beim schnellen DM-Wechsel behoben (kein Übersprechen mehr zwischen Kanälen).
|
||||
- Profil-Einstellungen laden sofort aus dem Cache, Server-Daten parallel im Hintergrund.
|
||||
- Persistenter Bild-Cache: Avatare und Banner sind nach Neustart sofort da statt nachzuladen.
|
||||
|
||||
### Spaces & Berechtigungen
|
||||
- Selbst-Aussperr-Schutz: Berechtigungsänderungen werden gegen den echten Server-Stand gemischt (nie mehr den lokalen Cache überschreiben). Zusätzlich bricht Pyramid jede Änderung ab, die den eigenen Account aussperren würde.
|
||||
- Space-Banner überleben jetzt App-Neustarts (importantStateEvents).
|
||||
- Aussagekräftige Fehlerdiagnose bei abgelehnten Space-Änderungen.
|
||||
|
||||
### Präsenz / Online-Status
|
||||
- Online wird nur noch angezeigt, wenn die letzte Aktivität < 5 Min. her ist (behebt "alle wirken online").
|
||||
- Präsenz-Heartbeat von 10 s auf 55 s reduziert (weniger Netzwerk/Akku).
|
||||
|
||||
### Teilen
|
||||
- "Teilen nach Pyramid" (Android): Text und Dateien aus anderen Apps teilen, mit Raum-Picker (Suche + Vorschau).
|
||||
|
||||
### Performance & Build
|
||||
- Inter-Schrift als Asset gebündelt (google_fonts entfernt) – offline-fähig, schnellerer Start.
|
||||
- Bilder laden zuerst als Thumbnail (spart Bandbreite, v. a. mobil).
|
||||
- 12 tote Quelldateien und ungenutzte Abhängigkeit dynamic_color entfernt.
|
||||
- Android-Release-Build: eigener Release-Keystore, R8/Minify + Resource-Shrinking, ProGuard-Keep-Regeln für WebRTC/LiveKit/Notifications.
|
||||
|
||||
### Infrastruktur (Server)
|
||||
- Defekter Space "Just us serious" repariert: sauber dupliziert (gleicher Name/Topic/Avatar/Banner, alle 6 Kanäle, Admin-Rechte wiederhergestellt).
|
||||
|
||||
### Hinweis zur Verteilung
|
||||
- Wegen des neuen Release-Keystores müssen bestehende (Debug-signierte) Android-Installationen einmalig deinstalliert und neu installiert werden – danach laufen Updates normal.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Pyramid – Arbeitsregeln für Claude
|
||||
|
||||
Pyramid ist Bernds eigener Matrix-Client (Flutter, kein FluffyChat-Fork). Bernd ist
|
||||
**kein Entwickler** – er gibt Ziele und Prioritäten vor, Claude trifft die technischen
|
||||
Entscheidungen und erklärt sie in einfachem Deutsch. Die App ist bei echten Nutzern im
|
||||
Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo.
|
||||
|
||||
**Dies ist das einzige aktuelle Repo:** `C:\Users\nordm\pyramid - Kopie\`.
|
||||
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
||||
April-Schnappschüsse – dort NIE arbeiten.)
|
||||
|
||||
## Infrastruktur
|
||||
|
||||
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
||||
- LiveKit/TURN: Hetzner „Leuchtturm" → `wss://livekit.steggi-matrix.work` (API-Key LKMatrixPi), coturn 94.130.78.116:3478
|
||||
- Git-Remote: Gitea auf dem Pi (`http://192.168.178.71:3000/steggi/pyramid.git`) – Push = Backup
|
||||
- Push-Benachrichtigungen: FCM + Sygnal auf dem Pi (Memory „Pyramid Push" lesen, bevor daran gearbeitet wird)
|
||||
- Vollbackup vom 2026-07-03: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`
|
||||
|
||||
## Arbeitszyklus (IMMER einhalten)
|
||||
|
||||
Jede Session – egal ob frisch gestartet oder fortgesetzt – läuft so:
|
||||
|
||||
1. **Einlesen:** `PROGRESS.md` (letzter Stand, Stolperfallen) und `ROADMAP.md`
|
||||
(nächster offener Punkt) lesen. NIE Arbeit doppelt machen, die dort als erledigt steht.
|
||||
2. **Einen Punkt nehmen:** den obersten nicht abgehakten Punkt des aktuellen Meilensteins
|
||||
aus `ROADMAP.md` – nicht mehrere gleichzeitig, keine Sprünge in spätere Meilensteine.
|
||||
3. **Umsetzen & prüfen:** Nach der Änderung mindestens `flutter analyze` sauber bekommen;
|
||||
wo sinnvoll `flutter test` und `flutter run -d windows` (schnellster Praxistest).
|
||||
4. **Protokollieren:** `PROGRESS.md` aktualisieren (Format siehe dort) und den Punkt in
|
||||
`ROADMAP.md` abhaken.
|
||||
5. **Commit + Push:** kleiner `git commit` (`feat:`/`fix:`/`refactor:`/`chore:`) und
|
||||
`git push origin master` (Gitea = Backup). Lieber 5 kleine Commits als ein riesiger.
|
||||
6. Weiter mit dem nächsten Punkt, solange Kontingent/Zeit da ist.
|
||||
|
||||
Wird eine Session mitten in einem Punkt abgebrochen (Limit erreicht), MUSS der letzte
|
||||
Eintrag in `PROGRESS.md` den Zwischenstand beschreiben: was halb fertig ist, welche
|
||||
Dateien angefasst wurden, was der nächste konkrete Handgriff ist.
|
||||
|
||||
## Qualitätsleitplanken
|
||||
|
||||
- **Kein Aussperren, kein Datenverlust:** Alles rund um Login, Sessions, Verschlüsselung
|
||||
und Key-Backup ist heilig. Der Uta-Random-Logout-Bug zeigt: Fehlerpfade dürfen NIE in
|
||||
einem stillen Logout enden. Änderungen daran immer mit Test: Login → Nachrichten →
|
||||
Logout → erneuter Login → alte verschlüsselte Nachrichten noch lesbar?
|
||||
- **Nach dem Refactoring (M2) gilt:** keine Verhaltensänderung ohne Not – Refactoring
|
||||
heißt gleiche Funktion, bessere Struktur. Nach jedem Refactoring-Schritt App starten
|
||||
und Kernflows prüfen (Login, Raum öffnen, Nachricht senden, Voice-Channel beitreten).
|
||||
- **Keine Secrets ins Repo:** Tokens, Recovery-Keys, Passwörter, Keystore-Dateien
|
||||
niemals committen (Signing-Keys liegen in `Documents\pyramid_keys_20260425.txt`).
|
||||
- **Modularität (Bernd wichtig!):** Die Architektur muss aus austauschbaren Bausteinen
|
||||
bestehen – jedes Feature (Calls, Streaming, Push, Chat-Timeline, …) hinter einer klaren,
|
||||
schmalen Schnittstelle, sodass man einen alten Baustein durch einen neuen ersetzen kann,
|
||||
ohne den Rest anzufassen. Beim Refactoring (M2) ist das DAS Leitprinzip; bei jedem neuen
|
||||
Feature fragen: „Könnte man dieses Modul in einem Jahr komplett neu schreiben, ohne
|
||||
andere Module zu ändern?"
|
||||
- **Abhängigkeiten:** neue Pakete nur mit gutem Grund, etabliert und gepflegt.
|
||||
- **Umgebung beachten:** Auf dem Pi (SSH/tmux-Sessions) ist evtl. kein Flutter-Toolchain
|
||||
verfügbar. Dann trotzdem sauber arbeiten, aber jeden Schritt in PROGRESS.md als
|
||||
„UNGETESTET (Pi)" markieren – der nächste PC-Lauf holt `flutter analyze` + Praxistest nach.
|
||||
- **Bernd fragen** nur bei echten Richtungsentscheidungen (UX-Geschmack, Priorität) –
|
||||
Fragen in PROGRESS.md unter „Fragen an Bernd" sammeln statt die Session zu blockieren.
|
||||
|
||||
## Nützliches Wissen
|
||||
|
||||
- Discord ist die UX-Referenz für Calls/Voice-Channels (Join/Leave, Nutzer-Lautstärke,
|
||||
Geräteauswahl, Noise Suppression, Streaming-Qualitätswahl).
|
||||
- `CHANGES.md` ist ein automatisches Änderungslog (Hook) – nicht von Hand pflegen.
|
||||
- Windows-Build ist der schnellste Testweg; Android-Release über das Release-Skript
|
||||
(siehe Commit d706ace), signiert – Play-Protect-Warnung siehe ROADMAP M6.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Pyramid – Arbeitsprotokoll
|
||||
|
||||
Neueste Einträge OBEN. Jede Claude-Session schreibt hier nach jedem abgeschlossenen
|
||||
Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
||||
|
||||
```
|
||||
## JJJJ-MM-TT – Kurztitel
|
||||
**Erledigt:** was fertig ist (mit Commit-Hash)
|
||||
**Offen/Nächster Schritt:** der konkrete nächste Handgriff
|
||||
**Stolperfallen:** was schiefging und wie es gelöst wurde (damit es nie zweimal passiert)
|
||||
**Fragen an Bernd:** (optional) gesammelte Richtungsfragen, die nicht blockieren
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 – Arbeitsstruktur angelegt + Vollbackup (Setup, noch kein Code)
|
||||
|
||||
**Erledigt:**
|
||||
- Vollbackup inkl. Git-Historie: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz` (510 MB)
|
||||
- `CLAUDE.md` (Arbeitsregeln), `ROADMAP.md` (M0–M7 nach Bernds Prioritäten), dieses Protokoll
|
||||
- Klargestellt: `C:\Users\nordm\pyramid - Kopie` ist das EINZIGE aktuelle Repo;
|
||||
`C:\Users\nordm\pyramid` und `MatrixPi\pyramid` sind veraltete April-Stände (Commit c9af913)
|
||||
|
||||
**Offen/Nächster Schritt:** M0, Punkt 2 – die ~140 uncommitteten Dateien mit `git status`
|
||||
sichten und in thematische Commits aufteilen (diese drei neuen Dateien mit committen),
|
||||
dann `git push origin master`.
|
||||
|
||||
**Stolperfallen:**
|
||||
- Letzter Commit ist vom 2026-04-28 (d706ace), gearbeitet wurde aber bis mindestens
|
||||
2026-06-12 → zwischen Commits und Realität liegen 6 Wochen. Genau deshalb: ab jetzt
|
||||
kleine Commits nach jedem Schritt.
|
||||
- Git-Remote (Gitea auf dem Pi) enthält Zugangsdaten in der URL – funktioniert, aber
|
||||
nur erreichbar, wenn der Pi läuft (unterwegs: WireGuard nötig).
|
||||
@@ -0,0 +1,386 @@
|
||||
# Pyramid – Entwicklungsprotokoll
|
||||
Stand: 24.04.2026
|
||||
|
||||
---
|
||||
|
||||
## Projektübersicht
|
||||
|
||||
**Pyramid** ist ein Flutter-basierter Matrix-Client, modelliert nach einem FluffyChat-Fork,
|
||||
mit zwei parallelen Call-Systemen:
|
||||
|
||||
| System | Einsatz | Datei |
|
||||
|--------|---------|-------|
|
||||
| **LiveKit** (`livekit_client`) | Gruppen-Sprachräume | `lib/core/livekit_call_manager.dart` |
|
||||
| **Matrix VoIP** (`matrix` SDK) | 1:1 Direktnachrichten | `lib/core/voip_manager.dart` |
|
||||
|
||||
Server-Infrastruktur läuft auf einem Raspberry Pi (`steggi-matrix.work`), LiveKit unter
|
||||
`livekit.steggi-matrix.work`.
|
||||
|
||||
---
|
||||
|
||||
## Erledigte Änderungen
|
||||
|
||||
### 1. LiveKit – Qualitäts-Bugs behoben (`livekit_call_manager.dart`)
|
||||
|
||||
**Problem:** Streamer und Zuschauer konnten die Übertragungsqualität nicht ändern.
|
||||
|
||||
**Ursachen:**
|
||||
- `_screenShareEncoding()` hatte einen hardcodierten `switch ('balanced')` — gab immer
|
||||
halbe Bitrate zurück, unabhängig vom eingestellten Qualitäts-Key.
|
||||
- `setSubscribeQuality('auto')` sprang vorzeitig raus und setzte die Qualität nie.
|
||||
|
||||
**Fix:**
|
||||
```dart
|
||||
VideoEncoding _screenShareEncoding() {
|
||||
return _screenShareEncodings[_localQualityKey] ??
|
||||
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
||||
}
|
||||
```
|
||||
`setSubscribeQuality` wendet jetzt immer die Qualität an, auch bei 'auto'.
|
||||
|
||||
---
|
||||
|
||||
### 2. LiveKit – Track-Events lösen UI-Rebuild aus (`livekit_call_manager.dart`)
|
||||
|
||||
**Problem:** Kamera-Feed wurde nach dem Publishen nicht angezeigt, weil kein Widget-Rebuild ausgelöst wurde.
|
||||
|
||||
**Fix:** Handler für alle Track-Events hinzugefügt:
|
||||
```dart
|
||||
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
|
||||
..on<TrackPublishedEvent>((_) => notifyListeners())
|
||||
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||
..on<TrackSubscribedEvent>((_) => notifyListeners())
|
||||
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. LiveKit – Optimistic Mute (`livekit_call_manager.dart`)
|
||||
|
||||
**Problem:** Mute-Button hatte eine spürbare Verzögerung (async API-Call).
|
||||
|
||||
**Fix:** `notifyListeners()` wird jetzt vor dem `await` aufgerufen:
|
||||
```dart
|
||||
Future<void> toggleMute() async {
|
||||
isMuted = !isMuted;
|
||||
notifyListeners(); // Sofort
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. VoIP – Kamera-Fix (`voip_manager.dart`)
|
||||
|
||||
**Problem:** Kamera-Tracks wurden nicht korrekt zugewiesen, weil der Renderer bereits belegt
|
||||
wurde bevor Video-Daten verfügbar waren.
|
||||
|
||||
**Fix:**
|
||||
- Track-Zuweisung erfolgt jetzt erst wenn `getVideoTracks().isNotEmpty` — kein Renderer-Update
|
||||
auf leere Streams mehr.
|
||||
- `_isCameraMuted` wird bei eingehenden Calls korrekt aus dem Call-Typ gesetzt:
|
||||
`_isCameraMuted = call.type == CallType.kVoice;`
|
||||
- Bei `kConnected`-State wird `setLocalVideoMuted(false)` nochmals aufgerufen falls Kamera
|
||||
aktiv sein soll.
|
||||
- Poll-Intervall von 1500ms auf 1000ms reduziert für schnellere Feed-Erkennung.
|
||||
|
||||
---
|
||||
|
||||
### 5. VoIP – Optimistic Mic/Camera Toggle (`voip_manager.dart`)
|
||||
|
||||
**Problem:** `toggleMic()` und `toggleCamera()` nutzten `safeAction()` mit 1500ms Delay.
|
||||
|
||||
**Fix:**
|
||||
- `toggleMic()` flippt State sofort, reverted bei Fehler automatisch.
|
||||
- `toggleCamera()` flippt `_isCameraMuted` sofort (optimistic), ruft `_updateRenderers()`
|
||||
mehrfach nach kurzen Delays auf.
|
||||
|
||||
---
|
||||
|
||||
### 6. VoIP – Desktop Speakerphone Fix (`voip_manager.dart`)
|
||||
|
||||
**Problem:** `MissingPluginException: No implementation found for method enableSpeakerphone`
|
||||
auf Windows/Desktop.
|
||||
|
||||
**Fix:** Speakerphone-Callback wird im Konstruktor unterdrückt:
|
||||
```dart
|
||||
try {
|
||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||
} catch (_) {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. SQLite-Stabilität auf Windows
|
||||
|
||||
**Problem:** Sporadische "Database is locked"-Fehler durch parallele Zugriffe.
|
||||
|
||||
**Fix:** WAL-Modus aktiviert und `busy_timeout` gesetzt, um Locking-Konflikte zu verhindern.
|
||||
|
||||
---
|
||||
|
||||
### 8. Einheitliches Call-UI (`voice_channel.dart`) – Kompletter Rewrite
|
||||
|
||||
Die gesamte `voice_channel.dart` wurde neu strukturiert mit einer einheitlichen Architektur
|
||||
für LiveKit und VoIP.
|
||||
|
||||
#### 8a. Unified Participant Model
|
||||
|
||||
```dart
|
||||
class _ParticipantData {
|
||||
final String id, name;
|
||||
final Widget? videoWidget;
|
||||
final bool isSpeaking, isMuted, hasVideo, isScreenShare;
|
||||
final Color avatarColor;
|
||||
final dynamic originalParticipant;
|
||||
}
|
||||
```
|
||||
|
||||
#### 8b. Adaptive Seitenverhältnisse (`voice_channel.dart`)
|
||||
|
||||
**Problem:** Video-Tiles waren auf `AspectRatio(16/9)` festgelegt — Hochkant-Videos vom
|
||||
Handy wurden stark beschnitten und falsch dargestellt.
|
||||
|
||||
**Fix:**
|
||||
- `_ParticipantData` bekommt ein `aspectRatio`-Feld (Default `16.0/9.0`).
|
||||
- VoIP: echte Dimension aus `RTCVideoRenderer.videoWidth/videoHeight` ausgelesen.
|
||||
- LiveKit: echte Dimension aus `(videoTrack as dynamic).currentDimensions` ausgelesen.
|
||||
- Alle Renderer nutzen `VideoViewFit.contain` / `RTCVideoViewObjectFitContain` statt `cover`
|
||||
— kein Beschnitt mehr.
|
||||
- `_ParticipantGrid` wechselt von `GridView(childAspectRatio: 16/9)` zu `Wrap` mit
|
||||
individuell berechneter `SizedBox(width, height)` pro Tile.
|
||||
- `_ParticipantStrip` passt Tile-Breite an: `101 * p.aspectRatio` für Landscape-Tiles.
|
||||
|
||||
#### 8c. Hero/Spotlight-System
|
||||
|
||||
- **Auto-Spotlight:** Screenshares werden automatisch als Hero angezeigt
|
||||
- **Manuelles Pinnen:** Klick auf Tile → wird Hero; erneuter Klick = unpin
|
||||
- Gear-Icon im Hero-Overlay öffnet das Qualitätsmenü (hover-reveal)
|
||||
- Fullscreen-Button öffnet eine eigene Fullscreen-Seite via `Navigator.push`
|
||||
|
||||
#### 8d. Aktive Sprecher
|
||||
|
||||
Pulsierende Rahmen-Animation (`AnimationController`) für Teilnehmer, die gerade sprechen.
|
||||
|
||||
#### 8e. Teilnehmer-Sortierung
|
||||
|
||||
```
|
||||
Screensharer → Kamera-an → Sprechend → Audio-only
|
||||
```
|
||||
|
||||
#### 8f. Responsive Layouts
|
||||
|
||||
**Portrait:** Hero oben (Expanded) + horizontaler Teilnehmer-Strip unten (120px)
|
||||
**Landscape:** Hero links (Expanded) + vertikale Sidebar rechts (200px)
|
||||
|
||||
Beide Layouts sind **einklappbar** (Swipe-Geste + Pfeil-Button).
|
||||
|
||||
#### 8g. Zentrales Mirroring
|
||||
|
||||
`_MirrorWrapper` spiegelt nur die eigene Kamera (`isLocal && !isScreenShare`).
|
||||
Screenshares und Remote-Teilnehmer bleiben ungespiegelt.
|
||||
|
||||
#### 8h. Grid-Layout mit einheitlichen Tiles
|
||||
|
||||
`SliverGridDelegateWithFixedCrossAxisCount`:
|
||||
- < 600px → 2 Spalten
|
||||
- 600–900px → 3 Spalten
|
||||
- > 900px → 4 Spalten
|
||||
|
||||
#### 8i. Steuerungs-Reihenfolge (alle Views)
|
||||
|
||||
```
|
||||
[ Mic ] [ Deaf ] [ Kamera ] [ Screen ] | [ Auflegen ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Mini-Call-Widget – Steuerungs-Reihenfolge (`mini_call_widget.dart`)
|
||||
|
||||
Gleiche Reihenfolge wie Haupt-Call-Ansicht: Mic → Deaf → Kamera → Screen.
|
||||
|
||||
---
|
||||
|
||||
### 10. 4K Support
|
||||
|
||||
4K (2160p) als Qualitätsoption in LiveKit- und VoIP-Qualitätsmenüs hinzugefügt.
|
||||
|
||||
---
|
||||
|
||||
### 11. App-Icon – Pyramid-Logo (`flutter_launcher_icons.yaml`)
|
||||
|
||||
**Problem:** App zeigte das Flutter-Standard-Icon statt dem Pyramid-Logo.
|
||||
|
||||
**Fix:**
|
||||
- `flutter_launcher_icons.yaml` auf `assets/logo.png` (goldene 3D-Pyramide, 1024px) umgestellt.
|
||||
- macOS-Generierung explizit aktiviert (`macos: generate: true`).
|
||||
- Windows ICO nach `dart run flutter_launcher_icons` mit Pillow neu generiert
|
||||
(16/32/48/64/128/256px multi-size ICO, verhindert das 48px-Limit des Packages).
|
||||
|
||||
---
|
||||
|
||||
### 12. Lade-Animation – 3D Pyramiden-Painter (`pyramid_loader.dart`)
|
||||
|
||||
**Problem:** Lottie-JSON zeigte inkorrekte Farben, SVG wurde nur statisch gerendert
|
||||
(flutter_svg führt kein JavaScript aus).
|
||||
|
||||
**Lösung:** JavaScript-Animations-Logik aus dem Original-SVG exakt nach Dart portiert
|
||||
als `CustomPainter`:
|
||||
|
||||
- `_ease(t, exp)`: kubische Ease-In/Out wie im JS-Original
|
||||
- `_yawAt(t)`: Yaw-Rotation mit Hold-Phase (80% Rotation, 20% Pause bei π/4)
|
||||
- `_PyramidPainter`: 4 Flächen, Y-Rotation, Z-Clipping, orthografische Projektion,
|
||||
20°-Tilt, Depth-Sorting (Painter's Algorithm)
|
||||
- Fill: `darken(accent, 0.45)`, Stroke: `accent`, strokeWidth: `1.4 * (size/96)`
|
||||
- `useLottie: false` ist nun der Standard — kein Lottie-Asset mehr nötig
|
||||
|
||||
**Loader Preview** in den Appearance Settings (zwei 120px-Boxen: Custom Painter + Lottie-Fallback).
|
||||
|
||||
---
|
||||
|
||||
### 13. Chat-Scroll-Stabilisierung (`chat_view.dart`)
|
||||
|
||||
**Problem:** Der Chat zuckte und zitterte je weiter man nach oben scrollte, besonders
|
||||
wenn neue History nachgeladen wurde.
|
||||
|
||||
**Ursache:** `DateDivider` und `MessageGroup` waren in einem gemeinsamen `Column`-Widget
|
||||
mit einem einzigen Key zusammengefasst. Wenn beim Nachladen von History die `showDate`-
|
||||
Eigenschaft einer Gruppe sich änderte (z.B. weil sie nicht mehr die älteste Gruppe war),
|
||||
änderte sich die Höhe des Items — direkt in dem Bereich, den der User gerade betrachtete.
|
||||
|
||||
**Fix:**
|
||||
- `DateDivider` und `MessageGroup` sind jetzt separate List-Items mit unabhängigen Keys:
|
||||
- Gruppen: `ValueKey('g:<firstEventId>')`
|
||||
- Datumstrennlinien: `ValueKey('d:<YYYY-MM-DD>')`
|
||||
- `findChildIndexCallback` nutzt eine vorberechnete `Map<String, int>` für O(1)-Lookup
|
||||
statt O(n)-`indexWhere`.
|
||||
- 200ms Cooldown nach `requestHistory` verhindert sofortige Re-Trigger durch veraltetes
|
||||
`maxScrollExtent`.
|
||||
|
||||
---
|
||||
|
||||
### 14. Startup-Flash Fix (`app.dart`)
|
||||
|
||||
**Problem:** Beim App-Start flackerte kurz die Server-Eingabe-Seite auf, bevor der
|
||||
korrekte Redirect zu `/app` stattfand.
|
||||
|
||||
**Ursache:** `isLoggedInProvider` (StreamProvider) gibt beim ersten Build `AsyncLoading`
|
||||
zurück. `valueOrNull ?? false` ergibt `false` → Router leitet sofort zu `/server` weiter.
|
||||
Kurz danach resolvet der Stream und der Router erstellt sich neu mit `isLoggedIn = true`.
|
||||
|
||||
**Fix:** `PyramidApp` prüft `isLoggedInProvider` zuerst. Solange noch `isLoading`:
|
||||
vollbild `PyramidLoader` statt Router — die Pyramiden-Animation überbrückt den kurzen
|
||||
Moment, bis der Auth-State bekannt ist.
|
||||
|
||||
---
|
||||
|
||||
### 15. Eigenes Profilbild (`user_panel.dart`)
|
||||
|
||||
**Problem:** Das eigene Profilbild wurde unten links (UserPanel) nicht angezeigt —
|
||||
nur ein Farbverlauf-Platzhalter mit Initial-Buchstabe.
|
||||
|
||||
**Fix:**
|
||||
- `_ownProfileProvider` (FutureProvider): ruft einmalig `client.getProfileFromUserId(userID)`
|
||||
auf und cached das Ergebnis.
|
||||
- `UserPanel` zeigt nun `MxcAvatar` mit dem echten Profilbild.
|
||||
- Anzeigename kommt aus `profile.displayName` statt nur aus der UserID.
|
||||
- Farbverlauf-Initial-Avatar bleibt als Fallback (`_InitialAvatar`) während des Ladens
|
||||
oder wenn kein Bild gesetzt ist.
|
||||
|
||||
---
|
||||
|
||||
### 16. QR-Code-Verifikation (`app_shell.dart`, `settings_modal.dart`)
|
||||
|
||||
**Problem:** Geräteverifikation unterstützte nur Emoji-SAS — kein QR-Code-Flow.
|
||||
|
||||
**Fix:**
|
||||
- `KeyVerificationMethod.qrShow` zu `verificationMethods` in `matrix_client.dart` hinzugefügt.
|
||||
- `pubspec.yaml`: `pretty_qr_code: ^3.6.0` und `qr: ^3.0.2` ergänzt.
|
||||
- Eingehender Verifizierungsdialog (`app_shell.dart`) und ausgehender Dialog (`settings_modal.dart`)
|
||||
unterstützen jetzt beide 3 neue States:
|
||||
- `askChoice` → Methodenwahl (Emoji oder QR)
|
||||
- `confirmQRScan` → QR-Code anzeigen (`QrCode.fromUint8List` + `PrettyQrView`) + "Gescannt bestätigen"-Button
|
||||
- `showQRSuccess` → Erfolgsanzeige
|
||||
|
||||
**Technischer Hinweis:** Matrix QR-Codes sind Binärdaten (`qrDataRawBytes: Uint8Buffer`).
|
||||
Rendering über `QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L)`.
|
||||
|
||||
---
|
||||
|
||||
### 17. Wiederherstellungsschlüssel ändern (`settings_modal.dart`)
|
||||
|
||||
**Problem:** Kein Feature zum Wechseln des SSSS-Wiederherstellungsschlüssels vorhanden.
|
||||
|
||||
**Implementierung:** `_ChangeRecoveryKeyDialog` mit Bootstrap-State-Machine:
|
||||
|
||||
**Flow:**
|
||||
1. **Laden** — Bootstrap startet, `wipeSsss(true)` → `newSsss()` generiert neuen Schlüssel
|
||||
2. **Schlüssel anzeigen** — Schlüssel selektierbar + Kopier-Button; wartet auf Nutzer
|
||||
3. **Bestätigen** — Nutzer tippt Schlüssel zur Bestätigung ein (`_ConfirmKeyBody`)
|
||||
4. **Aktivieren** — wartet auf Bootstrap `done` (Cross-Signing + Key Backup im Hintergrund)
|
||||
5. **Fertig** — Erfolgsmeldung
|
||||
|
||||
**Kritischer Fix (BootstrapBadStateException):**
|
||||
- `newSsss()` ruft intern `checkCrossSigning()` auf → State geht sofort zu `askWipeCrossSigning`.
|
||||
- `openExistingSsss()` ist nach `newSsss()` nicht mehr erreichbar — wurde entfernt.
|
||||
- Alle Post-`newSsss`-States werden ohne `_step`-Guards auto-advanced.
|
||||
- Bootstrap-`done` setzt `_step` nur wenn bereits in `applying` — verhindert vorzeitigen Dialog-Schluss.
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
### ⚠️ Screenshare auf Windows (LiveKit)
|
||||
|
||||
`startScreenShare()` wählt auf Windows automatisch die erste verfügbare Screen-Source.
|
||||
Der `ScreenSharePicker` ist implementiert, muss noch verifiziert werden.
|
||||
|
||||
### ℹ️ Profilbilder in Chats – Lazy Loading
|
||||
|
||||
Profilbilder in der Raumliste und im Chat werden erst beim Öffnen des jeweiligen
|
||||
Raums geladen (kein Preloading). Ist akzeptables Verhalten, kein Fehler.
|
||||
|
||||
### 📋 Noch nicht implementiert (Settings)
|
||||
|
||||
~~Alle erledigt in Sitzung 3 (26.04.2026): Gerät umbenennen, Alle anderen Sitzungen abmelden, Ed25519-Fingerabdruck, Blockierte Nutzer, Passwort ändern, Konto löschen, Logout-Button~~
|
||||
|
||||
### 💡 Nice-to-have
|
||||
|
||||
- **QR-Express-Login** (MSC4108): Desktop zeigt QR → Handy scannt → eingeloggt + verifiziert.
|
||||
Voraussetzung: Rendezvous-Server + SDK-Unterstützung (matrix-dart-sdk ≥ 7.x erwartet).
|
||||
Aktuell blockiert durch fehlende SDK-API in 6.2.0.
|
||||
|
||||
---
|
||||
|
||||
## Geänderte Dateien (vollständig)
|
||||
|
||||
| Datei | Inhalt |
|
||||
|-------|--------|
|
||||
| `lib/core/livekit_call_manager.dart` | Quality-Bug-Fix, Track-Events, Optimistic Mute, 4K |
|
||||
| `lib/core/voip_manager.dart` | Kamera-Fix, Optimistic Toggle, Speakerphone-Fix, 4K |
|
||||
| `lib/features/call/voice_channel.dart` | Kompletter Rewrite: Unified Architecture, adaptive AR, Hero, Mirroring, Grid, Sortierung, Controls |
|
||||
| `lib/features/call/mini_call_widget.dart` | Steuerungs-Reihenfolge |
|
||||
| `lib/core/matrix_client.dart` | WAL-Modus, busy_timeout, qrShow in verificationMethods |
|
||||
| `lib/widgets/pyramid_loader.dart` | 3D CustomPainter (JS-Port), useLottie=false Standard |
|
||||
| `lib/core/app.dart` | Startup-Flash-Fix: PyramidLoader während isLoggedInProvider lädt |
|
||||
| `lib/features/chat/chat_view.dart` | Scroll-Stabilisierung: flache Item-Liste, O(1)-Lookup, Cooldown |
|
||||
| `lib/features/rooms/user_panel.dart` | Eigenes Profilbild via _ownProfileProvider + MxcAvatar |
|
||||
| `lib/widgets/settings_modal.dart` | QR-Verifikation, Wiederherstellungsschlüssel ändern, Loader-Preview |
|
||||
| `lib/layout/app_shell.dart` | QR-Verifikation im eingehenden Verifizierungsdialog |
|
||||
| `pubspec.yaml` | pretty_qr_code, qr hinzugefügt |
|
||||
| `flutter_launcher_icons.yaml` | Pyramid-Logo für alle Plattformen |
|
||||
| `windows/runner/resources/app_icon.ico` | Multi-Size ICO (16/32/48/64/128/256px) via Pillow |
|
||||
| `assets/logo.png` | Pyramid-Logo (1024px) |
|
||||
| `assets/pyramid-loader.json` | Lottie-Fallback (aus Ordner mit Leerzeichen verschoben) |
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
| Datum | Pfad |
|
||||
|-------|------|
|
||||
| 24.04.2026 (Sitzung 1) | `E:\pyramid_backup_20260424` |
|
||||
| 24.04.2026 (Sitzung 2) | `E:\pyramid_backup_20260424_2` |
|
||||
| 26.04.2026 | `E:\pyramid_backup_20260426` |
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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`)
|
||||
- [ ] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
|
||||
- [ ] `flutter analyze` komplett sauber bekommen und sauber halten
|
||||
- [ ] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
||||
- [ ] README.md durch echte Projektbeschreibung ersetzen (was ist Pyramid, wie baut man es, wo liegt was)
|
||||
|
||||
## M1 – Sicherheit: Nie wieder aussperren / kein Datenverlust
|
||||
|
||||
- [ ] **Uta-Random-Logout untersuchen:** Ursache finden (Soft-Logout vom Server? Token
|
||||
ungültig? Fehlerpfad im Sync, der zum Logout führt?). Erst Logging an allen
|
||||
Logout-Pfaden einbauen, dann Ursache fixen. Ein unerwarteter Logout darf NIE
|
||||
Krypto-Schlüssel wegwerfen.
|
||||
- [ ] Sichere Speicherung von Access-Token & Krypto-DB prüfen (secure storage / sqlcipher korrekt?)
|
||||
- [ ] Key-Backup einrichten-Flow: Recovery-Key erzeugen, verständlich erklären, zum Sichern zwingen
|
||||
- [ ] Key-Backup wiederherstellen-Flow: nach Login auf neuem Gerät alte Nachrichten entschlüsseln
|
||||
- [ ] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt
|
||||
- [ ] Geräte-/Sessionverwaltung in den Einstellungen (Liste, Verifizieren, Abmelden)
|
||||
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
||||
|
||||
## 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.
|
||||
|
||||
- [ ] Ist-Analyse schreiben: Architektur-Schwächen auflisten (Call-Logik verteilt auf
|
||||
`livekit_call_manager.dart`, `voip_manager.dart`, `voice_channel.dart`?, State-Management
|
||||
uneinheitlich?, Gott-Dateien?, tote Widgets?) → als Refactoring-Plan in PROGRESS.md
|
||||
- [ ] Modul-Schnitt definieren: Module (auth, chat, rooms, calls, streaming, push, storage, …)
|
||||
mit je einer schmalen öffentlichen Schnittstelle; Abhängigkeiten nur über Interfaces,
|
||||
keine Quer-Importe zwischen Feature-Modulen
|
||||
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||
|
||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||
|
||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||
- [ ] Noise Suppression als optionale Einstellung (z. B. RNNoise/LiveKit-Audio-Filter)
|
||||
- [ ] Lautstärke pro Teilnehmer einstellbar (Discord-Style, persistent pro Nutzer)
|
||||
- [ ] Weitere Feinjustierung: Eingangsempfindlichkeit/Gate, Echo-Unterdrückung an/aus,
|
||||
Push-to-Talk prüfen – von Discords Sprach-Einstellungen inspirieren lassen
|
||||
- [ ] Anruf-Benachrichtigung auch bei gekillter App (Push-Architektur nutzen)
|
||||
|
||||
## M4 – Streaming (Bildschirmübertragung)
|
||||
|
||||
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||
Software- statt Hardware-Encoding?), dann gezielt fixen
|
||||
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||
|
||||
## M5 – Chat-Funktionen
|
||||
|
||||
- [ ] Sprachnachrichten aufnehmen & abspielen (MSC3245-kompatibel, wie Element)
|
||||
- [ ] **Bilder speichern → richtige Galerie:** über MediaStore/Downloads statt in den
|
||||
unzugänglichen App-Ordner (Android Scoped Storage korrekt nutzen)
|
||||
- [ ] Medien senden/empfangen generell prüfen (Fortschritt, Vorschau, Videos)
|
||||
|
||||
## M6 – Release & Verteilung
|
||||
|
||||
- [ ] **Google-Warnung beim Installieren untersuchen:** Play-Protect-Meldung seit der
|
||||
Signatur-Einführung analysieren (Signing-Konfig prüfen; unbekannte Signatur + Sideload
|
||||
löst das oft aus; ggf. Play-Protect-Appeal oder saubere v2/v3-Signierung)
|
||||
- [ ] Release-Skript prüfen/aktualisieren (Windows + Android), Versionierung sauber
|
||||
- [ ] In-App-Updater testen (existiert laut Git-Historie)
|
||||
|
||||
## M7 – Design & Feinschliff
|
||||
|
||||
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
||||
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
||||
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
||||
- [ ] Themes/Darkmode-Feinschliff, Schriftgrößen
|
||||
|
||||
## Ideen-Parkplatz (unsortiert, erst nach M7 bewerten)
|
||||
|
||||
- Threads, Sticker, Standort teilen, mehrere Konten
|
||||
- Watch-Together (z. B. Jellyfin gemeinsam schauen)
|
||||
|
||||
> Bernd: „Das ist nur der Anfang meiner Liste" – neue Punkte einfach hier in den
|
||||
> passenden Meilenstein eintragen (oder Claude sagen, er soll sie einsortieren).
|
||||
@@ -9,6 +9,11 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
# Design-Mockups, kein Produktionscode — nicht analysieren.
|
||||
- mockup/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
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 {
|
||||
@@ -11,6 +22,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -30,13 +42,42 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
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 {
|
||||
release {
|
||||
// Falls back to debug signing on machines without key.properties
|
||||
// so `flutter run --release` still works there.
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
// Required to compile PushService.kt which extends FirebaseMessagingService directly.
|
||||
// Version must stay in sync with FirebaseSDKVersion in firebase_core's gradle.properties.
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.16.0"))
|
||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -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.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
@@ -7,8 +9,13 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<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.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
|
||||
android:label="pyramid"
|
||||
@@ -40,6 +47,28 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</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>
|
||||
<!-- FileProvider for APK installs (update downloader) -->
|
||||
<provider
|
||||
@@ -51,6 +80,42 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -63,6 +128,15 @@
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<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>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
|
||||
/**
|
||||
* Bootstraps a headless Flutter background engine running the
|
||||
* `notificationEngineMain` Dart entrypoint and forwards work items
|
||||
* (decrypt-and-show, send-reply) to it via a MethodChannel.
|
||||
*
|
||||
* Only used when the app is KILLED (no main FlutterEngine). The Dart side builds
|
||||
* a short-lived Matrix client against the same DB to decrypt / send. This is safe
|
||||
* because callers gate on the main app being dead — never two clients at once.
|
||||
*/
|
||||
object BgEngine {
|
||||
private const val TAG = "PYRAMID-BGENGINE"
|
||||
private const val CHANNEL = "chat.pyramid.pyramid/bg_engine"
|
||||
|
||||
private var engine: FlutterEngine? = null
|
||||
private var channel: MethodChannel? = null
|
||||
private var ready = false
|
||||
private var appContext: Context? = null
|
||||
// Tasks queued until the Dart handler signals readiness.
|
||||
private val pending = mutableListOf<Pair<String, Map<String, Any?>>>()
|
||||
// Pending "Neue Nachricht" fallback placeholders, keyed by notifId. Shown
|
||||
// only if decryption doesn't replace them in time; cancelled by showDecrypted.
|
||||
private val fallbackHandler = Handler(Looper.getMainLooper())
|
||||
private val fallbacks = mutableMapOf<Int, Runnable>()
|
||||
|
||||
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
||||
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) {
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
fallbackHandler.post {
|
||||
cancelFallback(notifId)
|
||||
val r = Runnable {
|
||||
fallbacks.remove(notifId)
|
||||
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId)
|
||||
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
||||
}
|
||||
fallbacks[notifId] = r
|
||||
fallbackHandler.postDelayed(r, delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelFallback(notifId: Int) {
|
||||
fallbacks.remove(notifId)?.let { fallbackHandler.removeCallbacks(it) }
|
||||
}
|
||||
|
||||
/** Enqueue a task; boots the engine on first use. Must run on the main thread. */
|
||||
fun run(context: Context, method: String, args: Map<String, Any?>) {
|
||||
appContext = context.applicationContext
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ensureEngine(context.applicationContext)
|
||||
if (ready) {
|
||||
channel?.invokeMethod(method, args)
|
||||
} else {
|
||||
pending.add(method to args)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "run failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureEngine(context: Context) {
|
||||
if (engine != null) return
|
||||
|
||||
val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||
val handle = try {
|
||||
prefs.getLong("flutter.bg_engine_handle", 0L)
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
if (handle == 0L) {
|
||||
Log.w(TAG, "no bg_engine_handle stored — cannot start background engine")
|
||||
return
|
||||
}
|
||||
|
||||
// The Flutter native library must be loaded BEFORE looking up the
|
||||
// callback — nativeLookupCallbackInformation is a native method and
|
||||
// throws UnsatisfiedLinkError otherwise.
|
||||
val loader = FlutterInjector.instance().flutterLoader()
|
||||
loader.startInitialization(context)
|
||||
loader.ensureInitializationComplete(context, null)
|
||||
|
||||
val cbInfo = FlutterCallbackInformation.lookupCallbackInformation(handle)
|
||||
if (cbInfo == null) {
|
||||
Log.w(TAG, "callback info not found for handle $handle")
|
||||
return
|
||||
}
|
||||
|
||||
val eng = FlutterEngine(context)
|
||||
engine = eng
|
||||
|
||||
val ch = MethodChannel(eng.dartExecutor.binaryMessenger, CHANNEL)
|
||||
channel = ch
|
||||
ch.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"bgEngineReady" -> {
|
||||
ready = true
|
||||
Log.d(TAG, "Dart background engine ready — flushing ${pending.size} task(s)")
|
||||
pending.forEach { (m, a) -> ch.invokeMethod(m, a) }
|
||||
pending.clear()
|
||||
result.success(null)
|
||||
}
|
||||
// Dart finished decrypting — show the real content using the SAME
|
||||
// native notification (keeps the reliable MainActivity reply action).
|
||||
"showDecrypted" -> {
|
||||
val title = call.argument<String>("title") ?: ""
|
||||
val body = call.argument<String>("body") ?: "Neue Nachricht"
|
||||
val roomId = call.argument<String>("room_id") ?: ""
|
||||
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
|
||||
val ctx = appContext
|
||||
if (ctx != null && roomId.isNotEmpty()) {
|
||||
// Beat the fallback placeholder to it — show decrypted directly.
|
||||
cancelFallback(notifId)
|
||||
NotificationHelper.show(ctx, title, body, roomId, notifId)
|
||||
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
eng.dartExecutor.executeDartCallback(
|
||||
DartExecutor.DartCallback(context.assets, loader.findAppBundlePath(), cbInfo)
|
||||
)
|
||||
Log.d(TAG, "background engine started")
|
||||
}
|
||||
}
|
||||
@@ -3,49 +3,270 @@ package chat.pyramid.pyramid
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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 io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
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) {
|
||||
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)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "installApk") {
|
||||
val path = call.argument<String>("path")
|
||||
if (path == null) {
|
||||
result.error("INVALID_ARG", "path is null", null)
|
||||
return@setMethodCallHandler
|
||||
when (call.method) {
|
||||
// Flutter asks for the room_id embedded in the launch intent (cold-start tap).
|
||||
"getInitialRoomId" -> {
|
||||
result.success(intent?.getStringExtra("room_id"))
|
||||
}
|
||||
try {
|
||||
val file = File(path)
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
// 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 {
|
||||
Uri.fromFile(file)
|
||||
result.success(null)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
// 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")
|
||||
if (path == null) {
|
||||
result.error("INVALID_ARG", "path is null", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!packageManager.canRequestPackageInstalls()) {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
result.error("PERMISSION_REQUIRED", "install_permission", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
val file = File(path)
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("INSTALL_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"openNotificationSettings" -> {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("INSTALL_ERROR", e.message, null)
|
||||
}
|
||||
} else {
|
||||
result.notImplemented()
|
||||
"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">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</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>
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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("com.android.application") version "8.11.1" 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")
|
||||
|
||||
|
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,19 @@
|
||||
#!/bin/bash
|
||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
||||
# Start (am besten in tmux): tmux new -s pyramid './autopilot.sh'
|
||||
# Wieder reinschauen: tmux attach -t pyramid
|
||||
# Stoppen: Strg+C in der tmux-Session
|
||||
cd "$(dirname "$0")"
|
||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||
|
||||
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
|
||||
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
|
||||
Arbeite so viele Punkte ab wie möglich."
|
||||
|
||||
while true; do
|
||||
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
||||
claude -p "$PROMPT" --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
|
||||
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||
sleep 1200
|
||||
done
|
||||
@@ -0,0 +1,136 @@
|
||||
# Push-Benachrichtigungen & E2EE im Hintergrund
|
||||
|
||||
Stand: 2026-06-04. Dieses Dokument beschreibt, wie Pyramid auf Android
|
||||
verschlüsselte Push-Benachrichtigungen verarbeitet — inklusive Entschlüsselung
|
||||
und verschlüsseltem Antworten **bei komplett gekillter App**, ohne dass sich die
|
||||
App öffnet.
|
||||
|
||||
## Überblick der Kette
|
||||
|
||||
```
|
||||
Matrix-Homeserver (Continuwuity)
|
||||
│ (event_id_only Push)
|
||||
▼
|
||||
Sygnal (self-hosted: push.steggi-matrix.work)
|
||||
▼
|
||||
Firebase Cloud Messaging (FCM)
|
||||
▼
|
||||
PushService.kt (FirebaseMessagingService, nativ)
|
||||
├─ App lebt im Vordergrund → App zeigt selbst, Push wird übersprungen
|
||||
├─ App lebt im Hintergrund → Haupt-Engine entschlüsselt (warm, schnell)
|
||||
└─ App gekillt → BgEngine bootet ein headless Flutter-Isolate
|
||||
```
|
||||
|
||||
Der Flutter-eigene FCM-Dienst (`FlutterFirebaseMessagingService`) ist im
|
||||
`AndroidManifest.xml` **deaktiviert** — alle Pushes laufen über `PushService.kt`.
|
||||
Push-Format ist `event_id_only` (nur die Event-ID wird übertragen, nicht der
|
||||
Inhalt — datensparsam).
|
||||
|
||||
## Warum dieser Aufbau?
|
||||
|
||||
Die E2EE-Schlüssel (olm/megolm) liegen **nur** im Matrix-Client (verschlüsselte
|
||||
SQLite-DB), nicht im nativen Kotlin-Code. Kotlin kann daher Sender + Raumname
|
||||
(unverschlüsselte Room-States) per HTTP holen, aber **nicht** den Nachrichtentext
|
||||
entschlüsseln. Dafür braucht es einen laufenden Matrix-Client.
|
||||
|
||||
**Sicherheitsregel:** Niemals zwei Matrix-Clients gleichzeitig auf derselben
|
||||
olm-DB — das korrumpiert den Ratchet-State und führt zu dauerhaften
|
||||
„Unable to decrypt"-Fehlern. Deshalb läuft der Hintergrund-Client (`BgEngine`)
|
||||
**nur**, wenn die Haupt-App nachweislich tot ist (`FlutterEngineCache.get("main")
|
||||
== null`).
|
||||
|
||||
## Die drei Zustände
|
||||
|
||||
| Zustand | Erkennung | Pfad |
|
||||
|---|---|---|
|
||||
| Vordergrund | Heartbeat < 20s (`flutter.notif_app_heartbeat`) | Push übersprungen, App zeigt selbst |
|
||||
| Hintergrund (lebt) | `FlutterEngineCache.get("main") != null` | Platzhalter sofort → Haupt-Engine entschlüsselt + aktualisiert |
|
||||
| Gekillt | keine Haupt-Engine | `BgEngine` bootet headless Isolate, **decrypt-first** |
|
||||
|
||||
## Gekillter Zustand im Detail (decrypt-first)
|
||||
|
||||
1. `PushService.onMessageReceived` holt Titel (Sender · Raum) per HTTP.
|
||||
2. Statt sofort einen Platzhalter zu zeigen:
|
||||
- `BgEngine.scheduleFallback(…, 6s)` plant einen „Neue Nachricht"-Platzhalter,
|
||||
der **nur** erscheint, falls die Entschlüsselung > 6s dauert oder scheitert.
|
||||
- `BgEngine.run("decryptAndShow", …)` bootet das Isolate.
|
||||
3. `BgEngine` (BgEngine.kt) startet eine headless `FlutterEngine` über den
|
||||
gespeicherten Callback-Handle (`flutter.bg_engine_handle`) und führt das
|
||||
Dart-Entrypoint `notificationEngineMain` (background_push.dart) aus.
|
||||
4. Dart `_bgDecryptAndShow`:
|
||||
- `_buildClient()` öffnet **dieselbe** DB (`pyramid.sqlite`,
|
||||
`MatrixSdkDatabase.init('pyramid')`, `NativeImplementationsDummy`).
|
||||
- `client.getEventByPushNotification(...)` entschlüsselt mit den lokal
|
||||
vorhandenen megolm-Schlüsseln (oneShotSync nur als Fallback).
|
||||
- Gibt Titel + Text per MethodChannel `showDecrypted` an Native zurück.
|
||||
5. `BgEngine` zeigt via `NotificationHelper.show` die echte Nachricht und
|
||||
bricht den Fallback-Platzhalter ab.
|
||||
|
||||
Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
|
||||
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
|
||||
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
||||
|
||||
## Verschlüsselt antworten (gekillt, vollständig still)
|
||||
|
||||
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
||||
**nicht** MainActivity — die App öffnet sich also nicht.
|
||||
- `ReplyReceiver`:
|
||||
- App lebt → MethodChannel `replyFromNotification` an die Haupt-Engine.
|
||||
- Gekillt → `BgEngine.run("sendReply", …)` → Dart `_bgSendReply` → oneShotSync +
|
||||
`room.sendTextEvent` (verschlüsselt). `goAsync()` + finish nach 15 s hält den
|
||||
Receiver-Prozess am Leben, bis der Versand durch ist.
|
||||
- RemoteInput-Extraktion hat mehrere Key-Fallbacks (OEM-Eigenheiten) und
|
||||
funktioniert auf Samsung S23 trotz der alten „getBroadcast verschluckt
|
||||
RemoteInput"-Warnung.
|
||||
|
||||
## Gelöste Fallstricke (NICHT wieder einbauen)
|
||||
|
||||
1. **Loader-Reihenfolge:** `FlutterCallbackInformation.lookupCallbackInformation`
|
||||
erst **nach** `flutterLoader.startInitialization` + `ensureInitializationComplete`
|
||||
aufrufen — sonst `UnsatisfiedLinkError` (native Methode, libflutter.so noch
|
||||
nicht geladen).
|
||||
2. **vodozemac doppelt:** Wenn Decrypt- und Reply-Task dasselbe wiederverwendete
|
||||
Isolate nutzen, wirft das zweite `vod.init()` „already initialized". In
|
||||
`_buildClient` in eigenem try/catch schlucken — sonst gibt `_buildClient`
|
||||
still `null` zurück und die Antwort geht nicht raus.
|
||||
3. **`database_closed`-Rauschen:** Vor `client.dispose()` 2 s warten, damit der
|
||||
asynchrone Key-Backup-Upload fertig wird.
|
||||
|
||||
## Relevante Dateien
|
||||
|
||||
| Datei | Rolle |
|
||||
|---|---|
|
||||
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
||||
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
||||
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
||||
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) |
|
||||
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
||||
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
||||
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
||||
| `lib/core/notification_service.dart` | Vordergrund/Hintergrund-Notifs, Heartbeat |
|
||||
| `lib/main.dart` | `registerBgEngineHandle()` beim Start |
|
||||
|
||||
## Debugging
|
||||
|
||||
`flutter run` verliert die Verbindung, sobald die App gekillt wird — daher direkt
|
||||
`adb logcat` nutzen:
|
||||
|
||||
```
|
||||
adb logcat -v time PYRAMID-PUSH:* PYRAMID-BGENGINE:* PYRAMID-REPLY:* PYRAMID-INTENT:* flutter:* AndroidRuntime:E *:S
|
||||
```
|
||||
|
||||
Erwartete Log-Sequenz (gekillt, Erfolg):
|
||||
```
|
||||
PYRAMID-PUSH: decrypt-first via background engine for <room>
|
||||
PYRAMID-BGENGINE: background engine started
|
||||
PYRAMID-BGENGINE: Dart background engine ready — flushing 1 task(s)
|
||||
PYRAMID-BGENGINE: showDecrypted → shown decrypted notification for <room>
|
||||
```
|
||||
Reply (gekillt):
|
||||
```
|
||||
PYRAMID-REPLY: extracted replyText=… (remoteInput=true)
|
||||
PYRAMID-REPLY: Flutter engine not running → background engine send
|
||||
PYRAMID-BGENGINE: (sendReply → sendTextEvent OK)
|
||||
```
|
||||
|
||||
Hinweis: Kotlin lässt sich nur auf einem Rechner mit JAVA_HOME/Android-SDK bauen.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Pyramid – Fortschritt & offene Punkte
|
||||
|
||||
Stand: 2026-06-04
|
||||
|
||||
## Erledigt
|
||||
|
||||
### Chat / UI
|
||||
- **Reply-Zitat-Karte** im Discord/WhatsApp-Stil (kein „In reply to @user" mehr), medien-bewusste Vorschau, getönter Hintergrund bündig mit Akzent-Strich.
|
||||
- **Per-Message-Selektion & -Hover** — einzelne Nachrichten statt ganzer Sender-Gruppen werden markiert/hervorgehoben.
|
||||
- **Scroll-Stabilität** — Crash durch doppelte Keys behoben, Pagination (kein endloses „Zurückziehen", lädt aber bis zum echten Anfang), Bild-/PDF-Platz wird vorab reserviert (kein Springen beim Laden).
|
||||
- **„Neue Nachrichten"-Divider** — korrekte Position, scrollt zuverlässig hin, blendet beim Erreichen des Endes aus; erscheint nur bei echten ungelesenen Nachrichten von anderen.
|
||||
- **Composer-Höhe** bündig mit dem Profil-Block; **Verbindungs-Statusbanner** bei Sync-Verlust; **fehlgeschlagene Nachrichten** bleiben mit „Wiederholen/Löschen" stehen statt zu verschwinden.
|
||||
- **„Zuletzt online"** nutzt echten Presence-Status + Frische statt `currentlyActive` (kein falsches „online" mehr).
|
||||
- **Members-Panel** mobil schließbar + schmaler.
|
||||
|
||||
### Medien & Dokumente
|
||||
- **Media-Cache** (LRU, byte-budgetiert, geteilt) — zuletzt betrachtete Medien sofort da, ältere automatisch verworfen.
|
||||
- **Datei-Vorschau** — höhere Auflösung (PNG statt JPEG), viele neue Text/Code/Daten-Formate, **immersiver Fullscreen-Viewer** (Pan, +/− 20%-Zoom, Ctrl+Mausrad, Seiten-Navigation, mobil-optimiert, Tap blendet UI aus).
|
||||
- **Eingebettete Link-Vorschauen** (Open-Graph-Karten) — serverseitig in Continuwuity aktiviert + Client-Karte.
|
||||
|
||||
### Suche
|
||||
- **Lokale Suche** über entschlüsselte Events (server-seitige `/search` funktioniert nicht für E2EE) — Nachrichten, Medien (mit **Thumbnail**), Personen/Räume.
|
||||
- **Sprung zur Nachricht** aus dem Suchergebnis inkl. Cross-Chat, mit sanft ausblendendem Highlight.
|
||||
|
||||
### Benachrichtigungen (Android)
|
||||
- Bei **gekillter App**: Hintergrund-Entschlüsselung (decrypt-first) + **verschlüsseltes Antworten** ohne App-Öffnen. Details siehe [NOTIFICATIONS.md](NOTIFICATIONS.md).
|
||||
|
||||
### Einstellungen
|
||||
- **Mobile Settings** im Element-Stil: Kategorie-Liste → Untermenü mit Zurück-Pfeil (statt gedrängtem Icon-Block). Desktop unverändert.
|
||||
|
||||
### Server (Pi 5, steggi@192.168.178.71)
|
||||
- rclone-Mount für Pixeldrain-Medien repariert + Boot-Reihenfolge abgesichert; Medien liegen nur auf Pixeldrain.
|
||||
- URL-Preview-Allowlist in `conduit.toml` aktiviert.
|
||||
|
||||
### Lesebestätigungen (Element-Stil, mit Haken)
|
||||
- **✓✓** an der zuletzt gesehenen Nachricht des Gegenübers (auch dessen eigener Antwort), berechnet aus `room.receiptState`. Wandert mit, statt an der eigenen letzten Antwort zu kleben.
|
||||
- **grauer ✓** nur auf der eigenen neuesten Nachricht, solange sie noch ungelesen ist; verschwindet sobald gelesen (dann nur ✓✓).
|
||||
|
||||
## Offen / geplant
|
||||
- **Weiterleiten** von Nachrichten (Forward) — später.
|
||||
- **Presence-Genauigkeit:** Nutzer, die den PC vor ~10 min unsauber heruntergefahren haben, werden teils noch als „online" angezeigt (Server expired Presence nicht zuverlässig). Außerdem: Online-Status im Chat-Header und in der Chat-Liste sind nicht immer konsistent — sollten dieselbe Quelle nutzen.
|
||||
- PDF-Seiten beim Upload vorrendern — bewusst zurückgestellt (geringer Mehrwert wegen Caches).
|
||||
- Selection-Menü-Politur (kein Bug, optional).
|
||||
@@ -1,6 +1,19 @@
|
||||
# Matrix / Element Feature Checklist für Pyramid
|
||||
|
||||
*Stand: April 2026 — Pyramid v0.1.x (Early Development)*
|
||||
*Letzte Tiefenanalyse: 2026-04-29 (Vergleich mit FluffyChat / Element Web)*
|
||||
|
||||
---
|
||||
|
||||
## Legende
|
||||
|
||||
- ✅ `[x]` — Implementiert und funktioniert korrekt
|
||||
- ⚠️ `[~]` — Teilweise / fehlerhaft implementiert (Details in Technische Analyse)
|
||||
- ❌ `[ ]` — Fehlt komplett
|
||||
- 🔴 P0 — Kritisch (Kernfunktionalität kaputt)
|
||||
- 🟠 P1 — Hoch (wichtige Funktion defekt oder nicht vorhanden)
|
||||
- 🟡 P2 — Mittel
|
||||
- 🔵 P3 — Niedrig / Nice-to-have
|
||||
|
||||
---
|
||||
|
||||
@@ -15,17 +28,19 @@
|
||||
- [x] Verschlüsselte Nachrichten anzeigen (Entschlüsselungsstatus)
|
||||
- [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden)
|
||||
- [x] Ungelesene-Nachrichten-Tracking
|
||||
- [~] Nachrichtenbearbeitung (Edit-Event erkannt, `getDisplayEvent` wird nicht gerendert) 🟠 P1
|
||||
- [~] Nachrichtenvorschau bei Reply (Zitat-Preview vorhanden, kein Jump-to-original) 🟡 P2
|
||||
|
||||
### Nachrichtenaktionen
|
||||
- [x] Nachrichtentext kopieren
|
||||
- [x] Eigene Nachrichten löschen (redact)
|
||||
- [x] Nachrichten anpinnen / Pinboard anzeigen
|
||||
- [~] Auf Nachrichten antworten (in-reply-to vorhanden, kein visuelles Zitatfeld)
|
||||
- [~] Nachrichten bearbeiten (m.edit im SDK erkannt, kein vollständiges UI)
|
||||
- [x] Auf Nachrichten antworten (in-reply-to)
|
||||
- [ ] Nachrichten weiterleiten
|
||||
- [ ] Zitat-Styling (visuelle Blockquote-Anzeige)
|
||||
|
||||
### Textformatierung
|
||||
### Textformatierung (formatted_body)
|
||||
- [~] Markdown-Rendering — `formatted_body` / `org.matrix.custom.html` wird nicht gesendet und nicht gerendert 🔵 P3
|
||||
- [ ] Fett (**text**)
|
||||
- [ ] Kursiv (*text*)
|
||||
- [ ] Durchgestrichen (~~text~~)
|
||||
@@ -33,7 +48,7 @@
|
||||
- [ ] Code-Block (```code```)
|
||||
- [ ] Aufzählungslisten
|
||||
- [ ] Klickbare Links
|
||||
- [ ] @Erwähnungen (User)
|
||||
- [~] @Erwähnungen — Button vorhanden, `onTap: null` 🟡 P2
|
||||
- [ ] #Kanal-Erwähnungen
|
||||
- [ ] Spoiler-Tags (||text||)
|
||||
|
||||
@@ -62,14 +77,118 @@
|
||||
- [x] Mehrere Reaktionen pro Nachricht
|
||||
- [x] Kategoriebasierte Emoji-Organisation
|
||||
|
||||
### Tipp-Indikatoren (Typing)
|
||||
- [~] `room.setTyping()` wird **nirgendwo aufgerufen** — Einstellung existiert, aber Composer sendet nie "tippt…" 🟡 P2
|
||||
- [ ] "X tippt gerade"-Anzeige empfangen (kein Widget)
|
||||
- [ ] Timeout nach Tipp-Ende
|
||||
|
||||
### Threads
|
||||
- [ ] Thread-Antworten (m.in_reply_to vorhanden, kein Thread-View)
|
||||
- [~] Thread-Button vorhanden (`onPressed: () {}` — leer) 🔵 P3
|
||||
- [ ] Thread aufklappen / eigene Ansicht
|
||||
- [ ] Thread-Benachrichtigungen
|
||||
- [ ] Thread als gelöst markieren
|
||||
|
||||
---
|
||||
|
||||
## Lesebestätigungen (Read Receipts)
|
||||
|
||||
- [x] Senden von `m.read`-Receipts via `room.setReadMarker()`
|
||||
- [x] Privacy-Toggle (kein Senden wenn deaktiviert)
|
||||
- [x] ✓-Icon (Nachricht gesendet/zugestellt) auf eigenen Nachrichten
|
||||
- [x] ✓✓-Icon (gelesen) in DMs
|
||||
- [x] Sende-Status (Uhr/Fehler/gesendet/gelesen)
|
||||
- [~] ✓✓-Logik nur für DMs — in Gruppenräumen wird `receiptState.global.otherUsers` nicht ausgewertet 🟡 P2
|
||||
- [~] **Off-by-one**: Divider erscheint *über* der Gruppe die das `fullyRead`-Event enthält, nicht darunter 🟡 P2
|
||||
- [ ] Gruppen-Read-Receipt-Display (Seen-by-Avatare wie in FluffyChat) 🟡 P2
|
||||
- [ ] Hover-Tooltip "Gelesen von: ..." auf ✓✓
|
||||
- [ ] **Notification-Cancellation**: `cancelNotification(roomId)` beim Setzen des Read-Markers 🟡 P2
|
||||
|
||||
---
|
||||
|
||||
## „Neue Nachrichten" Trennlinie
|
||||
|
||||
- [x] `_UnreadDivider`-Widget korrekt gestylt
|
||||
- [x] `_sessionFullyReadId` einmalig beim Öffnen erfasst (stabil für die Session)
|
||||
- [x] Keine Anzeige wenn Raum bereits gelesen
|
||||
- [~] **Position falsch** — Divider über der Gruppe mit `fullyRead`-Event statt darunter 🟡 P2
|
||||
- [ ] **Auto-Scroll zum Divider** beim Öffnen eines Raums mit ungelesenen Nachrichten 🟡 P2
|
||||
|
||||
---
|
||||
|
||||
## Presence / Zuletzt Online
|
||||
|
||||
- [x] `_PresenceLine` im DM-Header mit Live-Updates
|
||||
- [x] 4s-Debounce gegen Sync-Spike-Fehlmeldungen
|
||||
- [x] Relative Zeitanzeige (gerade eben / vor Xmin / vor Xh)
|
||||
- [x] Guard: eigene Presence wird nie angezeigt
|
||||
- [x] Presence-Dot im Rooms-Panel und Members-Panel
|
||||
- [x] `setPresence(online/offline)` in den Settings
|
||||
- [~] **Kein Auto-Heartbeat** — `setPresence` nur in Settings, nie periodisch während Nutzung 🔵 P3
|
||||
- [~] **Kein `WidgetsBindingObserver`** — App-Lifecycle (Hintergrund/Vordergrund) löst kein Presence-Event aus 🔵 P3
|
||||
- [ ] Presence-Dot in DM-Listen-Items (`room_list_item.dart`) 🔵 P3
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
### Local Notifications (App im Vordergrund)
|
||||
- [x] Android Local Notifications (`flutter_local_notifications`)
|
||||
- [x] Windows Toast-Notifications (`local_notifier`)
|
||||
- [x] Windows Taskbar-Badge (Progress-Error-Mode)
|
||||
- [x] Tap-Handler navigiert zum richtigen Raum (Android)
|
||||
- [x] Guards: nur fremde Nachrichten, nur aktive Rooms, nur `Membership.join`
|
||||
- [x] `notificationsBlockedProvider` + Banner in `app_shell.dart`
|
||||
- [~] **Alle 4 Notification-Prefs werden ignoriert** — `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` haben keinen Effekt 🟠 P1
|
||||
- [~] Android-Grouping ohne Summary-Notification 🔵 P3
|
||||
|
||||
### Remote Push (App im Hintergrund / geschlossen)
|
||||
- [ ] 🔴 **Keine Remote-Push-Integration** — FCM/UnifiedPush fehlt komplett, App muss laufen
|
||||
- [ ] iOS APNs-Setup
|
||||
- [ ] macOS-Notifications
|
||||
- [ ] Android Icon-Badge (Zahlenbadge)
|
||||
|
||||
---
|
||||
|
||||
## E2EE Verschlüsselungs-Pipeline
|
||||
|
||||
### Implementiert
|
||||
- [x] Vodozemac (Rust-Olm) via Flutter-Isolate — modern, performant
|
||||
- [x] Bootstrap: SSSS, Cross-Signing, Online-Key-Backup
|
||||
- [x] `_decryptLegacyEvents` — entschlüsselt ältere Events in-place
|
||||
- [x] Device-Key-Preload vor erstem Senden
|
||||
- [x] Key-Verification UI (SAS Emoji, QR-Code, alle States)
|
||||
- [x] Megolm Session Export/Import (Element-kompatibles Format)
|
||||
- [x] Encrypted-Attachment-Handling
|
||||
- [x] `m.fully_read`-Marker beim Timeline-Open
|
||||
- [x] `timeline.requestKeys(onlineKeyBackupOnly: false)`
|
||||
|
||||
### Fehlerhaft / Fehlend
|
||||
- [~] **"Entschlüsselt…"-Placeholder bleibt dauerhaft** wenn Key fehlt — kein Recovery-Button 🟠 P1
|
||||
- [~] `loadAllKeysFromRoom` Fehler werden silent geschluckt (`catch (_) {}`) 🟡 P2
|
||||
- [~] **Kein Incoming-Verification-Request-Handler** — `client.onKeyVerificationRequest` wird nicht abonniert 🔴 P0
|
||||
- [~] **Doppelte Bootstrap-Implementierung** (page vs. dialog) mit leicht inkonsistenter Logik 🔵 P3
|
||||
- [ ] Device-Trust-Indicator in der Timeline 🔵 P3
|
||||
- [ ] Unverified-Device-Warning beim Senden 🔵 P3
|
||||
- [ ] "Schlüssel anfordern"-Button bei dauerhaft verschlüsselten Events 🟠 P1
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
|
||||
- [x] Suchdialog öffnen (UI vorhanden)
|
||||
- [x] Server-seitige Volltextsuche via Matrix `/search` API
|
||||
- [x] Filter: Alle / Medien / Personen
|
||||
- [x] Ergebnisse: Raum + Absender + Body + Zeitstempel
|
||||
- [x] Keyboard-Navigation in Ergebnissen
|
||||
- [x] 400ms Debounce
|
||||
- [ ] Jump-to-Message bei Klick auf Suchergebnis
|
||||
- [ ] Suche nach Absender filtern
|
||||
- [ ] Filter nach Datum
|
||||
- [ ] Suchergebnisse im Chat hervorheben
|
||||
- [ ] Suchverlauf
|
||||
|
||||
---
|
||||
|
||||
## Raumverwaltung
|
||||
|
||||
### Grundfunktionen
|
||||
@@ -79,14 +198,12 @@
|
||||
- [x] Raumname & Avatar anzeigen
|
||||
- [x] Verschlüsselungsstatus (Schloss-Icon)
|
||||
- [x] Einladungen annehmen / ablehnen
|
||||
- [ ] Neuen Raum erstellen
|
||||
- [ ] Neuen Raum erstellen (UI-Einstiegspunkt)
|
||||
- [ ] Raumname bearbeiten
|
||||
- [ ] Raumthema/Beschreibung bearbeiten
|
||||
- [ ] Raum-Avatar ändern
|
||||
- [ ] Raumvisibilität (öffentlich / privat)
|
||||
- [ ] Zugriffsregeln (invite/knock/public)
|
||||
- [ ] Verlaufsvisibilität konfigurieren
|
||||
- [ ] Raum-Aliases verwalten
|
||||
|
||||
### Direktnachrichten (DMs)
|
||||
- [x] 1:1 DM-Räume anzeigen & öffnen
|
||||
@@ -94,261 +211,214 @@
|
||||
- [x] Echtzeit-Presence-Updates
|
||||
- [x] Sprachanruf aus DM starten
|
||||
- [x] Videoanruf aus DM starten
|
||||
- [ ] DM starten (neuen Chat beginnen, nur über Suchfeld)
|
||||
- [ ] DM starten (neuen Chat beginnen)
|
||||
|
||||
### Mitglieder & Rollen
|
||||
- [x] Mitgliederliste anzeigen
|
||||
- [x] Mitgliederanzahl anzeigen
|
||||
- [x] Avatar & Anzeigename der Mitglieder
|
||||
- [x] Nutzer in Raum einladen (Raum-Kontextmenü)
|
||||
- [x] Nutzer in Raum einladen
|
||||
- [ ] Nutzer aus Raum entfernen (Kick)
|
||||
- [ ] Nutzer sperren (Ban)
|
||||
- [ ] Power Levels bearbeiten
|
||||
- [ ] Nutzer zum Moderator / Admin befördern
|
||||
- [ ] Bans aufheben
|
||||
|
||||
### Raumeinstellungen
|
||||
- [x] Raumeinstellungen-Dialog (Name, Thema, Moderationsregeln)
|
||||
- [ ] Raumverschlüsselung aktivieren/deaktivieren
|
||||
- [ ] Erweiterte Raumeinstellungen (Fluffychat-Level)
|
||||
- [ ] Widget-/App-Integration in Räume
|
||||
|
||||
---
|
||||
|
||||
## Spaces & Gemeinschaften
|
||||
|
||||
### Spaces
|
||||
- [x] Spaces in der Seitenleiste anzeigen
|
||||
- [x] Zwischen Spaces navigieren
|
||||
- [x] Space-Avatar anzeigen
|
||||
- [x] Space-Banner hochladen & anzeigen (Discord-Style)
|
||||
- [x] Space-Avatar & Banner anzeigen
|
||||
- [x] Räume nach Space filtern
|
||||
- [x] Virtuelle "Rooms"- und "DMs"-Spaces
|
||||
- [x] Unterkategorien (Sub-Spaces) als Kategorien in der Kanalleiste
|
||||
- [x] Unterkategorien (Sub-Spaces)
|
||||
- [x] Drag-and-drop Kanalreihenfolge für Admins
|
||||
- [x] Space-Verwaltungsdialog (Name, Avatar, Banner, Einladungen, Mitglieder, Power Levels)
|
||||
- [x] Space-Verwaltungsdialog
|
||||
- [ ] Neuen Space erstellen
|
||||
- [ ] Räume zum Space hinzufügen / entfernen
|
||||
- [ ] Space-Regeln / Beschreibung
|
||||
- [ ] Space-Entdeckung (öffentliche Spaces durchsuchen)
|
||||
|
||||
### Kanalorganisation
|
||||
- [x] Kanäle nach letzter Aktivität sortieren
|
||||
- [x] Ungelesene-Badge auf Kanälen
|
||||
- [x] Sprachkanal-Teilnehmerliste (Discord-Style, mit Echtzeit-Updates)
|
||||
- [x] Kollabierbare Kategorien
|
||||
- [ ] Favoriten / angeheftete Räume
|
||||
- [ ] Räume stummschalten
|
||||
- [ ] Räume ausblenden / archivieren
|
||||
- [ ] Kanalreihenfolge für reguläre Nutzer
|
||||
- [ ] Space-Entdeckung (öffentliche Spaces)
|
||||
|
||||
---
|
||||
|
||||
## Sprach- & Videoanrufe
|
||||
## Sprach- & Videoanrufe (LiveKit)
|
||||
|
||||
### Sprachanrufe (LiveKit)
|
||||
- [x] Sprachkanal betreten / verlassen (Toggle)
|
||||
- [x] Sprachkanal betreten / verlassen
|
||||
- [x] Mikrofon stummschalten / Deafen
|
||||
- [x] Teilnehmerliste im Sprachkanal
|
||||
- [x] Mikrofon stummschalten
|
||||
- [x] Audio-Ausgabe deaktivieren (Deafen)
|
||||
- [x] Anruf-UI im Profil-Panel (aktiver Anruf)
|
||||
- [x] Anruf-Ansicht öffnen (Vollansicht)
|
||||
- [x] Mini-Call-Widget bei VOIP-Anrufen
|
||||
- [x] Anruf-UI (Vollansicht + Mini-Widget)
|
||||
- [x] Videoanruf mit Kamera-Toggle
|
||||
- [ ] Klingelton bei eingehenden Anrufen
|
||||
- [ ] Anruf annehmen / ablehnen (UI)
|
||||
- [ ] Anrufdauer-Timer
|
||||
- [ ] Anrufverlauf
|
||||
|
||||
### Videoanrufe
|
||||
- [x] Videoanruf starten (aus DM)
|
||||
- [x] Kamera-Toggle
|
||||
- [x] Videoteilnehmer-Anzeige
|
||||
- [ ] Bildschirmfreigabe (Toggle-UI vorhanden, Implementierung unklar)
|
||||
- [ ] Picture-in-Picture-Modus
|
||||
- [ ] Virtueller Hintergrund
|
||||
|
||||
### Gruppenanrufe
|
||||
- [x] Mehrere Teilnehmer im Sprachkanal (LiveKit)
|
||||
- [ ] Gruppen-Videogitter
|
||||
- [ ] Spotlight-/Galerie-Ansicht
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen & Presence
|
||||
|
||||
### Push-Benachrichtigungen
|
||||
- [ ] Desktop-Benachrichtigungen (Windows/macOS/Linux)
|
||||
- [ ] Mobile Push-Benachrichtigungen (Android/iOS)
|
||||
- [ ] Benachrichtigungseinstellungen pro Raum
|
||||
- [ ] Nicht-Stören-Modus
|
||||
- [ ] Benachrichtigungssound konfigurieren
|
||||
|
||||
### Presence & Status
|
||||
- [x] Nutzer-Presence abrufen (online/offline/abwesend)
|
||||
- [x] Presence-Indikator anzeigen
|
||||
- [x] Letzte-Aktivität-Zeitstempel
|
||||
- [x] Echtzeit-Presence-Updates
|
||||
- [ ] Eigenen Status setzen (online/abwesend/unsichtbar)
|
||||
- [ ] Benutzerdefinierte Statusmeldung
|
||||
- [ ] Aktivitätsbasierte Status-Änderung
|
||||
|
||||
### Tipp-Indikatoren
|
||||
- [ ] "X tippt gerade"-Anzeige empfangen
|
||||
- [ ] Eigene Tipp-Benachrichtigung senden
|
||||
- [ ] Timeout nach Tipp-Ende
|
||||
- [ ] Bildschirmfreigabe
|
||||
|
||||
---
|
||||
|
||||
## Nutzereinstellungen & Profil
|
||||
|
||||
### Profilverwaltung
|
||||
- [x] Eigene User-ID anzeigen
|
||||
- [x] Eigenen Avatar anzeigen (Initialen-Fallback)
|
||||
- [x] Anzeigenamen anzeigen
|
||||
- [ ] Anzeigenamen bearbeiten
|
||||
- [ ] Eigenen Avatar hochladen / entfernen
|
||||
- [ ] Status-Nachricht setzen
|
||||
|
||||
### Kontosicherheit
|
||||
- [x] Hell / Dunkel-Theme, Akzentfarbe, Eckradius, Dichte
|
||||
- [x] Eigene User-ID, Avatar (Initialen-Fallback), Anzeigename anzeigen
|
||||
- [ ] Anzeigenamen & Avatar bearbeiten
|
||||
- [ ] Passwort ändern
|
||||
- [ ] E-Mail-Adresse verwalten
|
||||
- [ ] Sitzungsverwaltung (alle Sitzungen anzeigen)
|
||||
- [ ] Andere Sitzungen abmelden
|
||||
- [ ] Konto deaktivieren / löschen
|
||||
- [ ] Zwei-Faktor-Authentifizierung (2FA)
|
||||
|
||||
### App-Einstellungen
|
||||
- [x] Hell / Dunkel-Theme
|
||||
- [x] Akzentfarbe (6 Voreinstellungen)
|
||||
- [x] Eckradius anpassen
|
||||
- [x] UI-Dichte / Kompaktheit
|
||||
- [x] Animationssteuerung
|
||||
- [ ] Schriftgröße anpassen
|
||||
- [ ] Sprachauswahl (i18n)
|
||||
- [ ] Kompaktansicht (kein Avatar in Nachrichten)
|
||||
|
||||
---
|
||||
|
||||
## Datenschutz & Sicherheit
|
||||
|
||||
### Ende-zu-Ende-Verschlüsselung (E2EE)
|
||||
- [x] E2EE-Unterstützung (Olm/Megolm via vodozemac)
|
||||
- [x] E2EE-Schlüssel beim ersten Login einrichten (Bootstrap)
|
||||
- [x] Verschlüsselte Nachrichten-Indikatoren
|
||||
- [x] Entschlüsselungsstatus anzeigen
|
||||
- [x] SSSS (Symmetric Secret Storage)
|
||||
- [x] Recovery-Key erzeugen & anzeigen
|
||||
- [x] Recovery-Key aus bestehender Einrichtung importieren
|
||||
- [x] Schlüssel-Caching (lokal speichern)
|
||||
- [x] Session-Keys von anderen Geräten anfordern
|
||||
- [ ] Schlüssel-Backup (Server-seitig)
|
||||
- [ ] Schlüssel-Backup verifizieren
|
||||
|
||||
### Geräte-Verifizierung
|
||||
- [x] Cross-Signing (Master/Self-Signing/User-Signing Keys)
|
||||
- [x] Gerät per Emoji verifizieren
|
||||
- [x] Gerät per Zahlencodes verifizieren
|
||||
- [x] Geräte als vertrauenswürdig markieren
|
||||
- [ ] Gerätename / Modell anzeigen
|
||||
- [ ] Sitzungsverwaltung (alle Geräte dieses Accounts)
|
||||
- [ ] Fremde Geräte abmelden
|
||||
|
||||
### Vertrauen & Sicherheit
|
||||
- [ ] Geräte-Vertrauensstatus-Indikator in Nachrichten
|
||||
- [ ] Nutzer blockieren / entsperren
|
||||
- [ ] Blockierte Nutzer-Liste
|
||||
- [ ] Verifizierter-Nutzer-Badge
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
|
||||
### Nachrichtensuche
|
||||
- [x] Suchdialog öffnen (UI vorhanden)
|
||||
- [ ] Volltextsuche in Nachrichteninhalten
|
||||
- [ ] Filter nach Absender
|
||||
- [ ] Filter nach Datum
|
||||
- [ ] Filter nach Raum
|
||||
- [ ] Filter nach Medientyp
|
||||
- [ ] Suchergebnisse hervorheben
|
||||
- [ ] Suchverlauf
|
||||
|
||||
### Nutzer- / Raumsuche
|
||||
- [ ] Nutzer nach Anzeigenamen suchen
|
||||
- [ ] Nutzer nach User-ID suchen
|
||||
- [ ] Öffentliche Räume entdecken (Directory)
|
||||
- [ ] Eigene Räume filtern
|
||||
- [ ] Räume nach Name / Thema suchen
|
||||
- [ ] Sitzungsverwaltung
|
||||
- [ ] Konto deaktivieren
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update
|
||||
|
||||
- [x] GitHub-Releases als Update-Quelle
|
||||
- [x] Versions-Vergleich (SemVer)
|
||||
- [x] Rate-Limiting (max. alle 4 Stunden prüfen)
|
||||
- [x] Update-Banner im App-Header (Windows)
|
||||
- [x] Versions-Vergleich (SemVer), Rate-Limiting, Update-Banner
|
||||
- [x] "Diese Version überspringen"-Option
|
||||
- [x] Manueller Update-Check in den Einstellungen
|
||||
- [ ] Automatischer Download & Installation (Squirrel/NSIS)
|
||||
- [ ] Delta-Updates (nur geänderte Dateien)
|
||||
- [ ] Update-Kanal wählen (Stable/Beta/Nightly)
|
||||
- [ ] Automatischer Download & Installation
|
||||
|
||||
---
|
||||
|
||||
## Plattform-spezifisch
|
||||
|
||||
### Desktop (Windows / macOS / Linux)
|
||||
- [x] Native Desktop-App (Flutter Desktop)
|
||||
- [x] Tastenkürzel (Enter = Senden, Shift+Enter = Zeile)
|
||||
- [x] Drag-and-Drop für Dateianhänge
|
||||
### Desktop
|
||||
- [x] Native Desktop-App, Tastenkürzel, Drag-and-Drop
|
||||
- [ ] System-Tray-Integration
|
||||
- [ ] Desktop-Benachrichtigungen
|
||||
- [ ] Minimieren in System-Tray
|
||||
- [ ] Fenster-State speichern (Größe, Position)
|
||||
- [ ] MSIX / Installer-Paketierung
|
||||
|
||||
### Mobile (Android / iOS)
|
||||
- [x] Mobile App (Flutter)
|
||||
- [x] Drawer-Navigation (Hamburger-Menü)
|
||||
- [ ] Touch-optimiertes UI (derzeit Desktop-first)
|
||||
### Mobile
|
||||
- [x] Mobile App (Flutter), Drawer-Navigation
|
||||
- [ ] Touch-optimiertes UI
|
||||
- [ ] Mobile Push-Benachrichtigungen
|
||||
- [ ] Zurück-Geste Navigation
|
||||
- [ ] Bottom-Sheet-Modals
|
||||
- [ ] Share-Sheet-Integration
|
||||
- [ ] Haptisches Feedback
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Technische Tiefenanalyse (2026-04-29)
|
||||
|
||||
*Vergleichsbasis: FluffyChat (krille-chan/fluffychat) und Element Web*
|
||||
*Format: Befund → betroffene Datei:Zeile → Referenz-Implementierung*
|
||||
|
||||
---
|
||||
|
||||
## IMPLEMENTIERUNGS-BACKLOG (nach Priorität)
|
||||
|
||||
### 🔴 P0 — Kritisch
|
||||
|
||||
#### [P0-1] Incoming Key-Verification-Request-Handler fehlt
|
||||
**Problem:** Wenn ein anderes Gerät eine Verifikations-Anfrage (`m.key.verification.request`) an Pyramid schickt, gibt es keinen Listener. Die Anfrage läuft ab, ohne dass der Nutzer sie sieht. Das bedeutet: andere Geräte können Pyramid de facto nicht verifizieren — nur Pyramid kann aktiv verifizieren.
|
||||
**Betroffene Datei:** `lib/widgets/settings_modal.dart` (kein `onKeyVerificationRequest`-Listener)
|
||||
**FluffyChat-Referenz:** `lib/pages/key_verification/key_verification_dialog.dart` + `client.onKeyVerificationRequest.stream.listen(...)` in `lib/pages/matrix.dart`
|
||||
**Fix:** Stream auf `client.onKeyVerificationRequest` in `matrix_client.dart` oder `app_shell.dart` subscriben und `KeyVerificationDialog` öffnen.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 P1 — Hoch
|
||||
|
||||
#### [P1-1] Notification-Prefs werden ignoriert
|
||||
**Problem:** `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` sind in `settings_prefs.dart` gespeichert, werden in `notification_service.dart/_watchEvents()` aber nie ausgelesen. Notifications erscheinen immer, unabhängig von Nutzereinstellungen.
|
||||
**Betroffene Datei:** `lib/core/notification_service.dart`, Zeile 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
|
||||
|
||||
| Bereich | Implementiert | Teilweise | Fehlend |
|
||||
| Bereich | Implementiert | Fehlerhaft | Fehlend |
|
||||
|---|---|---|---|
|
||||
| Messaging | 14 | 2 | 14 |
|
||||
| Raumverwaltung | 8 | 0 | 14 |
|
||||
| Spaces | 10 | 0 | 6 |
|
||||
| Anrufe | 8 | 1 | 10 |
|
||||
| Benachrichtigungen | 4 | 0 | 8 |
|
||||
| Einstellungen/Profil | 5 | 0 | 11 |
|
||||
| Sicherheit/E2EE | 9 | 0 | 7 |
|
||||
| Suche | 1 | 0 | 10 |
|
||||
| Auto-Update | 6 | 0 | 3 |
|
||||
| Plattform | 5 | 0 | 10 |
|
||||
| **Gesamt** | **~70** | **~3** | **~93** |
|
||||
| Chat-Mechanik | Solide Basis | Edits, Typing, `requestFuture` | Jump-to-quote, Threads, Edited-Badge |
|
||||
| Read Receipts | DM-Logik ✓ | Gruppen, Off-by-one, Notif-Cancel | Seen-by-Avatare, Tooltip |
|
||||
| Presence | Anzeige ✓ | Broadcasting, Lifecycle | Heartbeat, Dot in Raumliste |
|
||||
| Benachrichtigungen | Local ✓ | Prefs ignoriert, Grouping | Remote Push, iOS, macOS |
|
||||
| Unread-Divider | Konzept ✓ | Position, Auto-Scroll | — |
|
||||
| E2EE | Stärkster Bereich | Incoming-Verif., Placeholder | Device-Trust-Indicator, Warn beim Senden |
|
||||
| Suche | API-Basis ✓ | — | Jump-to-message, Filter |
|
||||
|
||||
**Implementierungsgrad: ~42 %** (inkl. Teilimplementierungen)
|
||||
**Implementierungsgrad gesamt: ~42 %** (inkl. Teilimplementierungen)
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Feature-Prioritäten
|
||||
## Implementierungs-Fortschritt
|
||||
|
||||
1. **Textformatierung** — häufig genutzt, von Nutzern erwartet
|
||||
2. **Push-Benachrichtigungen** — Desktop-Benachrichtigungen für Windows
|
||||
3. **Tipp-Indikatoren** — verbessert Gesprächsgefühl deutlich
|
||||
4. **Nachrichtenbearbeitung** — SDK-Grundlage vorhanden, nur UI fehlt
|
||||
5. **Profilbearbeitung** — Avatar & Anzeigename ändern
|
||||
6. **Raum erstellen** — Basis-Workflow, derzeit nicht möglich
|
||||
7. **Sprachnachricht abspielen** — Struktur vorhanden
|
||||
8. **Suche** — Dialog vorhanden, Backend-Abfrage fehlt
|
||||
9. **Nutzer blockieren** — Sicherheits-Grundfunktion
|
||||
10. **System-Tray** — Desktop-Erlebnis verbessern
|
||||
| ID | Beschreibung | Status |
|
||||
|---|---|---|
|
||||
| P0-1 | Incoming Key-Verification-Request-Handler | ✅ War bereits implementiert (`app_shell.dart`) |
|
||||
| P1-1 | Notification-Prefs auswerten | ✅ Implementiert (`notification_service.dart`) |
|
||||
| P1-2 | Edits rendern (`getDisplayEvent`) | ✅ Implementiert (`message_group.dart`) |
|
||||
| P1-3 | Encrypted-Placeholder + Recovery-Button | ✅ Implementiert (`message_group.dart`) |
|
||||
| P2-1 | Typing: `room.setTyping()` senden | ✅ Implementiert (`chat_composer.dart`) |
|
||||
| P2-2 | Typing: Display-Widget | ✅ Implementiert (`chat_view.dart` — `_TypingIndicator`) |
|
||||
| P2-3 | Unread-Divider: Position + leere fullyRead-Guard | ✅ Neu implementiert (`chat_view.dart`) |
|
||||
| P2-4 | Unread-Divider: Auto-Scroll (Schätzung) | ✅ Implementiert (`_scrollToUnread`) |
|
||||
| P2-5 | Gruppen-Read-Receipts (DM-Guard entfernt) | ✅ Implementiert (`chat_view.dart`) |
|
||||
| P2-6 | Notification-Cancellation beim Lesen | ✅ Implementiert (`chat_view.dart/_markRead`) |
|
||||
| P3-1 | Presence: Auto-Heartbeat (10s) | ✅ Implementiert (`app_shell.dart`) |
|
||||
| P3-2 | Presence: App-Lifecycle-Hook | ✅ Implementiert (`app_shell.dart` — `WidgetsBindingObserver`) |
|
||||
| P3-3 | HTML/Markdown (`_MatrixHtmlText`, `_PlainLinkText`) | ✅ Implementiert (`message_group.dart`) |
|
||||
| P3-4 | `requestFuture()` | ✅ Implementiert (`chat_view.dart/_onScroll`) |
|
||||
| P3-5 | `message_bubble.dart` entfernen | ⏳ Offen |
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# 01 — Authentifizierung & Session
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Login mit Passwort](#login-mit-passwort)
|
||||
- [Login mit Token](#login-mit-token)
|
||||
- [SSO / OAuth](#sso--oauth)
|
||||
- [Register](#registrierung)
|
||||
- [Session prüfen & wiederherstellen](#session-prüfen--wiederherstellen)
|
||||
- [Logout](#logout)
|
||||
- [Multi-Account / Device](#multi-account--device)
|
||||
- [Homeserver ermitteln](#homeserver-ermitteln)
|
||||
- [Flows & Login-Typen abfragen](#flows--login-typen-abfragen)
|
||||
|
||||
---
|
||||
|
||||
## Login mit Passwort
|
||||
|
||||
```dart
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
final loginResponse = await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
password: 'geheimesPasswort',
|
||||
identifier: AuthenticationUserIdentifier(user: '@alice:example.org'),
|
||||
// oder nur Username (ohne Server):
|
||||
// identifier: AuthenticationUserIdentifier(user: 'alice'),
|
||||
initialDeviceDisplayName: 'Pyramid Android',
|
||||
);
|
||||
// loginResponse.accessToken, .deviceId, .userId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Login mit Token
|
||||
|
||||
```dart
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
await client.login(
|
||||
LoginType.mLoginToken,
|
||||
token: 'einmaliger_login_token',
|
||||
initialDeviceDisplayName: 'Pyramid',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSO / OAuth
|
||||
|
||||
```dart
|
||||
// Verfügbare Login-Flows abfragen
|
||||
final flows = await client.getLoginFlows();
|
||||
final hasSso = flows.flows?.any((f) => f.type == AuthenticationTypes.sso) ?? false;
|
||||
|
||||
// SSO-URL erzeugen (öffnet Browser/WebView)
|
||||
final redirectUri = 'io.pyramid.app://login-callback';
|
||||
final ssoUrl = client.homeserver!.toString() +
|
||||
'/_matrix/client/v3/login/sso/redirect?redirectUrl=${Uri.encodeComponent(redirectUri)}';
|
||||
// → Uri im Browser öffnen, nach Redirect den Token extrahieren
|
||||
|
||||
// Nach Redirect mit Token einloggen
|
||||
await client.login(
|
||||
LoginType.mLoginToken,
|
||||
token: tokenAusRedirectUrl,
|
||||
initialDeviceDisplayName: 'Pyramid',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registrierung
|
||||
|
||||
```dart
|
||||
// Homeserver-Registrierung prüfen
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
// UIAA-Flow starten
|
||||
try {
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.register(
|
||||
username: 'neuernutzer',
|
||||
password: 'sicheresPasswort',
|
||||
initialDeviceDisplayName: 'Pyramid',
|
||||
auth: auth,
|
||||
),
|
||||
);
|
||||
} on MatrixException catch (e) {
|
||||
// e.error: M_USER_IN_USE, M_INVALID_USERNAME, etc.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session prüfen & wiederherstellen
|
||||
|
||||
```dart
|
||||
// Nach client.init() prüfen ob Session noch gültig ist
|
||||
if (client.isLogged()) {
|
||||
print('Eingeloggt als: ${client.userID}');
|
||||
print('Homeserver: ${client.homeserver}');
|
||||
print('Device ID: ${client.deviceID}');
|
||||
print('Access Token: ${client.accessToken}');
|
||||
}
|
||||
|
||||
// Auf Login-State reagieren
|
||||
client.onLoginStateChanged.stream.listen((LoginState state) {
|
||||
switch (state) {
|
||||
case LoginState.loggedIn: // Session aktiv
|
||||
case LoginState.softLoggedOut: // Token abgelaufen, Re-Auth nötig
|
||||
case LoginState.loggedOut: // Ausgeloggt
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logout
|
||||
|
||||
```dart
|
||||
// Nur dieses Device
|
||||
await client.logout();
|
||||
|
||||
// Alle Devices
|
||||
await client.logoutAll();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Account / Device
|
||||
|
||||
```dart
|
||||
// Eigene Devices (Sessions) auflisten
|
||||
final devices = await client.getDevices();
|
||||
for (final device in devices ?? []) {
|
||||
print('${device.deviceId}: ${device.displayName} — ${device.lastSeenIp}');
|
||||
}
|
||||
|
||||
// Device umbenennen
|
||||
await client.updateDevice(
|
||||
deviceId: 'DEVICEID',
|
||||
displayName: 'Pyramid (Wohnzimmer)',
|
||||
);
|
||||
|
||||
// Device löschen (andere Session killen)
|
||||
await client.deleteDevice(
|
||||
'ANDERES_DEVICE_ID',
|
||||
auth: AuthenticationPassword(
|
||||
identifier: AuthenticationUserIdentifier(user: client.userID!),
|
||||
password: 'meinPasswort',
|
||||
),
|
||||
);
|
||||
|
||||
// Mehrere Devices auf einmal löschen
|
||||
await client.deleteDevices(
|
||||
['DEVICE1', 'DEVICE2'],
|
||||
auth: AuthenticationPassword(...),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Homeserver ermitteln
|
||||
|
||||
```dart
|
||||
// .well-known auflösen (Autodiscovery)
|
||||
final discovery = await client.checkHomeserver(
|
||||
Uri.parse('https://example.org'), // Domain reicht, SDK findet Matrix-Server
|
||||
);
|
||||
// client.homeserver ist jetzt gesetzt
|
||||
|
||||
// Direkt mit bekannter HS-URL
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
// Homeserver-Versioninfo
|
||||
final serverVersion = await client.getVersions();
|
||||
print(serverVersion.versions); // ['v1.1', 'v1.2', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flows & Login-Typen abfragen
|
||||
|
||||
```dart
|
||||
final response = await client.getLoginFlows();
|
||||
for (final flow in response.flows ?? []) {
|
||||
switch (flow.type) {
|
||||
case AuthenticationTypes.password: // m.login.password
|
||||
case AuthenticationTypes.sso: // m.login.sso
|
||||
case AuthenticationTypes.token: // m.login.token
|
||||
case 'm.login.cas': // Legacy-CAS
|
||||
}
|
||||
}
|
||||
|
||||
// SSO-Provider Details (für Brand-Buttons im UI)
|
||||
final ssoProviders = response.flows
|
||||
?.where((f) => f.type == AuthenticationTypes.sso)
|
||||
.expand((f) => f.identityProviders ?? [])
|
||||
.toList();
|
||||
// ssoProvider.id, .name, .brand, .icon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UIAA (User-Interactive Auth) Flows
|
||||
|
||||
```dart
|
||||
// Generisches UIAA-Handling (z.B. für Account-Löschung)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.deactivateAccount(auth: auth, eraseData: false),
|
||||
);
|
||||
|
||||
// Passwort ändern (braucht Re-Auth)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.changePassword(
|
||||
'neuesPasswort',
|
||||
auth: auth,
|
||||
oldPassword: 'altesPasswort', // optional, falls Server es erlaubt
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
```dart
|
||||
try {
|
||||
await client.login(...);
|
||||
} on MatrixException catch (e) {
|
||||
switch (e.error) {
|
||||
case MatrixError.M_FORBIDDEN: // Falsches Passwort
|
||||
case MatrixError.M_USER_IN_USE: // Username vergeben
|
||||
case MatrixError.M_INVALID_USERNAME:
|
||||
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limiting
|
||||
case MatrixError.M_UNKNOWN:
|
||||
}
|
||||
print(e.errorMessage); // Menschenlesbarer Text vom Server
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,436 @@
|
||||
# 02 — Räume & Spaces
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Raum erstellen](#raum-erstellen)
|
||||
- [Raum beitreten](#raum-beitreten)
|
||||
- [Raum verlassen](#raum-verlassen)
|
||||
- [Raumliste](#raumliste)
|
||||
- [Raum-Metadaten lesen](#raum-metadaten-lesen)
|
||||
- [Raum-Einstellungen ändern](#raum-einstellungen-ändern)
|
||||
- [Mitglieder](#mitglieder)
|
||||
- [Einladen & Kick & Ban](#einladen--kick--ban)
|
||||
- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis)
|
||||
- [Spaces](#spaces)
|
||||
- [Room Upgrades](#room-upgrades)
|
||||
|
||||
---
|
||||
|
||||
## Raum erstellen
|
||||
|
||||
```dart
|
||||
// Einfacher privater Raum
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Mein Raum',
|
||||
topic: 'Willkommen!',
|
||||
visibility: Visibility.private, // oder .public
|
||||
preset: CreateRoomPreset.privateChat, // oder .publicChat, .trustedPrivateChat
|
||||
);
|
||||
|
||||
// Raum mit Einladungen
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Projekt Alpha',
|
||||
invite: ['@bob:example.org', '@charlie:example.org'],
|
||||
enableEncryption: true, // E2EE aktivieren
|
||||
);
|
||||
|
||||
// Raum mit Alias
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Öffentlicher Kanal',
|
||||
roomAliasName: 'kanal', // → #kanal:example.org
|
||||
visibility: Visibility.public,
|
||||
preset: CreateRoomPreset.publicChat,
|
||||
);
|
||||
|
||||
// Raum mit Custom Power Levels
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Moderiert',
|
||||
powerLevelContentOverride: PowerLevelsContent(
|
||||
eventsDefault: 50, // Nur Moderatoren senden
|
||||
usersDefault: 0,
|
||||
users: {'@admin:example.org': 100},
|
||||
),
|
||||
);
|
||||
|
||||
// Raum mit History-Visibility
|
||||
final roomId = await client.createRoom(
|
||||
initialState: [
|
||||
StateEvent(
|
||||
type: EventTypes.HistoryVisibility,
|
||||
content: {'history_visibility': 'invited'}, // shared|invited|joined|world_readable
|
||||
),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum beitreten
|
||||
|
||||
```dart
|
||||
// Per Raum-ID
|
||||
await client.joinRoom('!roomId:server');
|
||||
|
||||
// Per Alias
|
||||
await client.joinRoomOrAlias('#kanal:example.org');
|
||||
|
||||
// Per Invite-Link / Matrix-URI
|
||||
// matrix:r/kanal:example.org
|
||||
// https://matrix.to/#/#kanal:example.org
|
||||
final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org');
|
||||
if (uri != null) {
|
||||
await client.joinRoomOrAlias(uri.primaryIdentifier);
|
||||
}
|
||||
|
||||
// Mit Via-Servern (für föderierte Räume)
|
||||
await client.joinRoom(
|
||||
'!roomId:remoteserver.org',
|
||||
serverName: ['remoteserver.org', 'matrix.org'],
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum verlassen
|
||||
|
||||
```dart
|
||||
// Verlassen (bleibt in Left-State)
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
await room.leave();
|
||||
|
||||
// Vergessen (entfernt aus Raumliste)
|
||||
await room.forget();
|
||||
|
||||
// Verlassen + Vergessen
|
||||
await room.leave();
|
||||
await room.forget();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raumliste
|
||||
|
||||
```dart
|
||||
// Alle Räume (joined + invited)
|
||||
final allRooms = client.rooms;
|
||||
|
||||
// Nur beigetretene
|
||||
final joinedRooms = client.rooms
|
||||
.where((r) => r.membership == Membership.join)
|
||||
.toList();
|
||||
|
||||
// Nur Einladungen
|
||||
final invites = client.rooms
|
||||
.where((r) => r.membership == Membership.invite)
|
||||
.toList();
|
||||
|
||||
// Sortiert nach letzter Aktivität
|
||||
final sorted = [...client.rooms]
|
||||
..sort((a, b) => (b.lastEvent?.originServerTs ?? DateTime(0))
|
||||
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
||||
|
||||
// Raum per ID
|
||||
final room = client.getRoomById('!roomId:server');
|
||||
|
||||
// Raum per Alias auflösen
|
||||
final resolved = await client.getRoomIdByAlias('#alias:server');
|
||||
// resolved.roomId
|
||||
|
||||
// Ungelesene Nachrichten
|
||||
final unread = client.rooms.fold<int>(
|
||||
0, (sum, r) => sum + r.notificationCount);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Metadaten lesen
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Basis-Info
|
||||
print(room.id); // !roomId:server
|
||||
print(room.name); // Anzeigename (aufgelöst)
|
||||
print(room.topic); // Thema
|
||||
print(room.avatar); // mxc://… URI
|
||||
print(room.canonicalAlias); // #alias:server
|
||||
print(room.membership); // Membership.join/invite/leave/ban
|
||||
print(room.isDirectChat); // DM?
|
||||
print(room.directChatMatrixID); // Gegenüber-MXID bei DM
|
||||
print(room.isEncrypted); // E2EE aktiv?
|
||||
print(room.joinRules); // JoinRules.public/invite/knock/restricted
|
||||
print(room.guestAccess); // GuestAccess.forbidden/canJoin
|
||||
print(room.historyVisibility); // HistoryVisibility.*
|
||||
print(room.notificationCount); // Ungelesene Notifications
|
||||
print(room.highlightCount); // Mentions
|
||||
print(room.summary.mJoinedMemberCount); // Mitgliederzahl
|
||||
|
||||
// Letztes Event
|
||||
final last = room.lastEvent;
|
||||
print(last?.body);
|
||||
print(last?.originServerTs);
|
||||
|
||||
// Display-Name (lokalisiert, wie FluffyChat)
|
||||
final displayName = room.getLocalizedDisplayname();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen ändern
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Name
|
||||
await room.setName('Neuer Name');
|
||||
|
||||
// Thema
|
||||
await room.setDescription('Neues Thema');
|
||||
|
||||
// Avatar (mxc:// URI nach Upload)
|
||||
final uri = await client.uploadContent(imageBytes, filename: 'avatar.png');
|
||||
await room.setAvatar(uri);
|
||||
|
||||
// Alias setzen / entfernen
|
||||
await client.setRoomAlias('#neu:example.org', room.id);
|
||||
await client.deleteRoomAlias('#alt:example.org');
|
||||
await room.setCanonicalAlias('#neu:example.org'); // Kanonischen Alias setzen
|
||||
|
||||
// Join-Rules
|
||||
await room.setJoinRules(JoinRules.invite); // private
|
||||
await room.setJoinRules(JoinRules.public); // öffentlich
|
||||
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [SpaceRoomJoinRulesAllowInner(
|
||||
roomId: '!spaceId:server',
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
)],
|
||||
);
|
||||
|
||||
// History-Visibility
|
||||
await room.setHistoryVisibility(HistoryVisibility.shared);
|
||||
// .shared | .invited | .joined | .worldReadable
|
||||
|
||||
// Guest Access
|
||||
await room.setGuestAccess(GuestAccess.canJoin);
|
||||
await room.setGuestAccess(GuestAccess.forbidden);
|
||||
|
||||
// E2EE aktivieren (nicht mehr deaktivierbar!)
|
||||
await room.enableEncryption();
|
||||
|
||||
// Generischer State-Event senden
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.room.custom_event',
|
||||
{'key': 'value'},
|
||||
stateKey: '',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mitglieder
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Alle Mitglieder (gecacht)
|
||||
final members = room.getParticipants();
|
||||
|
||||
// Vollständige Mitgliederliste vom Server laden
|
||||
final allMembers = await room.requestParticipants();
|
||||
|
||||
// Einzelnen User holen
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback('@alice:server');
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
print(user.powerLevel);
|
||||
print(user.membership); // Membership.join/invite/leave/ban/knock
|
||||
|
||||
// Aktuell tippende User
|
||||
final typingUsers = room.typingUsers; // List<User>
|
||||
|
||||
// Power Level eines Users
|
||||
final pl = room.getPowerLevelByUserId('@alice:server');
|
||||
|
||||
// Eigener Power Level
|
||||
final myPl = room.ownPowerLevel;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Einladen, Kick & Ban
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Einladen
|
||||
await room.invite('@alice:server');
|
||||
|
||||
// Kick (entfernt, kann wieder beitreten)
|
||||
await room.kick('@alice:server', reason: 'Regeln verletzt');
|
||||
|
||||
// Ban (dauerhaft gesperrt)
|
||||
await room.ban('@alice:server', reason: 'Spam');
|
||||
|
||||
// Unban
|
||||
await room.unban('@alice:server');
|
||||
|
||||
// Eigene Berechtigungen prüfen
|
||||
room.canBan; // bool
|
||||
room.canKick; // bool
|
||||
room.canInvite; // bool
|
||||
room.canSendEvent(EventTypes.Message); // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Power Levels
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Power Level eines Users setzen
|
||||
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
|
||||
|
||||
// Power Level Content auslesen
|
||||
final plContent = room.getState(EventTypes.RoomPowerLevels)
|
||||
?.content
|
||||
.tryGet<Map>('users');
|
||||
|
||||
// Vollständig setzen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
{
|
||||
'ban': 50,
|
||||
'events': {
|
||||
'm.room.name': 50,
|
||||
'm.room.power_levels': 100,
|
||||
},
|
||||
'events_default': 0,
|
||||
'invite': 50,
|
||||
'kick': 50,
|
||||
'redact': 50,
|
||||
'state_default': 50,
|
||||
'users': {
|
||||
'@alice:server': 100,
|
||||
'@mod:server': 50,
|
||||
},
|
||||
'users_default': 0,
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Berechtigungen prüfen
|
||||
room.canSendDefaultMessages; // Nachrichten senden
|
||||
room.canChangeStateEvent(EventTypes.RoomName); // State-Events ändern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Öffentliches Raumverzeichnis
|
||||
|
||||
```dart
|
||||
// Öffentliche Räume suchen
|
||||
final result = await client.queryPublicRooms(
|
||||
limit: 20,
|
||||
filter: PublicRoomQueryFilter(genericSearchTerm: 'flutter'),
|
||||
server: 'matrix.org', // optionaler anderer Server
|
||||
);
|
||||
|
||||
for (final room in result.chunk) {
|
||||
print(room.name);
|
||||
print(room.roomId);
|
||||
print(room.numJoinedMembers);
|
||||
print(room.worldReadable);
|
||||
}
|
||||
|
||||
// Nächste Seite
|
||||
final next = await client.queryPublicRooms(
|
||||
since: result.nextBatch,
|
||||
limit: 20,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spaces
|
||||
|
||||
```dart
|
||||
// Space erstellen
|
||||
final spaceId = await client.createRoom(
|
||||
name: 'Mein Space',
|
||||
creationContent: {'type': 'm.space'}, // markiert als Space
|
||||
visibility: Visibility.private,
|
||||
);
|
||||
|
||||
// Kind-Raum zu Space hinzufügen
|
||||
final space = client.getRoomById(spaceId)!;
|
||||
await space.client.sendState(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
{
|
||||
'via': ['example.org'],
|
||||
'suggested': true,
|
||||
'auto_join': false,
|
||||
},
|
||||
stateKey: '!childRoomId:server',
|
||||
);
|
||||
|
||||
// Space-Kinder auflisten (vom Server)
|
||||
final children = await client.getSpaceHierarchy(
|
||||
spaceId,
|
||||
maxDepth: 2,
|
||||
suggestedOnly: false,
|
||||
);
|
||||
for (final room in children.rooms) {
|
||||
print(room.name);
|
||||
print(room.roomType); // 'm.space' oder null
|
||||
print(room.childrenState); // Sub-Spaces
|
||||
}
|
||||
|
||||
// Kind-Raum entfernen
|
||||
await space.client.sendState(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
{}, // leeres Content → entfernt
|
||||
stateKey: '!childRoomId:server',
|
||||
);
|
||||
|
||||
// Space-Parent setzen (vom Kind-Raum aus)
|
||||
await client.sendState(
|
||||
'!childRoomId:server',
|
||||
EventTypes.SpaceParent,
|
||||
{
|
||||
'via': ['example.org'],
|
||||
'canonical': true,
|
||||
},
|
||||
stateKey: spaceId,
|
||||
);
|
||||
|
||||
// Alle Spaces des Users
|
||||
final spaces = client.rooms.where((r) =>
|
||||
r.membership == Membership.join &&
|
||||
r.getState('m.room.create')?.content['type'] == 'm.space');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Room Upgrades
|
||||
|
||||
```dart
|
||||
// Raum auf neuere Version upgraden (erstellt Nachfolger-Raum)
|
||||
final newRoomId = await client.upgradeRoom(
|
||||
'!alterRaumId:server',
|
||||
newVersion: '11', // Aktuelle empfohlene Version
|
||||
);
|
||||
// Alle Mitglieder werden automatisch in den neuen Raum eingeladen
|
||||
// tombstone-Event wird im alten Raum gesetzt
|
||||
|
||||
// Tombstone lesen
|
||||
final tombstone = room.getState(EventTypes.RoomTombstone);
|
||||
final replacement = tombstone?.content['replacement_room'] as String?;
|
||||
```
|
||||
@@ -0,0 +1,463 @@
|
||||
# 03 — Nachrichten & Events
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Textnachricht senden](#textnachricht-senden)
|
||||
- [HTML / Markdown senden](#html--markdown-senden)
|
||||
- [Antworten (Reply)](#antworten-reply)
|
||||
- [Bearbeiten (Edit)](#bearbeiten-edit)
|
||||
- [Reaktionen](#reaktionen)
|
||||
- [Löschen (Redact)](#löschen-redact)
|
||||
- [Threads](#threads)
|
||||
- [Polls](#polls)
|
||||
- [Standort](#standort)
|
||||
- [Timeline lesen](#timeline-lesen)
|
||||
- [Event-Typen Referenz](#event-typen-referenz)
|
||||
- [Typing-Indikator](#typing-indikator)
|
||||
- [Read Markers / Lesebestätigung](#read-markers--lesebestätigung)
|
||||
- [Pinned Messages](#pinned-messages)
|
||||
- [Fehlgeschlagene Events erneut senden](#fehlgeschlagene-events-erneut-senden)
|
||||
|
||||
---
|
||||
|
||||
## Textnachricht senden
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Einfacher Text
|
||||
final eventId = await room.sendTextEvent('Hallo Welt!');
|
||||
|
||||
// Gibt ein temporäres Event zurück (für optimistische UI)
|
||||
// Das Event bekommt Status EventStatus.sending → .sent → .synced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTML / Markdown senden
|
||||
|
||||
```dart
|
||||
// Mit HTML-Formatierung
|
||||
await room.sendTextEvent(
|
||||
'Fett und kursiv', // Fallback-Text (Plain)
|
||||
htmlBody: '<b>Fett</b> und <em>kursiv</em>',
|
||||
parseMarkdown: false, // Kein automatisches Markdown-Parsing
|
||||
);
|
||||
|
||||
// Automatisches Markdown-Parsing (SDK parst ** → <b>)
|
||||
await room.sendTextEvent(
|
||||
'**Fett** und *kursiv*',
|
||||
parseMarkdown: true, // Standard: true
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Antworten (Reply)
|
||||
|
||||
```dart
|
||||
// Event, auf das geantwortet wird
|
||||
final replyTo = timeline.events.first; // Event-Objekt
|
||||
|
||||
await room.sendTextEvent(
|
||||
'Das sehe ich auch so!',
|
||||
inReplyTo: replyTo,
|
||||
);
|
||||
|
||||
// Prüfen ob ein Event eine Antwort ist
|
||||
final isReply = event.isReply; // bool
|
||||
final repliedId = event.inReplyTo; // eventId als String?
|
||||
|
||||
// Das referenzierte Event laden
|
||||
final original = await room.getEventById(repliedId!);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bearbeiten (Edit)
|
||||
|
||||
```dart
|
||||
// Nachricht bearbeiten
|
||||
await room.sendTextEvent(
|
||||
'Korrigierter Text',
|
||||
editEventId: altesEvent.eventId, // Referenz auf Original
|
||||
htmlBody: '<s>Alt</s> <b>Korrigiert</b>', // optional
|
||||
);
|
||||
|
||||
// Prüfen ob ein Event bearbeitet wurde
|
||||
final isEdit = event.isEdit; // bool
|
||||
|
||||
// Gerendertes Event (mit letzter Bearbeitung) holen
|
||||
final displayed = event.getDisplayEvent(); // gibt die letzte Version zurück
|
||||
print(displayed.body); // aktuelle Nachricht
|
||||
|
||||
// Alle Bearbeitungen eines Events
|
||||
final edits = timeline.events
|
||||
.where((e) => e.isEdit && e.relationshipEventId == originalEvent.eventId)
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reaktionen
|
||||
|
||||
```dart
|
||||
// Reaktion senden (Emoji oder Text)
|
||||
await room.sendReaction(event.eventId, '👍');
|
||||
await room.sendReaction(event.eventId, '❤️');
|
||||
|
||||
// Alle Reaktionen eines Events lesen
|
||||
final reactions = event.aggregatedEvents(
|
||||
timeline,
|
||||
RelationshipTypes.reaction,
|
||||
);
|
||||
// Gruppiert nach Emoji-Key:
|
||||
// Map<String, List<Event>>
|
||||
|
||||
// Eigene Reaktion entfernen (= Redact)
|
||||
final myReaction = reactions['👍']
|
||||
?.firstWhere((e) => e.senderId == client.userID);
|
||||
if (myReaction != null) {
|
||||
await myReaction.redactEvent(reason: null);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Löschen (Redact)
|
||||
|
||||
```dart
|
||||
// Event löschen
|
||||
await room.redactEvent(
|
||||
event.eventId,
|
||||
reason: 'Unpassender Inhalt', // optional
|
||||
);
|
||||
|
||||
// Über das Event-Objekt direkt
|
||||
await event.redactEvent(reason: 'Spam');
|
||||
|
||||
// Prüfen ob redacted
|
||||
final redacted = event.isRedacted; // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threads
|
||||
|
||||
```dart
|
||||
// Thread-Antwort senden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'm.relates_to': {
|
||||
'rel_type': 'm.thread',
|
||||
'event_id': rootEvent.eventId,
|
||||
'is_falling_back': true, // true wenn kein echter Thread-Client
|
||||
'm.in_reply_to': {
|
||||
'event_id': rootEvent.eventId,
|
||||
},
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Antwort im Thread',
|
||||
},
|
||||
EventTypes.Message,
|
||||
);
|
||||
|
||||
// Thread-Events eines Root-Events laden
|
||||
final threadEvents = timeline.events
|
||||
.where((e) =>
|
||||
e.relationshipType == RelationshipTypes.thread &&
|
||||
e.relationshipEventId == rootEvent.eventId)
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Polls
|
||||
|
||||
```dart
|
||||
// Poll erstellen (MSC3381)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'org.matrix.msc3381.poll.start': {
|
||||
'question': {
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Was ist dein Lieblingsessen?',
|
||||
},
|
||||
'kind': 'org.matrix.msc3381.poll.disclosed', // oder .undisclosed
|
||||
'max_selections': 1,
|
||||
'answers': [
|
||||
{'id': '1', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pizza'}},
|
||||
{'id': '2', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pasta'}},
|
||||
{'id': '3', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Salat'}},
|
||||
],
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Was ist dein Lieblingsessen? (Poll)',
|
||||
},
|
||||
'org.matrix.msc3381.poll.start',
|
||||
);
|
||||
|
||||
// Abstimmen
|
||||
await room.sendEvent(
|
||||
{
|
||||
'm.relates_to': {
|
||||
'rel_type': 'm.reference',
|
||||
'event_id': pollEvent.eventId,
|
||||
},
|
||||
'org.matrix.msc3381.poll.response': {
|
||||
'answers': ['1'], // Ausgewählte IDs
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Meine Antwort: Pizza',
|
||||
},
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
// Poll beenden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'm.relates_to': {
|
||||
'rel_type': 'm.reference',
|
||||
'event_id': pollEvent.eventId,
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Poll beendet.',
|
||||
},
|
||||
'org.matrix.msc3381.poll.end',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standort
|
||||
|
||||
```dart
|
||||
// Standort senden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'msgtype': 'm.location',
|
||||
'body': 'Mein Standort: geo:52.5200,13.4050',
|
||||
'geo_uri': 'geo:52.5200,13.4050',
|
||||
'org.matrix.msc3488.location': {
|
||||
'uri': 'geo:52.5200,13.4050',
|
||||
'description': 'Berlin',
|
||||
},
|
||||
},
|
||||
EventTypes.Message,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeline lesen
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Timeline erstellen (Callbacks werden bei Änderungen gerufen)
|
||||
Timeline? timeline;
|
||||
timeline = await room.getTimeline(
|
||||
onUpdate: () {
|
||||
// Vollständiges UI-Rebuild
|
||||
setState(() {});
|
||||
},
|
||||
onChange: (int index) {
|
||||
// Einzelnes Event geändert (z.B. Reaktion, Edit, Status)
|
||||
setState(() {});
|
||||
},
|
||||
onInsert: (int index) {
|
||||
// Neues Event eingefügt
|
||||
setState(() {});
|
||||
},
|
||||
onRemove: (int index) {
|
||||
// Event entfernt (Redaction)
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
// WICHTIG: Immer aufräumen!
|
||||
@override
|
||||
void dispose() {
|
||||
timeline?.cancelSubscriptions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Events abrufen (neueste zuerst, Index 0 = aktuellstes)
|
||||
final events = timeline.events;
|
||||
|
||||
// Ältere Nachrichten nachladen (z.B. beim Scrollen nach oben)
|
||||
await timeline.requestHistory(count: 30);
|
||||
// Gibt true zurück wenn es noch mehr gibt
|
||||
final hasMore = timeline.canRequestHistory;
|
||||
|
||||
// Bestimmtes Event aus Timeline oder Server laden
|
||||
final event = await room.getEventById('$eventId');
|
||||
|
||||
// Neuere Events nachladen (nach Gap)
|
||||
await timeline.requestFuture(count: 20);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event-Typen Referenz
|
||||
|
||||
```dart
|
||||
// Nachrichten-Typen (msgtype)
|
||||
'm.text' // Textnachricht
|
||||
'm.emote' // /me Aktion
|
||||
'm.notice' // Bot-Nachricht (dezenter)
|
||||
'm.image' // Bild
|
||||
'm.video' // Video
|
||||
'm.audio' // Audio/Sprachnachricht
|
||||
'm.file' // Datei
|
||||
'm.location' // Standort
|
||||
'm.sticker' // Sticker (eigener Event-Typ: m.sticker)
|
||||
|
||||
// Event-Typen (type)
|
||||
EventTypes.Message // m.room.message
|
||||
EventTypes.Sticker // m.sticker
|
||||
EventTypes.Reaction // m.reaction
|
||||
EventTypes.RoomRedaction // m.room.redaction
|
||||
EventTypes.Encrypted // m.room.encrypted
|
||||
EventTypes.RoomName // m.room.name
|
||||
EventTypes.RoomTopic // m.room.topic
|
||||
EventTypes.RoomAvatar // m.room.avatar
|
||||
EventTypes.RoomMember // m.room.member
|
||||
EventTypes.RoomPowerLevels // m.room.power_levels
|
||||
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
|
||||
EventTypes.RoomJoinRules // m.room.join_rules
|
||||
EventTypes.RoomGuestAccess // m.room.guest_access
|
||||
EventTypes.HistoryVisibility // m.room.history_visibility
|
||||
EventTypes.RoomCreate // m.room.create
|
||||
EventTypes.RoomTombstone // m.room.tombstone
|
||||
EventTypes.Encryption // m.room.encryption
|
||||
EventTypes.SpaceChild // m.space.child
|
||||
EventTypes.SpaceParent // m.space.parent
|
||||
EventTypes.CallInvite // m.call.invite
|
||||
EventTypes.CallAnswer // m.call.answer
|
||||
EventTypes.CallHangup // m.call.hangup
|
||||
|
||||
// Event-Status prüfen
|
||||
event.status == EventStatus.sending // Wird gesendet (optimistisch)
|
||||
event.status == EventStatus.sent // Vom Server bestätigt
|
||||
event.status == EventStatus.synced // Im eigenen Sync empfangen
|
||||
event.status == EventStatus.error // Fehlgeschlagen
|
||||
event.status == EventStatus.removed // Gelöscht
|
||||
|
||||
// Nützliche Event-Properties
|
||||
event.type // String: Event-Typ
|
||||
event.eventId // String: ID
|
||||
event.senderId // String: MXID des Senders
|
||||
event.originServerTs // DateTime: Sendezeitpunkt
|
||||
event.body // String: Nachrichtentext (entschlüsselt)
|
||||
event.messageType // String?: msgtype
|
||||
event.formattedBody // String?: HTML-Inhalt
|
||||
event.isReply // bool
|
||||
event.isEdit // bool
|
||||
event.isReacted // bool
|
||||
event.isRedacted // bool
|
||||
event.isEncrypted // bool
|
||||
event.isBadEncrypted // bool: Entschlüsselung fehlgeschlagen
|
||||
event.isState // bool: State-Event?
|
||||
event.relationshipEventId // String?: ID des referenzierten Events
|
||||
event.relationshipType // String?: Beziehungstyp (m.replace, m.thread, etc.)
|
||||
|
||||
// Sender-User holen
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
print(sender.displayName);
|
||||
print(sender.avatarUrl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typing-Indikator
|
||||
|
||||
```dart
|
||||
// Schreiben anzeigen
|
||||
await room.setTyping(true, timeout: 30000); // 30 Sekunden
|
||||
|
||||
// Schreiben beenden
|
||||
await room.setTyping(false);
|
||||
|
||||
// Tippende User im Raum beobachten
|
||||
final typing = room.typingUsers; // List<User>
|
||||
// Automatisch aktuell durch Sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read Markers / Lesebestätigung
|
||||
|
||||
```dart
|
||||
// Gelesenes Event markieren
|
||||
await room.setReadMarker(
|
||||
event.eventId,
|
||||
mRead: event.eventId, // private receipt (unsichtbar für andere)
|
||||
// public receipt weglassen für private-only
|
||||
);
|
||||
|
||||
// Öffentliche + private Lesebestätigung
|
||||
await room.setReadMarker(
|
||||
event.eventId,
|
||||
mRead: event.eventId,
|
||||
);
|
||||
|
||||
// Letztes gelesenes Event
|
||||
final lastRead = room.lastReadEventId;
|
||||
|
||||
// Ungelesen-Badge
|
||||
final count = room.notificationCount; // Notifications
|
||||
final hl = room.highlightCount; // Mentions/Highlights
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pinned Messages
|
||||
|
||||
```dart
|
||||
// Events anpinnen
|
||||
final currentPinned = (room.getState(EventTypes.RoomPinnedEvents)
|
||||
?.content['pinned'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPinnedEvents,
|
||||
{'pinned': [...currentPinned, eventId]},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Angepinnte Events lesen
|
||||
final pinned = (room.getState(EventTypes.RoomPinnedEvents)
|
||||
?.content['pinned'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
|
||||
// Events laden
|
||||
final pinnedEvents = await Future.wait(
|
||||
pinned.map((id) => room.getEventById(id)),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlgeschlagene Events erneut senden
|
||||
|
||||
```dart
|
||||
// Events mit Status error finden
|
||||
final failed = timeline.events
|
||||
.where((e) => e.status == EventStatus.error)
|
||||
.toList();
|
||||
|
||||
// Erneut senden
|
||||
for (final event in failed) {
|
||||
await event.sendAgain();
|
||||
}
|
||||
|
||||
// Oder aus dem Timeline-Controller:
|
||||
await room.sendEvent(
|
||||
event.content,
|
||||
event.type,
|
||||
txnId: event.eventId, // Gleiche txnId → überschreibt fehlgeschlagenes
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
# 04 — Direct Messages (DMs)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [DM starten oder finden](#dm-starten-oder-finden)
|
||||
- [DM-Raum identifizieren](#dm-raum-identifizieren)
|
||||
- [DM-Liste](#dm-liste)
|
||||
- [Gruppen-DM](#gruppen-dm)
|
||||
- [DM-Raum schließen / vergessen](#dm-raum-schließen--vergessen)
|
||||
- [User-Profil für DM laden](#user-profil-für-dm-laden)
|
||||
- [Verschlüsselung in DMs](#verschlüsselung-in-dms)
|
||||
|
||||
---
|
||||
|
||||
## DM starten oder finden
|
||||
|
||||
```dart
|
||||
// Existierenden DM finden oder neuen erstellen
|
||||
// Gibt immer einen joined Room zurück
|
||||
final room = await client.startDirectChat('@alice:server');
|
||||
print(room.id); // !roomId:server
|
||||
|
||||
// Mit expliziten Optionen
|
||||
final room = await client.startDirectChat(
|
||||
'@alice:server',
|
||||
// enableEncryption: true, // Standard: true
|
||||
);
|
||||
|
||||
// Navigieren zum DM-Raum
|
||||
// (z.B. in Pyramid:)
|
||||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DM-Raum identifizieren
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Ist es ein DM?
|
||||
final isDm = room.isDirectChat; // bool
|
||||
|
||||
// Mit wem?
|
||||
final partner = room.directChatMatrixID; // '@alice:server' oder null
|
||||
|
||||
// DM zu einem bestimmten User finden
|
||||
final existingDm = client.getDirectChatFromUserId('@alice:server');
|
||||
// Gibt Room? zurück (null wenn kein DM vorhanden)
|
||||
|
||||
// Alle DMs des Users (Account-Data)
|
||||
final dmMap = client.directChats;
|
||||
// Map<String, List<String>>: { '@alice:server': ['!roomId:server'] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DM-Liste
|
||||
|
||||
```dart
|
||||
// Alle DM-Räume (joined)
|
||||
final dms = client.rooms.where((r) =>
|
||||
r.isDirectChat &&
|
||||
r.membership == Membership.join,
|
||||
).toList();
|
||||
|
||||
// Sortiert nach letzter Aktivität
|
||||
dms.sort((a, b) =>
|
||||
(b.lastEvent?.originServerTs ?? DateTime(0))
|
||||
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
||||
|
||||
// DMs mit ungelesenen Nachrichten
|
||||
final unreadDms = dms.where((r) => r.notificationCount > 0).toList();
|
||||
|
||||
// Display-Info für DM-Liste
|
||||
for (final dm in dms) {
|
||||
final partner = client.getDirectChatFromUserId(dm.directChatMatrixID!);
|
||||
final user = dm.unsafeGetUserFromMemoryOrFallback(dm.directChatMatrixID!);
|
||||
print(user.displayName ?? dm.directChatMatrixID);
|
||||
print(user.avatarUrl);
|
||||
print(dm.lastEvent?.body); // Vorschautext
|
||||
print(dm.lastEvent?.originServerTs); // Zeitstempel
|
||||
print(dm.notificationCount); // Ungelesen
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gruppen-DM
|
||||
|
||||
```dart
|
||||
// Gruppen-DM mit mehreren Personen erstellen
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Gruppenunterhaltung',
|
||||
invite: ['@bob:server', '@charlie:server'],
|
||||
isDirect: true, // Als DM markieren
|
||||
preset: CreateRoomPreset.trustedPrivateChat,
|
||||
enableEncryption: true,
|
||||
);
|
||||
|
||||
// Oder mit startDirectChat (für Einzel-DMs) und manuell Teilnehmer hinzufügen
|
||||
final room = await client.startDirectChat('@bob:server');
|
||||
await room.invite('@charlie:server');
|
||||
|
||||
// Gruppe als DM im Account-Data kennzeichnen
|
||||
// (passiert automatisch bei isDirect: true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DM-Raum schließen / vergessen
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Verlassen (Gegenüber kann weiter chatten)
|
||||
await room.leave();
|
||||
|
||||
// Aus Raumliste entfernen
|
||||
await room.forget();
|
||||
|
||||
// DM aus Account-Daten entfernen
|
||||
final dmMap = Map<String, dynamic>.from(client.directChats);
|
||||
dmMap.remove('@alice:server');
|
||||
// oder nur den spezifischen Raum entfernen:
|
||||
final rooms = List<String>.from(dmMap['@alice:server'] ?? []);
|
||||
rooms.remove(room.id);
|
||||
if (rooms.isEmpty) {
|
||||
dmMap.remove('@alice:server');
|
||||
} else {
|
||||
dmMap['@alice:server'] = rooms;
|
||||
}
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'm.direct',
|
||||
dmMap,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-Profil für DM laden
|
||||
|
||||
```dart
|
||||
// Profil des DM-Partners laden
|
||||
final partnerMxid = room.directChatMatrixID!;
|
||||
|
||||
// Aus Room-Membership (gecacht, schnell)
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback(partnerMxid);
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
|
||||
// Frisch vom Server (genauer, aber langsamer)
|
||||
final profile = await client.getProfileFromUserId(partnerMxid);
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
|
||||
// Avatar-URL in Widget verwenden
|
||||
// (mxc:// URI → HTTP-URL via SDK)
|
||||
final httpUrl = user.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 56,
|
||||
height: 56,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verschlüsselung in DMs
|
||||
|
||||
```dart
|
||||
// E2EE-Status prüfen
|
||||
final isEncrypted = room.isEncrypted;
|
||||
|
||||
// E2EE aktivieren (einmalig, nicht mehr rückgängig!)
|
||||
if (!room.isEncrypted) {
|
||||
await room.enableEncryption();
|
||||
}
|
||||
|
||||
// Geräte des Partners verifizieren
|
||||
final partnerDevices = await client.getUserDeviceKeys('@alice:server');
|
||||
for (final device in partnerDevices?.deviceKeys.values ?? []) {
|
||||
print('${device.deviceId}: verified=${device.verified}');
|
||||
}
|
||||
|
||||
// Fehlende Schlüssel für Entschlüsselung anfordern
|
||||
await client.requestMissingSessionKeys();
|
||||
|
||||
// Schlechte Verschlüsselung erkennen
|
||||
final badEvents = timeline.events
|
||||
.where((e) => e.isBadEncrypted)
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Einladungen (DM-Invite-Ansicht)
|
||||
|
||||
```dart
|
||||
// Eingehende DM-Einladungen
|
||||
final invites = client.rooms.where((r) =>
|
||||
r.membership == Membership.invite &&
|
||||
r.isDirectChat).toList();
|
||||
|
||||
// Einladung anzeigen
|
||||
for (final invite in invites) {
|
||||
final senderId = invite.getState(EventTypes.RoomMember, client.userID!)
|
||||
?.senderId; // Wer hat eingeladen?
|
||||
final sender = await client.getProfileFromUserId(senderId ?? '');
|
||||
print('Einladung von ${sender.displayname}');
|
||||
|
||||
// Annehmen
|
||||
await invite.join();
|
||||
|
||||
// Ablehnen
|
||||
await invite.leave();
|
||||
await invite.forget();
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,355 @@
|
||||
# 05 — Medien & Dateien
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Datei hochladen](#datei-hochladen)
|
||||
- [Bild senden](#bild-senden)
|
||||
- [Video senden](#video-senden)
|
||||
- [Audio / Sprachnachricht senden](#audio--sprachnachricht-senden)
|
||||
- [Datei senden](#datei-senden)
|
||||
- [Sticker senden](#sticker-senden)
|
||||
- [Medien herunterladen & entschlüsseln](#medien-herunterladen--entschlüsseln)
|
||||
- [Thumbnails & Vorschaubilder](#thumbnails--vorschaubilder)
|
||||
- [MXC-URL zu HTTP-URL](#mxc-url-zu-http-url)
|
||||
- [Verschlüsselte Medien](#verschlüsselte-medien)
|
||||
- [Avatar hochladen](#avatar-hochladen)
|
||||
- [MatrixFile-Objekte](#matrixfile-objekte)
|
||||
|
||||
---
|
||||
|
||||
## Datei hochladen
|
||||
|
||||
```dart
|
||||
// Bytes hochladen → mxc:// URI zurück
|
||||
final mxcUri = await client.uploadContent(
|
||||
fileBytes, // Uint8List
|
||||
filename: 'datei.pdf',
|
||||
contentType: 'application/pdf', // optional, SDK erkennt es
|
||||
);
|
||||
print(mxcUri); // mxc://example.org/AbCdEf...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bild senden
|
||||
|
||||
```dart
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
// MatrixImageFile erstellen
|
||||
final imageFile = MatrixImageFile(
|
||||
bytes: imageBytes, // Uint8List
|
||||
name: 'foto.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1920, // optional, für Info
|
||||
height: 1080,
|
||||
);
|
||||
|
||||
// Mit Thumbnail
|
||||
final thumbnail = MatrixImageFile(
|
||||
bytes: thumbnailBytes,
|
||||
name: 'foto_thumb.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 320,
|
||||
height: 180,
|
||||
);
|
||||
|
||||
// Senden (lädt automatisch hoch + sendet m.image Event)
|
||||
await room.sendFileEvent(
|
||||
imageFile,
|
||||
thumbnail: thumbnail, // optional
|
||||
);
|
||||
|
||||
// Alternativ mit sendImageEvent
|
||||
await room.sendImageEvent(
|
||||
imageFile,
|
||||
thumbnail: thumbnail,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Video senden
|
||||
|
||||
```dart
|
||||
final videoFile = MatrixVideoFile(
|
||||
bytes: videoBytes,
|
||||
name: 'video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
duration: 45000, // Millisekunden
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
);
|
||||
|
||||
final thumbFile = MatrixImageFile(
|
||||
bytes: thumbnailBytes,
|
||||
name: 'thumb.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 320,
|
||||
height: 180,
|
||||
);
|
||||
|
||||
await room.sendVideoEvent(
|
||||
videoFile,
|
||||
thumbnail: thumbFile,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audio / Sprachnachricht senden
|
||||
|
||||
```dart
|
||||
// Audio-Datei
|
||||
final audioFile = MatrixAudioFile(
|
||||
bytes: audioBytes,
|
||||
name: 'aufnahme.ogg',
|
||||
mimeType: 'audio/ogg',
|
||||
duration: 12000, // Millisekunden
|
||||
);
|
||||
|
||||
await room.sendAudioEvent(audioFile);
|
||||
|
||||
// Sprachnachricht (Voice Message MSC3245)
|
||||
await room.sendFileEvent(
|
||||
audioFile,
|
||||
extraContent: {
|
||||
'org.matrix.msc3245.voice': {}, // markiert als Sprachnachricht
|
||||
'org.matrix.msc1767.audio': {
|
||||
'duration': 12000,
|
||||
'waveform': [0, 50, 100, 75, ...], // Amplituden (0-1024)
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Datei senden
|
||||
|
||||
```dart
|
||||
final file = MatrixFile(
|
||||
bytes: fileBytes,
|
||||
name: 'dokument.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
);
|
||||
|
||||
await room.sendFileEvent(file);
|
||||
|
||||
// Mit Extra-Metadaten
|
||||
await room.sendFileEvent(
|
||||
file,
|
||||
extraContent: {
|
||||
'org.matrix.msc3554.caption': {
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Hier das Dokument',
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sticker senden
|
||||
|
||||
```dart
|
||||
// Sticker sind ein eigener Event-Typ (m.sticker)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'body': 'Ein Sticker',
|
||||
'url': 'mxc://example.org/stickerId',
|
||||
'info': {
|
||||
'mimetype': 'image/png',
|
||||
'w': 256,
|
||||
'h': 256,
|
||||
},
|
||||
},
|
||||
EventTypes.Sticker, // 'm.sticker'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medien herunterladen & entschlüsseln
|
||||
|
||||
```dart
|
||||
// Aus Event-Attachment herunterladen (+ automatisch entschlüsseln bei E2EE)
|
||||
final data = await event.downloadAndDecryptAttachment(
|
||||
getThumbnail: false, // true für Thumbnail statt Original
|
||||
);
|
||||
// data ist MatrixFile mit .bytes: Uint8List
|
||||
|
||||
// Direkte MXC-URL herunterladen
|
||||
final bytes = await client.database.getFile(mxcUri);
|
||||
// oder über HTTP:
|
||||
final httpUri = mxcUri.getDownloadUri(client);
|
||||
// → https://homeserver/_matrix/media/v3/download/server/mediaId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thumbnails & Vorschaubilder
|
||||
|
||||
```dart
|
||||
// Thumbnail-URL für Widget
|
||||
final thumbnailUri = event.thumbnailUrl; // mxc:// URI
|
||||
|
||||
// HTTP-URL für Thumbnail (mit Größe)
|
||||
final httpUrl = thumbnailUri?.getThumbnail(
|
||||
client,
|
||||
width: 800,
|
||||
height: 600,
|
||||
method: ThumbnailMethod.scale, // oder .crop
|
||||
);
|
||||
|
||||
// Avatar-Thumbnail
|
||||
final avatarUrl = user.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 56,
|
||||
height: 56,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
|
||||
// Im Flutter Widget (CachedNetworkImage o.ä.):
|
||||
Image.network(httpUrl.toString());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MXC-URL zu HTTP-URL
|
||||
|
||||
```dart
|
||||
// Download-URL
|
||||
final downloadUrl = Uri.parse(mxcUriString).getDownloadUri(client);
|
||||
// → https://homeserver/_matrix/media/v3/download/server/mediaId
|
||||
|
||||
// Thumbnail-URL
|
||||
final thumbUrl = Uri.parse(mxcUriString).getThumbnail(
|
||||
client,
|
||||
width: 300,
|
||||
height: 300,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
|
||||
// Direkt als String
|
||||
final urlString = downloadUrl.toString();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verschlüsselte Medien
|
||||
|
||||
```dart
|
||||
// In verschlüsselten Räumen werden Medien automatisch verschlüsselt.
|
||||
// Das SDK übernimmt Ver- und Entschlüsselung transparent.
|
||||
|
||||
// Verschlüsselter Download (automatisch):
|
||||
final file = await event.downloadAndDecryptAttachment();
|
||||
// file.bytes enthält die entschlüsselten Rohdaten
|
||||
|
||||
// Manuell ver-/entschlüsseln (selten nötig):
|
||||
// Nutze EncryptedFile aus dem matrix-package
|
||||
final encryptedFile = EncryptedFile(
|
||||
url: 'mxc://...',
|
||||
key: {/* JWK */},
|
||||
iv: 'base64...',
|
||||
hashes: {'sha256': 'base64...'},
|
||||
v: 'v2',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Avatar hochladen
|
||||
|
||||
```dart
|
||||
// Eigener Avatar
|
||||
final avatarBytes = await File('avatar.png').readAsBytes();
|
||||
final mxcUri = await client.uploadContent(
|
||||
avatarBytes,
|
||||
filename: 'avatar.png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await client.setAvatarUrl(client.userID!, mxcUri);
|
||||
|
||||
// Raum-Avatar
|
||||
final roomAvatarMxc = await client.uploadContent(imageBytes, filename: 'room.png');
|
||||
await room.setAvatar(roomAvatarMxc);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MatrixFile-Objekte
|
||||
|
||||
```dart
|
||||
// Basis-Klasse
|
||||
MatrixFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType,
|
||||
})
|
||||
|
||||
// Spezialisierte Unterklassen:
|
||||
MatrixImageFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType, // z.B. 'image/jpeg'
|
||||
int? width,
|
||||
int? height,
|
||||
bool? animated, // für GIFs
|
||||
})
|
||||
|
||||
MatrixVideoFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType, // 'video/mp4'
|
||||
int? duration, // ms
|
||||
int? width,
|
||||
int? height,
|
||||
MatrixImageFile? thumbnail,
|
||||
})
|
||||
|
||||
MatrixAudioFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType, // 'audio/ogg'
|
||||
int? duration, // ms
|
||||
})
|
||||
|
||||
// Eigenschaften
|
||||
file.bytes // Uint8List
|
||||
file.name // Dateiname
|
||||
file.mimeType // MIME-Typ
|
||||
file.sizeString // '1.4 MB' (formatiert)
|
||||
file.size // Bytes als int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medien-Events lesen
|
||||
|
||||
```dart
|
||||
// Attachment-Info aus Event
|
||||
if (event.messageType == 'm.image') {
|
||||
final url = event.attachmentMxcUrl; // mxc:// URI
|
||||
final mime = event.attachmentMimetype; // 'image/jpeg'
|
||||
final size = event.attachmentSize; // Bytes
|
||||
final width = event.infoMap['w'] as int?;
|
||||
final height = event.infoMap['h'] as int?;
|
||||
}
|
||||
|
||||
if (event.messageType == 'm.video') {
|
||||
final duration = event.infoMap['duration'] as int?; // ms
|
||||
}
|
||||
|
||||
if (event.messageType == 'm.audio') {
|
||||
final duration = event.infoMap['duration'] as int?;
|
||||
final waveform = event.content
|
||||
.tryGetMap('org.matrix.msc1767.audio')
|
||||
?.tryGetList<int>('waveform');
|
||||
}
|
||||
|
||||
// Datei-Größe als lesbarer String
|
||||
print(event.sizeString); // '2.3 MB'
|
||||
```
|
||||
@@ -0,0 +1,313 @@
|
||||
# 06 — E2EE Verschlüsselung
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Setup & Initialisierung](#setup--initialisierung)
|
||||
- [Verschlüsselung aktivieren](#verschlüsselung-aktivieren)
|
||||
- [Entschlüsselung & Fehlerfälle](#entschlüsselung--fehlerfälle)
|
||||
- [Device-Verifikation (SAS)](#device-verifikation-sas)
|
||||
- [Cross-Signing](#cross-signing)
|
||||
- [Key Backup](#key-backup)
|
||||
- [Fehlende Session Keys anfordern](#fehlende-session-keys-anfordern)
|
||||
- [Geräte-Keys abfragen](#geräte-keys-abfragen)
|
||||
- [Trust-Status](#trust-status)
|
||||
|
||||
---
|
||||
|
||||
## Setup & Initialisierung
|
||||
|
||||
```dart
|
||||
// flutter_vodozemac muss initialisiert sein BEVOR Client erstellt wird
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
await vod.init();
|
||||
|
||||
// Client mit E2EE-Support
|
||||
final client = Client(
|
||||
'Pyramid',
|
||||
database: await MatrixSdkDatabase.init('pyramid', database: db),
|
||||
nativeImplementations: NativeImplementationsIsolate(compute),
|
||||
// Oder für Hintergrund-Isolaten (kein compute):
|
||||
// nativeImplementations: NativeImplementationsDummy(),
|
||||
verificationMethods: {
|
||||
KeyVerificationMethod.numbers,
|
||||
KeyVerificationMethod.emoji,
|
||||
// KeyVerificationMethod.qrCodeScan, // falls QR-Scanner vorhanden
|
||||
// KeyVerificationMethod.qrCodeShow,
|
||||
},
|
||||
);
|
||||
|
||||
await client.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
);
|
||||
|
||||
// E2EE verfügbar prüfen
|
||||
print(client.encryptionEnabled); // true wenn Vodozemac/Olm geladen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verschlüsselung aktivieren
|
||||
|
||||
```dart
|
||||
// Raum-Verschlüsselung einschalten (PERMANENT, nicht rückgängig!)
|
||||
if (!room.isEncrypted) {
|
||||
await room.enableEncryption();
|
||||
}
|
||||
|
||||
// Verschlüsselung beim Erstellen aktivieren
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Verschlüsselter Raum',
|
||||
enableEncryption: true,
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
);
|
||||
|
||||
// Status prüfen
|
||||
print(room.isEncrypted); // bool
|
||||
print(room.encryptionAlgorithm); // 'm.megolm.v1.aes-sha2'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entschlüsselung & Fehlerfälle
|
||||
|
||||
```dart
|
||||
// Beim Anzeigen von Events prüfen:
|
||||
if (event.isBadEncrypted) {
|
||||
// Anzeigen: "Nachricht kann nicht entschlüsselt werden"
|
||||
// Mögliche Ursachen:
|
||||
// - Gerät war nicht in der Raumschlüssel-Verteilung
|
||||
// - Session fehlt (Megolm Session nicht vorhanden)
|
||||
// - Falsches Gerät / fehlende Olm-Session
|
||||
}
|
||||
|
||||
// Entschlüsselungs-Fehler-Typ
|
||||
if (event.content['can_not_decrypt'] != null) {
|
||||
// Generischer Fehler
|
||||
}
|
||||
if (event.type == EventTypes.Encrypted && event.plaintextContent == null) {
|
||||
// Noch nicht entschlüsselt
|
||||
}
|
||||
|
||||
// Event-Body bei Fehler prüfen
|
||||
final body = event.body;
|
||||
// Enthält "** Unable to decrypt: ..." bei Fehlern
|
||||
|
||||
// Fehlende Schlüssel anfordern (von anderen Geräten)
|
||||
await client.requestMissingSessionKeys();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device-Verifikation (SAS)
|
||||
|
||||
```dart
|
||||
// ── Verifikation starten (ich verifiziere ein anderes Gerät) ──────────────
|
||||
|
||||
// Verifikations-Anfrage senden
|
||||
final request = await client.userDeviceKeys[otherUserId]!
|
||||
.startVerification();
|
||||
// request.onUpdate: Stream mit Zustandsänderungen
|
||||
|
||||
// ── Verifikation empfangen (anderes Gerät will mich verifizieren) ─────────
|
||||
|
||||
// Eingehende Anfragen beobachten
|
||||
client.onKeyVerificationRequest.stream.listen((request) async {
|
||||
// Anfrage annehmen
|
||||
await request.acceptVerification();
|
||||
});
|
||||
|
||||
// ── SAS-Flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Auf SAS-Ready warten und Emojis/Zahlen anzeigen
|
||||
request.onUpdate.stream.listen((_) async {
|
||||
if (request.state == KeyVerificationState.showSas) {
|
||||
// Emojis anzeigen
|
||||
final emojis = request.sasEmojis; // List<KeyVerificationEmoji>
|
||||
for (final emoji in emojis) {
|
||||
print('${emoji.emoji} ${emoji.name}');
|
||||
}
|
||||
|
||||
// Oder Dezimalzahlen
|
||||
final decimals = request.sasDecimals; // List<int> (3 Zahlen)
|
||||
print(decimals); // z.B. [1234, 5678, 9012]
|
||||
|
||||
// Wenn User bestätigt dass sie übereinstimmen:
|
||||
await request.confirmSas();
|
||||
|
||||
// Wenn sie nicht übereinstimmen:
|
||||
// await request.rejectSas();
|
||||
}
|
||||
|
||||
if (request.state == KeyVerificationState.done) {
|
||||
print('Verifikation erfolgreich!');
|
||||
}
|
||||
|
||||
if (request.state == KeyVerificationState.error) {
|
||||
print('Verifikation fehlgeschlagen: ${request.cancelCode}');
|
||||
}
|
||||
});
|
||||
|
||||
// ── QR-Code Verifikation ─────────────────────────────────────────────────
|
||||
|
||||
// QR-Code zum Anzeigen generieren
|
||||
final qrData = await request.generateQrCodeData();
|
||||
// qrData als QR-Code rendern (z.B. mit qr_flutter-Package)
|
||||
|
||||
// Gescannten QR-Code verarbeiten
|
||||
await request.scanQrCode(scannedData);
|
||||
|
||||
// Bestätigen dass QR gescannt wurde
|
||||
await request.confirmQrCodeScanned();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Signing
|
||||
|
||||
```dart
|
||||
// Cross-Signing bootstrappen (erstellt Master-, Self- und User-Signing-Keys)
|
||||
// Nur einmal pro Account nötig
|
||||
await client.encryption!.bootstrapCrossSigning(
|
||||
setupMasterKey: true,
|
||||
setupSelfSigningKey: true,
|
||||
setupUserSigningKey: true,
|
||||
);
|
||||
|
||||
// Cross-Signing-Status
|
||||
final crossSigning = client.encryption!.crossSigning;
|
||||
print(crossSigning.enabled); // bool
|
||||
print(crossSigning.selfSigned); // bool: eigenes Gerät ist self-signed
|
||||
|
||||
// Eigenes Gerät ist verifiziert?
|
||||
final myDevice = client.userDeviceKeys[client.userID]
|
||||
?.deviceKeys[client.deviceID];
|
||||
print(myDevice?.verified); // bool
|
||||
|
||||
// Anderen User als vertrauenswürdig markieren
|
||||
await client.encryption!.crossSigning.signUser(otherUserId);
|
||||
|
||||
// Gerät verifizieren (nach SAS/QR)
|
||||
await myDevice?.setVerified(true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Backup
|
||||
|
||||
```dart
|
||||
// ── Key Backup einrichten ─────────────────────────────────────────────────
|
||||
|
||||
// Vollständiges Crypto-Setup (empfohlen):
|
||||
// Erstellt Secret Storage + Cross-Signing + Key Backup
|
||||
await client.bootstrapCryptoIdentity(
|
||||
wipeRecovery: false, // true: bestehende Recovery überschreiben
|
||||
);
|
||||
|
||||
// Nur Key Backup:
|
||||
final backup = client.encryption!.keyManager;
|
||||
|
||||
// Recovery-Key generieren und anzeigen
|
||||
final recoveryKey = await client.encryption!.ssss.generateKey();
|
||||
print(recoveryKey); // Langer alphanumerischer Schlüssel → User aufschreiben lassen!
|
||||
|
||||
// ── Key Backup Status ─────────────────────────────────────────────────────
|
||||
|
||||
// Ist Key Backup aktiv?
|
||||
final keyBackupEnabled = client.encryption!.keyManager.enabled;
|
||||
|
||||
// Alle Sessions hochladen
|
||||
await client.encryption!.keyManager.uploadInboundGroupSessions();
|
||||
|
||||
// ── Backup wiederherstellen (Recovery) ───────────────────────────────────
|
||||
|
||||
// Keys von Server-Backup wiederherstellen
|
||||
await client.encryption!.keyManager.loadFromResponse(
|
||||
await client.getRoomKeysBackup(),
|
||||
);
|
||||
|
||||
// Oder mit Recovery-Key:
|
||||
await client.encryption!.ssss.unlock(recoveryKey: eingabe);
|
||||
await client.encryption!.keyManager.restore();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlende Session Keys anfordern
|
||||
|
||||
```dart
|
||||
// Schlüssel von anderen Geräten desselben Users anfordern
|
||||
await client.requestMissingSessionKeys();
|
||||
|
||||
// Für einen spezifischen Raum
|
||||
await client.requestMissingSessionKeys(
|
||||
rooms: [room],
|
||||
);
|
||||
|
||||
// Einzelne Session anfordern (für ein spezifisches Event)
|
||||
if (event.isBadEncrypted) {
|
||||
final sessionId = event.content
|
||||
.tryGet<String>('session_id');
|
||||
final senderKey = event.content
|
||||
.tryGet<String>('sender_key');
|
||||
if (sessionId != null) {
|
||||
await room.requestKey(sessionId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Geräte-Keys abfragen
|
||||
|
||||
```dart
|
||||
// Alle Geräte eines Users laden
|
||||
final userDevices = await client.getUserDeviceKeys(userId);
|
||||
// → Map<String, DeviceKeysList>
|
||||
|
||||
// Geräte eines Raums (alle Mitglieder)
|
||||
final roomDevices = await room.getUserDeviceKeys();
|
||||
|
||||
// Eigene Geräte
|
||||
final myDevices = client.userDeviceKeys[client.userID]?.deviceKeys;
|
||||
|
||||
// Gerät-Details
|
||||
for (final device in myDevices?.values ?? []) {
|
||||
print(device.deviceId);
|
||||
print(device.displayName);
|
||||
print(device.verified);
|
||||
print(device.blocked);
|
||||
print(device.ed25519Key); // Signatur-Schlüssel
|
||||
print(device.curve25519Key); // Encryption-Schlüssel
|
||||
}
|
||||
|
||||
// Gerät sperren (Nachrichten werden nicht mehr entschlüsselt gesendet)
|
||||
await myDevices?['DEVICEID']?.setBlocked(true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trust-Status
|
||||
|
||||
```dart
|
||||
// Trust-Level für Raum berechnen
|
||||
final encryption = room.client.encryption;
|
||||
if (encryption != null) {
|
||||
final trust = await room.calcEncryptionHealthState();
|
||||
// trust: .allVerified | .unverifiedDevices | .unknown
|
||||
}
|
||||
|
||||
// User-Trust
|
||||
final userTrust = client.userDeviceKeys[userId]?.verified;
|
||||
// null = unbekannt, true = verifiziert, false = blockiert
|
||||
|
||||
// Nachrichten-Verifikation
|
||||
if (!event.encryptionHealthState.allVerified) {
|
||||
// Anzeigen: "Gesendet von unverifiziertem Gerät"
|
||||
}
|
||||
|
||||
// Eigene Identität
|
||||
final myIdentity = client.encryption?.crossSigning;
|
||||
print(myIdentity?.selfSigned); // Eigenes Gerät cross-signed?
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# 07 — Push & Benachrichtigungen
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Pusher registrieren (FCM)](#pusher-registrieren-fcm)
|
||||
- [Pusher abrufen & verwalten](#pusher-abrufen--verwalten)
|
||||
- [Push Rules](#push-rules)
|
||||
- [onNotification Stream](#onnotification-stream)
|
||||
- [Lokale Benachrichtigungen (Android)](#lokale-benachrichtigungen-android)
|
||||
- [Benachrichtigungen: Pyramid-Architektur](#benachrichtigungen-pyramid-architektur)
|
||||
- [Notification Counts](#notification-counts)
|
||||
|
||||
---
|
||||
|
||||
## Pusher registrieren (FCM)
|
||||
|
||||
```dart
|
||||
// FCM-Token holen
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
final fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
|
||||
// Pusher beim Homeserver registrieren
|
||||
await client.postPusher(
|
||||
Pusher(
|
||||
pushkey: fcmToken!,
|
||||
kind: 'http',
|
||||
appId: 'chat.pyramid.pyramid', // Unique App-ID (muss zu Push-Gateway passen)
|
||||
appDisplayName: 'Pyramid',
|
||||
deviceDisplayName: 'Mein Android',
|
||||
lang: 'de',
|
||||
data: PusherData(
|
||||
url: Uri.parse('https://push.example.org/_matrix/push/v1/notify'),
|
||||
format: 'event_id_only', // Empfohlen (nur Event-ID, nicht der Inhalt)
|
||||
),
|
||||
),
|
||||
append: true, // Nicht andere Pusher dieses Geräts löschen
|
||||
);
|
||||
|
||||
// Token-Refresh überwachen
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
|
||||
await client.postPusher(
|
||||
Pusher(pushkey: newToken, /* ... */ ),
|
||||
append: true,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pusher abrufen & verwalten
|
||||
|
||||
```dart
|
||||
// Alle Pusher dieses Users
|
||||
final pushers = await client.getPushers() ?? [];
|
||||
for (final pusher in pushers) {
|
||||
print(pusher.appId);
|
||||
print(pusher.pushkey);
|
||||
print(pusher.data.url);
|
||||
}
|
||||
|
||||
// Pusher entfernen
|
||||
await client.deletePusher(
|
||||
Pusher(
|
||||
pushkey: fcmToken,
|
||||
appId: 'chat.pyramid.pyramid',
|
||||
kind: 'http', // Pflichtfeld beim Löschen
|
||||
// ...
|
||||
),
|
||||
);
|
||||
|
||||
// Beim Logout alle Pusher entfernen
|
||||
final myPushers = (await client.getPushers() ?? [])
|
||||
.where((p) => p.appId == 'chat.pyramid.pyramid');
|
||||
for (final p in myPushers) {
|
||||
await client.deletePusher(p);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Push Rules
|
||||
|
||||
```dart
|
||||
// Alle Push Rules abrufen
|
||||
final ruleset = await client.getPushRules();
|
||||
|
||||
// Globale Regeln
|
||||
for (final rule in ruleset.global.content ?? []) {
|
||||
print(rule.ruleId);
|
||||
print(rule.enabled);
|
||||
print(rule.actions);
|
||||
}
|
||||
|
||||
// Kategorien der Regeln:
|
||||
// .override — höchste Priorität (z.B. DND-Modus)
|
||||
// .content — Inhalt-basiert (z.B. eigener Name erwähnt)
|
||||
// .room — Raum-spezifisch
|
||||
// .sender — Sender-spezifisch
|
||||
// .underride — niedrigste Priorität (Fallback)
|
||||
|
||||
// Push Rule aktivieren/deaktivieren
|
||||
await client.setPushRuleEnabled(
|
||||
'global',
|
||||
PushRuleKind.override,
|
||||
'.m.rule.master', // DND: stumm schalten
|
||||
false, // false = Regel deaktivieren
|
||||
);
|
||||
|
||||
// Eigene Push Rule erstellen (z.B. Raum stumm schalten)
|
||||
await client.setPushRule(
|
||||
'global',
|
||||
PushRuleKind.room,
|
||||
'!roomId:server', // RuleId = RoomId für Raum-Regeln
|
||||
[PushRuleAction.dontNotify],
|
||||
);
|
||||
|
||||
// Push Rule löschen
|
||||
await client.deletePushRule(
|
||||
'global',
|
||||
PushRuleKind.room,
|
||||
'!roomId:server',
|
||||
);
|
||||
|
||||
// Standard-Rules für Erwähnungen
|
||||
// Pyramid prüft lokal ob ein Event eine Mention enthält:
|
||||
final myId = client.userID!;
|
||||
final mentions = event.content
|
||||
.tryGetMap<String, dynamic>('m.mentions')
|
||||
?.tryGetList<String>('user_ids') ?? [];
|
||||
final isMentioned = mentions.contains(myId) ||
|
||||
event.body.contains(myId);
|
||||
|
||||
// Raum stumm schalten
|
||||
await client.setPushRule(
|
||||
'global',
|
||||
PushRuleKind.override,
|
||||
'!roomId:server',
|
||||
[PushRuleAction.dontNotify],
|
||||
conditions: [
|
||||
PushCondition(kind: 'event_match', key: 'room_id', pattern: '!roomId:server'),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## onNotification Stream
|
||||
|
||||
```dart
|
||||
// Matrix-SDK emittiert Events die Benachrichtigungen auslösen sollen
|
||||
// (nach Push-Rule-Matching, entschlüsselt)
|
||||
client.onNotification.stream.listen((Event event) {
|
||||
final roomId = event.room.id;
|
||||
final room = client.getRoomById(roomId)!;
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
|
||||
final title = room.isDirectChat
|
||||
? sender.displayName ?? event.senderId
|
||||
: '${sender.displayName} · ${room.getLocalizedDisplayname()}';
|
||||
final body = event.body;
|
||||
|
||||
// Lokale Benachrichtigung anzeigen
|
||||
showLocalNotification(title, body, roomId);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lokale Benachrichtigungen (Android)
|
||||
|
||||
```dart
|
||||
// Siehe auch: lib/core/notification_service.dart und PushService.kt
|
||||
|
||||
// flutter_local_notifications initialisieren
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: _handleNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundResponse,
|
||||
);
|
||||
|
||||
// Notification-Kanal erstellen (Android 8+)
|
||||
await plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
'pyramid_messages',
|
||||
'Nachrichten',
|
||||
description: 'Neue Nachrichten',
|
||||
importance: Importance.high,
|
||||
),
|
||||
);
|
||||
|
||||
// Benachrichtigung anzeigen (mit Reply-Action)
|
||||
await plugin.show(
|
||||
notifId,
|
||||
'Absender',
|
||||
'Nachrichtentext',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'pyramid_messages',
|
||||
'Nachrichten',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@drawable/ic_notification',
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'reply', 'Antworten',
|
||||
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
|
||||
allowGeneratedReplies: true,
|
||||
showsUserInterface: false, // BroadcastReceiver-Pfad
|
||||
),
|
||||
AndroidNotificationAction('read', 'Gelesen',
|
||||
showsUserInterface: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
payload: roomId,
|
||||
);
|
||||
|
||||
// Benachrichtigung schließen
|
||||
await plugin.cancel(notifId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen: Pyramid-Architektur
|
||||
|
||||
```
|
||||
FCM-Payload → PushService.kt (Kotlin, kein Dart-Overhead)
|
||||
│
|
||||
├─ App im Vordergrund? → Heartbeat-Check → Skip
|
||||
│
|
||||
├─ HTTP-Fetch: Absender + Raumname (ohne Entschlüsselung möglich)
|
||||
│
|
||||
├─ Native Notification anzeigen: "Neue Nachricht"
|
||||
│ └─ Reply-Action → getActivity(MainActivity) → onNewIntent
|
||||
│
|
||||
└─ Flutter-Engine im Cache? → MethodChannel "decryptAndUpdateNotification"
|
||||
└─ Dart: decryptAndUpdateNotification() → showNativeNotification() [mit echtem Text]
|
||||
|
||||
Reply-Flow:
|
||||
User tippt Antwort → Reply-PendingIntent (getActivity)
|
||||
→ MainActivity.onNewIntent → MethodChannel "replyFromNotification"
|
||||
→ Dart: room.sendTextEvent(text)
|
||||
→ moveTaskToBack(true)
|
||||
```
|
||||
|
||||
```dart
|
||||
// Heartbeat (App aktiv?) setzen:
|
||||
void _pingHeartbeat() {
|
||||
SharedPreferences.getInstance().then(
|
||||
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// Heartbeat löschen (App im Hintergrund):
|
||||
void setAppForeground(bool value) {
|
||||
if (!value) {
|
||||
SharedPreferences.getInstance()
|
||||
.then((p) => p.setInt('notif_app_heartbeat', 0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notification Counts
|
||||
|
||||
```dart
|
||||
// Pro Raum
|
||||
print(room.notificationCount); // Ungelesene Nachrichten
|
||||
print(room.highlightCount); // Mentions / Highlights
|
||||
|
||||
// Gesamt
|
||||
final totalUnread = client.rooms.fold<int>(
|
||||
0, (sum, r) => sum + r.notificationCount);
|
||||
final totalHighlights = client.rooms.fold<int>(
|
||||
0, (sum, r) => sum + r.highlightCount);
|
||||
|
||||
// Badge für Windows Taskbar
|
||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||
if (totalUnread > 0) {
|
||||
WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
|
||||
} else {
|
||||
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,422 @@
|
||||
# 08 — VoIP, Calls & LiveKit (MatrixRTC)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Architektur-Überblick](#architektur-überblick)
|
||||
- [1:1 Call (Legacy m.call)](#11-call-legacy-mcall)
|
||||
- [MatrixRTC (Gruppen-Calls)](#matrixrtc-gruppen-calls)
|
||||
- [LiveKit-Integration](#livekit-integration)
|
||||
- [Klingel-Flow](#klingel-flow)
|
||||
- [WebRTC Setup in Flutter](#webrtc-setup-in-flutter)
|
||||
- [Call-State Management](#call-state-management)
|
||||
- [Pyramid VoIP-Architektur](#pyramid-voip-architektur)
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
```
|
||||
Matrix Signaling (to-device / room events)
|
||||
│
|
||||
▼
|
||||
MatrixRTC Layer
|
||||
┌─────────────────────────────────┐
|
||||
│ com.famedly.call.member Events │
|
||||
│ (Membership, Backend, Expiry) │
|
||||
└──────────────┬──────────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ │
|
||||
Mesh (P2P) LiveKit (SFU)
|
||||
WebRTC direct livekit-server
|
||||
(1:1, max ~4) (skalierbar, n:m)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1:1 Call (Legacy m.call)
|
||||
|
||||
```dart
|
||||
// ── Anruf starten ────────────────────────────────────────────────────────
|
||||
// Wird vom SDK über room events (m.call.invite, m.call.candidates,
|
||||
// m.call.answer, m.call.hangup) signalisiert.
|
||||
|
||||
// Anruf-Einladung senden (nach WebRTC offer)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': 'eindeutige-call-id',
|
||||
'version': 1,
|
||||
'lifetime': 60000, // ms, wie lange das Invite gültig ist
|
||||
'offer': {
|
||||
'type': 'offer',
|
||||
'sdp': sdpString, // WebRTC SDP Offer
|
||||
},
|
||||
},
|
||||
EventTypes.CallInvite, // 'm.call.invite'
|
||||
);
|
||||
|
||||
// ICE-Kandidaten senden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'candidates': [
|
||||
{
|
||||
'candidate': 'candidate:...',
|
||||
'sdpMid': '0',
|
||||
'sdpMLineIndex': 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
EventTypes.CallCandidates, // 'm.call.candidates'
|
||||
);
|
||||
|
||||
// Anruf annehmen (SDP Answer)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'answer': {
|
||||
'type': 'answer',
|
||||
'sdp': sdpAnswerString,
|
||||
},
|
||||
},
|
||||
EventTypes.CallAnswer, // 'm.call.answer'
|
||||
);
|
||||
|
||||
// Auflegen
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'reason': 'user_hangup', // oder 'ice_failed', 'invite_timeout'
|
||||
},
|
||||
EventTypes.CallHangup, // 'm.call.hangup'
|
||||
);
|
||||
|
||||
// Ablehnen (Reject)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'reason': 'user_hangup',
|
||||
},
|
||||
EventTypes.CallReject, // 'm.call.reject'
|
||||
);
|
||||
|
||||
// Eingehende Anrufe überwachen
|
||||
client.onCallInvite.stream.listen((CallInvite invite) {
|
||||
print('Eingehender Anruf von ${invite.roomId}');
|
||||
print('Call-ID: ${invite.callId}');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MatrixRTC (Gruppen-Calls)
|
||||
|
||||
```dart
|
||||
// MatrixRTC verwendet com.famedly.call.member State-Events
|
||||
|
||||
// ── Call beitreten (Membership senden) ───────────────────────────────────
|
||||
|
||||
final myDeviceId = client.deviceID!;
|
||||
final callId = room.id; // Normalerweise = Raum-ID
|
||||
|
||||
// Membership-Event senden (signalisiert "ich bin im Call")
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'org.matrix.msc3401.call.member', // oder 'com.famedly.call.member'
|
||||
{
|
||||
'application': 'm.call',
|
||||
'call_id': callId,
|
||||
'device_id': myDeviceId,
|
||||
'expires_ts': DateTime.now()
|
||||
.add(const Duration(hours: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
'backend': {
|
||||
'type': 'm.sfu', // LiveKit
|
||||
'livekit_service_url': 'https://livekit.example.org',
|
||||
},
|
||||
// Für Mesh:
|
||||
// 'backend': {'type': 'm.mesh'},
|
||||
'membershipID': 'eindeutige-membership-id',
|
||||
},
|
||||
stateKey: '$myDeviceId:$callId',
|
||||
);
|
||||
|
||||
// ── Call verlassen (Membership entfernen) ─────────────────────────────────
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'org.matrix.msc3401.call.member',
|
||||
{}, // Leeres Content = Austritt
|
||||
stateKey: '$myDeviceId:$callId',
|
||||
);
|
||||
|
||||
// ── Aktive Call-Mitglieder auflesen ──────────────────────────────────────
|
||||
|
||||
final callStates = room.states['org.matrix.msc3401.call.member'];
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final activeMembers = callStates?.values.where((event) {
|
||||
final expiresTs = event.content['expires_ts'] as int? ?? 0;
|
||||
return expiresTs > now; // Nicht abgelaufen
|
||||
}).toList() ?? [];
|
||||
|
||||
for (final member in activeMembers) {
|
||||
print(member.content['device_id']);
|
||||
print(member.stateKey); // 'deviceId:callId'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LiveKit-Integration
|
||||
|
||||
```dart
|
||||
// LiveKit-Token vom Matrix-Homeserver anfragen (OpenID)
|
||||
// Dieser Flow authentifiziert den User beim LiveKit-Server ohne eigenes Passwort.
|
||||
|
||||
// 1. OpenID-Token vom Homeserver holen
|
||||
final openIdToken = await client.requestOpenIdToken(client.userID!);
|
||||
// openIdToken.accessToken, .matrixServerName
|
||||
|
||||
// 2. LiveKit-Token beim MatrixRTC-Service tauschen
|
||||
// (Pyramid sendet openIdToken an livekit_service_url des Raums)
|
||||
final response = await http.post(
|
||||
Uri.parse('https://livekit-service.example.org/sfu/get'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'room': room.id,
|
||||
'openid_token': {
|
||||
'access_token': openIdToken.accessToken,
|
||||
'token_type': 'Bearer',
|
||||
'matrix_server_name': openIdToken.matrixServerName,
|
||||
},
|
||||
'device_id': client.deviceID,
|
||||
}),
|
||||
);
|
||||
final livekitToken = jsonDecode(response.body)['token'];
|
||||
|
||||
// 3. Mit LiveKit verbinden
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
final room = await LiveKitClient.connect(
|
||||
'wss://livekit.example.org',
|
||||
livekitToken,
|
||||
roomOptions: const RoomOptions(
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
defaultVideoPublishOptions: VideoPublishOptions(
|
||||
simulcast: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Mikrofon aktivieren
|
||||
await room.localParticipant?.setMicrophoneEnabled(true);
|
||||
|
||||
// 5. Kamera aktivieren (Video-Call)
|
||||
await room.localParticipant?.setCameraEnabled(true);
|
||||
|
||||
// 6. Teilnehmer observieren
|
||||
room.addListener(() {
|
||||
for (final participant in room.participants.values) {
|
||||
print(participant.identity); // Matrix User-ID
|
||||
for (final track in participant.videoTracks) {
|
||||
// Widget: VideoTrackRenderer(track.track!)
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Klingel-Flow
|
||||
|
||||
```dart
|
||||
// ── Anruf initiieren ──────────────────────────────────────────────────────
|
||||
|
||||
// 1. Membership-Event senden (MatrixRTC)
|
||||
// 2. To-Device-Event senden für Push-Benachrichtigung beim Empfänger
|
||||
|
||||
// Klingelanruf via to-device-Event
|
||||
await client.sendToDevice(
|
||||
'com.famedly.call', // Event-Typ
|
||||
{
|
||||
otherUserId: {
|
||||
'*': { // Alle Geräte des Users
|
||||
'call_id': callId,
|
||||
'room_id': room.id,
|
||||
'type': 'invite',
|
||||
'sender_session_id': client.deviceID,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ── Eingehenden Anruf empfangen ───────────────────────────────────────────
|
||||
|
||||
client.onToDeviceEvent.stream
|
||||
.where((e) => e.type == 'com.famedly.call')
|
||||
.listen((event) {
|
||||
final callId = event.content['call_id'];
|
||||
final roomId = event.content['room_id'];
|
||||
final type = event.content['type']; // 'invite' | 'hangup'
|
||||
// Klingelton abspielen, UI anzeigen
|
||||
});
|
||||
|
||||
// ── Anruf ablehnen (während Klingeln) ────────────────────────────────────
|
||||
|
||||
await client.sendToDevice(
|
||||
'com.famedly.call',
|
||||
{
|
||||
callerId: {'*': {'call_id': callId, 'type': 'reject'}},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebRTC Setup in Flutter
|
||||
|
||||
```dart
|
||||
// flutter_webrtc Package
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
// Media-Stream holen
|
||||
final stream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': false, // true für Video-Call
|
||||
});
|
||||
|
||||
// PeerConnection erstellen
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': [
|
||||
{'urls': 'stun:stun.matrix.org:3478'},
|
||||
// TURN-Server (für NAT-Traversal):
|
||||
{
|
||||
'urls': 'turn:turn.example.org:5349',
|
||||
'username': 'user',
|
||||
'credential': 'pass',
|
||||
},
|
||||
],
|
||||
'iceCandidatePoolSize': 10,
|
||||
});
|
||||
|
||||
// Track hinzufügen
|
||||
for (final track in stream.getTracks()) {
|
||||
await pc.addTrack(track, stream);
|
||||
}
|
||||
|
||||
// ICE-Kandidaten sammeln
|
||||
pc.onIceCandidate = (RTCIceCandidate candidate) {
|
||||
// Kandidaten via Matrix senden
|
||||
sendIceCandidates([candidate]);
|
||||
};
|
||||
|
||||
// Angebot erstellen
|
||||
final offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
// offer.sdp → via Matrix senden
|
||||
|
||||
// Angebot annehmen
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdpOffer, 'offer'));
|
||||
final answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
// answer.sdp → via Matrix senden
|
||||
|
||||
// Eingehende Streams
|
||||
pc.onTrack = (RTCTrackEvent event) {
|
||||
final remoteStream = event.streams.first;
|
||||
// Widget: RTCVideoRenderer() mit remoteStream
|
||||
};
|
||||
|
||||
// Aufräumen
|
||||
await pc.close();
|
||||
await stream.dispose();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Call-State Management
|
||||
|
||||
```dart
|
||||
// Call-Zustand in Pyramid
|
||||
enum CallState {
|
||||
idle,
|
||||
calling, // Anruf geht raus
|
||||
ringing, // Eingehender Anruf
|
||||
connecting, // ICE/SDP in Bearbeitung
|
||||
connected, // Verbunden
|
||||
ended,
|
||||
}
|
||||
|
||||
// Stummschalten
|
||||
final audioTrack = localStream.getAudioTracks().first;
|
||||
audioTrack.enabled = false; // Mute
|
||||
audioTrack.enabled = true; // Unmute
|
||||
|
||||
// Lautsprecher vs. Hörer (Android/iOS)
|
||||
// flutter_webrtc kümmert sich darum via Helper.setSpeakerphoneOn
|
||||
|
||||
// DTMF (Zifferntasten)
|
||||
final sender = pc.getSenders().firstWhere(
|
||||
(s) => s.track?.kind == 'audio');
|
||||
await sender.dtmf?.insertDTMF('1');
|
||||
|
||||
// Screen-Sharing starten
|
||||
final screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
'video': true,
|
||||
'audio': false,
|
||||
});
|
||||
final screenTrack = screenStream.getVideoTracks().first;
|
||||
final videoSender = pc.getSenders()
|
||||
.firstWhere((s) => s.track?.kind == 'video');
|
||||
await videoSender.replaceTrack(screenTrack);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pyramid VoIP-Architektur
|
||||
|
||||
```
|
||||
Pyramid Call-Stack:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Dart (Flutter) │
|
||||
│ lib/features/call/ │
|
||||
│ call_notifier.dart — Zustand, Aktionen │
|
||||
│ call_page.dart — UI (Kamera/Mic Controls) │
|
||||
│ livekit_service.dart — LiveKit-Token-Tausch │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Matrix SDK │
|
||||
│ room.sendState(com.famedly.call.member) │
|
||||
│ client.sendToDevice(com.famedly.call) │
|
||||
│ client.requestOpenIdToken() │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ LiveKit Client (livekit_client package) │
|
||||
│ LiveKitClient.connect(url, token) │
|
||||
│ room.localParticipant.setMicrophoneEnabled() │
|
||||
│ VideoTrackRenderer Widget │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Native (Android/iOS) │
|
||||
│ FlutterForegroundService — hält Call am Leben │
|
||||
│ Klingelton via AudioManager │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
TURN/STUN-Server Konfiguration (from Matrix):
|
||||
await client.getTurnServer() →
|
||||
TurnServerCredentials(
|
||||
username, password, ttl,
|
||||
uris: ['turn:turn.example.org:5349?transport=udp']
|
||||
)
|
||||
```
|
||||
|
||||
```dart
|
||||
// TURN-Credentials vom Homeserver holen
|
||||
final turn = await client.getTurnServer();
|
||||
final iceServers = [
|
||||
{'urls': turn.uris, 'username': turn.username, 'credential': turn.password},
|
||||
{'urls': 'stun:stun.matrix.org:3478'},
|
||||
];
|
||||
```
|
||||
@@ -0,0 +1,264 @@
|
||||
# 09 — Profil, Präsenz & Account-Daten
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Eigenes Profil lesen](#eigenes-profil-lesen)
|
||||
- [Profil aktualisieren](#profil-aktualisieren)
|
||||
- [Profil anderer User](#profil-anderer-user)
|
||||
- [Präsenz (Presence)](#präsenz-presence)
|
||||
- [Account-Daten (Account Data)](#account-daten-account-data)
|
||||
- [Ignorierte User](#ignorierte-user)
|
||||
- [Tags / Favoriten](#tags--favoriten)
|
||||
- [Account löschen / deaktivieren](#account-löschen--deaktivieren)
|
||||
|
||||
---
|
||||
|
||||
## Eigenes Profil lesen
|
||||
|
||||
```dart
|
||||
// Aus dem Client (gecacht)
|
||||
print(client.userID); // '@alice:server'
|
||||
print(client.displayName); // 'Alice'
|
||||
print(client.avatarUrl); // mxc://...
|
||||
|
||||
// Frisch vom Server
|
||||
final profile = await client.fetchOwnProfile();
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
|
||||
// Avatar als HTTP-URL
|
||||
final avatarHttp = client.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 128,
|
||||
height: 128,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profil aktualisieren
|
||||
|
||||
```dart
|
||||
// Displayname setzen
|
||||
await client.setDisplayName(client.userID!, 'Neuer Name');
|
||||
|
||||
// Avatar hochladen und setzen
|
||||
final avatarBytes = await File('avatar.png').readAsBytes();
|
||||
final mxcUri = await client.uploadContent(
|
||||
avatarBytes,
|
||||
filename: 'avatar.png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await client.setAvatarUrl(client.userID!, mxcUri);
|
||||
|
||||
// Avatar entfernen
|
||||
await client.setAvatarUrl(client.userID!, null);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profil anderer User
|
||||
|
||||
```dart
|
||||
// Profil via API
|
||||
final profile = await client.getProfileFromUserId('@bob:server');
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
|
||||
// Spezifisches Feld (Matrix 1.16+ API)
|
||||
final displayname = await client.getProfileField(
|
||||
'@bob:server',
|
||||
'displayname',
|
||||
);
|
||||
final avatar = await client.getProfileField(
|
||||
'@bob:server',
|
||||
'avatar_url',
|
||||
);
|
||||
|
||||
// User in einem Raum (schneller, gecacht)
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server');
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
print(user.id);
|
||||
|
||||
// User-Suche
|
||||
final results = await client.searchUserDirectory(
|
||||
'alice',
|
||||
limit: 20,
|
||||
);
|
||||
for (final user in results.results) {
|
||||
print(user.userId);
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Präsenz (Presence)
|
||||
|
||||
```dart
|
||||
// Eigene Präsenz setzen
|
||||
await client.setPresence(
|
||||
client.userID!,
|
||||
PresenceType.online, // .online | .unavailable | .offline
|
||||
statusMsg: 'Im Meeting', // Optionaler Status-Text
|
||||
);
|
||||
|
||||
// Präsenz anderer User abrufen
|
||||
final presence = await client.fetchCurrentPresence('@bob:server');
|
||||
print(presence.presence); // PresenceType
|
||||
print(presence.currentlyActive); // bool?
|
||||
print(presence.statusMsg); // String?
|
||||
print(presence.lastActiveAgo); // int? (Millisekunden seit letzter Aktivität)
|
||||
|
||||
// Präsenz aus Raum-Mitglieder
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server');
|
||||
// Präsenz wird nicht direkt im User gecacht — fetchCurrentPresence nötig
|
||||
|
||||
// Präsenz-Anzeige im UI
|
||||
String getPresenceText(PresenceType? type, int? lastActiveAgo) {
|
||||
switch (type) {
|
||||
case PresenceType.online: return 'Online';
|
||||
case PresenceType.unavailable:
|
||||
return 'Abwesend';
|
||||
case PresenceType.offline:
|
||||
if (lastActiveAgo != null) {
|
||||
final mins = lastActiveAgo ~/ 60000;
|
||||
if (mins < 60) return 'Vor $mins Min. aktiv';
|
||||
final hours = mins ~/ 60;
|
||||
if (hours < 24) return 'Vor $hours Std. aktiv';
|
||||
return 'Vor ${hours ~/ 24} Tagen aktiv';
|
||||
}
|
||||
return 'Offline';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Account-Daten (Account Data)
|
||||
|
||||
```dart
|
||||
// Account-Daten sind private, servergespeicherte Einstellungen.
|
||||
// Bleiben über Geräte hinweg synchron.
|
||||
|
||||
// Lesen
|
||||
final data = client.accountData['com.example.my_setting']?.content;
|
||||
|
||||
// Schreiben
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'com.example.my_setting',
|
||||
{'key': 'value', 'enabled': true},
|
||||
);
|
||||
|
||||
// Wichtige Standard-Account-Daten:
|
||||
// 'm.direct' → DM-Raumliste
|
||||
// 'm.push_rules' → Push-Regel-Overrides
|
||||
// 'm.ignored_user_list' → Ignorierte User
|
||||
// 'm.identity_server' → Identity-Server-URL
|
||||
// 'im.vector.setting.breadcrumbs' → FluffyChat letzte Räume
|
||||
|
||||
// Raumspezifische Account-Daten
|
||||
await client.setRoomAccountData(
|
||||
client.userID!,
|
||||
'!roomId:server',
|
||||
'm.fully_read',
|
||||
{'event_id': eventId},
|
||||
);
|
||||
|
||||
// Tag für Raum setzen (Favorit, Low-Priority)
|
||||
await client.setRoomTag(
|
||||
client.userID!,
|
||||
'!roomId:server',
|
||||
'm.favourite',
|
||||
TagContent(order: 0.5),
|
||||
);
|
||||
await client.setRoomTag(client.userID!, '!roomId:server', 'm.lowpriority', TagContent());
|
||||
await client.deleteRoomTag(client.userID!, '!roomId:server', 'm.favourite');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ignorierte User
|
||||
|
||||
```dart
|
||||
// User ignorieren
|
||||
final ignoredList = client.ignoredUsers; // List<String>
|
||||
final updated = [...ignoredList, '@spammer:server'];
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'm.ignored_user_list',
|
||||
{'ignored_users': Map.fromIterable(updated, value: (_) => {})},
|
||||
);
|
||||
|
||||
// User entblockieren
|
||||
final filtered = ignoredList.where((id) => id != '@spammer:server').toList();
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'm.ignored_user_list',
|
||||
{'ignored_users': Map.fromIterable(filtered, value: (_) => {})},
|
||||
);
|
||||
|
||||
// Ignorierte User abrufen
|
||||
final ignored = client.ignoredUsers; // automatisch aus Account-Data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tags / Favoriten
|
||||
|
||||
```dart
|
||||
// Raum als Favorit markieren
|
||||
await room.addTag('m.favourite', order: 0.1);
|
||||
|
||||
// Raum als Low-Priority markieren
|
||||
await room.addTag('m.lowpriority');
|
||||
|
||||
// Alle Tags eines Raums
|
||||
final tags = room.tags; // Map<String, Tag>
|
||||
final isFavourite = tags.containsKey('m.favourite');
|
||||
final isLowPriority = tags.containsKey('m.lowpriority');
|
||||
|
||||
// Tag entfernen
|
||||
await room.removeTag('m.favourite');
|
||||
|
||||
// Eigene Tags
|
||||
await room.addTag('com.example.myapp.tag', order: 0.5);
|
||||
|
||||
// Räume nach Tag filtern
|
||||
final favourites = client.rooms
|
||||
.where((r) => r.tags.containsKey('m.favourite'))
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Account löschen / deaktivieren
|
||||
|
||||
```dart
|
||||
// Account deaktivieren (PERMANENT, nicht rückgängig!)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.deactivateAccount(
|
||||
auth: auth,
|
||||
eraseData: true, // true: alle Nachrichten löschen (soweit möglich)
|
||||
),
|
||||
);
|
||||
|
||||
// Passwort ändern (braucht Re-Auth via UIAA)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.changePassword(
|
||||
'neuesPasswort',
|
||||
auth: auth,
|
||||
oldPassword: 'altesPasswort',
|
||||
),
|
||||
);
|
||||
|
||||
// E-Mail-Adresse hinzufügen (über Identity-Server)
|
||||
// Dies ist ein mehrstufiger UIAA-Flow und erfordert E-Mail-Bestätigung.
|
||||
// Normalerweise über den Homeserver-spezifischen Registration-Flow.
|
||||
```
|
||||
@@ -0,0 +1,410 @@
|
||||
# 10 — Raum-Administration & Moderation
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Power Levels vollständig](#power-levels-vollständig)
|
||||
- [Moderation: Kick, Ban, Unban](#moderation-kick-ban-unban)
|
||||
- [Server-Side Room Admin](#server-side-room-admin)
|
||||
- [Raum-Einstellungen komplett](#raum-einstellungen-komplett)
|
||||
- [Raum-Alias verwalten](#raum-alias-verwalten)
|
||||
- [Server ACL (IP-Sperren)](#server-acl-ip-sperren)
|
||||
- [Raum-Audit / State inspizieren](#raum-audit--state-inspizieren)
|
||||
- [Widgets / Bots verwalten](#widgets--bots-verwalten)
|
||||
- [Room Tombstone / Upgrade](#room-tombstone--upgrade)
|
||||
- [Reporting (Melden)](#reporting-melden)
|
||||
|
||||
---
|
||||
|
||||
## Power Levels vollständig
|
||||
|
||||
```dart
|
||||
// Power Level eines Users ändern
|
||||
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
|
||||
|
||||
// Alle Power Levels auf einmal setzen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
{
|
||||
// Welche Power-Level für Standard-Aktionen benötigt werden:
|
||||
'ban': 50,
|
||||
'kick': 50,
|
||||
'invite': 0,
|
||||
'redact': 50,
|
||||
'events_default': 0, // Nachrichten senden
|
||||
'state_default': 50, // State-Events ändern
|
||||
|
||||
// Spezifische Event-Typen:
|
||||
'events': {
|
||||
'm.room.name': 50,
|
||||
'm.room.topic': 50,
|
||||
'm.room.avatar': 50,
|
||||
'm.room.power_levels': 100,
|
||||
'm.room.canonical_alias': 50,
|
||||
'm.room.history_visibility': 100,
|
||||
'm.room.encryption': 100,
|
||||
'm.space.child': 50,
|
||||
},
|
||||
|
||||
// User-Overrides:
|
||||
'users': {
|
||||
'@admin:server': 100,
|
||||
'@moderator:server': 50,
|
||||
},
|
||||
'users_default': 0, // Alle anderen User
|
||||
'notifications': {
|
||||
'room': 50, // @room mention power level
|
||||
},
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Power Level lesen
|
||||
final plContent = room.getState(EventTypes.RoomPowerLevels)?.content;
|
||||
final usersMap = plContent?.tryGetMap<String, dynamic>('users') ?? {};
|
||||
final adminPl = usersMap['@alice:server'] as int? ?? 0;
|
||||
|
||||
// Berechtigungen prüfen
|
||||
room.ownPowerLevel; // int: eigener PL
|
||||
room.getPowerLevelByUserId('@alice:server'); // int
|
||||
room.canBan; // bool
|
||||
room.canKick; // bool
|
||||
room.canInvite; // bool
|
||||
room.canRedactEvent(event); // bool
|
||||
room.canSendEvent(EventTypes.Message); // bool
|
||||
room.canSendEvent(EventTypes.RoomName); // bool
|
||||
room.canChangeStateEvent(EventTypes.RoomTopic); // bool
|
||||
room.canChangeNameAndTopic; // bool
|
||||
room.canChangeJoinRules; // bool
|
||||
room.canChangeHistoryVisibility; // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Moderation: Kick, Ban, Unban
|
||||
|
||||
```dart
|
||||
// Kick (User wird entfernt, kann wieder beitreten)
|
||||
await room.kick('@alice:server', reason: 'Verstoß gegen Regeln');
|
||||
|
||||
// Ban (User kann nicht mehr beitreten)
|
||||
await room.ban('@alice:server', reason: 'Dauerhafter Ausschluss');
|
||||
|
||||
// Unban
|
||||
await room.unban('@alice:server');
|
||||
|
||||
// Alle gebannten User anzeigen
|
||||
final bannedMembers = room.getParticipants(
|
||||
membershipFilter: [Membership.ban],
|
||||
);
|
||||
|
||||
// Eigene Berechtigungen prüfen vor Moderation-Aktionen
|
||||
if (room.canKick) {
|
||||
await room.kick(userId);
|
||||
}
|
||||
if (room.canBan) {
|
||||
await room.ban(userId);
|
||||
}
|
||||
|
||||
// User-PL prüfen (kann man diesen User kicken/bannen?)
|
||||
// Man kann nur User mit niedrigerem PL als dem eigenen moderieren.
|
||||
final theirPl = room.getPowerLevelByUserId(userId);
|
||||
final myPl = room.ownPowerLevel;
|
||||
final canModerate = myPl > theirPl;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Room Admin
|
||||
|
||||
> ⚠️ **Pyramid läuft auf Continuwuity** — Synapse Admin API (`/_synapse/admin/`) **existiert nicht!**
|
||||
> Für vollständige Continuwuity-Admin-Dokumentation: **[→ 13-server-admin-continuwuity.md](13-server-admin-continuwuity.md)**
|
||||
|
||||
```dart
|
||||
// Server-Admin-Status prüfen (Matrix-Standard, funktioniert überall)
|
||||
final isAdmin = await client.getIsAdmin();
|
||||
|
||||
// Für Continuwuity: Admin-Aktionen über Standard Matrix Client API
|
||||
// oder über den Admin-Room (#admins:steggi-matrix.work)
|
||||
|
||||
// Power Level in einem Raum als Admin setzen:
|
||||
await client.setRoomStateWithKey(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
{
|
||||
'users': {'@admin:server': 100},
|
||||
'users_default': 0,
|
||||
'state_default': 50,
|
||||
'events_default': 0,
|
||||
'ban': 50, 'kick': 50, 'invite': 0, 'redact': 50,
|
||||
},
|
||||
);
|
||||
|
||||
// Raum beitreten (Admin kann invite-only-Räume per API beitreten):
|
||||
await client.joinRoom(room.id);
|
||||
|
||||
// Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
await client.setRoomStateWithKey(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
channelRoomId,
|
||||
{'via': [client.homeserver.host], 'suggested': false},
|
||||
);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Continuwuity Admin HTTP API
|
||||
# Basis: https://server/_conduwuit/admin/v1/
|
||||
|
||||
# User zu Server-Admin machen:
|
||||
curl -X POST "https://server/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@admin:server"}'
|
||||
|
||||
# Power Level in Raum setzen (Standard Matrix API, mit Admin-Token):
|
||||
curl -X PUT "https://server/_matrix/client/v3/rooms/!roomId:server/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"users": {"@admin:server": 100}, "users_default": 0, "state_default": 50}'
|
||||
|
||||
# Für Synapse (falls Server-Wechsel zu Synapse):
|
||||
# curl .../server/_synapse/admin/v1/rooms/{roomId} ← dann wieder relevant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen komplett
|
||||
|
||||
```dart
|
||||
// Name
|
||||
await room.setName('Neuer Raumname');
|
||||
|
||||
// Thema
|
||||
await room.setDescription('Neues Thema');
|
||||
|
||||
// Avatar
|
||||
final mxcUri = await client.uploadContent(imageBytes, filename: 'avatar.png');
|
||||
await room.setAvatar(mxcUri);
|
||||
|
||||
// Join-Rules
|
||||
await room.setJoinRules(JoinRules.invite); // Privat
|
||||
await room.setJoinRules(JoinRules.public); // Öffentlich
|
||||
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
|
||||
|
||||
// Restricted (Nur Space-Mitglieder können beitreten)
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [
|
||||
SpaceRoomJoinRulesAllowInner(
|
||||
roomId: '!spaceId:server',
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// History-Visibility
|
||||
await room.setHistoryVisibility(HistoryVisibility.shared);
|
||||
// .shared = alle sehen vergangene Nachrichten (nach Join)
|
||||
// .invited = sieht Nachrichten ab dem Einladungs-Zeitpunkt
|
||||
// .joined = sieht Nachrichten nur ab dem Beitritts-Zeitpunkt
|
||||
// .worldReadable = öffentlich lesbar (auch ohne Account)
|
||||
|
||||
// Guest Access
|
||||
await room.setGuestAccess(GuestAccess.canJoin); // Gäste erlaubt
|
||||
await room.setGuestAccess(GuestAccess.forbidden); // Keine Gäste
|
||||
|
||||
// E2EE aktivieren (einmalig, nicht deaktivierbar!)
|
||||
await room.enableEncryption();
|
||||
|
||||
// Notifications für @room: Welcher PL nötig?
|
||||
// → über Power Levels 'notifications.room' setzen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Alias verwalten
|
||||
|
||||
```dart
|
||||
// Alias erstellen
|
||||
await client.setRoomAlias('#neuer-alias:server', room.id);
|
||||
|
||||
// Kanonischen Alias setzen (sichtbarer Hauptalias)
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomCanonicalAlias,
|
||||
{
|
||||
'alias': '#hauptalias:server',
|
||||
'alt_aliases': ['#alternativ:server'],
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Alias auflösen → Raum-ID
|
||||
final resolved = await client.getRoomIdByAlias('#alias:server');
|
||||
print(resolved.roomId); // !roomId:server
|
||||
|
||||
// Alias löschen
|
||||
await client.deleteRoomAlias('#alter-alias:server');
|
||||
|
||||
// Alle Aliases eines Raums
|
||||
final aliases = (room.getState(EventTypes.RoomCanonicalAlias)
|
||||
?.content['alt_aliases'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
final canonical = room.canonicalAlias;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server ACL (IP-Sperren)
|
||||
|
||||
```dart
|
||||
// Server ACL: Welche Server dürfen am Raum teilnehmen?
|
||||
// Wichtig für öffentliche Räume um Spam-Server zu sperren.
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.room.server_acl',
|
||||
{
|
||||
'allow': ['*'], // Alle erlauben
|
||||
'deny': ['spam.example.org', '*.badserver.org'],
|
||||
'allow_ip_literals': false, // IP-Adressen als Server-Namen verboten
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// ACL lesen
|
||||
final acl = room.getState('m.room.server_acl')?.content;
|
||||
final denied = (acl?['deny'] as List?)?.cast<String>() ?? [];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Audit / State inspizieren
|
||||
|
||||
```dart
|
||||
// Alle State-Events lesen
|
||||
final states = room.states;
|
||||
// Map<EventType, Map<StateKey, StrippedStateEvent>>
|
||||
|
||||
// Spezifischer State
|
||||
final nameEvent = room.getState(EventTypes.RoomName);
|
||||
final topicEvent = room.getState(EventTypes.RoomTopic);
|
||||
final memberEvent = room.getState(EventTypes.RoomMember, '@alice:server');
|
||||
final encEvent = room.getState(EventTypes.Encryption);
|
||||
|
||||
// Alle Mitglieder-States
|
||||
final memberStates = states[EventTypes.RoomMember] ?? {};
|
||||
for (final entry in memberStates.entries) {
|
||||
final mxid = entry.key; // stateKey = MXID
|
||||
final membership = entry.value.content['membership'];
|
||||
print('$mxid: $membership');
|
||||
}
|
||||
|
||||
// Raum-History vom Server laden (Admin)
|
||||
final response = await client.request(
|
||||
RequestType.GET,
|
||||
'/rooms/${room.id}/state',
|
||||
);
|
||||
|
||||
// Event-Timeline abrufen (Admin)
|
||||
final timeline = await client.request(
|
||||
RequestType.GET,
|
||||
'/rooms/${room.id}/messages',
|
||||
query: {'limit': '100', 'dir': 'b'},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Widgets / Bots verwalten
|
||||
|
||||
```dart
|
||||
// Widget hinzufügen (z.B. Jitsi, Etherpad)
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.widget',
|
||||
{
|
||||
'type': 'jitsi',
|
||||
'url': 'https://jitsi.example.org/#!roomId',
|
||||
'name': 'Jitsi Call',
|
||||
'data': {'domain': 'jitsi.example.org'},
|
||||
'waitForIframeLoad': true,
|
||||
'creatorUserId': client.userID,
|
||||
'id': 'jitsi-widget',
|
||||
},
|
||||
stateKey: 'jitsi-widget',
|
||||
);
|
||||
|
||||
// Widget entfernen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.widget',
|
||||
{}, // Leeres Content = entfernen
|
||||
stateKey: 'jitsi-widget',
|
||||
);
|
||||
|
||||
// Alle Widgets lesen
|
||||
final widgets = room.states['m.widget'] ?? {};
|
||||
for (final widget in widgets.values) {
|
||||
print(widget.content['name']);
|
||||
print(widget.content['url']);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Room Tombstone / Upgrade
|
||||
|
||||
```dart
|
||||
// Raum-Upgrade (Nachfolger-Raum erstellen)
|
||||
final newRoomId = await client.upgradeRoom(
|
||||
room.id,
|
||||
newVersion: '11', // Aktuelle empfohlene Version
|
||||
);
|
||||
// SDK sendet m.room.tombstone in alten Raum
|
||||
// Und m.room.create mit predecessor in neuen Raum
|
||||
// Alle Mitglieder werden in neuen Raum eingeladen
|
||||
|
||||
// Tombstone lesen
|
||||
final tombstone = room.getState(EventTypes.RoomTombstone);
|
||||
if (tombstone != null) {
|
||||
final replacementRoomId = tombstone.content['replacement_room'] as String;
|
||||
final serverMessage = tombstone.content['body'] as String;
|
||||
// → Zum neuen Raum navigieren
|
||||
}
|
||||
|
||||
// Predecessor lesen (im neuen Raum)
|
||||
final create = room.getState(EventTypes.RoomCreate);
|
||||
final predecessor = create?.content['predecessor'] as Map?;
|
||||
if (predecessor != null) {
|
||||
final oldRoomId = predecessor['room_id'] as String;
|
||||
final lastEventId = predecessor['event_id'] as String;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporting (Melden)
|
||||
|
||||
```dart
|
||||
// Event melden (z.B. Spam, illegaler Inhalt)
|
||||
await client.reportContent(
|
||||
room.id,
|
||||
event.eventId,
|
||||
reason: 'Spam/Belästigung',
|
||||
score: -100, // -100 = sehr schlimm, 0 = nicht schlimm
|
||||
);
|
||||
|
||||
// User melden (via MSC4151, wenn vom Server unterstützt)
|
||||
await client.httpClient.post(
|
||||
Uri.parse('${client.homeserver}/_matrix/client/v3/report/${Uri.encodeComponent("@user:server")}'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer ${client.accessToken}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({'reason': 'Belästigung'}),
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,238 @@
|
||||
# 11 — Suche & Discovery
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Nachrichten in einem Raum suchen](#nachrichten-in-einem-raum-suchen)
|
||||
- [Globale Nachrichten-Suche](#globale-nachrichten-suche)
|
||||
- [User-Suche](#user-suche)
|
||||
- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis)
|
||||
- [Matrix-URI auflösen](#matrix-uri-auflösen)
|
||||
- [Raum per Alias finden](#raum-per-alias-finden)
|
||||
|
||||
---
|
||||
|
||||
## Nachrichten in einem Raum suchen
|
||||
|
||||
```dart
|
||||
// In der lokalen Timeline suchen (gecacht)
|
||||
final found = timeline.events
|
||||
.where((e) =>
|
||||
e.type == EventTypes.Message &&
|
||||
e.body.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
// Auf dem Server suchen (Matrix-Suchendpoint)
|
||||
final result = await client.searchEvents(
|
||||
Categories(
|
||||
roomEvents: RoomEventsCriteria(
|
||||
searchTerm: 'suchbegriff',
|
||||
roomId: room.id, // optional: nur in einem Raum
|
||||
orderBy: SearchOrder.recent, // oder .rank
|
||||
keys: [SearchKey.contentBody], // oder .contentName, .contentTopic
|
||||
filter: RoomEventFilter(
|
||||
limit: 20,
|
||||
rooms: [room.id],
|
||||
notRooms: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Ergebnisse
|
||||
for (final result in result.roomEvents?.results ?? []) {
|
||||
print(result.result.body);
|
||||
print(result.result.originServerTs);
|
||||
print(result.result.senderId);
|
||||
}
|
||||
|
||||
// Paginierung
|
||||
final nextToken = result.roomEvents?.nextBatch;
|
||||
if (nextToken != null) {
|
||||
final more = await client.searchEvents(
|
||||
/* gleiche Criteria */,
|
||||
nextBatch: nextToken,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Globale Nachrichten-Suche
|
||||
|
||||
```dart
|
||||
// Über alle Räume suchen
|
||||
final result = await client.searchEvents(
|
||||
Categories(
|
||||
roomEvents: RoomEventsCriteria(
|
||||
searchTerm: 'flutter matrix',
|
||||
orderBy: SearchOrder.recent,
|
||||
includeState: false,
|
||||
groupings: Groupings(
|
||||
groupBy: [Group(key: GroupKey.roomId)], // Gruppiert nach Raum
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Gruppen-Ergebnisse
|
||||
for (final entry in result.roomEvents?.groups?.entries ?? []) {
|
||||
final groupKey = entry.key; // 'room_id'
|
||||
for (final roomGroup in entry.value.entries) {
|
||||
final roomId = roomGroup.key;
|
||||
print('Treffer in $roomId: ${roomGroup.value.count}');
|
||||
// roomGroup.value.results: List<String> (Event-IDs)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-Suche
|
||||
|
||||
```dart
|
||||
// User-Verzeichnis durchsuchen
|
||||
final response = await client.searchUserDirectory(
|
||||
'alice', // Suchbegriff (Name oder MXID)
|
||||
limit: 20,
|
||||
);
|
||||
|
||||
for (final user in response.results) {
|
||||
print(user.userId); // @alice:server
|
||||
print(user.displayName); // Alice
|
||||
print(user.avatarUrl); // mxc://...
|
||||
|
||||
// Avatar als HTTP-URL
|
||||
final avatarHttp = user.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 56,
|
||||
height: 56,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
}
|
||||
|
||||
print(response.limited); // true wenn weitere Ergebnisse vorhanden
|
||||
|
||||
// MXID direkt laden (ohne Suche)
|
||||
final profile = await client.getProfileFromUserId('@alice:server');
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Öffentliches Raumverzeichnis
|
||||
|
||||
```dart
|
||||
// Öffentliche Räume (des eigenen Servers)
|
||||
final result = await client.queryPublicRooms(
|
||||
limit: 50,
|
||||
);
|
||||
|
||||
// Mit Suchbegriff
|
||||
final searched = await client.queryPublicRooms(
|
||||
limit: 20,
|
||||
filter: PublicRoomQueryFilter(
|
||||
genericSearchTerm: 'flutter',
|
||||
),
|
||||
);
|
||||
|
||||
// Anderen Server durchsuchen
|
||||
final remote = await client.queryPublicRooms(
|
||||
server: 'matrix.org',
|
||||
limit: 20,
|
||||
filter: PublicRoomQueryFilter(genericSearchTerm: 'linux'),
|
||||
);
|
||||
|
||||
// Paginierung
|
||||
var batch = await client.queryPublicRooms(limit: 50);
|
||||
while (batch.nextBatch != null) {
|
||||
batch = await client.queryPublicRooms(
|
||||
limit: 50,
|
||||
since: batch.nextBatch,
|
||||
);
|
||||
}
|
||||
|
||||
// Einzelnes Ergebnis
|
||||
for (final room in result.chunk) {
|
||||
print(room.roomId);
|
||||
print(room.name);
|
||||
print(room.topic);
|
||||
print(room.numJoinedMembers);
|
||||
print(room.worldReadable);
|
||||
print(room.guestCanJoin);
|
||||
print(room.avatarUrl);
|
||||
print(room.canonicalAlias);
|
||||
print(room.joinRule); // 'public' | 'knock' | etc.
|
||||
print(room.roomType); // 'm.space' | null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Matrix-URI auflösen
|
||||
|
||||
```dart
|
||||
// Matrix-to URI parsen
|
||||
// https://matrix.to/#/@alice:server
|
||||
// https://matrix.to/#/!roomId:server
|
||||
// https://matrix.to/#/#alias:server
|
||||
// matrix:u/alice:server
|
||||
// matrix:r/alias:server
|
||||
// matrix:roomid/roomId:server
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org');
|
||||
if (uri != null) {
|
||||
switch (uri.primaryType) {
|
||||
case MatrixUriType.room: // Raum-Alias
|
||||
case MatrixUriType.roomId: // Raum-ID
|
||||
case MatrixUriType.user: // User-MXID
|
||||
case MatrixUriType.event: // Event-Link
|
||||
}
|
||||
|
||||
final identifier = uri.primaryIdentifier;
|
||||
// z.B. '#kanal:example.org' oder '!roomId:server'
|
||||
|
||||
// Beitreten (bei Raum)
|
||||
if (uri.primaryType == MatrixUriType.room ||
|
||||
uri.primaryType == MatrixUriType.roomId) {
|
||||
await client.joinRoomOrAlias(
|
||||
identifier,
|
||||
serverName: uri.viaServers, // föderierte Server
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix-URI generieren
|
||||
final roomUri = MatrixUri(
|
||||
primaryType: MatrixUriType.roomId,
|
||||
primaryIdentifier: room.id,
|
||||
viaServers: ['example.org'],
|
||||
).toUri().toString();
|
||||
// → matrix:roomid/roomId:server?via=example.org
|
||||
|
||||
// Matrix.to Link generieren
|
||||
final matrixToLink = 'https://matrix.to/#/${Uri.encodeComponent(room.canonicalAlias ?? room.id)}';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum per Alias finden
|
||||
|
||||
```dart
|
||||
// Alias → Raum-ID auflösen
|
||||
final result = await client.getRoomIdByAlias('#alias:server');
|
||||
print(result.roomId);
|
||||
print(result.servers); // Via-Server für Beitritt
|
||||
|
||||
// Direkt beitreten via Alias
|
||||
await client.joinRoomOrAlias('#alias:server');
|
||||
|
||||
// Lokal prüfen (gecacht)
|
||||
final localRoom = client.rooms.firstWhere(
|
||||
(r) => r.canonicalAlias == '#alias:server',
|
||||
orElse: () => throw Exception('Nicht gefunden'),
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,366 @@
|
||||
# 12 — Schnellreferenz: Typen, Konstanten & Patterns
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Enum-Werte
|
||||
|
||||
```dart
|
||||
// Membership
|
||||
Membership.join // Mitglied
|
||||
Membership.invite // Eingeladen
|
||||
Membership.leave // Verlassen
|
||||
Membership.ban // Gesperrt
|
||||
Membership.knock // Klopf-Anfrage
|
||||
|
||||
// JoinRules
|
||||
JoinRules.public // Offen
|
||||
JoinRules.invite // Nur per Einladung
|
||||
JoinRules.knock // Beitrittsanfragen erlaubt
|
||||
JoinRules.restricted // Nur Mitglieder bestimmter Spaces/Räume
|
||||
JoinRules.knockRestricted
|
||||
|
||||
// HistoryVisibility
|
||||
HistoryVisibility.worldReadable // Ohne Login lesbar
|
||||
HistoryVisibility.shared // Alle Mitglieder sehen History
|
||||
HistoryVisibility.invited // Ab Einladungszeitpunkt
|
||||
HistoryVisibility.joined // Ab Beitrittszeitpunkt
|
||||
|
||||
// GuestAccess
|
||||
GuestAccess.canJoin // Gäste erlaubt
|
||||
GuestAccess.forbidden // Keine Gäste
|
||||
|
||||
// Visibility
|
||||
Visibility.public // Im Verzeichnis
|
||||
Visibility.private // Nicht im Verzeichnis
|
||||
|
||||
// CreateRoomPreset
|
||||
CreateRoomPreset.privateChat // invite + joined History
|
||||
CreateRoomPreset.publicChat // public + shared History
|
||||
CreateRoomPreset.trustedPrivateChat // wie private + alle Mitglieder sind Admins
|
||||
|
||||
// EventStatus
|
||||
EventStatus.sending // Wird übertragen
|
||||
EventStatus.sent // Server-ACK
|
||||
EventStatus.synced // Im Sync empfangen
|
||||
EventStatus.error // Fehlgeschlagen
|
||||
EventStatus.removed // Gelöscht
|
||||
|
||||
// PresenceType
|
||||
PresenceType.online
|
||||
PresenceType.unavailable
|
||||
PresenceType.offline
|
||||
|
||||
// ThumbnailMethod
|
||||
ThumbnailMethod.crop // Ausschneiden auf exakte Größe
|
||||
ThumbnailMethod.scale // Skalieren ohne Abschneiden
|
||||
|
||||
// LoginType
|
||||
LoginType.mLoginPassword
|
||||
LoginType.mLoginToken
|
||||
LoginType.mLoginSso
|
||||
|
||||
// PushRuleKind
|
||||
PushRuleKind.override
|
||||
PushRuleKind.content
|
||||
PushRuleKind.room
|
||||
PushRuleKind.sender
|
||||
PushRuleKind.underride
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EventTypes Konstanten
|
||||
|
||||
```dart
|
||||
EventTypes.Message // m.room.message
|
||||
EventTypes.Sticker // m.sticker
|
||||
EventTypes.Reaction // m.reaction
|
||||
EventTypes.RoomRedaction // m.room.redaction
|
||||
EventTypes.Encrypted // m.room.encrypted
|
||||
EventTypes.RoomCreate // m.room.create
|
||||
EventTypes.RoomName // m.room.name
|
||||
EventTypes.RoomTopic // m.room.topic
|
||||
EventTypes.RoomAvatar // m.room.avatar
|
||||
EventTypes.RoomMember // m.room.member
|
||||
EventTypes.RoomPowerLevels // m.room.power_levels
|
||||
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
|
||||
EventTypes.RoomJoinRules // m.room.join_rules
|
||||
EventTypes.RoomGuestAccess // m.room.guest_access
|
||||
EventTypes.HistoryVisibility // m.room.history_visibility
|
||||
EventTypes.RoomPinnedEvents // m.room.pinned_events
|
||||
EventTypes.RoomTombstone // m.room.tombstone
|
||||
EventTypes.Encryption // m.room.encryption
|
||||
EventTypes.SpaceChild // m.space.child
|
||||
EventTypes.SpaceParent // m.space.parent
|
||||
EventTypes.CallInvite // m.call.invite
|
||||
EventTypes.CallAnswer // m.call.answer
|
||||
EventTypes.CallHangup // m.call.hangup
|
||||
EventTypes.CallCandidates // m.call.candidates
|
||||
EventTypes.CallReject // m.call.reject
|
||||
EventTypes.CallSelectAnswer // m.call.select_answer
|
||||
EventTypes.CallNegotiate // m.call.negotiate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Häufige Content-Zugriffe
|
||||
|
||||
```dart
|
||||
// Sicher auf Map-Inhalte zugreifen (Matrix SDK Extension)
|
||||
final text = event.content.tryGet<String>('body');
|
||||
final map = event.content.tryGetMap<String, dynamic>('info');
|
||||
final list = event.content.tryGetList<String>('aliases');
|
||||
|
||||
// Event-Inhalt strukturiert
|
||||
final msgtype = event.content['msgtype'] as String?;
|
||||
final body = event.content['body'] as String?;
|
||||
final formatted = event.content['formatted_body'] as String?;
|
||||
final format = event.content['format'] as String?; // 'org.matrix.custom.html'
|
||||
|
||||
// Relation
|
||||
final relatesTo = event.content
|
||||
.tryGetMap<String, dynamic>('m.relates_to');
|
||||
final relType = relatesTo?.tryGet<String>('rel_type');
|
||||
final eventId = relatesTo?.tryGet<String>('event_id');
|
||||
|
||||
// Verschlüsselte Medien
|
||||
final file = event.content.tryGetMap<String, dynamic>('file');
|
||||
final url = file?.tryGet<String>('url'); // mxc://
|
||||
final key = file?.tryGetMap<String, dynamic>('key');
|
||||
|
||||
// Mentions (MSC3952)
|
||||
final mentions = event.content.tryGetMap<String, dynamic>('m.mentions');
|
||||
final userIds = mentions?.tryGetList<String>('user_ids') ?? [];
|
||||
final roomMention = mentions?.tryGet<bool>('room') ?? false;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeline-Widget Pattern (Flutter)
|
||||
|
||||
```dart
|
||||
class ChatView extends ConsumerStatefulWidget {
|
||||
final String roomId;
|
||||
const ChatView({required this.roomId, super.key});
|
||||
@override
|
||||
ConsumerState<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends ConsumerState<ChatView> {
|
||||
Timeline? _timeline;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTimeline();
|
||||
}
|
||||
|
||||
Future<void> _loadTimeline() async {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final room = client.getRoomById(widget.roomId)!;
|
||||
_timeline = await room.getTimeline(
|
||||
onUpdate: () => setState(() {}),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timeline?.cancelSubscriptions(); // WICHTIG!
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final events = _timeline?.events ?? [];
|
||||
return ListView.builder(
|
||||
reverse: true, // Neueste unten
|
||||
itemCount: events.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final event = events[i];
|
||||
if (event.type != EventTypes.Message) return const SizedBox();
|
||||
return ListTile(
|
||||
title: Text(event.body),
|
||||
subtitle: Text(event.senderId),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Liste Widget Pattern
|
||||
|
||||
```dart
|
||||
// StreamProvider für reaktive Raumliste
|
||||
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
yield _sortedRooms(client);
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield _sortedRooms(client);
|
||||
}
|
||||
});
|
||||
|
||||
List<Room> _sortedRooms(Client client) => client.rooms
|
||||
.where((r) => r.membership == Membership.join)
|
||||
.toList()
|
||||
..sort((a, b) =>
|
||||
(b.lastEvent?.originServerTs ?? DateTime(0))
|
||||
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
||||
|
||||
// In Widget:
|
||||
final rooms = ref.watch(roomsProvider);
|
||||
rooms.when(
|
||||
data: (list) => ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final room = list[i];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(child: Text(room.name[0])),
|
||||
title: Text(room.getLocalizedDisplayname()),
|
||||
subtitle: Text(room.lastEvent?.body ?? ''),
|
||||
trailing: room.notificationCount > 0
|
||||
? Badge(label: Text('${room.notificationCount}'))
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, s) => Text('Fehler: $e'),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nachrichten senden mit optimistischem UI
|
||||
|
||||
```dart
|
||||
// Das SDK fügt das Event sofort mit Status 'sending' in die Timeline ein.
|
||||
// UI kann es sofort anzeigen, ohne auf Server zu warten.
|
||||
|
||||
class MessageInput extends ConsumerWidget {
|
||||
final Room room;
|
||||
const MessageInput({required this.room, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = TextEditingController();
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: TextField(controller: controller)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
controller.clear();
|
||||
try {
|
||||
await room.sendTextEvent(text);
|
||||
} catch (e) {
|
||||
// Event bleibt mit Status 'error' in Timeline
|
||||
// User kann auf "Erneut senden" tippen
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nützliche Client-Extensions & Helpers
|
||||
|
||||
```dart
|
||||
// User-Erwähnung formatieren
|
||||
'@${userId.split(':').first.replaceFirst('@', '')}' // kurzform ohne Server
|
||||
|
||||
// Raum-Display-Name (FluffyChat-Stil)
|
||||
// Für DMs: Name des Gegenübers
|
||||
// Für Räume mit Name: Name
|
||||
// Für Räume ohne Name: kommagetrennte Mitgliedernamen
|
||||
room.getLocalizedDisplayname()
|
||||
|
||||
// Avatar-URL sicher holen
|
||||
user.avatarUrl?.getThumbnail(client, width: 56, height: 56)
|
||||
|
||||
// Event-Zeitstempel formatieren
|
||||
event.originServerTs.toLocal().toString()
|
||||
// oder mit intl:
|
||||
DateFormat.Hm().format(event.originServerTs.toLocal())
|
||||
|
||||
// Stable Notification-ID aus Raum-ID (deterministisch)
|
||||
int stableId(String roomId) {
|
||||
var h = 0;
|
||||
for (final c in roomId.codeUnits) {
|
||||
h = ((h * 31) + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return h == 0 ? 1 : h;
|
||||
}
|
||||
|
||||
// Matrix-Größenangabe
|
||||
// event.sizeString → '2.3 MB', '512 KB'
|
||||
|
||||
// mxc:// URI in HTTP umwandeln
|
||||
mxcUri.getDownloadUri(client)
|
||||
mxcUri.getThumbnail(client, width: 300, height: 300)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehler-Handling
|
||||
|
||||
```dart
|
||||
// MatrixException — Fehler vom Server
|
||||
try {
|
||||
await client.joinRoom('!room:server');
|
||||
} on MatrixException catch (e) {
|
||||
switch (e.error) {
|
||||
case MatrixError.M_FORBIDDEN: // Kein Zugang
|
||||
case MatrixError.M_NOT_FOUND: // Raum existiert nicht
|
||||
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limit
|
||||
final retryAfter = e.retryAfterMs; // Wann wieder versuchen
|
||||
case MatrixError.M_UNKNOWN:
|
||||
default:
|
||||
print(e.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// TimeoutException — Keine Antwort vom Server
|
||||
// SocketException — Keine Netzwerkverbindung
|
||||
// FormatException — Ungültige Server-Antwort
|
||||
|
||||
// Verbindungsstatus überwachen
|
||||
client.onConnectionStateChanged.stream.listen((state) {
|
||||
switch (state) {
|
||||
case ConnectionState.connected: // Sync läuft
|
||||
case ConnectionState.connecting: // Verbindet
|
||||
case ConnectionState.reconnecting: // Verbindung wiederhergestellt
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Pakete für Pyramid
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
matrix: ^6.x.x # Matrix SDK (Haupt-SDK)
|
||||
flutter_vodozemac: ^x.x.x # E2EE (Rust-basiert, empfohlen)
|
||||
firebase_messaging: ^x.x.x # FCM Push (Android)
|
||||
firebase_core: ^x.x.x
|
||||
flutter_local_notifications: ^x.x.x # Lokale Notifications
|
||||
flutter_riverpod: ^x.x.x # State Management
|
||||
sqflite: ^x.x.x # Datenbank
|
||||
path_provider: ^x.x.x # App-Verzeichnisse
|
||||
shared_preferences: ^x.x.x # Einfache KV-Speicherung
|
||||
livekit_client: ^x.x.x # LiveKit für VoIP
|
||||
flutter_webrtc: ^x.x.x # WebRTC
|
||||
go_router: ^x.x.x # Navigation
|
||||
local_notifier: ^x.x.x # Windows Notifications
|
||||
windows_taskbar: ^x.x.x # Windows Badge
|
||||
```
|
||||
@@ -0,0 +1,437 @@
|
||||
# 13 — Server-Administration: Continuwuity
|
||||
|
||||
> **Gilt für:** Continuwuity 0.5.x (Fork von Conduwuit, welches ein Fork von Conduit ist)
|
||||
> **Homeserver:** `steggi-matrix.work`
|
||||
> **WICHTIG:** Continuwuity hat KEINE Synapse Admin API (`/_synapse/admin/` existiert nicht!)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Server-Admin vs. Room Power Level](#server-admin-vs-room-power-level)
|
||||
- [Admin Room — Befehle](#admin-room--befehle)
|
||||
- [Continuwuity Admin HTTP API](#continuwuity-admin-http-api)
|
||||
- [Power Level in Räumen erzwingen (als Server-Admin)](#power-level-in-räumen-erzwingen)
|
||||
- [MSC3083: Restricted Join Rules auf Continuwuity](#msc3083-restricted-join-rules-auf-continuwuity)
|
||||
- [Diagnose: Warum M_FORBIDDEN?](#diagnose-warum-m_forbidden)
|
||||
- [User-Admin-Status prüfen/setzen](#user-admin-status-prüfensetzen)
|
||||
- [Raum-Administration via Matrix API](#raum-administration-via-matrix-api)
|
||||
- [Wichtige Unterschiede: Continuwuity vs. Synapse](#wichtige-unterschiede-continuwuity-vs-synapse)
|
||||
- [Continuwuity `homeserver.yaml` — relevante Einstellungen](#continuwuity-homeserveryaml--relevante-einstellungen)
|
||||
|
||||
---
|
||||
|
||||
## Server-Admin vs. Room Power Level
|
||||
|
||||
```
|
||||
Continuwuity Server-Admin
|
||||
├── Ermöglicht: Admin-Room-Befehle, Server-API
|
||||
├── Ermöglicht: User deaktivieren, Räume löschen (Server-Level)
|
||||
└── NICHT: Automatisch PL 100 in jedem Matrix-Raum!
|
||||
|
||||
Matrix Room Power Level 100
|
||||
├── Gilt pro Raum (in m.room.power_levels State-Event gespeichert)
|
||||
├── Ermöglicht: Name/Topic/Avatar setzen, Join-Rules ändern, User kicken/bannen
|
||||
└── Unabhängig vom Server-Admin-Status
|
||||
```
|
||||
|
||||
**Beispiel-Problem:**
|
||||
- Du bist Server-Admin auf `steggi-matrix.work` ✅
|
||||
- Du hast PL 100 im Space `!xyz:steggi-matrix.work` ✅
|
||||
- Du erstellst einen neuen Channel → Server gibt dir PL 100 als Ersteller ✅
|
||||
- Du versuchst `join_rules: restricted` zu setzen → M_FORBIDDEN ❌
|
||||
- **Grund:** Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig
|
||||
|
||||
---
|
||||
|
||||
## Admin Room — Befehle
|
||||
|
||||
Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Befehle eingeben:
|
||||
|
||||
```
|
||||
# Admin-Raum beitreten (normalerweise automatisch eingeladen)
|
||||
# Raum-Alias: #admins:steggi-matrix.work
|
||||
|
||||
# Befehle im Admin-Raum (als Nachricht senden):
|
||||
|
||||
!admin help # Alle Befehle anzeigen
|
||||
|
||||
!admin make_user_admin @user:server # User zu Server-Admin machen
|
||||
!admin deactivate_user @user:server # User deaktivieren
|
||||
!admin list_users # Alle User auflisten
|
||||
|
||||
!admin get_room_state !roomId:server # Room-State anzeigen
|
||||
!admin delete_room !roomId:server # Raum löschen
|
||||
|
||||
!admin get_mem_usage # Server-Speicherverbrauch
|
||||
!admin server_version # Server-Version
|
||||
```
|
||||
|
||||
```bash
|
||||
# Als curl (Admin-Token erforderlich):
|
||||
# Admin-Room-ID herausfinden:
|
||||
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Continuwuity Admin HTTP API
|
||||
|
||||
```bash
|
||||
# Basis-URL: https://steggi-matrix.work/_conduwuit/admin/v1/
|
||||
# (Nicht /_synapse/admin/ — das ist Synapse-only!)
|
||||
|
||||
# User zu Server-Admin machen
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
|
||||
# Server-Status abfragen
|
||||
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
||||
|
||||
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
||||
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Power Level in Räumen erzwingen (als Server-Admin)
|
||||
|
||||
Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setzen,
|
||||
**auch wenn du kein Mitglied bist** (Continuwuity erlaubt das für Server-Admins):
|
||||
|
||||
```bash
|
||||
# Schritt 1: Aktuelles Power-Level-Event lesen
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
SERVER="https://steggi-matrix.work"
|
||||
|
||||
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
|
||||
# Schritt 2: Power Level setzen (users-Map ändern)
|
||||
curl -X PUT "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"users": {
|
||||
"@todesneutron:steggi-matrix.work": 100
|
||||
},
|
||||
"users_default": 0,
|
||||
"events_default": 0,
|
||||
"state_default": 50,
|
||||
"ban": 50,
|
||||
"kick": 50,
|
||||
"invite": 0,
|
||||
"redact": 50
|
||||
}'
|
||||
```
|
||||
|
||||
```dart
|
||||
// In Flutter / Dart (mit Admin-Token):
|
||||
// Zuerst aktuellen PL-State lesen:
|
||||
final currentPl = await client.getRoomStateWithKey(
|
||||
roomId,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
);
|
||||
|
||||
// Users-Map erweitern und zurückschreiben:
|
||||
final users = Map<String, dynamic>.from(
|
||||
(currentPl['users'] as Map?)?.cast<String, dynamic>() ?? {},
|
||||
);
|
||||
users['@todesneutron:steggi-matrix.work'] = 100;
|
||||
|
||||
await client.setRoomStateWithKey(
|
||||
roomId,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
{
|
||||
...currentPl,
|
||||
'users': users,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MSC3083: Restricted Join Rules auf Continuwuity
|
||||
|
||||
`join_rules: restricted` (MSC3083) erlaubt Räumen, nur Space-Mitglieder beizutreten.
|
||||
**Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig.**
|
||||
|
||||
```bash
|
||||
# Testen ob restricted join rules funktionieren:
|
||||
ROOM_ID="!testRaum:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
SPACE_ID="!spaceId:steggi-matrix.work"
|
||||
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"join_rule": "restricted",
|
||||
"allow": [
|
||||
{
|
||||
"type": "m.room_membership",
|
||||
"room_id": "'"${SPACE_ID}"'"
|
||||
}
|
||||
]
|
||||
}'
|
||||
# → 200 OK: Unterstützt!
|
||||
# → M_FORBIDDEN oder M_UNRECOGNIZED: Nicht unterstützt oder PL zu niedrig
|
||||
```
|
||||
|
||||
**Workaround wenn restricted nicht funktioniert:**
|
||||
```bash
|
||||
# Fallback: invite-only (explizit jeden User einladen)
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"join_rule": "invite"}'
|
||||
```
|
||||
|
||||
```dart
|
||||
// In space_admin_dialog.dart — Fallback wenn restricted nicht supported:
|
||||
try {
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [
|
||||
SpaceRoomJoinRulesAllowInner(
|
||||
roomId: spaceId,
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
),
|
||||
],
|
||||
);
|
||||
} on MatrixException catch (e) {
|
||||
if (e.error == MatrixError.M_FORBIDDEN || e.error == MatrixError.M_UNKNOWN) {
|
||||
// Continuwuity unterstützt restricted join rules nicht
|
||||
// Fallback zu invite-only:
|
||||
await room.setJoinRules(JoinRules.invite);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagnose: Warum M_FORBIDDEN?
|
||||
|
||||
```bash
|
||||
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
# Antwort zeigt 'users' Map — prüfen ob '@todesneutron:steggi-matrix.work' drin ist
|
||||
|
||||
# 2. Prüfen: Was ist state_default?
|
||||
# Im Antwort-JSON: "state_default": 50 bedeutet: PL 50 nötig für State-Events
|
||||
# "m.room.join_rules": 100 in events-Map = PL 100 für Join-Rule-Änderung
|
||||
|
||||
# 3. Prüfen: Bin ich Mitglied des Raums?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
# → {"membership": "join"} = Mitglied ✅
|
||||
# → {"errcode": "M_NOT_FOUND"} = Nicht Mitglied ❌ (Server-Admin kann trotzdem State setzen)
|
||||
|
||||
# 4. Prüfen: Unterstützt der Server restricted join rules?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
# Antwort: capabilities-Objekt — MSC3083 ist nicht explizit gelistet (kein Standard-Capability)
|
||||
```
|
||||
|
||||
**Häufige Ursachen und Lösungen:**
|
||||
|
||||
| Fehler | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| M_FORBIDDEN beim State-Event | PL zu niedrig im Raum | Power Level via curl erhöhen (s.o.) |
|
||||
| M_FORBIDDEN bei join_rules: restricted | MSC3083 nicht unterstützt | Fallback zu `invite` nutzen |
|
||||
| M_FORBIDDEN beim Channel erstellen | Bug in space_admin_dialog.dart | Raum manuell erstellen, dann Space-Child setzen |
|
||||
| M_NOT_FOUND | Raum nicht bekannt | Raum beitreten oder Admin-Join |
|
||||
| M_FORBIDDEN bei Power-Level-Änderung | PL darf nur gleich/höher gesetzt werden | Als Ersteller oder via Admin-API |
|
||||
|
||||
---
|
||||
|
||||
## User-Admin-Status prüfen/setzen
|
||||
|
||||
```bash
|
||||
# Prüfen ob User Server-Admin ist:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
||||
# Alternativ: Über Admin-Room-Befehl
|
||||
# (Im Admin-Room als Nachricht senden:)
|
||||
# !admin make_user_admin @todesneutron:steggi-matrix.work
|
||||
|
||||
# Über HTTP (Conduwuit Admin-Endpoint):
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Administration via Matrix API
|
||||
|
||||
Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Admin-Token:
|
||||
|
||||
```bash
|
||||
# Raum beitreten (als Admin, auch wenn invite-only):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
|
||||
# User in Raum einladen:
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@user:steggi-matrix.work"}'
|
||||
|
||||
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
"suggested": false
|
||||
}'
|
||||
|
||||
# Space-Parent-Relation setzen (im Channel-Raum):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
"canonical": true
|
||||
}'
|
||||
|
||||
# Alle Mitglieder eines Raums auflisten:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# Raum verlassen (als User, nicht löschen):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Unterschiede: Continuwuity vs. Synapse
|
||||
|
||||
| Feature | Synapse | Continuwuity 0.5.x |
|
||||
|---|---|---|
|
||||
| Admin API URL | `/_synapse/admin/v1/` | `/_conduwuit/admin/v1/` |
|
||||
| Admin Room | Optional | Standard |
|
||||
| MSC3083 (restricted join rules) | ✅ Vollständig | ⚠️ Teilweise / experimentell |
|
||||
| Spaces (MSC1772) | ✅ | ✅ Grundfunktionen |
|
||||
| MatrixRTC / MSC3401 | ✅ (Element Call) | ⚠️ Eingeschränkt |
|
||||
| Push-Gateway | ✅ | ✅ |
|
||||
| Server-side search | ✅ | ✅ Grundfunktionen |
|
||||
| E2EE / Cross-Signing | ✅ | ✅ |
|
||||
| Presence | ✅ | ✅ |
|
||||
| Account Deactivation | Admin API | Admin Room / API |
|
||||
| Room Deletion/Purge | Admin API | Admin Room / API |
|
||||
| User Listing | Admin API | Admin Room / API |
|
||||
| Federation | ✅ | ✅ |
|
||||
| Application Services | ✅ | ⚠️ Eingeschränkt |
|
||||
|
||||
---
|
||||
|
||||
## Continuwuity `homeserver.yaml` — relevante Einstellungen
|
||||
|
||||
```yaml
|
||||
# /etc/continuwuity/homeserver.yaml (oder /etc/conduwuit/conduwuit.toml je nach Version)
|
||||
|
||||
# Matrix-RTC / LiveKit
|
||||
# MSC3266: Room Summary
|
||||
# MSC3401: MatrixRTC
|
||||
allow_unstable_room_versions: true
|
||||
|
||||
# Federation (für Multi-Server-Calls nötig)
|
||||
allow_federation: true
|
||||
allow_public_room_directory_without_auth: false
|
||||
|
||||
# Push (für Notifications)
|
||||
# Kein spezieller Config — nutzt Standard-Matrix-Push-API
|
||||
|
||||
# TURN-Server für WebRTC
|
||||
turn_uris:
|
||||
- "turn:steggi-matrix.work:3478?transport=udp"
|
||||
- "turn:steggi-matrix.work:3478?transport=tcp"
|
||||
turn_secret: "dein-turn-secret"
|
||||
turn_username: "" # leer = HMAC-basiert
|
||||
turn_password: ""
|
||||
turn_ttl: 86400
|
||||
|
||||
# Registrierung
|
||||
allow_registration: false # Kein offener Signup
|
||||
registration_token: "dein-token" # Oder token-basiert
|
||||
|
||||
# Log-Level
|
||||
log: "warn,conduwuit=info"
|
||||
|
||||
# Performance
|
||||
rocksdb_parallelism_threads: 4 # Pi 5 hat 4 Cores
|
||||
|
||||
# Unter Continuwuity: Admin-User beim ersten Start setzen
|
||||
# Erster registrierter User wird automatisch Server-Admin
|
||||
```
|
||||
|
||||
```bash
|
||||
# Auf dem Pi: Config-Datei editieren
|
||||
ssh steggi@192.168.178.71
|
||||
sudo nano /etc/continuwuity/homeserver.yaml
|
||||
# oder:
|
||||
sudo nano /etc/conduwuit/conduwuit.toml # Falls TOML-Format
|
||||
|
||||
# Service neu starten:
|
||||
sudo systemctl restart continuwuity
|
||||
# oder:
|
||||
sudo systemctl restart conduwuit
|
||||
|
||||
# Logs prüfen:
|
||||
sudo journalctl -u continuwuity -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schnell-Diagnose Checkliste
|
||||
|
||||
Wenn du M_FORBIDDEN bekommst:
|
||||
|
||||
```bash
|
||||
# 1. Token gültig?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# 2. PL im betroffenen Raum prüfen:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# 3. Mitglied des Raums?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
||||
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
||||
# M_FORBIDDEN + "Not a member" → Raum beitreten
|
||||
# M_UNRECOGNIZED → Feature nicht unterstützt
|
||||
# M_FORBIDDEN + "..." → join_rules restricted Problem
|
||||
```
|
||||
@@ -0,0 +1,433 @@
|
||||
# Pyramid — Feature-Checklist & Implementierungsstand
|
||||
|
||||
> Stand: Mai 2026
|
||||
> Legende: ✅ Fertig | ⚠️ Teilweise / Bug | ❌ Fehlt | 🔧 In Arbeit
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Authentifizierung](#authentifizierung)
|
||||
- [Raumliste](#raumliste)
|
||||
- [Chat / Timeline](#chat--timeline)
|
||||
- [Nachrichtentypen](#nachrichtentypen)
|
||||
- [Nachrichtenfunktionen](#nachrichtenfunktionen)
|
||||
- [Direct Messages](#direct-messages)
|
||||
- [E2EE Verschlüsselung](#e2ee-verschlüsselung)
|
||||
- [Push Notifications](#push-notifications)
|
||||
- [VoIP & Calls](#voip--calls)
|
||||
- [Settings / Einstellungen](#settings--einstellungen)
|
||||
- [Benutzerprofil](#benutzerprofil)
|
||||
- [Raum-Einstellungen](#raum-einstellungen)
|
||||
- [Suche](#suche)
|
||||
- [Medien](#medien)
|
||||
- [Read Receipts & Typing](#read-receipts--typing)
|
||||
- [Spaces](#spaces)
|
||||
- [Windows / Desktop](#windows--desktop)
|
||||
- [Sonstige Features](#sonstige-features)
|
||||
- [Dokumentations-Vollständigkeit](#dokumentations-vollständigkeit)
|
||||
|
||||
---
|
||||
|
||||
## Authentifizierung
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Login mit Passwort | ✅ | `login_page.dart`, `login_notifier.dart` |
|
||||
| Homeserver-Auswahl | ✅ | `server_page.dart` — HTTPS-Prefix fix, .well-known Autodiscovery |
|
||||
| Session wiederherstellen (init) | ✅ | `matrix_client.dart` — `client.init()` beim App-Start |
|
||||
| Logout | ✅ | Via Matrix SDK `client.logout()` |
|
||||
| Login-Redirect nach Init | ✅ | `router.dart` — StreamProvider-basierter Guard |
|
||||
| SSO / OAuth | ❌ | Nicht implementiert |
|
||||
| Registrierung / Account erstellen | ❌ | Keine UI |
|
||||
| Login mit Token | ❌ | Nur Passwort-Login |
|
||||
| Multi-Account | ❌ | Nur ein Account gleichzeitig |
|
||||
| UIAA Re-Auth (Passwort ändern) | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Raumliste
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Raumliste anzeigen | ✅ | `rooms_page.dart`, `room_list_item.dart` |
|
||||
| Sortierung nach letzter Aktivität | ✅ | In `rooms_provider.dart` |
|
||||
| Avatar-Anzeige (MXC) | ✅ | `mxc_image.dart` |
|
||||
| Vorschautext (letztes Event) | ✅ | `room_list_item.dart` |
|
||||
| Ungelesen-Badge | ✅ | `notificationCount` |
|
||||
| Ungelesen-Punkt (kein Count) | ✅ | Bei `notificationCount == 0` aber ungelesen |
|
||||
| Zeitstempel-Formatierung | ✅ | Heute: HH:MM, andere: DD.MM |
|
||||
| DM-Erkennung (`isDirectChat`) | ✅ | Typ-Differenzierung in Liste |
|
||||
| Space-Filterung | ✅ | `SpacesRail` filtert nach aktiver Space |
|
||||
| Einladungen anzeigen | ⚠️ | Membership.invite vorhanden, gesonderte Ansicht fehlt |
|
||||
| Raum suchen / filtern | ✅ | Via `search_modal.dart` |
|
||||
| Archivierte Räume | ❌ | Kein UI für left-rooms |
|
||||
| Raum erstellen (Knopf) | ✅ | `create_join_dialog.dart` |
|
||||
| Raum beitreten (Alias) | ✅ | `create_join_dialog.dart` |
|
||||
|
||||
---
|
||||
|
||||
## Chat / Timeline
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Nachrichten anzeigen | ✅ | `message_bubble.dart`, `message_group.dart` |
|
||||
| Gruppen-Bubbles (gleicher Sender) | ✅ | Continuation-Nachrichten (eingerückt, kein Avatar) |
|
||||
| Datums-Trenner | ✅ | Tagstrennlinie |
|
||||
| Ungelesen-Trenner ("Neue Nachrichten") | ✅ | `fullyRead`-Position |
|
||||
| Scroll-to-Unread beim Öffnen | ✅ | `chat_view.dart` |
|
||||
| History-Nachladen beim Scrollen | ✅ | `_onScroll()` → `requestHistory(60)` |
|
||||
| Optimistische Nachrichtenanzeige | ✅ | SDK fügt Event mit Status `sending` ein |
|
||||
| Nachrichtenstatus (senden/sent) | ✅ | Uhren-Icons |
|
||||
| Timeline-Cleanup (`cancelSubscriptions`) | ✅ | In `dispose()` |
|
||||
| Sprung zu bestimmtem Event | ⚠️ | Infrastruktur vorhanden, kein UI |
|
||||
| Event-Suche in Timeline | ❌ | Nur globale Suche |
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtentypen
|
||||
| Typ | Status | Details |
|
||||
|-----|--------|---------|
|
||||
| Text (`m.text`) | ✅ | Vollständig |
|
||||
| Bild (`m.image`) | ✅ | Thumbnail + Vollansicht mit Zoom |
|
||||
| Video (`m.video`) | ✅ | Playback via `media_kit` |
|
||||
| Audio (`m.audio`) | ✅ | Player mit Progress/Dauer |
|
||||
| Datei (`m.file`) | ✅ | Download mit MIME-Icon |
|
||||
| Sticker (`m.sticker`) | ✅ | Anzeige und Senden (Sticker-Picker) |
|
||||
| Sprachnachricht (MSC3245) | ⚠️ | Audio-Aufnahme fehlt, Abspielen funktioniert |
|
||||
| Standort (`m.location`) | ❌ | Nicht implementiert |
|
||||
| Emote (`m.emote`) | ❌ | Kein gesondertes Rendering |
|
||||
| Poll (MSC3381) | ❌ | Nicht implementiert |
|
||||
| Verschlüsselte Nachrichten | ✅ | Automatisch via SDK, Fehler-UI vorhanden |
|
||||
| Redacted-Events | ⚠️ | Wird angezeigt, kein "Nachricht gelöscht"-Platzhalter |
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtenfunktionen
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Antworten (Reply) | ✅ | Swipe-Geste + Quote-Anzeige |
|
||||
| Reaktionen (Emojis) | ✅ | Quick-Picker + vollständiger Emoji-Picker |
|
||||
| Reaktionen entfernen | ⚠️ | UI zum Entfernen fehlt |
|
||||
| Nachrichten bearbeiten (Edit) | ⚠️ | `getDisplayEvent()` vorhanden, Senden nicht im UI |
|
||||
| Nachricht löschen (Redact) | ❌ | Kein Button / Dialog |
|
||||
| Threads | ❌ | Nicht implementiert |
|
||||
| Polls | ❌ | Nicht implementiert |
|
||||
| Weitersenden (Forward) | ❌ | Nicht implementiert |
|
||||
| Kopieren (Text) | ⚠️ | Nicht explizit sichtbar |
|
||||
| Long-Press Auswahl-Modus | ✅ | `message_bubble.dart` — Long-Press aktiviert Selection |
|
||||
| Bulk-Aktionen (Auswahl) | ⚠️ | Modus vorhanden, Aktionen begrenzt |
|
||||
| Pinned Messages Panel | ✅ | `pinned_messages_panel.dart` |
|
||||
| Pins setzen (UI-Aktion) | ❌ | Kein Pin-Button in Nachricht |
|
||||
| Linkvorschau | ❌ | Keine URL-Preview |
|
||||
| Mention-Links klickbar | ✅ | `@user`-Links via `url_launcher` |
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Neuen DM starten | ✅ | `new_dm_dialog.dart` → `startDirectChat()` |
|
||||
| User-Verzeichnis-Suche | ✅ | `searchUserDirectory()` |
|
||||
| DM-Erkennung in Raumliste | ✅ | `room.isDirectChat` |
|
||||
| Partner-Avatar/Name | ✅ | `directChatMatrixID` → Profil laden |
|
||||
| DM-Verschlüsselung | ✅ | Standard E2EE bei DM-Erstellung |
|
||||
| Gruppen-DM | ⚠️ | Erstellbar, kein gesondertes UI |
|
||||
| DM vergessen / verlassen | ⚠️ | Kein UI-Button |
|
||||
| DM-spezifische Notification-Sounds | ❌ | Nicht implementiert |
|
||||
|
||||
---
|
||||
|
||||
## E2EE Verschlüsselung
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Vodozemac Rust-Crypto | ✅ | `flutter_vodozemac` initialisiert in `matrix_client.dart` |
|
||||
| E2EE in verschlüsselten Räumen | ✅ | Automatisch via SDK |
|
||||
| Bootstrap-Flow (SSSS + Cross-Signing) | ✅ | `bootstrap_dialog.dart` — vollständige State-Machine |
|
||||
| Cross-Signing Setup | ✅ | Master-, Self- und User-Signing-Key Erstellung |
|
||||
| Key Backup (online) | ✅ | SSSS-gesichertes Backup |
|
||||
| Schlüssel-Verifikation (SAS/Emoji) | ✅ | Via Bootstrap-Flow |
|
||||
| QR-Code-Verifikation | ⚠️ | SDK-Support vorhanden, kein UI |
|
||||
| Device-Liste anzeigen | ❌ | Kein UI |
|
||||
| Gerät sperren/blockieren | ❌ | Kein UI |
|
||||
| Fehlende Schlüssel anfordern | ✅ | `requestMissingSessionKeys()` bei Bad-Encrypted |
|
||||
| Entschlüsselungs-Fehler-UI | ✅ | "Nachricht konnte nicht entschlüsselt werden" + Schlüssel-Button |
|
||||
| Schlüssel importieren/exportieren | ❌ | Kein UI (Infrastruktur vorhanden) |
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| FCM Token Registrierung | ✅ | `fcm_push_service.dart` |
|
||||
| Pusher auf Homeserver | ✅ | `postPusher()` mit Sygnal-Gateway |
|
||||
| Token-Refresh Handling | ✅ | `onTokenRefresh.listen()` |
|
||||
| PushService.kt (native FCM) | ✅ | Läuft auch bei geschlossener App |
|
||||
| Benachrichtigung bei geschlossener App | 🔧 | APK neu gebaut, noch nicht getestet |
|
||||
| Absender-Name in Notification | ✅ | HTTP-Fetch von Matrix API in PushService.kt |
|
||||
| Verschlüsselte Nachricht entschlüsseln | ⚠️ | "Neue Nachricht" bei geschlossener App; `decryptAndUpdateNotification` bei Hintergrund-App |
|
||||
| Inline Reply in Notification | 🔧 | ReplyReceiver auf getActivity umgestellt — APK gebaut, ungetestet |
|
||||
| Antwort wirklich abschicken | 🔧 | `replyFromNotification` MethodChannel — APK gebaut, ungetestet |
|
||||
| Gelesen-Button in Notification | ✅ | `ACTION_DISMISS` über `ReplyReceiver.kt` |
|
||||
| Notification bei Hintergrund-App | ✅ | Heartbeat-Check verhindert Duplikate |
|
||||
| Notification-Kanal Android | ✅ | `pyramid_messages`, HIGH Importance |
|
||||
| Stabile Notification-ID | ✅ | `stableId(roomId)` — gleicher Algo in Kotlin + Dart |
|
||||
| Windows Notification | ✅ | `local_notifier` (Windows Toast) |
|
||||
| Windows Taskbar Flash | ✅ | `WindowsTaskbar.setProgressMode()` |
|
||||
| Ausstehende Reply beim App-Öffnen senden | ✅ | `_sendPendingReply()` bei Resume + Sync |
|
||||
| Push Rules konfigurieren | ❌ | Kein UI |
|
||||
| Raum stumm schalten | ❌ | Kein UI |
|
||||
| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` — Preferences |
|
||||
| iOS Push | ❌ | Kein iOS-Build |
|
||||
| Web Push | ❌ | Kein Web-Build |
|
||||
|
||||
---
|
||||
|
||||
## VoIP & Calls
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Matrix WebRTC Legacy-Calls | ✅ | `voip_manager.dart` — CallSession |
|
||||
| LiveKit SFU-Calls | ✅ | `livekit_call_manager.dart` |
|
||||
| Unified Call-UI | ✅ | `voice_channel.dart` |
|
||||
| Audio-Call | ✅ | Mikrofon + Speaker |
|
||||
| Video-Call | ✅ | Kamera + Remote-Video |
|
||||
| Screen-Sharing | ✅ | `screen_share_picker.dart` |
|
||||
| Video-Qualitätsstufen | ✅ | SD/HD/FHD/4K Presets |
|
||||
| Mute-Toggle | ✅ | `setMicrophoneEnabled()` |
|
||||
| Deafen-Toggle | ✅ | Audio komplett stumm |
|
||||
| Kamera-Toggle | ✅ | `setCameraEnabled()` |
|
||||
| Kamera-Wechsel (Front/Rück) | ⚠️ | SDK-Support vorhanden |
|
||||
| Teilnehmer-Grid | ✅ | Mehrere Participants anzeigen |
|
||||
| Lokale Video-Vorschau | ✅ | Overlay-Widget |
|
||||
| Klingel-System (to-device) | ⚠️ | Infrastruktur vorhanden, Klingelton fehlt |
|
||||
| Incoming-Call-UI | ⚠️ | Kein dediziertes Klingel-Screen |
|
||||
| Call-History | ❌ | Nicht implementiert |
|
||||
| TURN-Server via Matrix | ⚠️ | `getTurnServer()` — ob konfiguriert unklar |
|
||||
| Hintergrund-Foreground-Service | ✅ | `FlutterForegroundService` via webrtc |
|
||||
| MatrixRTC Membership-Events | ✅ | `io.pyramid.voice.presence` State-Events |
|
||||
| LiveKit-Token-Exchange | ✅ | `livekit_token.dart` |
|
||||
|
||||
---
|
||||
|
||||
## Settings / Einstellungen
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Settings-Seite (Hauptseite) | ⚠️ | `settings_page.dart` — NUR PLATZHALTER |
|
||||
| Settings Modal | ✅ | `settings_modal.dart` — vollständiger Modal |
|
||||
| Dark/Light Mode | ✅ | `themeModeProvider` + Toggle |
|
||||
| Akzentfarbe (5 Presets) | ✅ | Amber/Coral/Mint/Violet/Cobalt/Lime |
|
||||
| Eckenradius Slider | ✅ | `radiusProvider` |
|
||||
| UI-Motion Toggle | ✅ | `motionProvider` |
|
||||
| UI-Density Toggle | ✅ | `densityProvider` |
|
||||
| Desktop Notifications | ✅ | `notifDesktopProvider` |
|
||||
| Notification Sound | ✅ | `notifSoundProvider` |
|
||||
| Nachrichtenvorschau | ✅ | `notifPreviewProvider` |
|
||||
| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` |
|
||||
| Presence anzeigen (Online) | ✅ | `privacyPresenceProvider` |
|
||||
| Read Receipts senden | ✅ | `privacyReadReceiptsProvider` |
|
||||
| Typing-Indikator senden | ✅ | `privacyTypingProvider` |
|
||||
| Profil bearbeiten (Name/Avatar) | ❌ | Kein UI |
|
||||
| Geräte-Verwaltung | ❌ | Kein UI |
|
||||
| Session-Liste | ❌ | Kein UI |
|
||||
| Passwort ändern | ❌ | Kein UI |
|
||||
| Account löschen | ❌ | Kein UI |
|
||||
| Raumspezifische Benachrichtigungen | ❌ | Kein UI |
|
||||
| Sprache / Lokalisierung | ❌ | Nur Systemsprache |
|
||||
| Barrierefreiheit | ❌ | Keine Einstellungen |
|
||||
|
||||
---
|
||||
|
||||
## Benutzerprofil
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Avatar anzeigen (MXC) | ✅ | `mxc_image.dart` überall |
|
||||
| Displayname auflösen | ✅ | Fallback auf `@username` |
|
||||
| Online-Präsenz senden | ✅ | `setPresence(online)` alle 10s in `app_shell.dart` |
|
||||
| Präsenz-Anzeige anderer User | ⚠️ | Infrastruktur, kein UI |
|
||||
| Profil-Popover (Hover-Karte) | ✅ | `profile_popover.dart` |
|
||||
| Eigenes Profil bearbeiten | ❌ | Kein UI |
|
||||
| Avatar hochladen | ❌ | Kein UI |
|
||||
| Status-Nachricht | ❌ | Nicht implementiert |
|
||||
| User ignorieren / blockieren | ❌ | Kein UI |
|
||||
| User melden | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Space-Admin Panel (Name/Avatar/Banner) | ✅ | `space_admin_dialog.dart` — Overview-Tab |
|
||||
| Power-Level-Editor (Mitglieder) | ✅ | `space_admin_dialog.dart` — Members-Tab |
|
||||
| Permissions-Tab (Raum-PLs) | ✅ | `space_admin_dialog.dart` — Permissions-Tab |
|
||||
| Kind-Räume verwalten | ✅ | `space_admin_dialog.dart` — Channels-Tab |
|
||||
| Raum-Name ändern (normaler Raum) | ❌ | Kein UI |
|
||||
| Raum-Thema ändern | ❌ | Kein UI |
|
||||
| Raum-Avatar ändern | ❌ | Kein UI |
|
||||
| Join-Rules ändern | ❌ | Kein UI |
|
||||
| History-Visibility ändern | ❌ | Kein UI |
|
||||
| E2EE aktivieren | ❌ | Kein UI |
|
||||
| Raum-Alias verwalten | ❌ | Kein UI |
|
||||
| Raum verlassen (Button) | ❌ | Kein UI |
|
||||
| Raum vergessen (forget) | ❌ | Kein UI |
|
||||
| User einladen (aus Chat) | ❌ | Kein UI |
|
||||
| Kick / Ban (Moderation) | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Server-seitige Nachrichtensuche | ✅ | `search_modal.dart` → `/search` API |
|
||||
| Lokale Raum-/DM-Suche | ✅ | Filter auf `client.rooms` |
|
||||
| Personen-Suche | ✅ | Lokale DM-Partner-Suche |
|
||||
| Medien-Filter (Bilder/Videos/Audio) | ✅ | `search_modal.dart` |
|
||||
| Paginierung (nextBatch) | ✅ | Infinite scroll |
|
||||
| Suchergebnis-Highlighting | ✅ | Suchbegriff hervorheben |
|
||||
| Debounced Query (400ms) | ✅ | `debounce` in Suchfeld |
|
||||
| Öffentliches Raumverzeichnis | ❌ | Kein UI zum Entdecken |
|
||||
| User-Verzeichnis-Suche (global) | ✅ | In `new_dm_dialog.dart` |
|
||||
|
||||
---
|
||||
|
||||
## Medien
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Bild senden | ✅ | `MatrixImageFile` + Upload |
|
||||
| Bild anzeigen (Thumbnail + Vollbild) | ✅ | `MediaViewer` mit Zoom/Pan |
|
||||
| Video senden | ✅ | |
|
||||
| Video abspielen | ✅ | `media_kit` |
|
||||
| Audio senden | ✅ | |
|
||||
| Audio abspielen | ✅ | Player mit Progress |
|
||||
| Datei senden | ✅ | `MatrixFile` |
|
||||
| Datei herunterladen | ✅ | MIME-Icon + Browser-Öffner |
|
||||
| Clipboard-Bild einfügen (Windows) | ✅ | FFI + `clipboard_image.dart` |
|
||||
| Drag & Drop Upload (Desktop) | ✅ | `desktop_drop` |
|
||||
| GIF-Picker (Giphy) | ✅ | Trending + Suche + Favoriten |
|
||||
| Sticker-Picker | ✅ | Pack-Browser |
|
||||
| Bild-Komprimierung | ✅ | `shrinkImageMaxDimension` |
|
||||
| Verschlüsselte Medien | ✅ | Automatisch bei E2EE-Räumen |
|
||||
| Sprachnachricht aufnehmen | ❌ | Kein Recorder |
|
||||
| Kamera direkt (in App) | ❌ | Nur Gallery-Picker |
|
||||
| Dokument-Vorschau | ❌ | Kein PDF-Viewer |
|
||||
| Media-Galerie (Raum-Mediathek) | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Read Receipts & Typing
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Read Receipts senden | ✅ | `setReadMarker()` (privacy-gesteuert) |
|
||||
| Eigene ✓✓ Bestätigung | ✅ | Anzeige auf eigenen Nachrichten |
|
||||
| Read-Marker anderer anzeigen | ⚠️ | Position-Marker vorhanden, kein Avatar-Stack |
|
||||
| Typing-Indikator senden | ✅ | 7s Auto-Timeout |
|
||||
| Typing-Indikator empfangen | ✅ | Anzeige-Platzhalter |
|
||||
| Typing-User-Namen anzeigen | ⚠️ | Platzhalter, keine Namen |
|
||||
|
||||
---
|
||||
|
||||
## Spaces
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Space-Erkennung (`isSpace`) | ✅ | |
|
||||
| Spaces-Rail (Sidebar) | ✅ | `spaces_rail.dart` |
|
||||
| Space-Filterung der Raumliste | ✅ | Nur Räume der aktiven Space |
|
||||
| Space-Admin Panel | ✅ | `space_admin_dialog.dart` |
|
||||
| Space erstellen | ✅ | `create_join_dialog.dart` |
|
||||
| Kind-Räume hinzufügen | ✅ | In Space-Admin |
|
||||
| Kind-Räume entfernen | ✅ | In Space-Admin |
|
||||
| Voice-Präsenz in Space | ✅ | `io.pyramid.voice.presence` Events |
|
||||
| Space-Banner | ✅ | Mit Crop-Dialog |
|
||||
| Verschachtelte Spaces | ⚠️ | Anzeige, kein eigener Sub-Space-Rail |
|
||||
| Space beitreten (öffentlich) | ⚠️ | Technisch möglich, kein Discovery-UI |
|
||||
|
||||
---
|
||||
|
||||
## Windows / Desktop
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Windows Build | ✅ | CMake + MSIX |
|
||||
| Windows Notifications | ✅ | `local_notifier` |
|
||||
| Taskbar Flash | ✅ | `WindowsTaskbar` |
|
||||
| Update-Checker (Gitea API) | ✅ | `update_checker.dart` — 4h Intervall |
|
||||
| Update-Banner | ✅ | `update_banner.dart` |
|
||||
| Update-Download | ✅ | `update_download_dialog.dart` |
|
||||
| APK Auto-Install (Android) | ✅ | Kotlin MethodChannel + FileProvider |
|
||||
| PC Update (exe/msix) Download | ⚠️ | Release-Script pusht .zip statt .exe → öffnet Git-Seite |
|
||||
| Drag & Drop | ✅ | `desktop_drop` |
|
||||
| Clipboard Paste (Bild) | ✅ | FFI Windows API |
|
||||
| Resizable Sidebar | ✅ | `panel_layout.dart` — Drag-Divider |
|
||||
| Keyboard Shortcuts | ⚠️ | Enter zum Senden, Ctrl+V — kein vollständiges System |
|
||||
| System Tray | ❌ | Nicht implementiert |
|
||||
| Fenster-Badge (ungelesen) | ⚠️ | Nur Taskbar-Flash, kein Zähler |
|
||||
| macOS / Linux | ❌ | Nicht gebaut/getestet |
|
||||
|
||||
---
|
||||
|
||||
## Sonstige Features
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Emoji-Picker | ✅ | Kategorisiert + Suche |
|
||||
| Quick-Reaktionen (6 Emojis) | ✅ | Hover-Aktionsleiste |
|
||||
| Mitglieder-Panel (Raum) | ✅ | `members/` — Teilnehmerliste |
|
||||
| Profil-Popover (Hover) | ✅ | `profile_popover.dart` |
|
||||
| Neuer-DM Dialog | ✅ | `new_dm_dialog.dart` |
|
||||
| Raum-erstellen Dialog | ✅ | `create_join_dialog.dart` |
|
||||
| Nachricht-Auswahl-Modus | ✅ | Long-Press |
|
||||
| Reply-Swipe-Geste | ✅ | Rechts-wischen zum Antworten |
|
||||
| Immersive-Mode (Android) | ✅ | Status-Bar ausgeblendet |
|
||||
| PyramidLoader (Ladeanimation) | ✅ | `pyramid_loader.dart` |
|
||||
| Rich-Text-Editor | ❌ | Kein Markdown/Formatting-UI |
|
||||
| HTML-Nachrichtenrendering | ❌ | Kein `flutter_html` |
|
||||
| Message Forwarding | ❌ | |
|
||||
| Nachrichtenübersetzung | ❌ | |
|
||||
| Bot-Kommandos (`/`) | ❌ | |
|
||||
| Matrix-URI Handler | ❌ | Kein Deep-Link-Handler |
|
||||
| Barcode/QR-Scanner | ⚠️ | Nur in Verifikation |
|
||||
|
||||
---
|
||||
|
||||
## Dokumentations-Vollständigkeit
|
||||
|
||||
| Doku-Abschnitt | Vollständig | Anmerkungen |
|
||||
|----------------|-------------|-------------|
|
||||
| 01 Auth | ✅ | SSO, UIAA, alle Login-Flows |
|
||||
| 02 Räume | ✅ | Create, Join, Spaces, Power Levels |
|
||||
| 03 Nachrichten | ✅ | Text, Replies, Edits, Threads, Polls |
|
||||
| 04 DMs | ✅ | Alle DM-Flows |
|
||||
| 05 Medien | ✅ | Upload, Download, alle Dateitypen |
|
||||
| 06 E2EE | ✅ | Setup, SAS, Cross-Signing, Key Backup |
|
||||
| 07 Push | ✅ | FCM, Push Rules, Pyramid-Architektur |
|
||||
| 08 VoIP | ✅ | Legacy m.call, MatrixRTC |
|
||||
| 08b LiveKit | ✅ | **KOMPLETT** — Server-Setup bis Flutter-UI |
|
||||
| 09 Profil | ✅ | Presence, Account-Data, Tags |
|
||||
| 10 Room-Admin | ✅ | Power Levels, Moderation, ACL, Synapse Admin |
|
||||
| 11 Suche | ✅ | Event-Suche, User-Suche, Raumverzeichnis |
|
||||
| 12 Schnellreferenz | ✅ | Alle Enums, Widget-Patterns, Fehlerhandling |
|
||||
|
||||
---
|
||||
|
||||
## Prioritäts-Roadmap
|
||||
|
||||
### 🔴 Kritisch (bald testen/fixen)
|
||||
1. **Notification-Reply testen** — Neue APK (getActivity-Fix) installieren und Reply-Flow verifizieren
|
||||
2. **Benachrichtigung bei geschlossener App** — Mit neuer APK testen (PushService.kt Ansatz)
|
||||
3. **PC Update-Script** — `.zip` durch `.exe`-Installer ersetzen in `scripts/release.ps1`
|
||||
|
||||
### 🟠 Wichtig (nächste Features)
|
||||
4. **Nachricht löschen (Redact)** — Long-Press-Menü → `event.redactEvent()`
|
||||
5. **Nachricht bearbeiten** — Edit-Modus im Composer → `sendTextEvent(editEventId:...)`
|
||||
6. **Raum-Einstellungen** — Name/Thema/Avatar für normale Räume (`room.setName()` etc.)
|
||||
7. **Profil bearbeiten** — Displayname + Avatar in Settings
|
||||
8. **Registrierung** — Account-Erstellung im Onboarding
|
||||
|
||||
### 🟡 Sinnvoll (mittelfristig)
|
||||
9. **Einladungen-Screen** — Pending invites sichtbar darstellen
|
||||
10. **Kick/Ban UI** — Moderation aus der Mitgliederliste
|
||||
11. **Device-Verwaltung** — Eigene Sessions anzeigen + löschen
|
||||
12. **Rich-Text-Editor** — Markdown/Formatierung in Nachrichten
|
||||
13. **Klingelton bei eingehendem Call** — Audio + dedizierter Klingel-Screen
|
||||
14. **Lautlos/Stumm per Raum** — Push-Rules im UI
|
||||
|
||||
### 🟢 Nice-to-have
|
||||
15. Sprachnachrichten aufnehmen
|
||||
16. HTML-Nachrichtenrendering (`flutter_html`)
|
||||
17. Matrix-URI Deep-Links
|
||||
18. Media-Galerie pro Raum
|
||||
19. System Tray (Windows)
|
||||
20. Polls / Abstimmungen
|
||||
@@ -0,0 +1,122 @@
|
||||
# Matrix SDK — Pyramid Entwicklungsreferenz
|
||||
|
||||
> **Basis:** [`package:matrix`](https://pub.dev/packages/matrix) (Dart/Flutter Matrix SDK)
|
||||
> **Ziel:** Vollständige Landkarte aller Matrix-Funktionen mit Code-Snippets für den Pyramid-App-Bau.
|
||||
> **Referenz-Implementierung:** FluffyChat (https://gitlab.com/famedly/fluffychat)
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
| # | Thema | Beschreibung |
|
||||
|---|-------|--------------|
|
||||
| [01](01-auth.md) | **Authentifizierung** | Login, Logout, Register, SSO, Token-Management |
|
||||
| [02](02-rooms.md) | **Räume & Spaces** | Erstellen, Beitreten, Verlassen, Spaces, Suche |
|
||||
| [03](03-messages.md) | **Nachrichten & Events** | Senden, Timeline, Reaktionen, Edits, Threads, Polls |
|
||||
| [04](04-dm.md) | **Direct Messages** | DM-Räume finden, starten, verwalten |
|
||||
| [05](05-media.md) | **Medien & Dateien** | Upload, Download, Thumbnails, verschlüsselte Medien |
|
||||
| [06](06-encryption.md) | **E2EE Verschlüsselung** | Keys, Verifizierung, Cross-Signing, Key-Backup |
|
||||
| [07](07-push.md) | **Push & Notifications** | Pusher, Push Rules, FCM, lokale Benachrichtigungen |
|
||||
| [08](08-voip.md) | **VoIP & Calls** | MatrixRTC, Legacy m.call, Klingel-Flow |
|
||||
| [08b](08b-livekit-streaming.md) | **LiveKit Streaming** ⭐ | **Vollständige Server-Anleitung + Flutter-Implementierung** |
|
||||
| [09](09-profile.md) | **Profil & Präsenz** | Avatar, Displayname, Presence, Account-Daten |
|
||||
| [10](10-room-admin.md) | **Raum-Administration** | Power Levels, Moderierung, Einstellungen, Bans |
|
||||
| [11](11-search.md) | **Suche & Discovery** | Nachrichten-Suche, User-Suche, Raum-Verzeichnis |
|
||||
| [12](12-quick-reference.md) | **Schnellreferenz** | Alle Enums, Widget-Patterns, Fehlerhandling |
|
||||
| [13](13-server-admin-continuwuity.md) | **Server-Admin Continuwuity** ⭐ | Admin API, Power Levels erzwingen, MSC3083, Diagnose |
|
||||
| [CHECKLIST](CHECKLIST.md) | **Feature-Checklist** ⭐ | Implementierungsstand + Roadmap |
|
||||
|
||||
---
|
||||
|
||||
## Schnellreferenz: Client-Initialisierung
|
||||
|
||||
```dart
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
final client = Client(
|
||||
'MeinApp', // App-Name (User-Agent)
|
||||
database: MatrixSdkDatabase(...), // Persistenz (sqflite)
|
||||
nativeImplementations: NativeImplementationsIsolate(compute), // E2EE
|
||||
logLevel: Level.warning,
|
||||
);
|
||||
|
||||
// Initialisieren (lädt gespeicherten Session-State)
|
||||
await client.init(
|
||||
waitForFirstSync: false, // UI nicht blockieren
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
```
|
||||
Client
|
||||
├── rooms: List<Room> // alle bekannten Räume
|
||||
├── userID: String? // eigene MXID (@user:server)
|
||||
├── accessToken: String? // Bearer-Token
|
||||
├── homeserver: Uri // https://matrix.example.org
|
||||
│
|
||||
├── Room
|
||||
│ ├── id: String // !roomId:server
|
||||
│ ├── getTimeline() // Event-Stream
|
||||
│ ├── sendTextEvent()
|
||||
│ ├── sendEvent()
|
||||
│ ├── members // Map<String, User>
|
||||
│ └── states // Map<String, Map<String, StrippedStateEvent>>
|
||||
│
|
||||
├── Event
|
||||
│ ├── type: String // m.room.message, m.reaction, …
|
||||
│ ├── content: Map<String, dynamic>
|
||||
│ ├── body: String // text body (decrypted)
|
||||
│ └── messageType: String? // m.text, m.image, m.file, …
|
||||
│
|
||||
└── User
|
||||
├── id: String // MXID
|
||||
├── displayName: String?
|
||||
└── avatarUrl: Uri?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Streams / Provider-Hooks
|
||||
|
||||
```dart
|
||||
// Auf Sync-Events reagieren
|
||||
client.onSync.stream.listen((SyncUpdate update) { ... });
|
||||
|
||||
// Neue/geänderte Räume
|
||||
client.onRoomUpdate.stream.listen((String roomId) { ... });
|
||||
|
||||
// Verbindungsstatus
|
||||
client.onConnectionStateChanged.stream.listen((ConnectionState s) { ... });
|
||||
|
||||
// Eigener User geändert
|
||||
client.onUserEvent.stream.listen((EventUpdate update) { ... });
|
||||
|
||||
// Benachrichtigungs-Events (gefiltert nach Push-Rules)
|
||||
client.onNotification.stream.listen((Event event) { ... });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipps für Riverpod-Integration
|
||||
|
||||
```dart
|
||||
// Matrix-Client als Provider
|
||||
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||
final client = Client('Pyramid', ...);
|
||||
await client.init(waitForFirstSync: false);
|
||||
return client;
|
||||
});
|
||||
|
||||
// Raumliste reaktiv
|
||||
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
yield client.rooms;
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield client.rooms;
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Server-Incident: Messaging-Ausfall — 2026-05-31
|
||||
|
||||
## Betroffene Nutzer
|
||||
`@uta:steggi-matrix.work`, `@circuitslave:steggi-matrix.work`
|
||||
|
||||
## Symptome
|
||||
- Uta und Circuitslave konnten in verschlüsselten DM-Räumen keine Nachrichten senden
|
||||
- Nachrichten von `@todesneutron` kamen nicht an bzw. konnten nicht entschlüsselt werden
|
||||
- `@jochen` (Test-Account, FluffyChat) war nicht betroffen
|
||||
- Später: Element zeigt „Verbindung unterbrochen / Server offline"
|
||||
- Nach Behebung: alle Chats zunächst leer (History nicht sichtbar)
|
||||
|
||||
---
|
||||
|
||||
## Ursachen (in Reihenfolge des Auftretens)
|
||||
|
||||
### 1. Continuwuity 0.5.8 — `room_keys/version` 500-Fehler
|
||||
**Endpunkt:** `GET /_matrix/client/unstable/room_keys/version`
|
||||
|
||||
Continuwuity 0.5.8 crashte bei dieser Anfrage mit:
|
||||
```
|
||||
response error: JSON serialization failed: missing field `algorithm` at line 1 column 2
|
||||
500 Internal Server Error
|
||||
```
|
||||
|
||||
Element prüft diesen Endpunkt beim Start, bevor es verschlüsselte Nachrichten sendet.
|
||||
Der 500-Fehler verhinderte den korrekten Aufbau des E2E-Zustands → Senden blockiert.
|
||||
|
||||
FluffyChat/Pyramid implementieren diesen Check nicht → waren nicht betroffen.
|
||||
|
||||
### 2. Zu viele unverifizierte Geräte bei `@todesneutron`
|
||||
Durch die Pyramid-Entwicklung wurden 14 Geräte angesammelt (alte FluffyChat-,
|
||||
Commet- und Pyramid-Debug-Sessions). Element von Uta/Circuitslave sah all diese
|
||||
Geräte als unbekannt und konnte nicht für alle gleichzeitig verschlüsseln.
|
||||
|
||||
### 3. Continuwuity 0.5.9-latest — Axum Routing Panic (Folgeschaden)
|
||||
Beim Update auf `latest` (0.5.9, Commit `0514491`) trat ein Panic auf:
|
||||
```
|
||||
thread 'conduwuit:worker' panicked at axum-0.8.9/src/routing/path_router.rs:406:22:
|
||||
no route for id. This is a bug in axum.
|
||||
```
|
||||
|
||||
Ausgelöst durch: `GET /_matrix/client/v3/user/@circuitslave:steggi-matrix.work/filter/ieOT`
|
||||
|
||||
Circuitslaves Element hatte nach dem ersten Neustart seinen Filter verloren und
|
||||
sendete diese Anfrage in einer Schleife → Server-Worker crashte wiederholt →
|
||||
Cloudflare bekam EOF → alle Clients offline.
|
||||
|
||||
### 4. Leere Chat-History nach Behebung
|
||||
Continuwuity 0.5.9 hat die interne RocksDB-Struktur von 98 auf 100 Column Families
|
||||
erweitert. Das invalidierte alle gespeicherten Sync-Token der Clients → Fresh Initial
|
||||
Sync → Server schickt nur aktuelle Room-States ohne vollständige Timeline.
|
||||
|
||||
**Die Daten waren nicht verloren** — Pagination via `/messages` funktionierte korrekt.
|
||||
Durch Hochscrollen in den Räumen wurde die History nachgeladen.
|
||||
|
||||
---
|
||||
|
||||
## Maßnahmen
|
||||
|
||||
| Schritt | Aktion |
|
||||
|---|---|
|
||||
| Diagnose | Geräteliste, Raumzustand, Server-Logs via SSH geprüft |
|
||||
| Geräte aufräumen | 14 → 5 aktive Geräte bei `@todesneutron` |
|
||||
| Server-Update | Wechsel auf `continuwuity:main` (Commit `1faa09b`) |
|
||||
| Ergebnis | `room_keys/version` gibt gültige Antwort, Filter-Panic behoben |
|
||||
| History | Nutzer scrollten in Räumen nach oben → Pagination lud History nach |
|
||||
|
||||
---
|
||||
|
||||
## Server-Infrastruktur
|
||||
|
||||
- **Homeserver:** Continuwuity (Conduit-Fork, Rust) via Docker
|
||||
- **Compose-Datei:** `/home/steggi/matrix/docker-compose.yml`
|
||||
- **Image (vorher):** `registry.gitlab.com/continuwuity/continuwuity:latest` (0.5.8 → 0.5.9)
|
||||
- **Image (jetzt):** `registry.gitlab.com/continuwuity/continuwuity:main` (0.5.9, Commit `1faa09b`)
|
||||
- **Tunnel:** Cloudflare (`cloudflared`)
|
||||
- **Datenbank:** RocksDB unter `/home/steggi/matrix/db/`
|
||||
|
||||
---
|
||||
|
||||
## Lektionen
|
||||
|
||||
- `latest`-Tag vor produktivem Update testen (0.5.9-latest hatte Routing-Bug)
|
||||
- Alte Geräte regelmäßig aus Element/Pyramid abmelden (verhindert E2E-Probleme)
|
||||
- `continuwuity:main` ist stabiler als `continuwuity:latest`
|
||||
- FluffyChat/Pyramid senden auch ohne Key-Backup-Check → können als Fallback dienen
|
||||
@@ -0,0 +1,12 @@
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
macos:
|
||||
generate: true
|
||||
image_path: "assets/logo.png"
|
||||
windows:
|
||||
generate: true
|
||||
image_path: "assets/logo.png"
|
||||
image_path: "assets/logo.png"
|
||||
adaptive_icon_background: "#1A1B1E"
|
||||
adaptive_icon_foreground: "android/app/src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
@@ -431,7 +431,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -488,7 +488,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 971 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,29 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.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/settings_prefs.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});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final colorScheme = ref.watch(colorSchemeProvider);
|
||||
ConsumerState<PyramidApp> createState() => _PyramidAppState();
|
||||
}
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Pyramid',
|
||||
theme: buildTheme(colorScheme, Brightness.light),
|
||||
darkTheme: buildTheme(colorScheme, Brightness.dark),
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: router,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('de'), Locale('en')],
|
||||
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',
|
||||
theme: buildPyramidThemeData(false),
|
||||
darkTheme: buildPyramidThemeData(true),
|
||||
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
||||
routerConfig: router,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('de'), Locale('en')],
|
||||
debugShowCheckedModeBanner: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
|
||||
// ─── Theme state ──────────────────────────────────────────────────────────────
|
||||
|
||||
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
|
||||
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
||||
|
||||
const accentPresets = [
|
||||
(name: 'amber', color: PyramidColors.accentAmber),
|
||||
(name: 'coral', color: PyramidColors.accentCoral),
|
||||
(name: 'mint', color: PyramidColors.accentMint),
|
||||
(name: 'violet', color: PyramidColors.accentViolet),
|
||||
(name: 'cobalt', color: PyramidColors.accentCobalt),
|
||||
(name: 'lime', color: PyramidColors.accentLime),
|
||||
];
|
||||
|
||||
final radiusProvider = StateProvider<double>((ref) => 12);
|
||||
final densityProvider = StateProvider<double>((ref) => 1);
|
||||
final motionProvider = StateProvider<double>((ref) => 1);
|
||||
|
||||
// ─── Shell layout state ───────────────────────────────────────────────────────
|
||||
|
||||
final railCollapsedProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
// True while the app window is the foreground / focused window.
|
||||
// Set to false when the lifecycle transitions to inactive/paused/detached.
|
||||
final windowFocusedProvider = StateProvider<bool>((ref) => true);
|
||||
final membersPanelOpenProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
// Set to an eventId to make the open chat scroll to + briefly highlight that
|
||||
// message (e.g. when tapping a search result). ChatView consumes and clears it.
|
||||
final pendingJumpEventProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
// Currently selected room ID (null = no room selected)
|
||||
final activeRoomIdProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
// ─── Share-Target ("Teilen nach Pyramid", Android) ───────────────────────────
|
||||
// Von MainActivity.kt via MethodChannel geliefert; AppShell zeigt dann den
|
||||
// Raum-Picker. paths = bereits in den App-Cache kopierte Dateien.
|
||||
class PendingShare {
|
||||
final String? text;
|
||||
final List<String> paths;
|
||||
const PendingShare({this.text, this.paths = const []});
|
||||
|
||||
bool get isEmpty => (text == null || text!.isEmpty) && paths.isEmpty;
|
||||
}
|
||||
|
||||
final pendingShareProvider = StateProvider<PendingShare?>((ref) => null);
|
||||
|
||||
// Space filter: null = all rooms, otherwise space ID
|
||||
final activeSpaceIdProvider = StateProvider<String?>((ref) => 'dms');
|
||||
|
||||
// ─── Modal state ─────────────────────────────────────────────────────────────
|
||||
|
||||
enum ModalKind { none, settings, search }
|
||||
|
||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||
|
||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||
|
||||
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
|
||||
return LiveKitCallManager.instance;
|
||||
});
|
||||
|
||||
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
|
||||
return PyramidVoipManager.instance;
|
||||
});
|
||||
|
||||
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
|
||||
|
||||
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
||||
|
||||
enum ViewMode { chat, voice }
|
||||
|
||||
final viewModeProvider = StateProvider<ViewMode>((ref) => ViewMode.chat);
|
||||
|
||||
// ─── Spaces from Matrix ───────────────────────────────────────────────────────
|
||||
|
||||
final spacesProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
yield _getSpaces(client);
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield _getSpaces(client);
|
||||
}
|
||||
});
|
||||
|
||||
List<Room> _getSpaces(Client client) {
|
||||
return client.rooms.where((r) => r.isSpace).toList();
|
||||
}
|
||||
|
||||
// ─── Toast notifications ─────────────────────────────────────────────────────
|
||||
|
||||
class ToastMessage {
|
||||
final String text;
|
||||
final String icon;
|
||||
ToastMessage(this.text, this.icon);
|
||||
}
|
||||
|
||||
final toastProvider = StateProvider<ToastMessage?>((ref) => null);
|
||||
|
||||
// Remembers the last active room per space section (key = spaceId or null)
|
||||
final lastRoomPerSpaceProvider = StateProvider<Map<String?, String?>>((ref) => {});
|
||||
|
||||
// Space IDs the user just accepted an invite for. The server confirms the join
|
||||
// immediately, but Continuwuity's incremental /sync sometimes does not flip the
|
||||
// local membership from invite → join. We treat these as joined optimistically
|
||||
// so the UI switches to the channel list (channels load via the space hierarchy).
|
||||
// Persisted so the user does not have to re-accept on every app start.
|
||||
final forceJoinedSpacesProvider =
|
||||
StateNotifierProvider<StringSetPref, Set<String>>(
|
||||
(ref) => StringSetPref('force_joined_spaces'));
|
||||
|
||||
// ─── Voice channel state ──────────────────────────────────────────────────────
|
||||
|
||||
// ID of the Matrix room the user is currently in as a voice channel (null = none)
|
||||
final activeVoiceRoomIdProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
const kVoicePresenceType = 'io.pyramid.voice.presence';
|
||||
|
||||
// Local override cache: spaceId → bannerMxcUri (set immediately on upload,
|
||||
// before the server sync delivers the state event back)
|
||||
final spaceBannerOverrideProvider =
|
||||
StateProvider<Map<String, String?>>((ref) => {});
|
||||
|
||||
// ─── Server-level banner ──────────────────────────────────────────────────────
|
||||
// Persisted to SharedPreferences keyed by homeserver URL.
|
||||
// Key in store: homeserverUrl → mxcUri
|
||||
|
||||
class ServerBannerNotifier extends AsyncNotifier<Map<String, String>> {
|
||||
@override
|
||||
Future<Map<String, String>> build() => loadAllServerBanners();
|
||||
|
||||
Future<void> set(String homeserverUrl, String? mxcUri) async {
|
||||
final current = Map<String, String>.from(state.valueOrNull ?? {});
|
||||
if (mxcUri == null || mxcUri.isEmpty) {
|
||||
current.remove(homeserverUrl);
|
||||
} else {
|
||||
current[homeserverUrl] = mxcUri;
|
||||
}
|
||||
await saveServerBanner(homeserverUrl, mxcUri);
|
||||
state = AsyncData(current);
|
||||
}
|
||||
}
|
||||
|
||||
final serverBannerProvider =
|
||||
AsyncNotifierProvider<ServerBannerNotifier, Map<String, String>>(
|
||||
ServerBannerNotifier.new,
|
||||
);
|
||||
|
||||
// Active voice participants for a room, read from Matrix state events
|
||||
final voiceParticipantsProvider =
|
||||
Provider.family<List<Map<String, dynamic>>, String>((ref, roomId) {
|
||||
ref.watch(roomListProvider); // Rebuild on every sync so presence updates show
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
if (client == null) return [];
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) return [];
|
||||
final states = room.states[kVoicePresenceType] ?? {};
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final result = <Map<String, dynamic>>[];
|
||||
for (final entry in states.entries) {
|
||||
final content = entry.value.content;
|
||||
if (content['active'] != true) continue;
|
||||
final joinedAt = content['joined_at'] as int? ?? 0;
|
||||
if (now - joinedAt > 10 * 60 * 1000) continue;
|
||||
result.add({
|
||||
'userId': entry.key,
|
||||
'displayName': content['display_name'] as String? ?? entry.key,
|
||||
'avatarUrl': content['avatar_url'] as String?,
|
||||
});
|
||||
}
|
||||
|
||||
// Optimistically insert the local user immediately (state event may not
|
||||
// have synced back from the server yet after joining)
|
||||
final activeVoiceRoomId = ref.watch(activeVoiceRoomIdProvider);
|
||||
if (activeVoiceRoomId == roomId && client.userID != null) {
|
||||
final userId = client.userID!;
|
||||
if (!result.any((p) => p['userId'] == userId)) {
|
||||
String displayName = userId.split(':').first.replaceAll('@', '');
|
||||
String? avatarUrl;
|
||||
for (final r in client.rooms) {
|
||||
try {
|
||||
final m = r.getParticipants().firstWhere((u) => u.id == userId);
|
||||
if (m.displayName != null) {
|
||||
displayName = m.displayName!;
|
||||
avatarUrl = m.avatarUrl?.toString();
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
result.insert(0, {
|
||||
'userId': userId,
|
||||
'displayName': displayName,
|
||||
'avatarUrl': avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
|
||||
const _kChannel = 'pyramid_messages';
|
||||
|
||||
/// Port name used to locate the main isolate from background isolates.
|
||||
/// Registered by notification_service.dart when the app starts.
|
||||
const kMainIsolateName = 'pyramid_main';
|
||||
|
||||
int _stableId(String roomId) {
|
||||
var h = 0;
|
||||
for (final c in roomId.codeUnits) {
|
||||
h = ((h * 31) + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return h == 0 ? 1 : h;
|
||||
}
|
||||
|
||||
// ── FCM background handler ────────────────────────────────────────────────────
|
||||
// Runs in its own Dart isolate (or fresh process) when the app is killed or
|
||||
// backgrounded. Firebase calls this after Firebase.initializeApp() is done.
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> handleBackgroundMessage(RemoteMessage message) async {
|
||||
// PushService.kt handles all FCM-triggered notifications natively.
|
||||
// This callback is registered for API completeness but never fires because
|
||||
// FlutterFirebaseMessagingService is removed from the manifest.
|
||||
}
|
||||
|
||||
// ── Background engine entrypoint (killed-app E2EE work) ───────────────────────
|
||||
// PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this
|
||||
// entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel
|
||||
// so native can ask us to (a) decrypt a push event and update its notification,
|
||||
// or (b) send an encrypted reply. Building a Matrix client here is safe ONLY
|
||||
// because native gates this on the main app being dead — never two clients on
|
||||
// the same olm DB at once.
|
||||
|
||||
const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void notificationEngineMain() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel(_kBgEngineChannel);
|
||||
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
final args = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
|
||||
switch (call.method) {
|
||||
case 'decryptAndShow':
|
||||
return await _bgDecryptAndShow(
|
||||
args['room_id'] as String?,
|
||||
args['event_id'] as String?,
|
||||
(args['notif_id'] as num?)?.toInt(),
|
||||
);
|
||||
case 'sendReply':
|
||||
return await _bgSendReply(
|
||||
args['room_id'] as String?,
|
||||
args['text'] as String?,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] handler error: $e');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Signal native that the channel handler is installed and tasks can be sent.
|
||||
channel.invokeMethod('bgEngineReady');
|
||||
}
|
||||
|
||||
Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async {
|
||||
if (roomId == null || eventId == null) return false;
|
||||
final client = await _buildClient();
|
||||
if (client == null) return false;
|
||||
try {
|
||||
final event = await client
|
||||
.getEventByPushNotification(
|
||||
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
|
||||
storeInDatabase: false,
|
||||
returnNullIfSeen: false,
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (event == null) return false;
|
||||
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
||||
if (event.body.startsWith('**')) return false;
|
||||
|
||||
final room = client.getRoomById(roomId);
|
||||
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
final title = (room?.isDirectChat ?? true)
|
||||
? sender
|
||||
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
||||
|
||||
// Hand the decrypted content back to native so it re-shows the SAME
|
||||
// notification via NotificationHelper — that keeps the reliable
|
||||
// MainActivity-based reply action (the flutter_local_notifications reply
|
||||
// path doesn't fire dependably when the app is killed).
|
||||
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
||||
'title': title,
|
||||
'body': _buildBody(event),
|
||||
'room_id': roomId,
|
||||
'notif_id': notifId ?? _stableId(roomId),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||
return false;
|
||||
} finally {
|
||||
// Let the async key-backup upload settle before closing the DB — otherwise
|
||||
// it logs a harmless "database_closed" error mid-flight.
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
await client.dispose().catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _bgSendReply(String? roomId, String? text) async {
|
||||
if (roomId == null || text == null || text.trim().isEmpty) return false;
|
||||
final client = await _buildClient();
|
||||
if (client == null) return false;
|
||||
try {
|
||||
// A one-shot sync ensures the room and recipient device keys are present so
|
||||
// the message can be encrypted. Safe here: main app is dead (native-gated).
|
||||
await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
// Fall back to the prefs queue so the main app sends it on next launch.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
return false;
|
||||
}
|
||||
await room.sendTextEvent(text.trim());
|
||||
print('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] sendReply error: $e');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
return false;
|
||||
} finally {
|
||||
// Allow the encrypted send + any key upload to flush before closing the DB.
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
await client.dispose().catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
||||
/// bootstrap the background engine. Call once at app startup.
|
||||
Future<void> registerBgEngineHandle() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try {
|
||||
final handle = PluginUtilities.getCallbackHandle(notificationEngineMain);
|
||||
if (handle == null) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build a short-lived Matrix client for background work ────────────────────
|
||||
// Uses NativeImplementationsDummy so Vodozemac runs synchronously in this
|
||||
// isolate — NativeImplementationsIsolate(compute) would try to spawn a
|
||||
// sub-isolate, which fails inside a Firebase background isolate.
|
||||
|
||||
Future<Client?> _buildClient() async {
|
||||
try {
|
||||
// vodozemac may already be initialised in this isolate (e.g. a decrypt task
|
||||
// ran before this reply task reused the same engine). Calling init() twice
|
||||
// throws — swallow it so it doesn't fail the whole client build.
|
||||
try {
|
||||
await vod.init();
|
||||
} catch (_) {}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await sqflite_native.databaseFactory
|
||||
.openDatabase(dbPath)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
// Prevent "database is locked" if the main process still has the DB open.
|
||||
try {
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
await db.execute('PRAGMA journal_mode = WAL');
|
||||
} catch (_) {}
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
final client = Client(
|
||||
'Pyramid',
|
||||
database: sdkDb,
|
||||
nativeImplementations: NativeImplementationsDummy(),
|
||||
logLevel: Level.error,
|
||||
);
|
||||
|
||||
await client
|
||||
.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
return client;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] _buildClient failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _buildBody(Event event) {
|
||||
final msgType = event.messageType;
|
||||
final rawBody = event.body;
|
||||
return switch (msgType) {
|
||||
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
|
||||
'm.image' => '📷 Bild',
|
||||
'm.video' => '🎥 Video',
|
||||
'm.audio' => '🎵 Audio',
|
||||
'm.file' => '📄 Datei',
|
||||
'm.sticker' => '🏷️ Sticker',
|
||||
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notification action handler ───────────────────────────────────────────────
|
||||
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
||||
// Must return quickly — Android keeps the notification's "sending" spinner
|
||||
// visible until this function returns.
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> handleBackgroundNotificationResponse(
|
||||
NotificationResponse details) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
print('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
||||
print('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
||||
|
||||
// ── Fast path: main isolate is running ───────────────────────────────────
|
||||
// If the Pyramid UI process is alive (app is backgrounded but not killed),
|
||||
// forward the action directly via IsolateNameServer. This avoids starting
|
||||
// any new engine and works silently without bringing the app to foreground.
|
||||
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
|
||||
if (sendPort != null) {
|
||||
print('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
|
||||
sendPort.send(<String, dynamic>{
|
||||
'actionId': details.actionId,
|
||||
'payload': details.payload,
|
||||
'input': details.input,
|
||||
'id': details.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Slow path: app was killed ─────────────────────────────────────────────
|
||||
// Main isolate is gone. Dismiss the notification and, for reply actions,
|
||||
// persist the text in SharedPreferences so _sendPendingReply picks it up
|
||||
// the next time the app opens (via getInitialReply cold-start flow).
|
||||
print('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
|
||||
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification')),
|
||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
|
||||
);
|
||||
|
||||
final roomId = details.payload;
|
||||
if (roomId != null) {
|
||||
await plugin.cancel(_stableId(roomId));
|
||||
print('[NOTIF-BG] cancelled notification for room $roomId');
|
||||
} else if (details.id != null) {
|
||||
await plugin.cancel(details.id!);
|
||||
print('[NOTIF-BG] cancelled notification id ${details.id}');
|
||||
}
|
||||
|
||||
if (details.actionId != 'reply') {
|
||||
print('[NOTIF-BG] not a reply action, done');
|
||||
return;
|
||||
}
|
||||
|
||||
final text = details.input?.trim() ?? '';
|
||||
if (text.isEmpty || roomId == null) {
|
||||
print('[NOTIF-BG] text empty or roomId null, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
// App killed — send the encrypted reply directly from this background isolate.
|
||||
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
|
||||
// delivers it on next launch).
|
||||
final sent = await _bgSendReply(roomId, text);
|
||||
print('[NOTIF-BG] background reply sent=$sent room=$roomId');
|
||||
}
|
||||
|
||||
// ── Notification builder ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _showNotif({
|
||||
required String title,
|
||||
required String body,
|
||||
required String? roomId,
|
||||
required int notifId,
|
||||
}) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification')),
|
||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
|
||||
);
|
||||
|
||||
await plugin.show(
|
||||
notifId, title, body,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_kChannel, 'Nachrichten',
|
||||
channelDescription: 'Neue Nachrichten',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@drawable/ic_notification',
|
||||
color: const Color(0xFF7B61FF),
|
||||
groupKey: 'pyramid_messages',
|
||||
actions: roomId != null
|
||||
? const [
|
||||
AndroidNotificationAction(
|
||||
'reply', 'Antworten',
|
||||
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
|
||||
allowGeneratedReplies: true,
|
||||
showsUserInterface: false, // BroadcastReceiver path — app never opens
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'read', 'Gelesen',
|
||||
showsUserInterface: false,
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
),
|
||||
payload: roomId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HTTP fallback — fetch sender / room name without the SDK ──────────────────
|
||||
|
||||
Future<String?> _fetchTitle(
|
||||
String hs, String token, String roomId, String eventId) async {
|
||||
try {
|
||||
final encRoom = Uri.encodeComponent(roomId);
|
||||
final encEvent = Uri.encodeComponent(eventId);
|
||||
|
||||
final rawEvent = await _get(
|
||||
'$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent', token);
|
||||
if (rawEvent == null) return null;
|
||||
|
||||
final senderId = rawEvent['sender'] as String? ?? '';
|
||||
String senderName = senderId.split(':').first.replaceFirst('@', '');
|
||||
|
||||
if (senderId.isNotEmpty) {
|
||||
final encSender = Uri.encodeComponent(senderId);
|
||||
final memberState = await _get(
|
||||
'$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender',
|
||||
token);
|
||||
final name = memberState?['displayname'] as String?;
|
||||
if (name != null && name.isNotEmpty) senderName = name;
|
||||
}
|
||||
|
||||
final nameState =
|
||||
await _get('$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name', token);
|
||||
final roomName = nameState?['name'] as String?;
|
||||
|
||||
return roomName?.isNotEmpty == true
|
||||
? '$senderName · $roomName'
|
||||
: senderName;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _get(String url, String token) async {
|
||||
try {
|
||||
final req = await HttpClient()
|
||||
.getUrl(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
req.headers.add('Authorization', 'Bearer $token');
|
||||
final res = await req.close();
|
||||
if (res.statusCode != 200) return null;
|
||||
final raw = await res.transform(utf8.decoder).join();
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class E2eeDiagEntry {
|
||||
final DateTime timestamp;
|
||||
final String roomId;
|
||||
final String eventId;
|
||||
final String sessionId;
|
||||
final String error;
|
||||
|
||||
const E2eeDiagEntry({
|
||||
required this.timestamp,
|
||||
required this.roomId,
|
||||
required this.eventId,
|
||||
required this.sessionId,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'ts': timestamp.toIso8601String(),
|
||||
'room': roomId,
|
||||
'event': eventId,
|
||||
'session': sessionId,
|
||||
'error': error,
|
||||
};
|
||||
}
|
||||
|
||||
class E2eeDiagnosticsNotifier extends Notifier<List<E2eeDiagEntry>> {
|
||||
static const int _maxEntries = 500;
|
||||
|
||||
@override
|
||||
List<E2eeDiagEntry> build() => [];
|
||||
|
||||
void add(E2eeDiagEntry entry) {
|
||||
final next = [...state, entry];
|
||||
state = next.length > _maxEntries ? next.sublist(next.length - _maxEntries) : next;
|
||||
}
|
||||
|
||||
void clear() => state = [];
|
||||
|
||||
Future<String> upload({required String serverUrl, required String token}) async {
|
||||
if (state.isEmpty) return 'Keine Diagnosedaten vorhanden.';
|
||||
|
||||
final payload = utf8.encode(jsonEncode({
|
||||
'client': 'Pyramid',
|
||||
'uploaded_at': DateTime.now().toIso8601String(),
|
||||
'entry_count': state.length,
|
||||
'entries': state.map((e) => e.toJson()).toList(),
|
||||
}));
|
||||
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final uri = Uri.parse('$serverUrl/api/e2ee-diagnostics');
|
||||
final request = await client.postUrl(uri);
|
||||
request.headers.set('Content-Type', 'application/json; charset=utf-8');
|
||||
request.headers.set('Authorization', 'Bearer $token');
|
||||
request.headers.contentLength = payload.length;
|
||||
request.add(payload);
|
||||
final response = await request.close();
|
||||
await response.drain<void>();
|
||||
if (response.statusCode == 200) {
|
||||
final count = state.length;
|
||||
clear();
|
||||
return '$count Einträge hochgeladen und lokal gelöscht.';
|
||||
}
|
||||
return 'Server-Fehler: HTTP ${response.statusCode}';
|
||||
} on SocketException catch (e) {
|
||||
return 'Netzwerkfehler: ${e.message}';
|
||||
} catch (e) {
|
||||
return 'Fehler: $e';
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final e2eeDiagnosticsProvider =
|
||||
NotifierProvider<E2eeDiagnosticsNotifier, List<E2eeDiagEntry>>(
|
||||
E2eeDiagnosticsNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// Self-hosted Sygnal instance on the Pi — must be configured with the
|
||||
// Firebase service account for the 'chat-pyramid' Firebase project.
|
||||
// push.element.io only supports Element's own apps and will actively reject
|
||||
// chat.pyramid.pyramid pushkeys, causing the homeserver to auto-remove the pusher.
|
||||
const _kGatewayUrl = 'https://push.steggi-matrix.work/_matrix/push/v1/notify';
|
||||
const _kAppId = 'chat.pyramid.pyramid';
|
||||
|
||||
// ── Foreground-Setup & Pusher-Registrierung ───────────────────────────────────
|
||||
|
||||
Future<String> fcmDiagnostics(Client matrixClient) async {
|
||||
if (!Platform.isAndroid) return 'Kein Android';
|
||||
final buf = StringBuffer();
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
buf.writeln('✓ Firebase initialisiert');
|
||||
} catch (e) {
|
||||
buf.writeln('✗ Firebase-Init: $e');
|
||||
return buf.toString();
|
||||
}
|
||||
try {
|
||||
final settings = await FirebaseMessaging.instance.requestPermission();
|
||||
buf.writeln('✓ Berechtigung: ${settings.authorizationStatus.name}');
|
||||
} catch (e) {
|
||||
buf.writeln('✗ Berechtigung: $e');
|
||||
}
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) {
|
||||
buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}…');
|
||||
} else {
|
||||
buf.writeln('✗ FCM-Token: null');
|
||||
}
|
||||
} catch (e) {
|
||||
buf.writeln('✗ FCM-Token: $e');
|
||||
}
|
||||
try {
|
||||
final pushers = await matrixClient.getPushers() ?? [];
|
||||
final fcmPushers = pushers.where((p) => p.appId == _kAppId).toList();
|
||||
if (fcmPushers.isEmpty) {
|
||||
buf.writeln('✗ Pusher: keiner bei Synapse registriert!');
|
||||
} else {
|
||||
buf.writeln('✓ Pusher: ${fcmPushers.length} registriert');
|
||||
}
|
||||
} catch (e) {
|
||||
buf.writeln('✗ Pusher-Abfrage: $e');
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
Future<void> initFcm(Client matrixClient) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (_) {
|
||||
// Bereits initialisiert (main.dart) oder google-services.json fehlt – weiter
|
||||
}
|
||||
|
||||
// Background messages are handled by handleBackgroundMessage (background_push.dart)
|
||||
// via FlutterFirebaseMessagingService. It decrypts E2EE events using the Matrix
|
||||
// SDK and shows notifications via flutter_local_notifications with
|
||||
// showsUserInterface:false so no Activity opens when the user replies.
|
||||
|
||||
// Notification-Berechtigung anfordern (Android 13+)
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
|
||||
// Foreground-Notifications unterdrücken — App zeigt selbst Nachrichten
|
||||
await FirebaseMessaging.instance
|
||||
.setForegroundNotificationPresentationOptions(
|
||||
alert: false,
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
|
||||
// Re-register pusher if token was refreshed while the app was killed.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final pendingToken = prefs.getString('flutter.fcm_pending_token');
|
||||
if (pendingToken != null && matrixClient.isLogged()) {
|
||||
await _registerPusher(matrixClient, pendingToken);
|
||||
await prefs.remove('flutter.fcm_pending_token');
|
||||
}
|
||||
|
||||
final token = pendingToken ?? await _getToken();
|
||||
if (token != null && matrixClient.isLogged()) {
|
||||
await _registerPusher(matrixClient, token);
|
||||
// Save homeserver + token for PushService.kt (Kotlin native notification handler)
|
||||
await _saveNotifCredentials(matrixClient);
|
||||
}
|
||||
|
||||
// Bei Token-Refresh: Pusher + gespeicherte Credentials aktualisieren
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
|
||||
if (matrixClient.isLogged()) {
|
||||
await _registerPusher(matrixClient, newToken);
|
||||
await _saveNotifCredentials(matrixClient);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveNotifCredentials(Client client) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_homeserver', client.homeserver.toString());
|
||||
await prefs.setString('notif_access_token', client.accessToken ?? '');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<String?> _getToken() async {
|
||||
try {
|
||||
return await FirebaseMessaging.instance.getToken();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerPusher(Client client, String fcmToken) async {
|
||||
try {
|
||||
await client.postPusher(
|
||||
Pusher(
|
||||
pushkey: fcmToken,
|
||||
kind: 'http',
|
||||
appId: _kAppId,
|
||||
appDisplayName: 'Pyramid',
|
||||
deviceDisplayName: client.deviceName ?? 'Android',
|
||||
lang: 'de',
|
||||
data: PusherData(
|
||||
url: Uri.parse(_kGatewayUrl),
|
||||
format: 'event_id_only',
|
||||
),
|
||||
),
|
||||
append: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Pusher-Registrierung fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to register the FCM pusher and returns a human-readable result string.
|
||||
Future<String> retryPusherRegistration(Client client) async {
|
||||
if (!Platform.isAndroid) return 'Kein Android';
|
||||
final buf = StringBuffer();
|
||||
try {
|
||||
String? token;
|
||||
try {
|
||||
token = await FirebaseMessaging.instance.getToken();
|
||||
} catch (e) {
|
||||
return '✗ FCM-Token: $e';
|
||||
}
|
||||
if (token == null) {
|
||||
return '✗ FCM-Token: null (Google Play Services Problem?)';
|
||||
}
|
||||
buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}…');
|
||||
buf.writeln(' Gateway: $_kGatewayUrl');
|
||||
await client.postPusher(
|
||||
Pusher(
|
||||
pushkey: token,
|
||||
kind: 'http',
|
||||
appId: _kAppId,
|
||||
appDisplayName: 'Pyramid',
|
||||
deviceDisplayName: client.deviceName ?? 'Android',
|
||||
lang: 'de',
|
||||
data: PusherData(
|
||||
url: Uri.parse(_kGatewayUrl),
|
||||
format: 'event_id_only',
|
||||
),
|
||||
),
|
||||
append: true,
|
||||
);
|
||||
buf.writeln('✓ Pusher registriert');
|
||||
await _saveNotifCredentials(client);
|
||||
buf.writeln('✓ Credentials gespeichert');
|
||||
} catch (e) {
|
||||
buf.writeln('✗ Fehler: $e');
|
||||
}
|
||||
return buf.toString().trim();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Zentrale Quelle für ICE-Server (STUN/TURN), die in ALLE WebRTC-Verbindungen
|
||||
/// injiziert werden (Matrix-1:1-Calls und LiveKit).
|
||||
///
|
||||
/// Hintergrund: Der Heimserver hängt hinter CGNAT (keine Portfreigabe möglich).
|
||||
/// Direkte Verbindungen brauchen deshalb ICE-Hole-Punching mit STUN auf beiden
|
||||
/// Seiten; für harte Fälle (symmetrische Mobilfunk-NAT) können später
|
||||
/// TURN-Server (z. B. Cloudflare TURN, 1 TB/Monat gratis) ergänzt werden —
|
||||
/// dafür muss nur die ice.json auf dem Pi aktualisiert werden, ohne App-Update.
|
||||
class IceServers {
|
||||
/// Wird vom Pi ausgeliefert und kann dort per Cron mit frischen
|
||||
/// TURN-Credentials befüllt werden (siehe /home/steggi/matrix/ice.json).
|
||||
static const _remoteUrl =
|
||||
'https://steggi-matrix.work/.well-known/pyramid/ice.json';
|
||||
|
||||
/// Fallback, falls die ice.json nicht erreichbar ist.
|
||||
static const List<Map<String, dynamic>> _fallback = [
|
||||
{
|
||||
'urls': [
|
||||
'stun:stun.l.google.com:19302',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
static List<Map<String, dynamic>>? _cached;
|
||||
static DateTime? _fetchedAt;
|
||||
|
||||
/// Liefert die ICE-Server als WebRTC-`iceServers`-Maps
|
||||
/// (`{'urls': [...], 'username': ..., 'credential': ...}`).
|
||||
/// Ergebnis wird 30 Minuten gecached; TURN-Credentials in der ice.json
|
||||
/// sollten deutlich länger gültig sein.
|
||||
static Future<List<Map<String, dynamic>>> get() async {
|
||||
final cached = _cached;
|
||||
if (cached != null &&
|
||||
_fetchedAt != null &&
|
||||
DateTime.now().difference(_fetchedAt!) <
|
||||
const Duration(minutes: 30)) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse(_remoteUrl))
|
||||
.timeout(const Duration(seconds: 4));
|
||||
if (resp.statusCode == 200) {
|
||||
final body = jsonDecode(resp.body);
|
||||
final list = (body['ice_servers'] as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => e.cast<String, dynamic>())
|
||||
.toList();
|
||||
if (list.isNotEmpty) {
|
||||
_cached = list;
|
||||
_fetchedAt = DateTime.now();
|
||||
return list;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline/Endpoint fehlt — Fallback unten.
|
||||
}
|
||||
return _cached ?? _fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:matrix/matrix.dart' hide Room;
|
||||
import 'package:pyramid/core/ice_servers.dart';
|
||||
import 'package:pyramid/core/livekit_token.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _kVoicePresenceType = 'io.pyramid.voice.presence';
|
||||
|
||||
class LiveKitCallManager with ChangeNotifier {
|
||||
static final LiveKitCallManager instance = LiveKitCallManager._();
|
||||
LiveKitCallManager._();
|
||||
|
||||
// ── Publish quality presets (Streamer → Server) ──────────────────────────
|
||||
static final Map<String, VideoParameters> qualityPresets = {
|
||||
'sd': VideoParametersPresets.h360_169,
|
||||
'hd': VideoParametersPresets.h720_169,
|
||||
'fhd': VideoParametersPresets.h1080_169,
|
||||
'4k': VideoParametersPresets.h2160_169,
|
||||
};
|
||||
|
||||
static const Map<String, VideoEncoding> _screenShareEncodings = {
|
||||
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
|
||||
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
||||
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
||||
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
|
||||
};
|
||||
|
||||
Room? _room;
|
||||
EventsListener<RoomEvent>? _roomEventListener;
|
||||
String _displayName = '';
|
||||
bool isConnecting = false;
|
||||
String? error;
|
||||
bool isMuted = false;
|
||||
bool isCameraOff = false;
|
||||
bool isScreenSharing = false;
|
||||
String _localQualityKey = 'hd';
|
||||
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
|
||||
String? _currentScreenShareSourceId;
|
||||
LocalVideoTrack? _screenShareTrack;
|
||||
bool isDeafened = false;
|
||||
bool _mutedBeforeDeafen = false;
|
||||
bool isVoiceChannel = false;
|
||||
String? _matrixRoomId;
|
||||
Client? _matrixClient;
|
||||
Timer? _presenceHeartbeat;
|
||||
|
||||
Timer? _statsTimer;
|
||||
|
||||
bool get isActive => _room != null || isConnecting;
|
||||
Room? get room => _room;
|
||||
String? get currentRoomId => _matrixRoomId;
|
||||
String get displayName => _displayName;
|
||||
bool get noiseSuppression => false;
|
||||
String get currentQualityKey => _localQualityKey;
|
||||
String get subscribeQualityKey => _subscribeQualityKey;
|
||||
|
||||
VideoParameters get _currentQuality =>
|
||||
qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169;
|
||||
|
||||
void _onRoomChanged() => notifyListeners();
|
||||
|
||||
void _cleanUpAfterDisconnect() {
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
if (_matrixClient != null && _matrixRoomId != null) {
|
||||
_writePresence(false).catchError((_) {});
|
||||
}
|
||||
_roomEventListener?.dispose();
|
||||
_roomEventListener = null;
|
||||
_room?.removeListener(_onRoomChanged);
|
||||
_room = null;
|
||||
_displayName = '';
|
||||
isConnecting = false;
|
||||
error = null;
|
||||
isMuted = false;
|
||||
isCameraOff = false;
|
||||
isScreenSharing = false;
|
||||
isDeafened = false;
|
||||
isVoiceChannel = false;
|
||||
_mutedBeforeDeafen = false;
|
||||
_screenShareTrack = null;
|
||||
_currentScreenShareSourceId = null;
|
||||
_matrixRoomId = null;
|
||||
_matrixClient = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _writePresence(bool active) async {
|
||||
final client = _matrixClient;
|
||||
final roomId = _matrixRoomId;
|
||||
final userId = client?.userID;
|
||||
if (client == null || roomId == null || userId == null) return;
|
||||
try {
|
||||
Map<String, dynamic> content;
|
||||
if (active) {
|
||||
// Resolve display name + avatar from any joined room's member state
|
||||
User? member;
|
||||
for (final r in client.rooms) {
|
||||
final participants = r.getParticipants();
|
||||
try {
|
||||
member = participants.firstWhere((u) => u.id == userId);
|
||||
if (member.displayName != null || member.avatarUrl != null) break;
|
||||
} catch (_) {}
|
||||
}
|
||||
content = {
|
||||
'active': true,
|
||||
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
||||
'display_name': member?.displayName ?? userId,
|
||||
if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(),
|
||||
};
|
||||
} else {
|
||||
content = {'active': false};
|
||||
}
|
||||
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
|
||||
} catch (e) {
|
||||
print('[VoicePresence] $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startCall({
|
||||
required String roomName,
|
||||
required String roomDisplayName,
|
||||
required String identity,
|
||||
bool audioOnly = false,
|
||||
bool voiceChannel = false,
|
||||
Client? matrixClient,
|
||||
String? matrixRoomId,
|
||||
}) async {
|
||||
if (isActive) return;
|
||||
|
||||
final url = 'wss://livekit.steggi-matrix.work';
|
||||
|
||||
isConnecting = true;
|
||||
isVoiceChannel = voiceChannel;
|
||||
error = null;
|
||||
_displayName = roomDisplayName;
|
||||
isCameraOff = audioOnly;
|
||||
isMuted = false;
|
||||
isScreenSharing = false;
|
||||
isDeafened = false;
|
||||
_mutedBeforeDeafen = false;
|
||||
_localQualityKey = 'hd';
|
||||
_subscribeQualityKey = 'auto';
|
||||
_matrixClient = matrixClient;
|
||||
_matrixRoomId = matrixRoomId;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final token = LiveKitTokenGenerator.generate(
|
||||
roomName: roomName,
|
||||
identity: identity,
|
||||
displayName: identity,
|
||||
ttlSeconds: 21600,
|
||||
);
|
||||
|
||||
final room = Room(
|
||||
roomOptions: const RoomOptions(
|
||||
adaptiveStream: false, // Matches FluffyChat fix
|
||||
dynacast: true,
|
||||
),
|
||||
);
|
||||
room.addListener(_onRoomChanged);
|
||||
|
||||
// Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige
|
||||
// iceServers ERSETZEN die vom LiveKit-Server gelieferten — die zeigten
|
||||
// bisher auf einen hinter CGNAT unerreichbaren coturn.
|
||||
RTCConfiguration rtcConfig = const RTCConfiguration();
|
||||
try {
|
||||
final ice = await IceServers.get();
|
||||
rtcConfig = RTCConfiguration(
|
||||
iceServers: ice
|
||||
.map((m) => RTCIceServer(
|
||||
urls: m['urls'] is List
|
||||
? List<String>.from(m['urls'] as List)
|
||||
: [m['urls'] as String],
|
||||
username: m['username'] as String?,
|
||||
credential: m['credential'] as String?,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[LiveKit] ICE server fetch error: $e');
|
||||
}
|
||||
|
||||
await room.connect(
|
||||
url,
|
||||
token,
|
||||
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
|
||||
);
|
||||
|
||||
_room = room;
|
||||
isConnecting = false;
|
||||
notifyListeners();
|
||||
|
||||
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
||||
await _writePresence(true);
|
||||
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
_writePresence(true).catchError((_) {});
|
||||
});
|
||||
}
|
||||
|
||||
_roomEventListener = room.createListener()
|
||||
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
|
||||
..on<LocalTrackUnpublishedEvent>((event) {
|
||||
if (event.publication.source == TrackSource.screenShareVideo) {
|
||||
isScreenSharing = false;
|
||||
_currentScreenShareSourceId = null;
|
||||
_screenShareTrack = null;
|
||||
}
|
||||
notifyListeners();
|
||||
})
|
||||
..on<TrackSubscribedEvent>((event) {
|
||||
final pub = event.publication;
|
||||
if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) {
|
||||
pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey));
|
||||
}
|
||||
// Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks.
|
||||
if (pub.kind == TrackType.AUDIO) {
|
||||
try {
|
||||
rtc.Helper.setVolume(
|
||||
_outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack);
|
||||
} catch (_) {}
|
||||
}
|
||||
notifyListeners();
|
||||
})
|
||||
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
|
||||
..on<TrackPublishedEvent>((_) => notifyListeners())
|
||||
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
|
||||
..on<RoomDisconnectedEvent>((_) {
|
||||
_cleanUpAfterDisconnect();
|
||||
});
|
||||
|
||||
// In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs).
|
||||
// Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell
|
||||
// vorhandenen Geräte validiert und sonst ignoriert.
|
||||
String? prefMic, prefSpeaker, prefCam;
|
||||
var deviceIds = <String>{};
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0;
|
||||
prefMic = prefs.getString('voice_mic_device');
|
||||
prefSpeaker = prefs.getString('voice_speaker_device');
|
||||
prefCam = prefs.getString('voice_camera_device');
|
||||
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
||||
deviceIds = devices.map((d) => d.deviceId).toSet();
|
||||
} catch (e) {
|
||||
print('[LiveKit] Device prefs/enumerate error: $e');
|
||||
}
|
||||
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
|
||||
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
|
||||
if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null;
|
||||
|
||||
await room.localParticipant?.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions:
|
||||
prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null,
|
||||
);
|
||||
|
||||
if (prefSpeaker != null) {
|
||||
try {
|
||||
await rtc.Helper.selectAudioOutput(prefSpeaker);
|
||||
} catch (e) {
|
||||
print('[LiveKit] selectAudioOutput error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioOnly) {
|
||||
try {
|
||||
await room.localParticipant?.setCameraEnabled(
|
||||
true,
|
||||
cameraCaptureOptions: CameraCaptureOptions(
|
||||
deviceId: prefCam,
|
||||
params: _currentQuality,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[LiveKit] Failed to enable camera: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_startStatsLogging();
|
||||
} catch (e) {
|
||||
print('[VOIP] Start call error: $e');
|
||||
isConnecting = false;
|
||||
error = e.toString();
|
||||
_cleanUpAfterDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
VideoQuality _videoQualityFromKey(String key) {
|
||||
switch (key) {
|
||||
case 'low': return VideoQuality.LOW;
|
||||
case 'medium': return VideoQuality.MEDIUM;
|
||||
case 'high': return VideoQuality.HIGH;
|
||||
default: return VideoQuality.HIGH; // auto defaults to high
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
||||
double _outputVolume = 1.0;
|
||||
|
||||
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
|
||||
Future<void> setOutputVolume(double v) async {
|
||||
_outputVolume = v;
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
for (final p in room.remoteParticipants.values) {
|
||||
for (final pub in p.audioTrackPublications) {
|
||||
final t = pub.track;
|
||||
if (t != null) {
|
||||
try {
|
||||
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setSubscribeQuality(String key) async {
|
||||
print('[LiveKit] Viewer changing subscribe quality to: $key');
|
||||
_subscribeQualityKey = key;
|
||||
if (_room == null) { notifyListeners(); return; }
|
||||
|
||||
final quality = _videoQualityFromKey(key);
|
||||
for (final p in _room!.remoteParticipants.values) {
|
||||
for (final pub in p.videoTrackPublications) {
|
||||
if (pub.kind == TrackType.VIDEO) {
|
||||
print('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
|
||||
pub.setVideoQuality(quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleMute() async {
|
||||
isMuted = !isMuted;
|
||||
notifyListeners();
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
||||
}
|
||||
|
||||
Future<void> toggleDeafen() async {
|
||||
if (!isDeafened) {
|
||||
_mutedBeforeDeafen = isMuted;
|
||||
isDeafened = true;
|
||||
if (!isMuted) {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(false);
|
||||
isMuted = true;
|
||||
}
|
||||
} else {
|
||||
isDeafened = false;
|
||||
if (!_mutedBeforeDeafen) {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(true);
|
||||
isMuted = false;
|
||||
}
|
||||
}
|
||||
_applyDeafen();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _applyDeafen() {
|
||||
if (_room == null) return;
|
||||
for (final p in _room!.remoteParticipants.values) {
|
||||
for (final trackPub in p.audioTrackPublications) {
|
||||
final track = trackPub.track;
|
||||
if (track != null) {
|
||||
track.mediaStreamTrack.enabled = !isDeafened;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleCamera() async {
|
||||
if (isCameraOff) {
|
||||
try {
|
||||
await _room?.localParticipant?.setCameraEnabled(
|
||||
true,
|
||||
cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality),
|
||||
);
|
||||
isCameraOff = false;
|
||||
} catch (e) {
|
||||
print('[LiveKit] toggleCamera ON error: $e');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await _room?.localParticipant?.setCameraEnabled(false);
|
||||
} catch (e) {
|
||||
print('[LiveKit] toggleCamera OFF error: $e');
|
||||
}
|
||||
isCameraOff = true;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleScreenShare() async {
|
||||
if (isScreenSharing) {
|
||||
await stopScreenShare();
|
||||
} else {
|
||||
try {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) {
|
||||
final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]);
|
||||
if (sources.isNotEmpty) {
|
||||
await startScreenShare(sources.first.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _room?.localParticipant?.setScreenShareEnabled(true);
|
||||
isScreenSharing = true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('[ScreenShare] Toggle error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VideoEncoding _screenShareEncoding() {
|
||||
return _screenShareEncodings[_localQualityKey] ??
|
||||
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
||||
}
|
||||
|
||||
Future<void> startScreenShare(String sourceId) async {
|
||||
if (isScreenSharing) return;
|
||||
try {
|
||||
final encoding = _screenShareEncoding();
|
||||
final track = await LocalVideoTrack.createScreenShareTrack(
|
||||
ScreenShareCaptureOptions(
|
||||
sourceId: sourceId,
|
||||
maxFrameRate: encoding.maxFramerate.toDouble(),
|
||||
),
|
||||
);
|
||||
|
||||
if (_room == null || _room!.localParticipant == null) return;
|
||||
|
||||
await _room!.localParticipant!.publishVideoTrack(
|
||||
track,
|
||||
publishOptions: VideoPublishOptions(
|
||||
simulcast: true,
|
||||
degradationPreference: DegradationPreference.balanced,
|
||||
screenShareEncoding: encoding,
|
||||
),
|
||||
);
|
||||
|
||||
isScreenSharing = true;
|
||||
_currentScreenShareSourceId = sourceId;
|
||||
_screenShareTrack = track;
|
||||
notifyListeners();
|
||||
unawaited(_applyFpsToAllSimulcastLayers(track));
|
||||
} catch (e) {
|
||||
print('[ScreenShare] ERROR: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async {
|
||||
final targetFps = _screenShareEncoding().maxFramerate;
|
||||
for (var attempt = 1; attempt <= 5; attempt++) {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
if (!isScreenSharing || _screenShareTrack != track) return;
|
||||
final sender = track.transceiver?.sender;
|
||||
if (sender == null) continue;
|
||||
final params = sender.parameters;
|
||||
final encodings = params.encodings;
|
||||
if (encodings == null || encodings.isEmpty) continue;
|
||||
var anyUpdated = false;
|
||||
for (final enc in encodings) {
|
||||
if ((enc.maxFramerate ?? 0) < targetFps) {
|
||||
enc.maxFramerate = targetFps;
|
||||
anyUpdated = true;
|
||||
}
|
||||
}
|
||||
if (anyUpdated) await sender.setParameters(params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopScreenShare() async {
|
||||
final local = _room?.localParticipant;
|
||||
if (local != null) {
|
||||
final toStop = <String>[];
|
||||
for (final pub in local.videoTrackPublications) {
|
||||
if (pub.source == TrackSource.screenShareVideo) {
|
||||
toStop.add(pub.sid);
|
||||
await pub.track?.stop();
|
||||
}
|
||||
}
|
||||
for (final sid in toStop) {
|
||||
try { await local.removePublishedTrack(sid); } catch (_) {}
|
||||
}
|
||||
}
|
||||
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
||||
isScreenSharing = false;
|
||||
_currentScreenShareSourceId = null;
|
||||
_screenShareTrack = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> hangUp() async {
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
if (_matrixClient != null && _matrixRoomId != null) {
|
||||
await _writePresence(false).catchError((_) {});
|
||||
}
|
||||
_roomEventListener?.dispose();
|
||||
_roomEventListener = null;
|
||||
_room?.removeListener(_onRoomChanged);
|
||||
await _room?.disconnect();
|
||||
_room = null;
|
||||
_displayName = '';
|
||||
isConnecting = false;
|
||||
error = null;
|
||||
isMuted = false;
|
||||
isCameraOff = false;
|
||||
isScreenSharing = false;
|
||||
isDeafened = false;
|
||||
isVoiceChannel = false;
|
||||
_mutedBeforeDeafen = false;
|
||||
_screenShareTrack = null;
|
||||
_currentScreenShareSourceId = null;
|
||||
_matrixRoomId = null;
|
||||
_matrixClient = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> changeQuality(String qualityKey) async {
|
||||
print('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
||||
_localQualityKey = qualityKey;
|
||||
final params = qualityPresets[qualityKey];
|
||||
if (params == null) { notifyListeners(); return; }
|
||||
if (_room == null) { notifyListeners(); return; }
|
||||
|
||||
final local = _room!.localParticipant;
|
||||
if (local == null) { notifyListeners(); return; }
|
||||
|
||||
if (!isCameraOff) {
|
||||
await local.setCameraEnabled(false);
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
try {
|
||||
await local.setCameraEnabled(
|
||||
true,
|
||||
cameraCaptureOptions: CameraCaptureOptions(params: params),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[LiveKit] Camera restart error: $e');
|
||||
isCameraOff = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isScreenSharing && _currentScreenShareSourceId != null) {
|
||||
final sid = _currentScreenShareSourceId!;
|
||||
await stopScreenShare();
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
await startScreenShare(sid);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> changeAudioInput(String deviceId) async {
|
||||
final participant = _room?.localParticipant;
|
||||
if (participant == null) return;
|
||||
try {
|
||||
final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId));
|
||||
for (final pub in List.of(participant.audioTrackPublications)) {
|
||||
await participant.removePublishedTrack(pub.sid);
|
||||
}
|
||||
await participant.publishAudioTrack(track);
|
||||
if (isMuted) await participant.setMicrophoneEnabled(false);
|
||||
} catch (e) { print('[Audio] error: $e'); }
|
||||
}
|
||||
|
||||
void setRemoteVolume(double volume) {
|
||||
if (_room == null) return;
|
||||
final mute = volume < 0.05;
|
||||
for (final p in _room!.remoteParticipants.values) {
|
||||
for (final pub in p.audioTrackPublications) {
|
||||
pub.track?.mediaStreamTrack.enabled = !mute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _applyNoiseSuppression(bool enabled) {}
|
||||
|
||||
void _startStatsLogging() {
|
||||
_stopStatsLogging();
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
|
||||
}
|
||||
|
||||
void _stopStatsLogging() {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _logStats() async {
|
||||
if (_room == null) return;
|
||||
// Basic stats logging...
|
||||
print('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||
|
||||
class LiveKitTokenGenerator {
|
||||
// Your LiveKit Credentials
|
||||
static const String apiKey = 'LKMatrixPi';
|
||||
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
|
||||
|
||||
static String generate({
|
||||
required String roomName,
|
||||
required String identity,
|
||||
String? displayName,
|
||||
int ttlSeconds = 3600, // Valid for 1 hour by default
|
||||
}) {
|
||||
final jwt = JWT(
|
||||
{
|
||||
'video': {
|
||||
'roomJoin': true,
|
||||
'room': roomName,
|
||||
'canPublish': true,
|
||||
'canSubscribe': true,
|
||||
'canPublishData': true,
|
||||
},
|
||||
'metadata': displayName ?? identity,
|
||||
},
|
||||
issuer: apiKey,
|
||||
subject: identity,
|
||||
);
|
||||
|
||||
// Sign the token with your secret
|
||||
final token = jwt.sign(
|
||||
SecretKey(apiSecret),
|
||||
expiresIn: Duration(seconds: ttlSeconds),
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,78 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/encryption/utils/key_verification.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
|
||||
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||
if (!kIsWeb && (Platform.isWindows || Platform.isLinux)) {
|
||||
final DatabaseFactory factory;
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
|
||||
// no .so loading required.
|
||||
factory = sqflite_native.databaseFactory;
|
||||
} else if (!kIsWeb) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
factory = databaseFactoryFfi;
|
||||
} else {
|
||||
throw UnsupportedError('Web not supported');
|
||||
}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await databaseFactoryFfi.openDatabase(dbPath);
|
||||
final db = await factory.openDatabase(dbPath);
|
||||
|
||||
// Optimization for Windows/FFI to prevent "database is locked" errors
|
||||
try {
|
||||
await db.execute('PRAGMA journal_mode=WAL');
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('Could not set PRAGMAs: $e');
|
||||
}
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
final client = Client('Pyramid', database: sdkDb);
|
||||
await client.init();
|
||||
final client = Client(
|
||||
'Pyramid',
|
||||
database: sdkDb,
|
||||
// Custom State-Events, die beim App-Start mit aus der DB geladen werden
|
||||
// müssen — sonst sind Space-Banner & Voice-Presence nach Neustart leer,
|
||||
// bis der jeweilige Raum vollständig nachgeladen wird.
|
||||
importantStateEvents: {
|
||||
'io.pyramid.space.banner',
|
||||
'io.pyramid.voice.presence',
|
||||
},
|
||||
verificationMethods: {
|
||||
KeyVerificationMethod.numbers,
|
||||
KeyVerificationMethod.emoji,
|
||||
KeyVerificationMethod.qrShow,
|
||||
},
|
||||
logLevel: kReleaseMode ? Level.warning : Level.verbose,
|
||||
nativeImplementations: NativeImplementationsIsolate(
|
||||
compute,
|
||||
vodozemacInit: () => vod.init(),
|
||||
),
|
||||
);
|
||||
|
||||
await client.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
);
|
||||
|
||||
// Re-use or create singleton instance
|
||||
try {
|
||||
PyramidVoipManager.instance.client;
|
||||
} catch (_) {
|
||||
PyramidVoipManager(client);
|
||||
}
|
||||
|
||||
return client;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
|
||||
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
|
||||
/// so the most-recently-viewed media (i.e. your recent messages) stays instantly
|
||||
/// available while older media is dropped and re-fetched on demand.
|
||||
///
|
||||
/// In addition to the in-memory layer there is a small disk layer for
|
||||
/// persistent entries (avatars, banners), so they appear instantly after an
|
||||
/// app restart instead of popping in once re-downloaded.
|
||||
class MediaCache {
|
||||
MediaCache._();
|
||||
static final MediaCache instance = MediaCache._();
|
||||
|
||||
// ~256 MB in memory, capped at 300 entries — comfortably covers the last
|
||||
// ~100 messages of media per active chat without unbounded growth.
|
||||
static const int _maxBytes = 256 * 1024 * 1024;
|
||||
static const int _maxEntries = 300;
|
||||
// Don't cache very large single files (videos etc.) — they'd evict everything.
|
||||
static const int _maxEntryBytes = 32 * 1024 * 1024;
|
||||
|
||||
// Disk layer: only small entries (avatars, banners), bounded total size.
|
||||
static const int _maxDiskEntryBytes = 1024 * 1024; // 1 MB pro Eintrag
|
||||
static const int _maxDiskTotalBytes = 64 * 1024 * 1024; // 64 MB gesamt
|
||||
|
||||
final LinkedHashMap<String, Uint8List> _entries = LinkedHashMap();
|
||||
int _totalBytes = 0;
|
||||
|
||||
Directory? _diskDir;
|
||||
bool _pruneScheduled = false;
|
||||
|
||||
Uint8List? get(String key) {
|
||||
final v = _entries.remove(key);
|
||||
if (v != null) _entries[key] = v; // move to most-recently-used position
|
||||
return v;
|
||||
}
|
||||
|
||||
void put(String key, Uint8List bytes) {
|
||||
if (bytes.lengthInBytes > _maxEntryBytes) return;
|
||||
|
||||
final existing = _entries.remove(key);
|
||||
if (existing != null) _totalBytes -= existing.lengthInBytes;
|
||||
|
||||
_entries[key] = bytes;
|
||||
_totalBytes += bytes.lengthInBytes;
|
||||
|
||||
_evict();
|
||||
}
|
||||
|
||||
/// Memory lookup first, then disk. Disk hits are promoted back into memory.
|
||||
Future<Uint8List?> getPersistent(String key) async {
|
||||
final mem = get(key);
|
||||
if (mem != null) return mem;
|
||||
try {
|
||||
final file = await _diskFile(key);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
if (bytes.isNotEmpty) {
|
||||
put(key, bytes);
|
||||
// Touch für LRU-Pruning nach mtime.
|
||||
file.setLastModified(DateTime.now()).catchError((_) {});
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stores in memory and (for small entries) on disk, surviving restarts.
|
||||
Future<void> putPersistent(String key, Uint8List bytes) async {
|
||||
put(key, bytes);
|
||||
if (bytes.lengthInBytes > _maxDiskEntryBytes) return;
|
||||
try {
|
||||
final file = await _diskFile(key);
|
||||
await file.writeAsBytes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<File> _diskFile(String key) async {
|
||||
final dir = _diskDir ??= await _initDiskDir();
|
||||
final name = sha1.convert(utf8.encode(key)).toString();
|
||||
return File('${dir.path}${Platform.pathSeparator}$name.bin');
|
||||
}
|
||||
|
||||
Future<Directory> _initDiskDir() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final dir = Directory(
|
||||
'${support.path}${Platform.pathSeparator}media_cache');
|
||||
await dir.create(recursive: true);
|
||||
_schedulePrune(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Once per session: drop the oldest disk entries when over budget.
|
||||
void _schedulePrune(Directory dir) {
|
||||
if (_pruneScheduled) return;
|
||||
_pruneScheduled = true;
|
||||
Future(() async {
|
||||
try {
|
||||
final files = <File, FileStat>{};
|
||||
var total = 0;
|
||||
await for (final e in dir.list()) {
|
||||
if (e is! File) continue;
|
||||
final stat = await e.stat();
|
||||
files[e] = stat;
|
||||
total += stat.size;
|
||||
}
|
||||
if (total <= _maxDiskTotalBytes) return;
|
||||
final sorted = files.entries.toList()
|
||||
..sort((a, b) => a.value.modified.compareTo(b.value.modified));
|
||||
for (final entry in sorted) {
|
||||
if (total <= _maxDiskTotalBytes) break;
|
||||
total -= entry.value.size;
|
||||
await entry.key.delete().catchError((_) => entry.key);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
void _evict() {
|
||||
while ((_totalBytes > _maxBytes || _entries.length > _maxEntries) &&
|
||||
_entries.isNotEmpty) {
|
||||
final oldestKey = _entries.keys.first;
|
||||
final removed = _entries.remove(oldestKey);
|
||||
if (removed != null) _totalBytes -= removed.lengthInBytes;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
_totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/background_push.dart';
|
||||
import 'package:pyramid/core/fcm_push_service.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||
|
||||
const _kInstallChannel = MethodChannel('chat.pyramid.pyramid/install');
|
||||
|
||||
// ─── Stable notification ID per room (same algorithm as PushService.kt) ──────
|
||||
// Dart's String.hashCode is randomised per-process; this gives a deterministic
|
||||
// integer that matches the Kotlin side so FCM and sync notifications collapse.
|
||||
int _stableRoomId(String roomId) {
|
||||
var h = 0;
|
||||
for (final c in roomId.codeUnits) {
|
||||
h = ((h * 31) + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return h == 0 ? 1 : h;
|
||||
}
|
||||
|
||||
// ─── Android plugin ───────────────────────────────────────────────────────────
|
||||
|
||||
final _androidPlugin = FlutterLocalNotificationsPlugin();
|
||||
bool _androidInitialized = false;
|
||||
|
||||
// ─── IsolateNameServer port ───────────────────────────────────────────────────
|
||||
// Registered on startup so background isolates can forward notification actions
|
||||
// to the running main isolate instead of starting a new Flutter engine.
|
||||
ReceivePort? _mainIsolatePort;
|
||||
|
||||
// Room → active Windows notification (so we can close it on room-open).
|
||||
final _windowsNotifs = <String, LocalNotification>{};
|
||||
|
||||
// ─── Notification tap payload → navigate to room ──────────────────────────────
|
||||
|
||||
final notificationTapProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
// ─── Permission state (Android) ───────────────────────────────────────────────
|
||||
|
||||
/// True when the user has permanently denied notification permission on Android.
|
||||
final notificationsBlockedProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> initNotifications(WidgetRef ref) async {
|
||||
if (Platform.isAndroid) {
|
||||
await _initAndroid(ref);
|
||||
} else if (Platform.isWindows) {
|
||||
await _initWindows();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initAndroid(WidgetRef ref) async {
|
||||
if (_androidInitialized) return;
|
||||
_androidInitialized = true;
|
||||
|
||||
// Register the IsolateNameServer port so background isolates can forward
|
||||
// notification actions to us instead of starting a new Flutter engine.
|
||||
_mainIsolatePort?.close();
|
||||
_mainIsolatePort = ReceivePort();
|
||||
IsolateNameServer.removePortNameMapping(kMainIsolateName);
|
||||
IsolateNameServer.registerPortWithName(
|
||||
_mainIsolatePort!.sendPort, kMainIsolateName);
|
||||
_mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
|
||||
print('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
|
||||
|
||||
await _androidPlugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
print('[NOTIF-FG] onDidReceiveNotificationResponse fired');
|
||||
print('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
|
||||
if (details.actionId == 'reply') {
|
||||
final text = details.input?.trim() ?? '';
|
||||
final roomId = details.payload;
|
||||
print('[NOTIF-FG] reply: text="$text" roomId=$roomId');
|
||||
if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) {
|
||||
_androidPlugin.cancel(_stableRoomId(roomId));
|
||||
ref.read(matrixClientProvider.future).then((client) async {
|
||||
final room = client.getRoomById(roomId);
|
||||
print('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||
if (room != null) {
|
||||
try {
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-FG] sendTextEvent OK');
|
||||
} catch (e) {
|
||||
print('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
}
|
||||
} else {
|
||||
print('[NOTIF-FG] room null — storing in prefs, waiting for sync');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
try {
|
||||
await client.onSync.stream.first;
|
||||
print('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
|
||||
await _sendPendingReply(ref);
|
||||
} catch (e) {
|
||||
print('[NOTIF-FG] sync/send error: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
print('[NOTIF-FG] reply skipped: text empty or roomId null');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (details.actionId == 'read') {
|
||||
final roomId = details.payload;
|
||||
if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
|
||||
print('[NOTIF-FG] read action — dismissed');
|
||||
return;
|
||||
}
|
||||
final roomId = details.payload;
|
||||
print('[NOTIF-FG] tap → opening room $roomId');
|
||||
if (roomId != null && roomId.isNotEmpty) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
}
|
||||
},
|
||||
// Registers the background action handler so flutter_local_notifications
|
||||
// can invoke it in a fresh isolate when the app is killed and the user
|
||||
// presses Reply or Gelesen on a background notification.
|
||||
onDidReceiveBackgroundNotificationResponse:
|
||||
handleBackgroundNotificationResponse,
|
||||
);
|
||||
|
||||
// Handle notification taps from PushService.kt (native FCM notifications).
|
||||
// "openRoom" arrives when app is already running and user taps a notification.
|
||||
_kInstallChannel.setMethodCallHandler((call) async {
|
||||
final args = call.arguments;
|
||||
if (call.method == 'openRoom') {
|
||||
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||
print('[NOTIF-FG] openRoom: $roomId');
|
||||
if (roomId != null && roomId.isNotEmpty) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
}
|
||||
await _sendPendingReply(ref);
|
||||
} else if (call.method == 'replyFromNotification') {
|
||||
// Native Android path: ReplyReceiver forwarded the inline reply text via
|
||||
// MethodChannel — no Activity was opened.
|
||||
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||
final text = (args is Map ? args['text'] : null) as String?;
|
||||
print('[NOTIF-FG] replyFromNotification: room=$roomId text=$text');
|
||||
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
|
||||
_androidPlugin.cancel(_stableRoomId(roomId));
|
||||
Future<void> storePending() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
}
|
||||
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room != null) {
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-FG] replyFromNotification: sent OK');
|
||||
} else {
|
||||
print('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
|
||||
await storePending();
|
||||
// Retry on the next sync — room list is populated after first sync.
|
||||
client.onSync.stream.first
|
||||
.then((_) => _sendPendingReply(ref))
|
||||
.catchError((_) {});
|
||||
}
|
||||
} catch (e) {
|
||||
print('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
|
||||
await storePending();
|
||||
// Resume-based retry (checkPendingReply in app.dart) will pick this up.
|
||||
}
|
||||
}
|
||||
} else if (call.method == 'sharedContent') {
|
||||
// "Teilen nach Pyramid" während die App läuft — AppShell zeigt den Picker.
|
||||
final text = (args is Map ? args['text'] : null) as String?;
|
||||
final paths = (args is Map ? args['paths'] : null);
|
||||
final share = PendingShare(
|
||||
text: text,
|
||||
paths: paths is List ? paths.whereType<String>().toList() : const [],
|
||||
);
|
||||
if (!share.isEmpty) {
|
||||
ref.read(pendingShareProvider.notifier).state = share;
|
||||
}
|
||||
} else if (call.method == 'decryptAndUpdateNotification') {
|
||||
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
|
||||
// We decrypt the event here (the Matrix client has the keys) and overwrite
|
||||
// the notification with the real sender name and message body.
|
||||
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||
final eventId = (args is Map ? args['event_id'] : null) as String?;
|
||||
final notifId = (args is Map ? args['notif_id'] : null) as int?;
|
||||
if (roomId != null && roomId.isNotEmpty) {
|
||||
_decryptAndUpdateNotification(ref, roomId, eventId, notifId).catchError((_) {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cold-start tap: app was killed, user tapped notification → check launch intent.
|
||||
try {
|
||||
final roomId = await _kInstallChannel.invokeMethod<String?>('getInitialRoomId');
|
||||
if (roomId != null && roomId.isNotEmpty) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Cold-start reply: app was killed, user pressed Reply → reply text is in the
|
||||
// launch intent (onCreate fires instead of onNewIntent when app is killed).
|
||||
try {
|
||||
final reply = await _kInstallChannel.invokeMethod<Object?>('getInitialReply');
|
||||
if (reply is Map) {
|
||||
final roomId = reply['room_id'] as String?;
|
||||
final text = reply['text'] as String?;
|
||||
print('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text');
|
||||
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
|
||||
// Persist so _sendPendingReply sends it after the first sync.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
// Send the app back to background immediately — the user replied from
|
||||
// the notification and doesn't want the full UI to appear.
|
||||
try {
|
||||
await _kInstallChannel.invokeMethod('minimizeApp');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Cold-start share: app was launched via "Teilen nach Pyramid".
|
||||
try {
|
||||
final share = await _kInstallChannel.invokeMethod<Object?>('getInitialShare');
|
||||
if (share is Map) {
|
||||
final text = share['text'] as String?;
|
||||
final paths = share['paths'];
|
||||
final pending = PendingShare(
|
||||
text: text,
|
||||
paths: paths is List ? paths.whereType<String>().toList() : const [],
|
||||
);
|
||||
if (!pending.isEmpty) {
|
||||
ref.read(pendingShareProvider.notifier).state = pending;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final androidPlugin = _androidPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
// Pre-create channel so it exists before the FCM background isolate runs.
|
||||
await androidPlugin?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
'pyramid_messages',
|
||||
'Nachrichten',
|
||||
description: 'Neue Nachrichten',
|
||||
importance: Importance.high,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
final granted = await androidPlugin?.requestNotificationsPermission();
|
||||
if (granted == false) {
|
||||
ref.read(notificationsBlockedProvider.notifier).state = true;
|
||||
}
|
||||
|
||||
// FCM: Firebase initialisieren + Pusher beim Homeserver registrieren.
|
||||
// Use .future so we wait for the Matrix client to be fully ready
|
||||
// rather than potentially getting null from .value during startup.
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
await initFcm(client);
|
||||
// Rooms are only reliably in memory after the first sync completes.
|
||||
// Send any reply that was stored while the app was killed.
|
||||
client.onSync.stream.first
|
||||
.then((_) => _sendPendingReply(ref))
|
||||
.catchError((_) {});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ─── IsolateNameServer message handler ───────────────────────────────────────
|
||||
// Called when a background isolate (handleBackgroundNotificationResponse)
|
||||
// forwards a notification action to the running main isolate.
|
||||
|
||||
void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
||||
if (message is! Map) return;
|
||||
final actionId = message['actionId'] as String?;
|
||||
final payload = message['payload'] as String?;
|
||||
final input = message['input'] as String?;
|
||||
final id = message['id'] as int?;
|
||||
|
||||
print('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input');
|
||||
|
||||
switch (actionId) {
|
||||
case 'reply':
|
||||
final roomId = payload;
|
||||
final text = (input ?? '').trim();
|
||||
if (roomId == null || roomId.isEmpty || text.isEmpty) return;
|
||||
_androidPlugin.cancel(id ?? _stableRoomId(roomId));
|
||||
ref.read(matrixClientProvider.future).then((client) async {
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room != null) {
|
||||
try {
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-ISOLATE] reply sent OK');
|
||||
} catch (e) {
|
||||
print('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
client.onSync.stream.first
|
||||
.then((_) => _sendPendingReply(ref))
|
||||
.catchError((_) {});
|
||||
}
|
||||
} else {
|
||||
print('[NOTIF-ISOLATE] room not in memory — storing in prefs');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
client.onSync.stream.first
|
||||
.then((_) => _sendPendingReply(ref))
|
||||
.catchError((_) {});
|
||||
}
|
||||
});
|
||||
|
||||
case 'read':
|
||||
if (payload != null) {
|
||||
_androidPlugin.cancel(_stableRoomId(payload));
|
||||
} else if (id != null) {
|
||||
_androidPlugin.cancel(id);
|
||||
}
|
||||
print('[NOTIF-ISOLATE] read → notification dismissed');
|
||||
|
||||
default:
|
||||
// Plain notification tap → navigate to room.
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = payload;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
print('[NOTIF-ISOLATE] tap → navigated to $payload');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a reply stored by the background notification handler.
|
||||
/// Called on cold start and on every app resume so no reply is ever lost.
|
||||
Future<void> _sendPendingReply(WidgetRef ref) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final roomId = prefs.getString('notif_pending_reply_room');
|
||||
final text = prefs.getString('notif_pending_reply_text');
|
||||
print('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text');
|
||||
if (roomId == null || text == null || text.isEmpty) return;
|
||||
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
print('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}');
|
||||
|
||||
final room = client.getRoomById(roomId);
|
||||
print('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||
if (room == null) {
|
||||
print('[NOTIF-REPLY] room not in memory yet, retrying after next sync');
|
||||
client.onSync.stream.first
|
||||
.then((_) => _sendPendingReply(ref))
|
||||
.catchError((_) {});
|
||||
return;
|
||||
}
|
||||
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-REPLY] sendTextEvent OK, clearing prefs');
|
||||
await prefs.remove('notif_pending_reply_room');
|
||||
await prefs.remove('notif_pending_reply_text');
|
||||
} catch (e) {
|
||||
print('[NOTIF-REPLY] error: $e — prefs kept for retry');
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the OS notification-settings screen for this app (Android only).
|
||||
Future<void> openNotificationSettings() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _kInstallChannel.invokeMethod('openNotificationSettings');
|
||||
}
|
||||
|
||||
/// Opens the system battery optimisation dialog for Pyramid (Android only).
|
||||
/// Samsung and other OEMs kill FCM background processes unless this is set
|
||||
/// to "Unrestricted".
|
||||
Future<void> openBatteryOptimizationSettings() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _kInstallChannel.invokeMethod('openBatterySettings');
|
||||
}
|
||||
|
||||
Future<void> _initWindows() async {
|
||||
await localNotifier.setup(appName: 'Pyramid');
|
||||
}
|
||||
|
||||
// ─── Heartbeat — tells the FCM background handler the app is running ──────────
|
||||
|
||||
bool _appInForeground = true;
|
||||
|
||||
/// Call from the app lifecycle observer in app.dart.
|
||||
void setAppForeground(bool value) {
|
||||
_appInForeground = value;
|
||||
if (!value && Platform.isAndroid) {
|
||||
// Clear immediately so the FCM background handler runs as soon as the app
|
||||
// is no longer in the foreground / is killed.
|
||||
SharedPreferences.getInstance()
|
||||
.then((p) => p.setInt('notif_app_heartbeat', 0));
|
||||
}
|
||||
}
|
||||
|
||||
void _pingHeartbeat() {
|
||||
if (!Platform.isAndroid || !_appInForeground) return;
|
||||
SharedPreferences.getInstance().then(
|
||||
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reset notification timestamp on app resume ───────────────────────────────
|
||||
// Events that arrived while the app was backgrounded were already notified by
|
||||
// the FCM background handler. Update _watchStartTime so the notification
|
||||
// watcher skips those catch-up events when the app comes back to foreground.
|
||||
|
||||
void resetNotificationStartTime() {
|
||||
_watchStartTime = DateTime.now();
|
||||
}
|
||||
|
||||
// ─── Cancel notification for a room (call when the chat is opened) ────────────
|
||||
|
||||
Future<void> cancelNotificationForRoom(String roomId) async {
|
||||
if (Platform.isAndroid && _androidInitialized) {
|
||||
await _androidPlugin.cancel(_stableRoomId(roomId));
|
||||
} else if (Platform.isWindows) {
|
||||
final notif = _windowsNotifs.remove(roomId);
|
||||
notif?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Decrypt & update notification (called by PushService via MethodChannel) ──
|
||||
// PushService shows a "Neue Nachricht" placeholder immediately. This function
|
||||
// is called while the app is backgrounded — the Matrix client has the E2EE keys
|
||||
// so it can decrypt the event and overwrite the placeholder with real content.
|
||||
|
||||
Future<void> _decryptAndUpdateNotification(
|
||||
WidgetRef ref, String roomId, String? eventId, int? notifId) async {
|
||||
try {
|
||||
final client = await ref
|
||||
.read(matrixClientProvider.future)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
var room = client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
print('[NOTIF] decryptAndUpdate: room $roomId not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// The event might not have synced yet — wait for the next sync (max 12 s).
|
||||
// We use lastEvent as a proxy: after sync it will be the event we want.
|
||||
Event? event = room.lastEvent;
|
||||
final bool alreadyHave =
|
||||
eventId == null || event?.eventId == eventId;
|
||||
if (!alreadyHave || event == null) {
|
||||
print('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
||||
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
||||
room = client.getRoomById(roomId);
|
||||
if (room == null) return;
|
||||
event = room.lastEvent;
|
||||
}
|
||||
|
||||
if (event == null) {
|
||||
print('[NOTIF] decryptAndUpdate: event still null after sync');
|
||||
return;
|
||||
}
|
||||
if (event.type != EventTypes.Message) return;
|
||||
if (event.senderId == client.userID) return;
|
||||
|
||||
final rawBody = event.body;
|
||||
// If decryption failed the body starts with "** Unable to decrypt"
|
||||
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
||||
print('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
||||
return;
|
||||
}
|
||||
|
||||
final notifBody =
|
||||
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
final displayName = sender.displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
final title = room.isDirectChat
|
||||
? displayName
|
||||
: '$displayName · ${room.getLocalizedDisplayname()}';
|
||||
|
||||
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
|
||||
// replaces the existing notification rather than stacking a new one.
|
||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
||||
'title': title,
|
||||
'body': notifBody,
|
||||
'roomId': roomId,
|
||||
'notifId': notifId ?? _stableRoomId(roomId),
|
||||
});
|
||||
print('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
||||
} catch (e) {
|
||||
print('[NOTIF] decryptAndUpdate error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Show notification ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> showMessageNotification({
|
||||
required String roomId,
|
||||
String? roomName,
|
||||
required String senderName,
|
||||
required String body,
|
||||
}) async {
|
||||
final title = roomName != null ? '$senderName · $roomName' : senderName;
|
||||
if (Platform.isAndroid && _androidInitialized) {
|
||||
// Use native Kotlin notification so the reply PendingIntent targets
|
||||
// ReplyReceiver directly — the app never opens when the user replies.
|
||||
try {
|
||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'roomId': roomId,
|
||||
'notifId': _stableRoomId(roomId),
|
||||
});
|
||||
} catch (e) {
|
||||
print('[NOTIF] showNativeNotification failed: $e');
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
// Close the previous notification for this room before showing a new one.
|
||||
_windowsNotifs.remove(roomId)?.close();
|
||||
|
||||
final notif = LocalNotification(
|
||||
identifier: 'msg_$roomId',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
notif.onShow = () {};
|
||||
_windowsNotifs[roomId] = notif;
|
||||
await localNotifier.notify(notif);
|
||||
|
||||
// Also flash the taskbar button
|
||||
WindowsTaskbar.setFlashTaskbarAppIcon();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Taskbar badge (Windows) ──────────────────────────────────────────────────
|
||||
|
||||
bool _badgeVisible = false;
|
||||
|
||||
Future<void> _updateTaskbar(int unreadCount) async {
|
||||
if (!Platform.isWindows) return;
|
||||
try {
|
||||
if (unreadCount > 0 && !_badgeVisible) {
|
||||
_badgeVisible = true;
|
||||
await WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
|
||||
} else if (unreadCount == 0 && _badgeVisible) {
|
||||
_badgeVisible = false;
|
||||
await WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Exported so AppShell can call it when the app resumes from background.
|
||||
Future<void> checkPendingReply(WidgetRef ref) => _sendPendingReply(ref);
|
||||
|
||||
// ─── Watcher provider ─────────────────────────────────────────────────────────
|
||||
|
||||
final notificationWatcherProvider = Provider<void>((ref) {
|
||||
ref.watch(matrixClientProvider).whenData((client) {
|
||||
_watchEvents(ref, client);
|
||||
_watchBadge(client);
|
||||
});
|
||||
});
|
||||
|
||||
// Set once on first _watchEvents call so we can skip catchup events from the
|
||||
// initial SDK sync (they were already shown by the FCM background handler).
|
||||
DateTime? _watchStartTime;
|
||||
|
||||
void _watchEvents(Ref ref, Client client) {
|
||||
_watchStartTime ??= DateTime.now();
|
||||
client.onNotification.stream.listen((Event event) {
|
||||
if (event.type != EventTypes.Message) return;
|
||||
if (event.senderId == client.userID) return;
|
||||
|
||||
// Skip events that predate this app session — the FCM background handler
|
||||
// already showed a notification for them while the app was killed.
|
||||
if (event.originServerTs.isBefore(_watchStartTime!)) return;
|
||||
|
||||
// ── Master notification toggle (desktop/Windows setting) ──────────────
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
if (!ref.read(notifDesktopProvider)) return;
|
||||
}
|
||||
|
||||
final roomId = event.room.id;
|
||||
// Suppress only when the window is focused AND the room is currently open.
|
||||
// If the window is minimized or in the background, always show the notification.
|
||||
final windowFocused = ref.read(windowFocusedProvider);
|
||||
if (windowFocused && roomId == ref.read(activeRoomIdProvider)) return;
|
||||
|
||||
final room = event.room;
|
||||
if (room.membership != Membership.join) return;
|
||||
|
||||
// ── Mention-only filter ───────────────────────────────────────────────
|
||||
if (ref.read(notifMentionOnlyDmProvider)) {
|
||||
final myId = client.userID ?? '';
|
||||
// Check MSC3952 m.mentions field first, then fall back to body scan.
|
||||
final mentionedIds = (event.content
|
||||
.tryGetMap<String, dynamic>('m.mentions')
|
||||
?.tryGetList<String>('user_ids')) ??
|
||||
[];
|
||||
final bodyMentionsUs = event.body.contains(myId) ||
|
||||
event.body.contains('@${myId.split(':').first.replaceFirst('@', '')}');
|
||||
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
|
||||
}
|
||||
|
||||
// ── Build notification body ───────────────────────────────────────────
|
||||
final rawBody = event.body;
|
||||
final showPreview = ref.read(notifPreviewProvider);
|
||||
final notifBody = showPreview
|
||||
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
|
||||
: 'Neue Nachricht';
|
||||
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
final displayName = sender.displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
|
||||
showMessageNotification(
|
||||
roomId: roomId,
|
||||
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
||||
senderName: displayName,
|
||||
body: notifBody,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _watchBadge(Client client) {
|
||||
client.onSync.stream.listen(
|
||||
(_) {
|
||||
try {
|
||||
final total = client.rooms
|
||||
.where((r) => r.membership == Membership.join)
|
||||
.fold<int>(0, (sum, r) => sum + r.notificationCount);
|
||||
_updateTaskbar(total);
|
||||
_pingHeartbeat(); // keep PushService.kt from double-notifying
|
||||
} catch (_) {}
|
||||
},
|
||||
onError: (_) {},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,14 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:pyramid/features/auth/login_notifier.dart';
|
||||
import 'package:pyramid/features/auth/login_page.dart';
|
||||
import 'package:pyramid/features/auth/server_page.dart';
|
||||
import 'package:pyramid/features/chat/chat_page.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_page.dart';
|
||||
import 'package:pyramid/features/settings/settings_page.dart';
|
||||
import 'package:pyramid/layout/shell_page.dart';
|
||||
import 'package:pyramid/layout/app_shell.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final isLoggedIn = ref.watch(isLoggedInProvider).valueOrNull ?? false;
|
||||
final hasServer = ref.watch(homeserverProvider) != null;
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/rooms',
|
||||
initialLocation: '/app',
|
||||
redirect: (context, state) {
|
||||
final loc = state.matchedLocation;
|
||||
final onAuth = loc == '/login' || loc == '/server';
|
||||
@@ -24,7 +21,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoggedIn && onAuth) return '/rooms';
|
||||
if (isLoggedIn && onAuth) return '/app';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -36,23 +33,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ShellPage(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/rooms',
|
||||
builder: (context, state) => const RoomsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat/:roomId',
|
||||
builder: (context, state) =>
|
||||
ChatPage(roomId: state.pathParameters['roomId']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
],
|
||||
GoRoute(
|
||||
path: '/app',
|
||||
builder: (context, state) => const AppShell(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||