Compare commits

..

38 Commits

Author SHA1 Message Date
Bernd Steckmeister 87ff15d760 chore: CHANGES.md Hook-Log nachführen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:52:32 +02:00
Bernd Steckmeister 16561bbdeb docs: SQLCipher-Migrationsplan + konsolidierter PC-Testplan (M1-Vorbereitung)
Die zwei obersten offenen ROADMAP-Punkte sind PC-only in der Umsetzung,
aber jetzt maximal vorbereitet: docs/SQLCIPHER_MIGRATION.md (gegen beide
DB-Öffnungsstellen und matrix-6.2.0 verifiziert, inkl. Push-Isolate-Falle
und Migration-Race) und docs/PC_TESTPLAN.md (alle UNGETESTET(Pi)-Punkte
aus 7 verstreuten PROGRESS-Einträgen an einer Stelle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:50:58 +02:00
Bernd Steckmeister 87b29b5912 docs: Fable-5-Review abgeschlossen und protokolliert (M0-Punkt abgehakt)
Review-Protokoll in PROGRESS.md nachgeholt (die Vorsession hatte f9979e4
und 891f348 gepusht, wurde aber vor dem Protokollieren abgebrochen) und
den Review zu Ende geführt: (a) Splits unabhängig per Zeilen-Multiset
verifiziert, (b) Soft-Logout-Guard gegen SDK-Quelle gegenverifiziert,
(c) Lint-Fixes äquivalent (onReorderItem gegen Flutter-SDK geprüft),
(d) ROADMAP-Haken stimmen mit dem Code überein. Nebenbefund: docs/
PROGRESS.md als Archiv markiert (Verwechslungsgefahr mit dem echten
Protokoll im Root).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:46:55 +02:00
Bernd Steckmeister 73420fe245 chore: Autopilot auf eigenes Claude-Profil (~/.claude-pyramid) + Review-Prompt
Bernds lokale Umstellung committen (Push = Backup): CLAUDE_CONFIG_DIR
zeigt auf das Pyramid-eigene Profil, Prompt um den Fable-5-Review-Pass
erweitert. *.bak in .gitignore (autopilot.sh.bak bleibt lokal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:46:55 +02:00
Bernd Steckmeister 891f3489bd fix: Soft-Logout darf nie mehr destruktiv enden (onSoftLogout-Guard)
Qualitäts-Review-Befund (b): Der Refresh-Token-Fix (9dc83f7) war richtig
analysiert, aber unvollständig – drei Restlücken gegen das matrix-SDK
6.2.0 verifiziert und geschlossen:

1. Client._handleSoftLogout ruft bei JEDEM Refresh-Fehler logout() auf,
   und logout() macht 'finally clear()' – die lokale Session inkl.
   Krypto-Schlüssel wird selbst bei einem transienten Netzwerkfehler
   zerstört. Mit refreshToken:true laufen Access-Tokens künftig ab,
   Refreshes werden Routine – das Restrisiko wäre also GESTIEGEN.
   Fix: eigener onSoftLogout-Handler (soft_logout_guard.dart) mit
   3 Versuchen, der nie wirft → SDK ruft nie logout() auf; bei
   Dauerfehler bleibt der Client soft-logged-out und der nächste
   Sync versucht es erneut (CLAUDE.md: Fehlerpfade dürfen NIE in
   einem stillen Logout enden).

2. Das Push-Hintergrund-Isolate (background_push.dart) teilt sich die
   pyramid.sqlite mit der Haupt-App; getEventByPushNotification()
   ruft ensureNotSoftLoggedOut() auf. Ohne Guard hätte ein
   Refresh-Fehler im Isolate (inkl. Token-Rotations-Race mit der
   Haupt-App) die Session der GESAMTEN App gelöscht. Guard auch dort
   gesetzt; das Race heilt der Retry, weil refreshAccessToken() den
   Token bei jedem Versuch frisch aus der DB liest.

3. isLoggedInProvider behandelte softLoggedOut wie ausgeloggt – die UI
   wäre bei jedem Routine-Token-Refresh kurz auf den Login-Screen
   gesprungen (für Nutzer nicht von einem 'Random Logout' zu
   unterscheiden). Jetzt zählt nur echtes loggedOut.

UNGETESTET (Pi) – gehört zum selben ausstehenden PC-Praxistest wie
9dc83f7: Login → Nachrichten → Logout → Neu-Login → alte Nachrichten
lesbar; zusätzlich auth_log.txt auf die neuen Guard-Einträge prüfen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:42:15 +02:00
Bernd Steckmeister f9979e4f32 fix: beim Gott-Datei-Split verlorene Erklärkommentare wiederhergestellt
Qualitäts-Review-Befund (a): Alle 6 part/part-of-Aufteilungen hatten Code
vollständig übernommen (Zeilen-Multiset-Vergleich Original vs. heutiger
Stand: keine Code-Zeile verloren, keine hinzugefügt), ABER erklärende
Kommentare über den Klassen gingen verloren (24 Blöcke, u. a. die
HTML-Subset-Doku über _MatrixHtmlText, die Selbst-Aussperr-Warnung über
updatePowerLevelsSafely, TURN-Verbrauchskarte, PDF-Cache/Viewer-Doku).
Alle an den richtigen Klassen wiederhergestellt; reine Trennlinien und
ALL-CAPS-Abschnittslabels bewusst nicht (Gott-Datei-Navigation, durch
die neuen Dateinamen ersetzt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:37:48 +02:00
Bernd Steckmeister d254a36de7 chore: Autopilot auf Fable 5 umgestellt + Qualitäts-Review-Auftrag in ROADMAP
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 14:31:51 +02:00
Bernd Steckmeister daf4cf3dad refactor: Gott-Datei document_viewer.dart in 5 Dateien aufgeteilt
Sechster Durchgang derselben part/part-of-Technik wie bei den 5 vorigen
Umbauten - reine Verschiebung, keine Logikänderung. document_viewer.dart
schrumpft von 1773 auf 20 Zeilen (nur noch Bibliothekskopf), 34
Top-Level-Bloecke (31 Klassen/Enums + 3 lose Funktionen) auf 5 thematische
Dateien unter lib/features/chat/document/ verteilt. Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze
(weiterhin nur 1 bekannter Hinweis).

voice_channel.dart und app_shell.dart bewusst nicht angefasst - haengen an
der noch unentschiedenen Call-Fassade bzw. dem Bootstrap/Login-Pfad.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 12:59:03 +02:00
Bernd Steckmeister a963262de2 docs: Session-Check – Call-Fassade und M1-Punkte weiterhin PC-only/Bernd-Entscheidung
Keine neue Information seit letzter Session: SQLCipher und Härtetest brauchen
PC/GUI, Call-Schicht-Fassade ist kein reiner Datei-Split (Singleton +
WebRTCDelegate + Timer-Polling) und wartet weiterhin auf Bernds Go, um nicht
blind ein produktiv genutztes Call-Feature zu riskieren.
2026-07-03 12:34:48 +02:00
Bernd Steckmeister 31e4001fb0 refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine
Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf
33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem
Blockvergleich gegen das Original und flutter analyze.

Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt;
ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur
vereinheitlichen" abgehakt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 11:03:31 +02:00
Bernd Steckmeister 0c7d359076 refactor: Gott-Datei space_admin_dialog.dart in 6 Dateien aufgeteilt
Gleiche Technik wie bei den vorigen drei Umbauten: reine Verschiebung per
Dart part/part-of, keine Logikänderung. space_admin_dialog.dart schrumpft
von 2278 auf 19 Zeilen (nur noch Bibliothekskopf). Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 11:01:58 +02:00
Bernd Steckmeister a863fa3b9a refactor: Gott-Datei rooms_panel.dart in 8 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart/message_group.dart: reine
Verschiebung per Dart part/part-of, keine Logikänderung. rooms_panel.dart
schrumpft von 2555 auf 31 Zeilen (nur noch Bibliothekskopf). Verifiziert
per automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 11:00:22 +02:00
Bernd Steckmeister 5bf45a9366 refactor: Gott-Datei message_group.dart in 7 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart
part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775
auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem
Blockvergleich gegen das Original und flutter analyze (Baseline unverändert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:58:46 +02:00
Bernd Steckmeister 5d772389d6 refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt
Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine
Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen.
Verifiziert per automatisiertem Blockvergleich gegen das Original und
flutter analyze (Baseline unverändert: 1 bekannter Hinweis).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:56:48 +02:00
Bernd Steckmeister 6400fb639c refactor: toten Code entfernen, doppelte Zeit-Formatierung zusammenführen
message_bubble.dart war vollständig unbenutzt (durch message_group.dart
abgelöst). Die zwei identischen _formatTime-Methoden in message_group.dart
zu einer Top-Level-Funktion zusammengeführt - reine Extraktion, keine
Verhaltensänderung. flutter analyze bleibt sauber.
2026-07-03 10:29:54 +02:00
Bernd Steckmeister f5b9ce9cd7 docs: M2-Modul-Schnitt definieren (docs/M2_MODULE_SCHNITT.md)
Öffentliche Schnittstellen für call_signaling, voice_channel, call_ui,
settings, verification, storage u.a. entworfen. Wichtigste Erkenntnis:
"Calls" ist architektonisch zwei getrennte Module (Matrix-VoIP vs.
LiveKit-SFU-Voice-Channels), keine gemeinsame Fassade. Korrigiert
nebenbei einen falschen Ist-Analyse-Befund (rooms_provider-Import war
fälschlich als toter Code eingestuft).
2026-07-03 10:27:35 +02:00
Bernd Steckmeister f3fd451034 docs: M2-Ist-Analyse der Architektur dokumentieren
Gott-Dateien, unsauber verteilte Call-Schicht, fehlende Storage-Fassade
und tote/duplizierte Code-Stellen per Code-Lektüre kartiert; 8 Modul-
Kandidaten für den nächsten Roadmap-Punkt festgehalten.
2026-07-03 10:25:10 +02:00
Bernd Steckmeister db653ea7a3 chore: CHANGES.md Hook-Log nachführen 2026-07-03 10:00:22 +02:00
Bernd Steckmeister 254be1b8da docs: M1-Bestandsaufnahme – 3 Punkte bereits vorhanden, SQLCipher-Lücke offen
Key-Backup-Flows und Geräteverwaltung waren schon implementiert (nur nicht
abgehakt). Neue Sicherheitslücke dokumentiert: sqlcipher_flutter_libs ist
eine ungenutzte Abhängigkeit, die Krypto-DB liegt unverschlüsselt vor.
Bewusst nicht blind gefixt (Migrationsrisiko für Bestandsinstallationen),
Frage an Bernd zur Priorität ergänzt.
2026-07-03 10:00:05 +02:00
Bernd Steckmeister b5762ea08d feat: Logout-Warnung bei fehlendem Key-Backup (M1)
Beide Logout-Bestätigungsdialoge prüfen jetzt vorab per neuem
isKeyBackupMissing(client) (bootstrap_dialog.dart), ob Cross-Signing/
Key-Backup eingerichtet ist. Fehlt es, zeigt der Dialog eine
Klartext-Warnung: alte verschlüsselte Nachrichten wären nach dem
Logout auf keinem Gerät mehr lesbar. Ohne fehlendes Backup bleibt der
bisherige Text unverändert.
2026-07-03 09:59:10 +02:00
Bernd Steckmeister e8ab626c92 docs: Push-Regression vermutlich identische Ursache wie Random-Logout
getEventByPushNotification() ruft ensureNotSoftLoggedOut() auf, das ohne
Refresh-Token (siehe 9dc83f7) denselben destruktiven Auto-Logout auslösen
kann wie beim normalen Sync — nur eben aus dem Hintergrund-Isolat, mit
Zugriff auf dieselbe DB. Der bestehende Fix deckt vermutlich beide
Symptome ab. ROADMAP abgehakt, UNGETESTET (Pi) bis Praxistest am PC.
2026-07-03 09:56:02 +02:00
Bernd Steckmeister fb7669a65b docs: Uta-Random-Logout-Fund in PROGRESS/ROADMAP protokollieren
Dokumentiert Root-Cause-Analyse und Fix aus 63e4919/9dc83f7, markiert
UNGETESTET (Pi) mit Pflicht-Praxistest am PC vor endgültigem Haken.
2026-07-03 09:52:22 +02:00
Bernd Steckmeister 9dc83f7bcb fix: Refresh-Token beim Login anfordern (Uta-Random-Logout, M1)
Ursache gefunden: Ohne Refresh-Token eskaliert das matrix-SDK jeden
Soft-Logout (z. B. abgelaufenes Access-Token, M_UNKNOWN_TOKEN mit
soft_logout=true) automatisch zu einem echten, destruktiven Logout —
_handleSoftLogout() versucht refreshAccessToken(), das ohne
gespeicherten Refresh-Token sofort mit "No refresh token available"
scheitert, und ruft danach client.logout() auf (client.dart:2374-2384
im matrix-Paket, Version 6.2.0).

Mit refreshToken: true beim Login bekommt der Client vom Server einen
Refresh-Token und kann einen Soft-Logout künftig durch stillen
Token-Refresh überstehen, statt die Sitzung zu zerstören. Falls der
Server keine Refresh-Tokens unterstützt, bleibt das Verhalten
unverändert (response.refreshToken ist dann null).

Wichtig: gilt nur für neue Logins. Bereits eingeloggte Geräte (z. B.
Uta) profitieren erst nach einmaligem Neu-Login. Zur Diagnose siehe
den mit 63e4919 eingeführten AuthLog.

UNGETESTET (Pi, kein GUI/Login hier möglich) – nächster PC-Lauf muss
den vollen Kreislauf prüfen: Login → Nachrichten → Logout → erneuter
Login → alte verschlüsselte Nachrichten noch lesbar?
2026-07-03 09:51:40 +02:00
Bernd Steckmeister 63e491952d feat: persistentes Auth-Log für Logout-Diagnose (M1)
Neuer AuthLog schreibt Login-Status-Wechsel und SDK-Warnungen/-Fehler
in eine Datei im App-Support-Verzeichnis, die einen Neustart übersteht.
Zusätzlich werden alle drei bewusst ausgelösten Logout-Buttons geloggt,
damit sich künftig unterscheiden lässt: Nutzer-Logout vs. vom SDK
automatisch ausgelöster Logout (Soft-Logout/M_UNKNOWN_TOKEN).

Reiner Logging-Zusatz, keine Verhaltensänderung – erster Schritt laut
ROADMAP M1 ("Erst Logging an allen Logout-Pfaden einbauen").
2026-07-03 09:50:55 +02:00
Bernd Steckmeister 13512064a3 docs: M0 in PROGRESS/ROADMAP abschließen
Alle M0-Punkte abgehakt (Commits, flutter analyze, call/calls-Leiche,
README). Nächster Schritt laut ROADMAP: M1 Uta-Logout-Bug.
2026-07-03 09:46:27 +02:00
Bernd Steckmeister 74d38fd267 docs: README durch echte Projektbeschreibung ersetzen
Ersetzt das Flutter-Boilerplate-README durch Funktionsübersicht,
Build-Anleitung und Projektstruktur.
2026-07-03 09:45:49 +02:00
Bernd Steckmeister 401bd6a7f8 style: weitere flutter-analyze Hinweise beheben
- document_viewer: Matrix4.translate/scale durch translateByDouble/
  scaleByDouble ersetzt (PDF-Zoom-Transformation, gleiches Verhalten)
- rooms_panel: onReorder durch onReorderItem ersetzt (Index-Korrektur
  jetzt intern erledigt), implementation_imports-Hinweis kommentiert
  (SpaceChild wird vom matrix-Paket nicht öffentlich exportiert)
- voip_manager: getSources()-Override kommentiert (erzwungen durch
  abstrakte MediaDevices-Klasse, die die Methode selbst deprecated hat)
- settings_modal: BuildContext-Nutzung nach await mit context.mounted
  abgesichert (Geräte umbenennen, Passwort-Abfrage bei Massenlogout)

Verbleibend: chat_provider.dart onUpdate (Timeline-Redecrypt-Pfad,
bewusst nicht angefasst ohne dedizierten E2EE-Test, siehe PROGRESS.md).
2026-07-03 09:45:05 +02:00
Bernd Steckmeister 057ac1e4ea style: verbleibende flutter-analyze Deprecations beheben
withOpacity/activeColor/cacheExtent/PublicRoomsChunk durch die neuen
Flutter-3.44-Äquivalente ersetzt.
2026-07-03 09:40:10 +02:00
Bernd Steckmeister 90593d2f81 docs: Regression in ROADMAP: Push-Benachrichtigungen werden nicht mehr entschlüsselt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 06:33:37 +02:00
Bernd Steckmeister d2fcc59f7a feat: Matrix-Statusberichte nach jeder Autopilot-Session
Nach jeder Session mit neuen Commits postet der Autopilot Commits + nächsten
Schritt in den Raum '🔺 Pyramid Autopilot' (Token vom Gatus-Bot, nur auf dem Pi).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 06:32:54 +02:00
Bernd Steckmeister 2395db50be style: weitere flutter-analyze Infos beheben
Ungenutzte Imports (dart:typed_data, flutter/services.dart), unnötige
String-Interpolations-Klammern, führender Unterstrich bei lokaler Funktion,
fehlende Blockklammern bei if-Statements und SCREAMING_CASE-Konstanten in
clipboard_image.dart auf lowerCamelCase umbenannt. Rein mechanisch, keine
Verhaltensänderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 06:32:50 +02:00
Bernd Steckmeister 99f10c5210 style: doppelte Unterstriche in Closure-Parametern bereinigen
Dart 3 erlaubt mehrere Wildcard-Parameter mit demselben Namen "_" in
einem Scope, daher sind __ / ___ für ungenutzte Callback-Parameter
(pageBuilder, errorBuilder etc.) unnötig. Rein mechanische Änderung,
keine Verhaltensänderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 06:30:40 +02:00
Bernd Steckmeister 133a8acbc8 refactor: print() durch debugPrint() ersetzen (avoid_print Lint)
65 flutter-analyze-Meldungen behoben, indem alle print()-Aufrufe in Core-
Dateien (Push, LiveKit, Matrix-Client, VoIP, Screen-Share) durch das
Flutter-eigene debugPrint() ersetzt wurden. Gleiche Funktion (Logging in
Debug-Sessions), aber zeilenlängen-sicher und ohne Lint-Warnung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 06:29:49 +02:00
Bernd Steckmeister 9dc11757e2 fix: flutter analyze Warnings beheben (totes/unerreichbares Code)
Entfernt tatsächlich toten Code, den flutter analyze als Warning markiert
hatte: unbenutzte Felder/Parameter (_saved, _hovered wird jetzt für Hover-
Feedback genutzt, destructive-Flag ohne Aufrufer, size-Parameter ohne
Override), unerreichbaren dead_code (Room.topic/Space.topic sind laut SDK
nie null, client.database ist nie null) sowie Legacy-Funktionen aus der
alten Push-Architektur (_showNotif/_fetchTitle, ersetzt durch natives
PushService.kt) und einen leeren Noise-Suppression-Stub ohne Aufrufer.
Keine funktionale Verhaltensänderung an aktiven Code-Pfaden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 06:26:31 +02:00
Bernd Steckmeister 594e6718be chore: autopilot.sh ausführbar machen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 06:16:26 +02:00
Bernd Steckmeister f1fde9b45a feat: Pause-Funktion im Autopilot (PAUSE-Datei)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 06:15:29 +02:00
Bernd Steckmeister 336c82fb76 chore: eingebettetes worktree-Repo aus dem Index entfernen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:44 +02:00
Bernd Steckmeister 25ed765a03 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
2026-07-03 05:47:18 +02:00
245 changed files with 51147 additions and 3300 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"model": "claude-fable-5",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python -c \"import json,sys,datetime; d=json.load(sys.stdin); f=d.get('tool_input',{}).get('file_path','unknown'); t=d.get('tool_name','Edit'); ts=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'); open('CHANGES.md','a').write(f'{ts} [{t}] {f}\\n')\" 2>/dev/null || true",
"statusMessage": "Dokumentation wird aktualisiert..."
}
]
}
]
}
}
+3
View File
@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
autopilot.log
.claude/worktrees/
*.bak
+53
View File
@@ -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 (0150 %) 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.
+1201
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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.
+901
View File
@@ -0,0 +1,901 @@
# 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 M1-Vorbereitung: SQLCipher-Migrationsplan + konsolidierter PC-Testplan
**Erledigt:** Nach Abschluss des Fable-5-Reviews (Abschnitt darunter) die zwei
obersten offenen ROADMAP-Punkte (SQLCipher, Härtetest) so weit gebracht, wie es
auf dem Pi ohne GUI/Testgerät sicher geht beide bleiben offen, aber der
nächste PC-Termin kann jetzt direkt loslegen statt zu planen:
- `docs/SQLCIPHER_MIGRATION.md`: vollständiger Umsetzungsplan, gegen den echten
Code geschrieben (beide DB-Öffnungsstellen, Factory-Wahl pro Plattform).
Kernpunkte: gemeinsame DB-Fassade `app_database.dart` (zahlt aufs
M2-Storage-Modul ein), Android via `databaseFactoryFfi` +
`openCipherOnAndroid`, im Push-Isolate zwingend `databaseFactoryFfiNoIsolate`
(dieselbe Isolate-Falle, an der schon `NativeImplementationsIsolate`
scheiterte), Schlüssel im Android Keystore (`flutter_secure_storage`, schon
Abhängigkeit), Migration per `sqlcipher_export` in NEUE Datei mit
Backup/Verifikation/atomarem Tausch kein Fehlerpfad darf die Klartext-DB
beschädigen. Marker-Datei gegen das Migration/Push-Race. Phase 1 nur
Android (sqlcipher_flutter_libs liefert keine Windows-Binaries).
Tabellenname für die Verifikation gegen matrix-6.2.0 geprüft
(`box_inbound_group_session`).
- `docs/PC_TESTPLAN.md`: ALLE aufgelaufenen UNGETESTET(Pi)-Punkte aus den
bisherigen PROGRESS-Einträgen an einer Stelle konsolidiert (Härtetest/
Krypto-Kreislauf zuerst, dann die 6 Split-Bereiche, Reorder-Off-by-One-Check,
PDF-Zoom, Push-Beobachtung, „Uta einmal neu einloggen"). Bisher waren die
über 7 Einträge verstreut Gefahr, dass der PC-Lauf etwas übersieht.
**Offen/Nächster Schritt:** Unverändert: PC-Termin (jetzt mit fertigem
Testplan + Migrationsplan) oder Bernds Antworten (Call-Pilot, SQLCipher-
Priorität). Auf dem Pi ist damit alles abgearbeitet, was ohne GUI/Entscheidung
sicher geht inklusive des kompletten Qualitäts-Reviews.
**Stolperfallen:** Keine neuen reine Dokumentation, kein Code angefasst.
---
## Fable-5-Review (Qualitäts-Review aller Autopilot-Commits 25ed765..HEAD)
Bernds Auftrag (ROADMAP M0): alle bisherigen Sonnet-5-Commits kritisch und in
der Tiefe nachprüfen. Jeder Punkt hier wird abgehakt, sobald er geprüft ist
**bereits abgehakte Punkte in künftigen Sessions NICHT erneut prüfen.**
Hinweis zur Historie: Eine frühere Fable-5-Session hatte den Review begonnen
und zwei Fix-Commits gepusht (f9979e4, 891f348), wurde aber VOR dem
Protokollieren abgebrochen dieser Abschnitt holt das Protokoll nach und
schließt den Review ab.
- [x] **(a) Die 6 Gott-Datei-Aufteilungen fachlich geprüft Ergebnis: korrekt,
1 Befund bereits gefixt (f9979e4).** Unabhängige Nachprüfung (nicht nur den
alten Blockvergleich wiederholt): eigener Zeilen-Multiset-Vergleich
(Code-Zeilen ohne Kommentare/Imports/part-Direktiven) Original vor dem Split
vs. heutiger Stand für alle 6 Bibliotheken → **keine Code-Zeile verloren,
keine hinzugekommen** (settings_modal 5055, message_group 2409, rooms_panel
2308, space_admin_dialog 2079, chat_view 1813, document_viewer 1543 Zeilen,
jeweils exakt identisch). Alle `part`-Direktiven == Dateien auf Platte,
alle Part-Dateien mit korrektem `part of`. Befund der Vorsession bestätigt:
Beim Split gingen 24 **Erklärkommentar-Blöcke** verloren (u. a. die
Selbst-Aussperr-Warnung über `updatePowerLevelsSafely`, HTML-Subset-Doku,
PDF-Cache-Doku) mit f9979e4 an den richtigen Klassen wiederhergestellt
(Stichprobe geprüft: sitzt korrekt). `flutter analyze`: unverändert 1
bekannter Hinweis (`chat_provider.dart:42`).
- [x] **(b) Uta-Logout-/Push-Fix (63e4919, 9dc83f7) gegen das matrix-SDK 6.2.0
verifiziert Analyse war richtig, aber unvollständig; Lücke mit 891f348
geschlossen (Vorsession), heute vollständig gegenverifiziert:**
- SDK-Quelle bestätigt (client.dart:2374 ff.): Ohne eigenen `onSoftLogout`-
Handler eskaliert JEDER fehlgeschlagene Token-Refresh (auch bloße
Netzwerkfehler) zu `logout()` + `clear()` → Session inkl. Krypto-Schlüssel
weg. Mit `refreshToken: true` (9dc83f7) wären Refreshes Routine geworden
das Restrisiko wäre also GESTIEGEN. Der Guard (`soft_logout_guard.dart`,
891f348) ist die notwendige zweite Hälfte des Fixes.
- Guard-Code geprüft: wirft nie (alle Pfade gefangen; `AuthLog.write` fängt
intern selbst alles wichtig, sonst würde ein Logging-Fehler im
catch-Pfad doch wieder `logout()` auslösen). SDK ruft bei nicht-werfendem
Handler nachweislich nie `logout()` auf.
- „Retry heilt das Token-Rotations-Race mit dem Push-Isolate"-Behauptung
gegen SDK-Quelle geprüft: stimmt `refreshAccessToken()` liest den
Refresh-Token bei jedem Aufruf frisch per `database.getClient()` aus der
geteilten DB.
- Beide `Client(`-Konstruktionen der App (matrix_client.dart,
background_push.dart) haben den Guard keine ungeschützte übrig.
- `isLoggedInProvider`-Änderung (softLoggedOut ≠ ausgeloggt) korrekt:
verhindert Login-Screen-Flackern bei Routine-Refresh; echtes `loggedOut`
führt weiterhin zum Login-Screen.
- **Dokumentiertes Restrisiko (kein Fix möglich/nötig):** Ein hartes
`M_UNKNOWN_TOKEN` OHNE `soft_logout`-Flag führt im SDK weiterhin direkt zu
`clear()`. Das ist ein echter serverseitiger Session-Widerruf (z. B.
Admin-Logout) lokal nicht abwendbar; Schlüssel kommen dann übers
Key-Backup zurück. Kein stiller Fehlerpfad, sondern legitimer Server-Befehl.
- [x] **(c) analyze-/Lint-Fixes (9dc1175, 133a8ac, 99f10c5, 2395db5, 057ac1e,
401bd6a) auf Verhaltensänderungen geprüft alle äquivalent, 1 Kleinigkeit
gefunden (bewusst belassen):**
- `onReorder``onReorderItem` (401bd6a): gegen Flutter-SDK-Quelle
verifiziert der neue Callback korrigiert den Index intern, das entfernte
manuelle `newIdx--` ist korrekt; einzige Aufrufstelle migriert. Kein
Off-by-One beim Raum-Umsortieren.
- `Matrix4.translate/scale``translateByDouble/scaleByDouble` (PDF-Zoom):
mathematisch äquivalent (uniforme Skalierung, w=1).
- `withOpacity``withValues`, `activeColor``activeThumbColor`,
`cacheExtent``scrollCacheExtent`, `PublicRoomsChunk``PublishedRoomsChunk`,
print→debugPrint, `__``_`-Parameter, Curly-Braces: reine Renames/Syntax.
- Toter Code (9dc1175): entfernte Funktionen (`_showNotif`, `_fetchTitle`,
`_get`, `_applyNoiseSuppression`, `destructive`-Param, `_saved`-Feld) per
grep bestätigt nirgends referenziert.
- **Kleinigkeit:** In `mini_call_widget.dart` wurde das ungenutzte
`_hovered`-Feld nicht gelöscht, sondern verdrahtet (Hover-Farbe auf
Call-Buttons) streng genommen eine Mini-Verhaltensänderung in einem
Lint-Commit. Offensichtlich die ursprünglich gemeinte Funktion des Feldes,
rein kosmetisch, kein Risiko → belassen, hier nur dokumentiert.
- [x] **(d) Abgehakte ROADMAP-Punkte gegen Code gegengeprüft alle stimmen:**
`isKeyBackupMissing`-Warnung sitzt nach dem Settings-Split weiterhin in
beiden Logout-Pfaden (settings_about.dart, settings_shared.dart);
`refreshToken: true` in login_notifier.dart:84; Bootstrap-Flows
(`askUnlockSsss`, `_triggerBootstrap`) vorhanden; `features/calls/`-Leiche
existiert nicht; `docs/M2_MODULE_SCHNITT.md` vorhanden; `flutter analyze`
sauber (1 bekannter Hinweis). Nebenbefund gefixt: `docs/PROGRESS.md` war ein
eingefrorener Alt-Stand (2026-06-04) mit Verwechslungsgefahr zum echten
Protokoll im Root → Archiv-Banner eingefügt.
**Fazit:** Die Sonnet-5-Arbeit war handwerklich sauber; die zwei echten Lücken
(verlorene Kommentare, fehlender Soft-Logout-Guard) hatte die abgebrochene
Fable-5-Vorsession bereits gefixt beide Fixes heute unabhängig verifiziert.
Alles weiterhin UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen";
der ausstehende PC-Praxistest (siehe Einträge unten) bleibt Pflicht.
---
## 2026-07-03 M2: Gott-Datei `document_viewer.dart` aufgeteilt (5 Dateien)
**Erledigt:** Vor dem Weiterarbeiten wie immer Arbeitszyklus durchlaufen
(`PROGRESS.md`/`ROADMAP.md` gelesen, `flutter doctor` geprüft GUI-Lücken
auf dem Pi unverändert). Die Ist-Analyse aus M2 hatte ursprünglich 8 große
Dateien gefunden, aber nur 5 wurden als „Gott-Dateien" tatsächlich
aufgeteilt (`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
`space_admin_dialog.dart`, `chat_view.dart`); `document_viewer.dart` (1773
Zeilen, 31 Klassen/Enums + 3 lose Top-Level-Helfer) war übrig, ist aber
inhaltlich genau derselbe Fall (viele unabhängige Widget-Klassen in einer
Datei) und anders als `voice_channel.dart`/`app_shell.dart` reines
Medien-/PDF-Anzeige-Feature ohne Berührung mit Login/Session/Verschlüsselung
oder der noch unentschiedenen Call-Fassade. Deshalb mit derselben
bewährten Technik aufgeteilt:
- `part`/`part of` wie bei den 5 vorigen Umbauten, per Skript extrahiert
(Klammer-Zählung für Blockgrenzen inkl. der 3 losen Top-Level-Funktionen
`_saveBytes`, `_extractPsdThumbnail`, `_detectType` nicht nur
`class`/`enum`, das wurde diesmal zusätzlich automatisiert gesucht, um
keine lose Funktion zu übersehen).
- 5 neue Dateien unter `lib/features/chat/document/`:
`document_viewer_core.dart` (`DocumentViewer`-Dispatcher, `_DocHeader`,
`_ExtBadge`, `_DocContent`, `_DocType`-Enum + `_detectType`, `_ErrorView`,
`_UnsupportedViewer`, `_FileFallback`), `document_viewer_pdf.dart`
(größter Teil: `_RenderCache`, `PdfInlinePreview`+State für die
Chat-Inline-Vorschau, `_NavArrow`, `_saveBytes`, `_PdfFullViewer`+State,
`_PdfFullscreenViewer`+State, `_FsBtn`, `_ThumbnailStrip`+State),
`document_viewer_text.dart` (SVG/Markdown/Text/Office-Text-Extraktion),
`document_viewer_archive.dart` (ZIP/TAR-Archiv-Browser, PSD-Thumbnail-
Extraktion inkl. `_extractPsdThumbnail`, Sketch/XD-ZIP-Preview),
`document_viewer_misc.dart` (`_ProprietaryViewer`-Infokarte für nicht
darstellbare Formate). `document_viewer.dart` selbst: nur noch
Bibliothekskopf (Imports + 5 `part`-Direktiven, 20 Zeilen).
- Verifikation wie bei den 5 vorigen Umbauten: automatisierte
Klammer-Zählung fand alle 34 Top-Level-Blöcke (31 Klassen/Enums + 3 lose
Funktionen), alle wortwörtlich in den neuen Dateien wiedergefunden,
`class`/`enum`-Anzahl (31) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis (`chat_provider.dart:42`). Einzige
externe Importstelle (`message_group.dart`) bleibt unverändert gültig, da
`document_viewer.dart` weiterhin der Bibliothekseinstieg ist.
- **Bewusst NICHT angefasst:** `voice_channel.dart` (1123 Zeilen) und
`app_shell.dart` (1073 Zeilen) auch große Dateien mit vielen Klassen,
aber `voice_channel.dart` gehört zur noch unentschiedenen Call-Fassade
(siehe Fragen an Bernd unten) und `app_shell.dart` enthält den
Bootstrap-Trigger (`_triggerBootstrap`) für Cross-Signing/Key-Backup
laut CLAUDE.md heiliger Bereich. Eine reine Datei-Aufteilung wäre zwar
vermutlich genauso risikoarm wie bei den anderen 6, aber ohne GUI-Test auf
dem Pi wollte ich das Risiko nicht zusätzlich zum ohnehin schon
aufgelaufenen UNGETESTET-Stapel erhöhen, bevor nicht wenigstens einmal ein
PC-Durchlauf stattgefunden hat.
**Offen/Nächster Schritt:** UNGETESTET (Pi) siehe ROADMAP-Eintrag oben,
gehört zum selben ausstehenden Windows-Praxistest wie die 5 vorigen
Datei-Aufteilungen (PDF/Text/Archiv/Bild im Chat öffnen, Vollbild-PDF-Viewer
inkl. Thumbnail-Leiste und Speichern-Button prüfen). Danach bleibt exakt
dieselbe Blockade wie im Eintrag direkt darunter: kein M1/M2-Punkt mehr
ohne Bernds Antwort (Call-Pilot, SQLCipher-Priorität) oder PC/GUI-Zugriff
sicher bearbeitbar.
**Stolperfallen:** Anders als bei den 5 vorigen Umbauten gab es diesmal 3
lose Top-Level-Funktionen zusätzlich zu den Klassen/Enums
(`_saveBytes`/`_extractPsdThumbnail`/`_detectType`) eine reine
`grep -E '^(class|enum) '`-Suche hätte sie übersehen und stillschweigend aus
der neuen Datei-Struktur fallen lassen. Deshalb diesmal zusätzlich per
Diff aller durch Klammer-Zählung abgedeckten vs. nicht abgedeckten
Zeilenbereiche geprüft, dass keine „Lücke" zwischen zwei Klassenblöcken
mehr als Kommentar/Leerzeile ist. Für künftige Datei-Aufteilungen (z. B.
`voice_channel.dart`/`app_shell.dart`, falls Bernd grünes Licht gibt): immer
zuerst auf lose Top-Level-Funktionen prüfen, nicht nur auf Klassen/Enums.
---
## 2026-07-03 Session-Check: weiterhin kein sicher bearbeitbarer Punkt auf dem Pi
**Erledigt:** Vor dem Weiterarbeiten Arbeitszyklus komplett durchlaufen: `PROGRESS.md`
+ `ROADMAP.md` gelesen, `flutter analyze` (weiterhin sauber, nur der eine bekannte
Hinweis in `chat_provider.dart:42`), `git status` (sauber, `master` = `origin/master`),
`flutter doctor` erneut geprüft (Android-SDK, Chrome, Linux-Desktop-Toolchain fehlen
weiterhin auf diesem Pi keine GUI möglich). Damit alle offenen Punkte in M1/M2
gegengeprüft:
- M1 „SQLCipher" und „Härtetest dokumentieren": unverändert PC-only (siehe Einträge
weiter unten), keine neue Information seit letzter Session.
- M2 „Call-Schicht entwirren": zum Einstieg `core/voip_manager.dart` (538 Zeilen)
gelesen, um eine Fassade zu entwerfen. Ergebnis: Das ist **kein** Datei-Split wie die
letzten 5 Gott-Datei-Umbauten (dort: reines `part`/`part of`, null Logikänderung,
automatisiert byte-genau verifizierbar). `PyramidVoipManager` ist ein Singleton
(`_instance`), der gleichzeitig `ChangeNotifier` UND die vom matrix-SDK verlangte
`WebRTCDelegate`-Schnittstelle implementiert, mit Timer-basiertem Renderer-Polling,
impliziten Reihenfolgen bei Mute/Unmute/Screenshare und einem `MediaDevicesWrapper`,
der direkt auf private Felder des Managers zugreift. Eine Fassade davor UND die
Umstellung der 7 Importstellen lässt sich nicht mehr rein mechanisch verifizieren
echtes Risiko für Verhaltensänderung an Calls, die echte Nutzer (Uta) im Einsatz
haben. Genau das hatte die vorige Session bereits zweimal als „erste Stelle mit
echtem Verhaltensrisiko in M2" geflaggt und Bernds Entscheidung dazu angefragt
(siehe „Fragen an Bernd" weiter unten) hier bestätigt und bewusst NICHT
eigenmächtig angefasst, weil auf diesem Pi ohnehin kein GUI-Test möglich ist, um
eine Regression aufzufangen.
**Offen/Nächster Schritt:** Es gibt aktuell **keinen** ROADMAP-Punkt in M1/M2, der auf
diesem Pi ohne (a) Bernds Antwort auf die Call-Pilot-Frage oder (b) echten PC/GUI-Zugriff
sicher bearbeitet werden kann. Nächster sinnvoller Schritt ist entweder ein PC-Termin
(Härtetest + SQLCipher-Migrationsplanung + Call-Fassade mit echtem Test) oder Bernds
kurze Antwort auf die unten stehende Frage, damit die nächste Pi-Session direkt starten
kann.
**Stolperfallen:** Keine neuen Bestätigung, dass die Vorsicht der letzten Session
richtig war: „sieht aus wie ein weiterer Datei-Split" ist bei der Call-Schicht trügerisch,
weil Singleton-Lifecycle + SDK-Delegate-Vertrag + Timer-Polling nicht mit einem
Textvergleich absicherbar sind wie bei reinen `part`/`part of`-Umbauten.
**Fragen an Bernd:** Unverändert offen (wiederholt aus den letzten beiden Einträgen,
damit sie nicht übersehen wird):
1. Call-Pilot (Fassade für `voip_manager.dart`/`livekit_call_manager.dart`) jetzt
angehen (nur auf einem PC mit echtem Test), oder erstmal zurückstellen?
2. SQLCipher-Verschlüsselung der lokalen DB: Priorität vor M2, oder reicht die
App-Sandbox als Schutz vorerst?
Ohne eine dieser beiden Antworten bleibt der Pi bei jedem weiteren Durchlauf an
derselben Stelle stehen ein kurzer PC-Termin für Härtetest + Call-Pilot würde
gleich mehrere blockierte Punkte auf einmal lösen.
---
## 2026-07-03 M2: Gott-Datei `chat_view.dart` aufgeteilt (6 Dateien) ROADMAP-Punkt abgehakt
**Erledigt:** Fünfter und letzter Durchgang derselben `part`/`part of`-Technik:
`features/chat/chat_view.dart` (2116 Zeilen, 22 Klassen, keine losen
Top-Level-Helfer) aufgeteilt in 6 Dateien unter `features/chat/view/`:
`chat_view_core.dart` (`ChatView`, `_ChatViewState` der komplette
Nachrichten-Senden/Scroll/Attachment-Zustandsautomat, mit 521 Zeilen
weiterhin der größte Einzelblock, aber nicht weiter unterteilt, da es EINE
zusammenhängende State-Klasse ist, keine künstliche Aufspaltung mitten in
einer Klasse), `chat_header.dart` (`_ChatHeader`, DM-Header inkl.
`_DmExpandingHeader`+State, `_DmHeaderAvatar`, `_InitialCircle`,
`_PresenceLine`+State), `chat_connection.dart` (`_ConnectionBanner`+State,
Offline/Reconnect-Banner), `chat_message_list.dart` (`_MessageList`,
`_SelectionHeader`, `_SelBtn`, `_UnreadDivider`), `chat_typing.dart`
(`_TypingIndicator`+State, `_TypingDots`+State), `chat_misc.dart`
(`_DragOverlay`+State für Datei-Drag&Drop, `NoChatSelected` bleibt
öffentlich, `app_shell.dart` importiert `chat_view.dart` und nutzt es).
`chat_view.dart` selbst: nur noch Bibliothekskopf (33 Zeilen).
Verifikation identisch zu den vier vorigen Umbauten: automatisierte
Klammer-Zählung, alle 22 Blöcke wortwörtlich wiedergefunden,
`class`/`enum`-Anzahl (22) und `Widget build(`-Methoden (15) vorher/nachher
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis.
**Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt**
(`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
`space_admin_dialog.dart`, `chat_view.dart` siehe die vier Einträge
darunter). ROADMAP-Punkt „Toten Code & Duplikate entfernen, Ordnerstruktur
vereinheitlichen" jetzt abgehakt.
**Offen/Nächster Schritt:** **Wichtig, bevor der nächste ROADMAP-Punkt
angefasst wird:** all diese Aufteilungen sind bisher NUR per `flutter
analyze` + automatisiertem Textvergleich geprüft, NICHT in echter UI
gesehen (kein GUI-Toolchain auf dem Pi verfügbar kein Android-SDK, kein
Linux-Desktop-Build, siehe `flutter doctor`). Der nächste PC-/Windows-Lauf
MUSS vor dem nächsten inhaltlichen Schritt einmal durch alle 5 betroffenen
Bereiche klicken (Einstellungen komplett, ein Chat mit allen
Nachrichtentypen, die Raumliste, eine Space-Verwaltung, ein normaler
Chat-Screen mit Header/Tippanzeige/Drag&Drop) das Risiko einer reinen
`part`/`part-of`-Verschiebung ist strukturell gering (Dart-Compiler hätte
jeden fehlenden Import/jede falsche Sichtbarkeit sofort als Analyzer-Fehler
gemeldet, nicht erst zur Laufzeit), aber „gering" ist nicht „null", und
CLAUDE.md verlangt nach jedem Refactoring-Schritt einen echten App-Start.
Danach nächster ROADMAP-Punkt in M2: „Call-Schicht entwirren" dafür
weiterhin Bernds Entscheidung zum Call-Pilot aus „M2: Modul-Schnitt
definiert" ausstehend, UND dieser Punkt braucht ohnehin einen PC/Windows-Test
(Verhaltensänderung an State-Management, nicht nur Datei-Verschiebung).
**Stolperfallen:** Keine neuen. Generelle Lehre aus allen 5 Durchgängen:
Skript-gestützte Klammer-Zählung + automatisierter Verbatim-Abgleich
(„jeder Originalblock muss wortwörtlich in den neuen Dateien auftauchen")
war der entscheidende Sicherheitsnetz-Schritt, um trotz fehlendem GUI-Test
auf dem Pi mit vertretbarem Risiko fortzufahren sollte bei künftigen
reinen Datei-Umbauten (z. B. `voice_channel.dart` beim Call-Pilot) als
Vorgehen wiederverwendet werden.
---
## 2026-07-03 M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien)
**Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik:
`features/spaces/space_admin_dialog.dart` (2278 Zeilen, 32 Klassen/Enums +
6 lose Top-Level-Helfer) aufgeteilt in 6 Dateien unter
`features/spaces/admin/`: `space_admin_core.dart` (`SpaceAdminDialog`,
`_SpaceAdminDialogState`, `_DialogHeader` + die breit genutzten Helfer
`_kSpaceBannerEvent`, `updatePowerLevelsSafely` (öffentliche Funktion
schützt vor Selbst-Aussperrung beim Ändern der Power-Levels, genutzt von
Members- UND Permissions-Tab), `_kPowerAdmin`/`_kPowerMod`/`_kPowerMember`,
`_powerLabel`, `_powerColor`), `space_admin_overview.dart` (`_OverviewTab`+State,
`_SpaceVisibilityToggle`+State, `_DangerZone`), `space_admin_members.dart`
(`_MembersTab`+State, `_InviteButton`+State, `_MemberTile`+State,
`_MemberActions`, `_MemberAction`), `space_admin_permissions.dart`
(`_PermissionsTab`+State, `_PermissionRow`), `space_admin_channels.dart`
(`_ChannelsTab`+State, `_ChannelItem`+State, `_CreateChannelDialog`+State,
`_AddExistingDialog`+State + die dort lokal genutzten Helfer `_kSpaceChild`/
`_kSpaceParent`/`_kVoiceChannelType`), `space_admin_shared.dart`
(`_SectionLabel`, `_Field`, `_SearchField`, `_AvatarEditor`, `_InitialAvatar`).
`space_admin_dialog.dart` selbst: nur noch Bibliothekskopf (19 Zeilen).
Auffällig beim Durchlesen (nicht angefasst, nur notiert): `updatePowerLevelsSafely`
ist als einzige Funktion in dieser Datei öffentlich (kein `_`-Präfix), wird
aber nirgends außerhalb von `space_admin_dialog.dart` importiert/aufgerufen
vermutlich für einen mal geplanten, nie gebrauchten externen Aufrufer public
gemacht. Bewusst nicht auf privat zurückgestuft, um diesen Schritt nicht mit
einer inhaltlichen Änderung zu vermischen; wäre ein Kandidat für den nächsten
"toter Code"-Durchgang.
Verifikation wie bei den drei vorigen Umbauten: automatisierte
Klammer-Zählung für Blockgrenzen, alle 32 Blöcke + 4 Helfer-Gruppen
wortwörtlich wiedergefunden, `class`/`enum`-Anzahl (32) und
`Widget build(`-Methoden (20) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis.
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Space-
Verwaltung öffnen Übersicht (Sichtbarkeit umschalten, Banner ändern,
Danger-Zone-Buttons NUR ansehen, nicht bestätigen), Mitglieder (einladen,
Rolle ändern, kicken/bannen wenn möglich an einem Test-Space, nicht an
Utas echtem), Berechtigungen, Kanäle (erstellen, bestehenden Raum hinzufügen).
Danach `chat_view.dart` (2116 Zeilen) als letzte der 5 in der Ist-Analyse
gefundenen Gott-Dateien damit wäre der komplette „Ordnerstruktur
vereinheitlichen"-Katalog aus M2 abgearbeitet (Häkchen dann setzen).
**Stolperfallen:** Keine neuen.
---
## 2026-07-03 M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien)
**Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt:
`features/rooms/rooms_panel.dart` (2555 Zeilen, 37 Klassen/Enums + 4 lose
Top-Level-Helfer) aufgeteilt in 8 Dateien unter `features/rooms/panel/`:
`rooms_panel_core.dart` (`RoomsPanel`, `_RoomsPanelState` + die
Top-Level-Helfer `_kVoiceRoomType`/`_kSpaceBannerType`/`_isVoiceRoom`/
`_compareSpaceChildren`, die von mehreren anderen Teilen genutzt werden
deshalb bewusst NICHT in ein eigenes „shared"-File, sondern beim Hauptwidget
belassen, wo sie ursprünglich standen), `rooms_header.dart` (`_Header`,
`_MenuRow`, `_NotificationsButton`, `_FilterInput`, `_SpaceMenuItem`),
`rooms_space_invite.dart` (`_SpaceInviteView`+State), `rooms_space_list.dart`
(`_SpaceRoomsList`+State, `_JoinableRoom`+Item+State, `_CategoryHeader`+State),
`rooms_list.dart` (`_RoomsList`, `_SectionHeader`+State, `_RoomItem`+State,
`_DraggableRoomItem`+State), `rooms_voice.dart` (`_VoiceParticipantList`,
`_Initials`, `_VoiceBarBtn`+State), `rooms_invite_item.dart`
(`_InviteItem`+State), `rooms_shared.dart` (`_DmAvatar`+State,
`_InitialCircle`, `_RoomMenuBtn`+State, `_HoverAction`+State, `_UnreadBadge`).
`rooms_panel.dart` selbst: nur noch Bibliothekskopf (Imports + 8
`part`-Direktiven, 31 Zeilen).
Verifikation wie bei den beiden vorigen Umbauten: automatisierte
Klammer-Zählung für Blockgrenzen, alle 37 Blöcke + 4 Helfer wortwörtlich in
den neuen Dateien wiedergefunden, Anzahl `class`/`enum` (37) und
`Widget build(`-Methoden (22) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis. `dart format` wieder bewusst nicht
gelaufen.
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Raumliste
prüfen Space wechseln, Raum beitreten/verlassen, Voice-Channel-Teilnehmerliste
in der Raumliste, Einladung annehmen/ablehnen, Drag&Drop zum Umsortieren,
Ungelesen-Badge, DM-Avatare. Danach `space_admin_dialog.dart` (2278 Zeilen)
oder `chat_view.dart` (2116 Zeilen) nach demselben Muster damit wären alle
in der Ist-Analyse gefundenen Gott-Dateien abgearbeitet.
**Stolperfallen:** Keine neuen.
---
## 2026-07-03 M2: Gott-Datei `message_group.dart` aufgeteilt (7 Dateien)
**Erledigt:** Gleiche Technik wie beim `settings_modal.dart`-Umbau direkt
darunter angewandt: `features/chat/message_group.dart` (2775 Zeilen, 41
Klassen/Enums + 5 lose Top-Level-Hilfsfunktionen) per Dart `part`/`part of`
in 7 thematische Dateien unter `features/chat/message/` aufgeteilt:
`message_group_core.dart` (`MessageGroup`, `_MessageGroupState`,
`_SendFailedRow`, `_MessageBody`, `_ContinuationMessage`(+State), inkl. der in
der letzten Session vereinheitlichten `_formatMessageTime`), `message_text.dart`
(`_MessageContent`, `_MatrixHtmlText`, `_PlainLinkText`),
`message_link_preview.dart` (`_LinkPreview`(+State/Data), `_ReplyPreview`),
`message_media.dart` (alle Medientypen: Bild/Audio/Video/Doc/Datei/proprietäre
Inline-Karten + die zugehörigen Top-Level-Helfer `_reservedImageSize`/
`_classifyMediaError`/`_checkedDownload`/`_mediaFallback` mit 1000 Zeilen
weiterhin der größte Teil, aber thematisch klar abgegrenzt),
`message_reactions.dart`, `message_actions.dart` (Hover-Aktionsleiste),
`message_shared.dart` (`_Avatar`, `DateDivider` bleibt öffentlich, da
`chat_view.dart` es importiert; über `part`/`part of` weiterhin ohne
Umbenennung nutzbar). `message_group.dart` selbst ist jetzt nur noch der
Bibliothekskopf (Imports + 7 `part`-Direktiven, 30 Zeilen).
Verifikation identisch zum Settings-Umbau: automatisierte Klammer-Zählung
für die Blockgrenzen (kein manuelles Abtippen), alle 41 Original-Blöcke +
5 Helfer wortwörtlich in den neuen Dateien wiedergefunden, Anzahl
`class`/`enum` (45) und `Widget build(`-Methoden (27) vorher/nachher
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis. `dart format`
diesmal bewusst NICHT ausgeführt (siehe Stolperfalle im Eintrag darunter).
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: einen
Chat mit mehreren Nachrichtentypen öffnen (Text mit Link-Vorschau, Bild,
Video, Audio, Datei-Anhang, Reaktionen setzen, Hover-Aktionsleiste,
Datums-Trenner, gescheiterte Nachricht mit Retry) rein mechanische
Verschiebung, aber `message_media.dart` ist der bisher am wenigsten oft
geprüfte Bereich (Download/Entschlüsselungs-Fehlerpfade), daher dort genauer
hinschauen. Danach ggf. `rooms_panel.dart` (2555 Zeilen) oder
`space_admin_dialog.dart` (2278 Zeilen) nach demselben Muster aufteilen.
**Stolperfallen:** Keine neuen siehe `dart format`-Hinweis im
`settings_modal.dart`-Eintrag darunter, hier von vornherein vermieden.
---
## 2026-07-03 M2: Gott-Datei `settings_modal.dart` aufgeteilt (12 Dateien)
**Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut
Ist-Analyse die größte „Gott-Datei". „Call-Schicht entwirren" (nächster
ROADMAP-Punkt) bleibt weiter blockiert, da es dabei um Verhaltens-/State-
Management-Änderungen an einem CLAUDE.md-„heiligen" Bereich (Calls) geht, die
laut Arbeitsregeln nach jedem Schritt einen echten App-Test brauchen
auf dem Pi ohne GUI-Toolchain (kein Android-SDK, kein Linux-Desktop-Build,
siehe `flutter doctor`) unmöglich zu prüfen. Stattdessen den in
`docs/M2_MODULE_SCHNITT.md` als zweiten (risikoärmeren) Schritt vorgeschlagenen
Umbau vorgezogen: reine Datei-Aufteilung ohne Verhaltensänderung, komplett über
`flutter analyze` prüfbar.
- Technik: Dart `part`/`part of` statt eigenständiger Libraries dadurch
bleiben alle privaten Klassen (`_ProfileSection`, `_SettingsGroup`, …) ohne
Umbenennung nutzbar, und alle Imports bleiben zentral in `settings_modal.dart`
(Part-Dateien brauchen keine eigenen Imports). Kein Klassenname geändert,
kein Zeichen Logik angefasst.
- 12 neue Dateien unter `lib/widgets/settings/`: `settings_shared.dart`
(gemeinsame Low-Level-Widgets wie `_SettingsGroup`/`_Toggle`/`_Divider`),
`settings_profile.dart`, `settings_notifications.dart`,
`settings_appearance.dart`, `settings_voice.dart`, `settings_keybinds.dart`
(Tastaturkürzel-Anzeige, NICHT zu verwechseln mit Verschlüsselungs-Keys),
`settings_privacy.dart`, `settings_sessions.dart`, `settings_verification.dart`
(SAS/QR-Dialog), `settings_encryption.dart` (inkl. der Megolm-Export/Import-
Kryptofunktionen `_runPbkdf2`/`_aesCtr`/`_encryptMegolmKeys`/`_decryptMegolmKeys`,
die bisher lose im Datei-Kopf lagen), `settings_account.dart` (Passwort ändern/
Account löschen), `settings_about.dart`.
- `settings_modal.dart` selbst: von 5541 auf 270 Zeilen geschrumpft enthält
nur noch Imports, die 12 `part`-Direktiven, `SettingsModal` und
`_SettingsModalState` (Navigation/Layout).
- Verifikation (da kein GUI-Test möglich): Skript-gestützte Extraktion per
Klammer-Zählung (kein manuelles Copy-Paste, um Übertragungsfehler
auszuschließen); anschließend automatisiert geprüft, dass alle 61
ursprünglichen Klassen/Enums wortwörtlich (`in`-Substring-Check) in den neuen
Dateien wieder auftauchen; Anzahl `class`/`enum`-Deklarationen (60) und
`Widget build(`-Methoden (40) vorher/nachher identisch; alle Lücken zwischen
den Klassenblöcken im Original einzeln geprüft ausschließlich Leerzeilen
und dekorative Trennkommentare, kein Code verloren.
- `flutter analyze`: weiterhin nur der eine bekannte, zurückgestellte Hinweis
(`chat_provider.dart:42`).
- **Stolperfalle vermieden:** `dart format` versuchte 40 neue Lint-Hinweise
einzuführen (`curly_braces_in_flow_control_structures`) Ursache: die
Originaldatei war selbst nie komplett `dart format`-clean (bestätigt per
`dart format --set-exit-if-changed` auf dem Original-Git-Stand), und Reflow
einzelner langer Einzeiler-`if`s ohne Klammern erzeugte neue Zeilenumbrüche,
die der Linter anders bewertet als die Originalformatierung. Formatierung
komplett verworfen und die Dateien unformatiert aus dem Original übernommen,
um „keine Verhaltensänderung ohne Not" strikt einzuhalten und die
`flutter analyze`-Baseline (1 Hinweis) nicht zu verwässern.
**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI
gesehen". Nächster Windows-Lauf: Einstellungen öffnen, durch alle Reiter
klicken (Profil, Benachrichtigungen, Erscheinungsbild, Voice, Tastaturkürzel,
Privatsphäre, Sessions, Verschlüsselung inkl. SAS/QR-Verifizierung und
Recovery-Key-Anzeige/-Import, Passwort ändern, Account löschen-Dialog öffnen
(ohne zu bestätigen!), Über). Da rein mechanisch verschoben, ist das Risiko
gering, aber Verschlüsselung/Recovery-Key ist laut CLAUDE.md heilig bitte
gezielt den Recovery-Key-Anzeige- und Passphrase-Export/Import-Flow einmal
durchklicken. Danach ROADMAP-Häkchen für „Ordnerstruktur vereinheitlichen"
erst setzen, wenn auch die übrigen Gott-Dateien (`message_group.dart`,
`rooms_panel.dart`, `space_admin_dialog.dart`, `chat_view.dart`,
`document_viewer.dart`) angegangen sind aktuell nur `settings_modal.dart`
erledigt.
**Stolperfallen:** `dart format` NICHT blind nach einem Refactoring laufen
lassen, wenn die Baseline vorher nicht schon formatter-clean war kann neue,
unrelated Lint-Hinweise einführen und die vermeintlich reine Verschiebung mit
Formatierungsrauschen vermischen (siehe oben).
**Fragen an Bernd:** Keine neuen die offene Frage zum Call-Pilot aus dem
Eintrag „M2: Modul-Schnitt definiert" ist weiterhin unbeantwortet, blockiert
aber nicht (siehe dort).
---
## 2026-07-03 M2: Toten Code & Duplikate entfernt (Chat-Timeline)
**Erledigt:** Vorgriff auf den ROADMAP-Punkt „Toten Code & Duplikate entfernen"
(technisch der 5. Punkt in M2, aber risikofrei und ohne GUI testbar im
Gegensatz zum aktuell blockierten „Call-Schicht entwirren", siehe Frage an
Bernd im Eintrag darunter):
- `lib/features/chat/message_bubble.dart` (178 Zeilen) gelöscht vollständig
toter Code, wurde nirgends importiert/instanziiert (durch `message_group.dart`
abgelöst), per `grep` doppelt bestätigt.
- Die zwei identischen `_formatTime`-Methoden in `message_group.dart`
(`_MessageGroupState` Zeile 322, `_ContinuationMessageState` Zeile 475
Vorher-Zeilennummern) zu einer gemeinsamen Top-Level-Funktion
`_formatMessageTime()` zusammengeführt, beide Aufrufstellen umgestellt.
Reine Extraktion, keine Verhaltensänderung (identischer Code vorher/nachher).
`pinned_messages_panel.dart:_formatTime` bewusst NICHT angefasst hat
andere Logik (relatives Datum „vor Xd"), ist kein echtes Duplikat.
- `flutter analyze` weiterhin sauber: nur der eine bekannte, bewusst
zurückgestellte Hinweis (`chat_provider.dart:42`, `Timeline.onUpdate`
deprecated, hängt am E2EE-Redecrypt-Pfad, siehe M0-Eintrag).
**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI
gesehen" kein GUI auf diesem Pi verfügbar. Da es sich aber um reine
Code-Verschiebung ohne Logikänderung handelt (identischer Funktionskörper),
ist das Risiko gering; trotzdem beim nächsten Windows-Lauf kurz einen
Chat mit mehreren aufeinanderfolgenden Nachrichten (Zeit-Anzeige) und eine
Fortsetzungsnachricht (Hover zeigt Uhrzeit) ansehen.
**Stolperfallen:** Keine.
---
## 2026-07-03 M2: Modul-Schnitt definiert (docs/M2_MODULE_SCHNITT.md)
**Erledigt:** Ausgehend von der Ist-Analyse (Eintrag darunter) die konkreten
Modul-Grenzen + öffentliche Schnittstellen entworfen, dokumentiert in
`docs/M2_MODULE_SCHNITT.md`. Reine Planung, kein Code geändert. Wichtigste
Erkenntnis beim genaueren Lesen von `voip_manager.dart` und
`livekit_call_manager.dart`: „Calls" ist architektonisch **kein einzelnes
Feature**, sondern zwei unabhängige Mechanismen, die getrennt bleiben sollten:
- `PyramidVoipManager` = natives Matrix-1:1-VoIP (`m.call.*`, direkte
WebRTC-Peer-Connection, kein SFU)
- `LiveKitCallManager` = SFU-basierte Voice-Channels (LiveKit-Raum, Presence
via Matrix-State-Event, Screensharing, Mehrpersonen) das ist der
Discord-artige Kanal aus M3/M4
Modul-Liste im Dokument: `call_signaling`, `voice_channel`, `call_ui`,
`settings` (aufgesplittet in Sektionsdateien), `verification` (neu, aus
Settings herausgelöst SAS/QR/Recovery-Key), `storage` (Ausbau der
bestehenden `settings_prefs.dart`-Abstraktion zur Pflicht), `auth` (schon
relativ sauber, kein akuter Bedarf), `push` (schon getrennt, eigener
`sqflite`-Zugriff in `background_push.dart` ist bewusst so, kein
Refactoring-Kandidat), `chat_timeline`/`rooms` (niedrigste Priorität, da
strukturell am wenigsten verwoben Hauptproblem dort ist Dateigröße/Duplikate,
nicht Kopplung). Jeweils mit Dart-Interface-Skizze bzw. Datei-Zielliste.
Empfohlene Umsetzungsreihenfolge: 1) Call-Pilot (`call_signaling` +
`voice_channel` + `call_ui`, deckt gleich zwei ROADMAP-Punkte ab), 2)
`settings_modal.dart` aufsplitten (geringes Risiko, reine Datei-Teilung),
3) `verification` herauslösen, 4) `storage`-Fassade schrittweise erzwingen,
5) `chat_timeline`/`rooms` zuletzt.
**Offen/Nächster Schritt:** Wartet auf Bernds Entscheidung, ob der Call-Pilot
wie vorgeschlagen als Nächstes umgesetzt wird (siehe Frage unten). Erst danach
den ROADMAP-Punkt „Call-Schicht entwirren" wirklich anfassen das ist die
erste Stelle mit echtem Verhaltensrisiko in M2 (CLAUDE.md: nach Refactoring-
Schritten immer App starten + Kernflows prüfen, hier zusätzlich brisant, weil
GUI-Test auf dem Pi nicht möglich ist).
**Stolperfallen:** Der erste Ist-Analyse-Durchgang (Subagent) hatte einen
falschen Befund (`rooms_provider.dart`-Import in `app_state.dart` sei toter
Code) beim genaueren Lesen für den Modul-Schnitt widerlegt
(`voiceParticipantsProvider` nutzt `roomListProvider` daraus). Bereits im
Ist-Analyse-Eintrag korrigiert. Lehre: Subagent-Ist-Analysen sind ein guter
Startpunkt, aber vor jedem darauf aufbauenden Schritt einzelne Befunde
gegenlesen, nicht blind übernehmen.
**Fragen an Bernd:**
- Call-Pilot wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen (zuerst
`call_signaling`/`voice_channel`/`call_ui` entwirren) als nächster
Umsetzungsschritt in Ordnung, oder lieber zuerst `settings_modal.dart`
aufsplitten (geringeres Risiko, aber deckt keinen ROADMAP-Punkt aus M2
direkt ab)? Ohne GUI-Zugriff auf dem Pi kann der riskantere Call-Umbau
hier ohnehin nicht praktisch getestet werden das spricht eher für
„auf dem PC anfangen", falls Zeitdruck besteht.
---
## 2026-07-03 M2: Ist-Analyse der Architektur geschrieben
**Erledigt:** M1 hat aktuell keinen auf dem Pi bearbeitbaren Punkt mehr offen
(SQLCipher wartet auf Bernds Priorisierung, Härtetest braucht GUI/PC siehe
Fragen an Bernd unten und Eintrag 2026-07-03 „3 schon vorhanden, 1 echte
Lücke"). Deshalb mit M2 weitergemacht, erster Punkt: Ist-Analyse. Per
Code-Lektüre (Zeilenzahlen, Import-Graph, grep) folgende Schwachstellen
gefunden:
- **Gott-Dateien:** `widgets/settings_modal.dart` mit 5541 Zeilen / 50+ Klassen
bündelt mindestens 8 unabhängige Bereiche (Profil, Benachrichtigungen,
Voice/TURN, E2EE-Verifizierung inkl. SAS/QR, Sessions, Account, Erscheinungsbild,
Updates). Weitere große Dateien: `message_group.dart` (2780), `rooms_panel.dart`
(2555), `space_admin_dialog.dart` (2278), `chat_view.dart` (2116),
`document_viewer.dart` (1773), `voice_channel.dart` (1123), `app_shell.dart` (1073).
- **Call-Schicht unsauber verteilt statt hinter einer Schnittstelle:**
`core/voip_manager.dart` (Matrix-VoIP-Signalisierung), `core/livekit_call_manager.dart`
(LiveKit-Transport), `features/call/voice_channel.dart` + `mini_call_widget.dart`
(UI) aber `chat_view.dart`, `matrix_client.dart`, `settings_modal.dart` greifen
alle DIREKT auf die Manager zu statt über eine gemeinsame Call-Fassade. Vier
verschiedene Module importieren `voice_channel.dart` direkt.
Genau das Gegenteil des „austauschbare Bausteine"-Ziels aus CLAUDE.md.
- **State-Management gemischt:** 2 zentrale `ChangeNotifier`-Singletons
(`voip_manager.dart`, `livekit_call_manager.dart`) statt Riverpod-`Notifier`,
eingebunden über `app_state.dart` (`callStateProvider`, `voipStateProvider`).
Migration sinnvoll, aber riskant wegen breiter Call-Sites und enger
SDK-Callback-Kopplung (LiveKit-`Room`-Events, Matrix-`WebRTCDelegate`) sollte
zusammen mit der Call-Fassade angegangen werden, nicht isoliert.
- **`core/` ist nicht rein generisch:** importiert Feature-Code direkt
(`app.dart`/`router.dart``features/auth/*`, `matrix_client.dart` instanziiert
`PyramidVoipManager` direkt). `app_state.dart` selbst ist ein Sammelbecken für
Theme-, Call-, Voice-, Share-Target- und Spaces-Provider aus praktisch allen
Features zentraler Kopplungspunkt, der jedem Modulschnitt im Weg steht.
(Korrektur beim genaueren Hinsehen für den Modul-Schnitt-Punkt: der
`rooms_provider.dart`-Import dort ist entgegen der ersten Vermutung KEIN
toter Altlast-Import `voiceParticipantsProvider` nutzt `roomListProvider`
daraus. Zeigt: Subagent-Befunde vor dem Weiterbauen selbst nachprüfen.)
- **Keine gemeinsame Storage-Schnittstelle:** `SharedPreferences.getInstance()`
wird an 12 Stellen direkt aufgerufen, obwohl `core/settings_prefs.dart` bereits
eine `StateNotifier`-Abstraktion (`BoolPref`, `StringSetPref`) anbietet wird
meist umgangen. `sqflite` wird zusätzlich unabhängig in `background_push.dart`
nochmal geöffnet (eigene DB-Instanz fürs Background-Isolate). `media_cache.dart`
ist dagegen sauber gekapselt gutes Vorbild für die anderen Speicherzugriffe.
- **Toter Code bestätigt:** `features/chat/message_bubble.dart` (178 Zeilen,
Klasse `MessageBubble`) wird nirgends instanziiert von `message_group.dart`
abgelöst, kann beim Refactoring entfernt werden.
- **Duplikate:** `_formatTime`/`_formatDate` unabhängig implementiert in
`pinned_messages_panel.dart:206`, `message_group.dart:322` UND `:475` (zwei Mal
im selben File!), sowie im toten `message_bubble.dart:80/165`.
**Modul-Kandidaten für den nächsten Punkt („Modul-Schnitt definieren"):**
1. `call_signaling` (Matrix-VoIP) `core/voip_manager.dart`
2. `call_transport` (LiveKit) `core/livekit_call_manager.dart`
3. `call_ui` (nur UI, konsumiert 1+2 über Interface) `features/call/*`
4. `settings/*` aufsplitten: profile, encryption_verification, sessions,
notifications, appearance, account aus `widgets/settings_modal.dart`
5. `verification` als eigenständiges Feature (SAS/QR/Recovery-Key), nicht
Teil von Settings wiederverwendbar (z. B. künftig beim Login)
6. `storage` gemeinsame Fassade, `settings_prefs.dart` als Pflichtzugang
statt 12 Direktzugriffsstellen
7. `app_state.dart` auflösen in feature-lokale Provider-Dateien
8. `chat_timeline` `message_group.dart` + `pinned_messages_panel.dart`,
dabei toten Code (`message_bubble.dart`) entfernen, `_formatTime`/`_formatDate`
in `chat/format_utils.dart` zusammenführen
**Offen/Nächster Schritt:** M2, zweiter Punkt „Modul-Schnitt definieren":
aus der obigen Kandidatenliste konkrete Modul-Interfaces (Dart-Abstract-Classes
o. ä.) entwerfen, dann EIN Modul zuerst umbauen (Vorschlag: `call_*`, da am
klarsten abgegrenzt und am dringendsten laut CLAUDE.md-Leitprinzip) als Pilot,
bevor der Rest folgt. Kein Codeumbau in diesem Schritt, nur Schnittstellen-Design.
**Stolperfallen:** Keine reine Lesearbeit, kein Risiko für Bestandsdaten.
Analyse wurde von einem Explore-Subagent erstellt und stichprobenartig
plausibilisiert (Zeilenzahlen/Imports selbst nachgezählt), nicht jede
Einzelbehauptung im Detail nachverifiziert.
**Fragen an Bernd:**
- SQLCipher-Priorität weiterhin offen (siehe Eintrag „3 schon vorhanden, 1 echte
Lücke" weiter unten) blockiert nicht, aber ohne Antwort bleibt der M1-Punkt
bis zum nächsten PC-Praxistest liegen.
- Für M2: Soll ich die Call-Schicht als erstes Pilot-Modul umbauen (mein
Vorschlag oben), oder hast du eine andere Präferenz (z. B. erst die
`settings_modal.dart` aufsplitten, weil die am unübersichtlichsten ist)?
---
## 2026-07-03 M1: Restliche Punkte geprüft 3 schon vorhanden, 1 echte Lücke gefunden
**Erledigt:**
- **Logout-Warnung bei fehlendem Key-Backup** (Commit b5762ea): neue Funktion
`isKeyBackupMissing(client)` in `bootstrap_dialog.dart` (prüft Cross-Signing +
`keyManager.isCached()`). Beide Logout-Dialoge in `settings_modal.dart` zeigen
jetzt eine rote Klartext-Warnung statt des neutralen Textes, wenn kein Backup
eingerichtet ist.
- **Drei ROADMAP-Punkte waren bereits umgesetzt** (nur nicht abgehakt) durch
Code-Lektüre verifiziert, nicht neu gebaut:
- Key-Backup einrichten/wiederherstellen: `bootstrap_dialog.dart` deckt beides
über den `Bootstrap`-Zustandsautomaten des matrix-SDK ab (`askNewSsss` für
Neueinrichtung, `askUnlockSsss` fragt auf einem neuen Gerät nach dem
Recovery-Key). Wird automatisch getriggert, wenn Cross-Signing fehlt
(`app_shell.dart:_triggerBootstrap`).
- Geräte-/Sessionverwaltung: `_SessionsSection` in `settings_modal.dart`
Liste, Umbenennen, SAS/QR-Geräteverifizierung, Einzel- und
Massen-Abmeldung (mit Passwort-Reauth bei UIA-Anforderung).
- **Echte Sicherheitslücke gefunden:** `sqlcipher_flutter_libs` ist eine
Abhängigkeit in `pubspec.yaml`, wird aber **nirgends im Code benutzt**.
`matrix_client.dart` (Haupt-App) und `background_push.dart` (Push-Hintergrund)
öffnen `pyramid.sqlite` beide über den normalen, unverschlüsselten
`sqflite`/`sqflite_common_ffi`-Factory. Die Krypto-DB (Access-Token,
gepickelter Olm-Account, Megolm-Sessions, komplette Nachrichtenhistorie)
liegt also **im Klartext** auf der Platte nur durch Androids App-Sandbox
geschützt, nicht durch eine eigene Verschlüsselung.
**Offen/Nächster Schritt:** SQLCipher-Anbindung NICHT blind auf dem Pi
umgesetzt siehe Stolperfalle unten. Braucht einen eigenen, vorsichtig
geplanten PC-Termin: Backup der Test-DB, Migrationscode (bestehende
Klartext-DB einmalig nach SQLCipher überführen), Test auf einem Gerät mit
echten Daten, erst dann Rollout.
**Fragen an Bernd:**
- Priorität der SQLCipher-Lücke: Soll das vor M2 (Refactoring) angegangen
werden, oder reicht die App-Sandbox als Schutz erstmal aus (Angreifer bräuchte
Root oder physischen Zugriff + ADB, um die Datei überhaupt zu lesen)? Die
Migration bestehender Installationen (Uta!) ist der aufwändige/riskante Teil,
nicht die Verschlüsselung selbst.
**Stolperfallen:**
- Eine DB-Verschlüsselungs-Migration ist genau die Art Änderung, die laut
CLAUDE.md nicht "mal eben" auf dem Pi ohne Testgerät gemacht werden darf:
scheitert die Migration (falscher Schlüssel, falsche PRAGMA-Reihenfolge,
Timing zwischen Haupt-App und Push-Hintergrund-Client), sind Utas
Nachrichten und Krypto-Schlüssel unwiederbringlich weg. Deshalb hier nur
dokumentiert, nicht implementiert.
---
## 2026-07-03 M1: Push-Regression vermutlich dieselbe Ursache wie Random-Logout
**Erledigt:** Vor dem Blick in den Push-Code die Vermutung geprüft, ob die
Push-Entschlüsselungs-Regression und der Uta-Random-Logout-Bug (siehe Eintrag
weiter unten) zusammenhängen Ergebnis: **sehr wahrscheinlich ja, exakt
dieselbe Ursache.**
- `client.getEventByPushNotification(...)` (aufgerufen in `_bgDecryptAndShow`,
`background_push.dart:84`) ruft als ALLERERSTES `ensureNotSoftLoggedOut()`
auf (matrix-Paket `client.dart:1878`). Diese Funktion prüft, ob der
Access-Token in < 1 Minute abläuft, und löst dann `_handleSoftLogout()` aus
**dieselbe Funktion**, die ohne Refresh-Token (unser Zustand vor Commit
9dc83f7) sofort einen echten `client.logout()` auslöst (siehe Eintrag unten).
- Der Hintergrund-Client aus `_buildClient()` liest **dieselbe** `pyramid.sqlite`
wie der Haupt-Client. Ein `logout()`/`clear()` aus dem Hintergrund-Isolat
würde also die komplette Session (inkl. Krypto-Schlüssel) für die GESAMTE
App zerstören nicht nur für den einen Push. Das erklärt, warum das Problem
als dauerhafte Regression erscheint ("seit dem letzten Update nie wieder"):
Nach dem ersten Auto-Logout-Ereignis (ausgelöst beim Sync ODER beim
Entschlüsseln eines Pushs) sind die Schlüssel weg, jede folgende
Entschlüsselung schlägt fehl → `BgEngine` zeigt nur noch den
„Neue Nachricht"-Platzhalter (Fallback nach 6 s), nie mehr den echten Text.
- Der bereits committete Fix (`refreshToken: true` beim Login, 9dc83f7) behebt
damit vermutlich BEIDE Symptome gleichzeitig: der Refresh-Token liegt in der
gemeinsamen DB, also kann auch der Hintergrund-Client ihn lesen und einen
drohenden Soft-Logout durch stillen Refresh abwenden, statt die Session zu
zerstören.
**Offen/Nächster Schritt:** Gehört zum selben Praxistest wie der
Random-Logout-Punkt oben zusätzlich beobachten, ob nach einer Weile
(mehrere Stunden/Tage) echte Push-Inhalte statt Platzhalter ankommen. Schlägt
das fehl, liegt eine zweite, unabhängige Ursache vor (dann `docs/NOTIFICATIONS.md`
+ Memory „Pyramid Push" erneut konsultieren, insbesondere die 3 dort gelisteten
Fallstricke der Hintergrund-Entschlüsselung).
**Stolperfallen:** Diese Verbindung wurde rein durch Lesen des matrix-SDK-
Quellcodes hergeleitet (`~/.pub-cache/hosted/pub.dev/matrix-6.2.0/lib/src/client.dart`),
nicht durch Beobachtung auf einem echten Gerät bewusst als Vermutung
markiert, bis der PC-Praxistest sie bestätigt.
---
## 2026-07-03 M1: Uta-Random-Logout Ursache gefunden + Fix (UNGETESTET/Pi)
**Erledigt:**
- Alle Logout-Auslöser im Code kartiert:
- 3 bewusste Nutzer-Logout-Buttons in `settings_modal.dart` (alle hinter
Bestätigungsdialog) kein Kandidat für "random"
- Der eigentliche Verdächtige: `matrix`-Paket (v6.2.0) `Client._innerSync()`
behandelt `M_UNKNOWN_TOKEN` vom `/sync`-Endpunkt so: bei `soft_logout: true`
`_handleSoftLogout()` → versucht `refreshAccessToken()` → **das SDK
hatte nie einen Refresh-Token angefordert** (`client.login(...)` in
`login_notifier.dart` rief ohne `refreshToken: true` auf) → `refreshAccessToken()`
wirft sofort "No refresh token available" → Catch-Block ruft `await logout()`
auf: ein **echter, destruktiver Logout**, ausgelöst rein durch einen normalen
Soft-Logout (z. B. abgelaufenes Access-Token), ganz ohne Nutzerinteraktion.
Quelle: `~/.pub-cache/hosted/pub.dev/matrix-6.2.0/lib/src/client.dart:2374-2384`
und `:2496-2509`.
- **Logging (Commit 63e4919):** neue `lib/core/auth_log.dart` schreibt jeden
Login-Status-Wechsel (`client.onLoginStateChanged`) sowie SDK-Warnungen/-Fehler
(`Logs().onLog`) in `auth_log.txt` im App-Support-Verzeichnis übersteht
App-Neustart. Zusätzlich loggen alle 3 Nutzer-Logout-Buttons sich selbst, damit
sich künftig unterscheiden lässt: Nutzer-Logout vs. SDK-Auto-Logout. Reine
Zusatzfunktion, keine Verhaltensänderung.
- **Fix (Commit 9dc83f7):** `client.login(...)` fordert jetzt `refreshToken: true`
an. Damit kann das SDK einen Soft-Logout künftig durch stillen Token-Refresh
überstehen, statt die Sitzung zu zerstören. Unterstützt der Server keine
Refresh-Tokens, bleibt das Verhalten exakt wie vorher (`response.refreshToken`
ist dann `null`) der Fix kann also nichts verschlimmern.
**Offen/Nächster Schritt:**
1. **Praxistest auf dem PC (zwingend vor Haken in ROADMAP!):** Login → Nachrichten
senden/lesen → Logout → erneuter Login → alte verschlüsselte Nachrichten noch
lesbar? Dabei prüfen, ob `auth_log.txt` (App-Support-Verzeichnis) die
Login-Status-Wechsel sauber mitschreibt.
2. Danach: den nächsten Punkt in M1 angehen (Push-Entschlüsselungs-Regression).
3. **Wichtig für Bernd/Uta:** Der Fix wirkt nur für NEUE Logins. Utas bereits
bestehende Sitzung bekommt erst nach einem einmaligen manuellen Neu-Login
einen Refresh-Token und ist bis dahin weiterhin anfällig. Sobald der PC-Test
grün ist, sollte Uta sich einmal aus- und wieder einloggen.
**Stolperfallen:**
- Konnte auf dem Pi nicht mit echtem Login/Logout getestet werden (kein GUI,
kein Matrix-Testaccount zur Hand) Fix beruht auf genauem Lesen des
matrix-SDK-Quellcodes, nicht auf Beobachtung im Betrieb. Deshalb zwingend
vor dem nächsten Haken in ROADMAP am PC nachprüfen.
- `chat_provider.dart:42` nutzt weiterhin das deprecated `Timeline.onUpdate`
(Re-Entschlüsseln nach Schlüssel-Empfang) hängt mit demselben Sync-Bereich
zusammen, aber bewusst nicht angefasst, um nicht zwei riskante Änderungen im
selben Bereich gleichzeitig ungetestet zu committen.
---
## 2026-07-03 M0 abgeschlossen: Ordnung, flutter analyze sauber, README
**Erledigt:**
- Liegen gebliebene Deprecation-Fixes aus einer abgebrochenen Session committet
(`withOpacity``withValues`, `activeColor``activeThumbColor`, `cacheExtent`
`ScrollCacheExtent`, `PublicRoomsChunk``PublishedRoomsChunk`) Commit 057ac1e
- Restliche `flutter analyze`-Hinweise behoben Commit 401bd6a:
- `document_viewer.dart`: `Matrix4.translate/scale``translateByDouble`/
`scaleByDouble` (PDF-Zoom, gleiches Verhalten)
- `rooms_panel.dart`: `onReorder``onReorderItem` (Index-Korrektur jetzt intern,
manuelles `newIdx--` entfernt); `implementation_imports`-Hinweis kommentiert
(SpaceChild wird vom `matrix`-Paket nicht öffentlich exportiert, kein Fix möglich)
- `voip_manager.dart`: `getSources()`-Override kommentiert (Pflicht-Override der
abstrakten `MediaDevices`-Klasse, die die Methode selbst deprecated hat)
- `settings_modal.dart`: zwei `BuildContext`-nach-`await`-Stellen mit
`context.mounted` abgesichert (Geräte umbenennen, Passwortabfrage bei Massenlogout)
- **Bewusst NICHT gefixt:** `chat_provider.dart:42` (`Timeline.onUpdate` deprecated,
Ersatz wäre `client.onSync` + Raum-Filter). Hängt am E2EE-Redecrypt-Pfad
(Re-Entschlüsseln nach Schlüssel-Empfang) laut CLAUDE.md heiliger Bereich,
Umbau nur mit dediziertem Login→Nachrichten→Logout→Login-Test, nicht nebenbei.
- `lib/features/calls/` (Leiche) geprüft: existiert nicht mehr, nur `lib/features/call/`
mit den echten Dateien Punkt war bereits erledigt, nur nicht abgehakt.
- README.md durch echte Projektbeschreibung ersetzt (Funktionsumfang, Build, Struktur) Commit 74d38fd
- ROADMAP.md: M0 komplett abgehakt.
**Offen/Nächster Schritt:** M1, erster Punkt Uta-Random-Logout-Bug untersuchen
(Logging an allen Logout-Pfaden einbauen, dann Ursache finden). Direkt danach die
Push-Entschlüsselungs-Regression (`docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
vorher lesen).
**Stolperfallen:**
- Kein `test/`-Ordner vorhanden → `flutter test` läuft ins Leere, keine automatisierte
Absicherung. `flutter analyze` lief sauber (0 Fehler/Warnungen), aber die Lint-Fixes
in `document_viewer.dart` (PDF-Zoom) und `rooms_panel.dart` (Drag-Reorder der
Raumliste) sind **UNGETESTET** in echter UI nächster Windows-Lauf sollte kurz
PDF-Zoom und Raum-Umsortieren per Drag prüfen.
- Der `ignore:`-Kommentar für einen deprecated-Aufruf muss auf der Zeile *unmittelbar*
über der betroffenen Codezeile stehen, nicht nur "in der Nähe" sonst wirkt er nicht
(bei `voip_manager.dart` erst falsch über `@override` gesetzt, dann korrigiert).
---
## 2026-07-03 Arbeitsstruktur angelegt + Vollbackup (Setup, noch kein Code)
**Erledigt:**
- Vollbackup inkl. Git-Historie: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz` (510 MB)
- `CLAUDE.md` (Arbeitsregeln), `ROADMAP.md` (M0M7 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).
+386
View File
@@ -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
- 600900px → 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` |
+50 -11
View File
@@ -1,17 +1,56 @@
# pyramid
# Pyramid
A new Flutter project.
Pyramid ist ein eigenständiger Matrix-Client (Flutter), kein Fork von FluffyChat
oder Element. Er läuft gegen den selbst gehosteten Homeserver `steggi-matrix.work`
(Continuwuity) und ist bei echten Nutzern im Einsatz Stabilität und
Datensicherheit haben Vorrang vor Feature-Tempo.
## Getting Started
## Funktionsumfang
This project is a starting point for a Flutter application.
- Matrix-Chat mit Timeline, Reply, Medien, Link-Vorschauen, lokaler Volltextsuche
über entschlüsselte Nachrichten (server-seitige Suche funktioniert bei E2EE nicht)
- Ende-zu-Ende-Verschlüsselung (vodozemac/Olm/Megolm) inkl. Key-Backup
- Sprach-/Video-Calls und Bildschirmübertragung über LiveKit
(`wss://livekit.steggi-matrix.work`)
- Push-Benachrichtigungen mit clientseitiger Entschlüsselung auch bei gekillter
App (FCM + Sygnal, siehe `docs/NOTIFICATIONS.md`)
- Größenverstellbares Panel-Layout (Rooms/Chat/Members) statt starrer Tabs
A few resources to get you started if this is your first Flutter project:
## Build
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
Voraussetzung: Flutter (stable channel, siehe `environment.sdk` in `pubspec.yaml`
für die unterstützte Dart-Version).
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
```
flutter pub get
flutter run -d windows # schnellster Praxistest während der Entwicklung
flutter analyze # muss sauber bleiben
```
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
`android/`, `windows/`, `linux/`, `ios/`, `macos/` sind Standard-Flutter-Projekte).
Release-Build (signiert, Windows + Android): `scripts/release.ps1`.
## Projektstruktur
```
lib/
core/ Matrix-Client, Auth, VoIP-Manager, Storage, App-State
features/
auth/ Login, Server-Auswahl
rooms/ Raumliste, Spaces
chat/ Timeline, Composer, Medien, Dokumentenvorschau
call/ LiveKit-Calls, Voice-Channel, Mini-Call-Widget
members/ Mitgliederliste/-panel
settings/ Einstellungen
layout/ Panel-Layout (resizable)
widgets/ geteilte UI-Bausteine
utils/ Hilfsfunktionen
```
## Weiterführende Dokumentation
- `CLAUDE.md` Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
- `ROADMAP.md` / `PROGRESS.md` aktueller Stand und nächste Schritte
- `docs/NOTIFICATIONS.md` Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
- `docs/matrix-sdk/` Hinweise zum verwendeten Matrix-SDK
+166
View File
@@ -0,0 +1,166 @@
# Pyramid Roadmap
Die lange Liste. Wird von oben nach unten abgearbeitet (Arbeitszyklus siehe `CLAUDE.md`).
Reihenfolge = Priorität: erst Ordnung & Sicherheit (Datenverlust-Risiken!), dann das
große Refactoring, dann Calls/Streaming/Chat-Features, dann Feinschliff.
Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
## M0 Backup & Ordnung
- [x] Vollbackup vor dem Refactoring (2026-07-03 → `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`)
- [x] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
(Sicherungs-Commit 25ed765 + Folge-Commits bis 74d38fd; Arbeitsbaum ist jetzt sauber)
- [x] `flutter analyze` komplett sauber bekommen und sauber halten (nur noch 1 bewusst
zurückgestellter Hinweis, siehe PROGRESS.md 2026-07-03)
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären Leiche entfernen
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
- [x] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
ERLEDIGT siehe Abschnitt „Fable-5-Review" oben in PROGRESS.md.
(a) alle 6 Gott-Datei-Aufteilungen unabhängig verifiziert (Zeilen-Multiset,
part-Bezüge): korrekt, verlorene Erklärkommentare waren mit f9979e4 schon
wiederhergestellt; (b) Uta-Logout-/Push-Fix gegen matrix-SDK 6.2.0
verifiziert: Analyse richtig, Lücke (SDK eskaliert Refresh-Fehler zu
logout()+clear()) war mit 891f348 (onSoftLogout-Guard) geschlossen, Guard
heute gegenverifiziert; (c) Lint-Fixes verhaltensäquivalent (onReorderItem
gegen Flutter-SDK geprüft, kein Off-by-One); (d) abgehakte Punkte stimmen
mit dem Code überein. Alles weiterhin UNGETESTET (Pi) → PC-Praxistest bleibt.
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) Ursache im matrix-SDK
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
Nächster PC-Lauf MUSS den vollen Kreislauf praktisch prüfen (Login →
Nachrichten → Logout → erneuter Login → alte Nachrichten lesbar?), erst
dann gilt der Punkt als wirklich erledigt.
- [x] **REGRESSION: Push-Benachrichtigungen werden nicht mehr entschlüsselt**
UNGETESTET (Pi), sehr wahrscheinlich DIESELBE Ursache wie der
Uta-Random-Logout-Bug (bereits mit 9dc83f7 mitgefixt), siehe PROGRESS.md
2026-07-03. Auf echtem Gerät prüfen: nach Neu-Login (für den Refresh-Token)
eine Weile laufen lassen und beobachten, ob Push-Nachrichten wieder mit
echtem Text statt nur „Neue Nachricht"-Platzhalter ankommen. Falls das
Problem weiterhin auftritt, liegt eine zweite, unabhängige Ursache vor
(z. B. fehlende Megolm-Schlüssel/Key-Sharing) dann `docs/NOTIFICATIONS.md`
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
benutzt `matrix_client.dart` und `background_push.dart` öffnen die
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
den normalen `sqflite`/`sqflite_common_ffi`-Factory, also **unverschlüsselt**
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
(Utas Gerät!) ohne Testgerät hier zu riskant für „Kein Datenverlust".
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd. **Umsetzungsplan liegt
fertig in `docs/SQLCIPHER_MIGRATION.md`** (2026-07-03, gegen Code und
SDK verifiziert) PC-Termin kann direkt damit starten.
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
`BootstrapState.askUnlockSsss` fragt nach Recovery-Key auf neuem Gerät)
- [x] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt (b5762ea)
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
(`_SessionsSection` in `settings_modal.dart` Liste, Umbenennen, Verifizieren
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
jetzt in `docs/PC_TESTPLAN.md` dort abarbeiten und abhaken)
## M2 Das große Refactoring (erst nach M0/M1!)
Ziel: gleiche Funktionen, saubere **modulare** Struktur als Fundament für alles Folgende.
Leitprinzip (Bernd): austauschbare Bausteine jedes Modul muss sich komplett neu schreiben
lassen, ohne dass andere Module angefasst werden müssen.
- [x] Ist-Analyse geschrieben (PROGRESS.md 2026-07-03): Gott-Datei `settings_modal.dart`
(5541 Zeilen/50+ Klassen), Call-Schicht ohne gemeinsame Fassade (4 Module
importieren `voice_channel.dart` direkt), `app_state.dart` als Kopplungspunkt
aller Features, keine gemeinsame Storage-Schnittstelle (12 direkte
`SharedPreferences`-Zugriffe), toter Code (`message_bubble.dart`) + Duplikate
(`_formatTime`/`_formatDate` 3-fach). 8 Modul-Kandidaten dokumentiert.
- [x] Modul-Schnitt definiert (`docs/M2_MODULE_SCHNITT.md`, PROGRESS.md 2026-07-03):
„Calls" ist architektonisch zwei getrennte Module (`call_signaling` =
Matrix-1:1-VoIP, `voice_channel` = LiveKit-SFU-Kanäle) + `call_ui`;
außerdem `settings` (aufgesplittet), `verification` (neu, aus Settings
herausgelöst), `storage` (Ausbau `settings_prefs.dart`), `auth`, `push`,
`chat_timeline`/`rooms`. Umsetzungsreihenfolge vorgeschlagen, wartet auf
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
LiveKit-Verbindung / UI-State sauber getrennt)
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in
thematische Dateien aufgeteilt `widgets/settings_modal.dart`
(5541→270 Zeilen, 12 Teile unter `widgets/settings/`),
`features/chat/message_group.dart` (2775→30 Zeilen, 7 Teile unter
`features/chat/message/`), `features/rooms/rooms_panel.dart`
(2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`),
`features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile
unter `features/spaces/admin/`), `features/chat/chat_view.dart`
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`),
`features/chat/document_viewer.dart` (1773→20 Zeilen, 5 Teile unter
`features/chat/document/`) jeweils verifiziert per automatisiertem
Blockvergleich gegen das Original und `flutter analyze`, siehe
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" nächster
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
öffnen, Vollbild-PDF-Viewer inkl. Thumbnail-Leiste und Speichern-Button
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
PROGRESS.md-Einträgen)
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
## M3 Calls: Logik, UI & Audio (Discord als Referenz)
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
Einstellungen auswählbar machen geht aktuell gar nicht)
- [ ] Noise Suppression als optionale Einstellung (z. B. RNNoise/LiveKit-Audio-Filter)
- [ ] Lautstärke pro Teilnehmer einstellbar (Discord-Style, persistent pro Nutzer)
- [ ] Weitere Feinjustierung: Eingangsempfindlichkeit/Gate, Echo-Unterdrückung an/aus,
Push-to-Talk prüfen von Discords Sprach-Einstellungen inspirieren lassen
- [ ] Anruf-Benachrichtigung auch bei gekillter App (Push-Architektur nutzen)
## M4 Streaming (Bildschirmübertragung)
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
Software- statt Hardware-Encoding?), dann gezielt fixen
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
## M5 Chat-Funktionen
- [ ] Sprachnachrichten aufnehmen & abspielen (MSC3245-kompatibel, wie Element)
- [ ] **Bilder speichern → richtige Galerie:** über MediaStore/Downloads statt in den
unzugänglichen App-Ordner (Android Scoped Storage korrekt nutzen)
- [ ] Medien senden/empfangen generell prüfen (Fortschritt, Vorschau, Videos)
## M6 Release & Verteilung
- [ ] **Google-Warnung beim Installieren untersuchen:** Play-Protect-Meldung seit der
Signatur-Einführung analysieren (Signing-Konfig prüfen; unbekannte Signatur + Sideload
löst das oft aus; ggf. Play-Protect-Appeal oder saubere v2/v3-Signierung)
- [ ] Release-Skript prüfen/aktualisieren (Windows + Android), Versionierung sauber
- [ ] In-App-Updater testen (existiert laut Git-Historie)
## M7 Design & Feinschliff
- [ ] Rooms-Icon überarbeiten passt stilistisch nicht zu den anderen Buttons
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
- [ ] Themes/Darkmode-Feinschliff, Schriftgrößen
## Ideen-Parkplatz (unsortiert, erst nach M7 bewerten)
- Threads, Sticker, Standort teilen, mehrere Konten
- Watch-Together (z. B. Jellyfin gemeinsam schauen)
> Bernd: „Das ist nur der Anfang meiner Liste" neue Punkte einfach hier in den
> passenden Meilenstein eintragen (oder Claude sagen, er soll sie einsortieren).
+5
View File
@@ -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`
+46 -5
View File
@@ -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 {
+29
View File
@@ -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"
}
+18
View File
@@ -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.**
+75 -1
View File
@@ -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 (2187). 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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

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>
+1 -1
View File
@@ -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
+1
View File
@@ -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")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+20
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

File diff suppressed because one or more lines are too long
+4
View File
@@ -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

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

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

+94 -16
View File
@@ -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,
),
);
}
}
+207
View File
@@ -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;
});
+51
View File
@@ -0,0 +1,51 @@
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
/// können, auch wenn die App zwischen dem Logout und dem nächsten Blick ins
/// Log neu gestartet wurde (siehe ROADMAP M1).
class AuthLog {
static const _maxLines = 500;
static File? _file;
static Future<File> _ensureFile() async {
final existing = _file;
if (existing != null) return existing;
final dir = await getApplicationSupportDirectory();
final file = File(p.join(dir.path, 'auth_log.txt'));
return _file = file;
}
static Future<void> write(String message) async {
try {
final file = await _ensureFile();
await file.writeAsString(
'${DateTime.now().toIso8601String()} $message\n',
mode: FileMode.append,
flush: true,
);
final lines = await file.readAsLines();
if (lines.length > _maxLines) {
await file.writeAsString(
'${lines.sublist(lines.length - _maxLines).join('\n')}\n',
);
}
} catch (_) {
// Logging darf selbst nie einen Fehlerpfad (oder gar einen Logout) auslösen.
}
}
static Future<String> read() async {
try {
final file = await _ensureFile();
if (!await file.exists()) return '(noch keine Einträge)';
return await file.readAsString();
} catch (e) {
return 'Log konnte nicht gelesen werden: $e';
}
}
}
+309
View File
@@ -0,0 +1,309 @@
import 'dart:io';
import 'dart:ui';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native;
/// Port name used to locate the main isolate from background isolates.
/// Registered by notification_service.dart when the app starts.
const kMainIsolateName = 'pyramid_main';
int _stableId(String roomId) {
var h = 0;
for (final c in roomId.codeUnits) {
h = ((h * 31) + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
// FCM background handler
// Runs in its own Dart isolate (or fresh process) when the app is killed or
// backgrounded. Firebase calls this after Firebase.initializeApp() is done.
@pragma('vm:entry-point')
Future<void> handleBackgroundMessage(RemoteMessage message) async {
// PushService.kt handles all FCM-triggered notifications natively.
// This callback is registered for API completeness but never fires because
// FlutterFirebaseMessagingService is removed from the manifest.
}
// Background engine entrypoint (killed-app E2EE work)
// PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this
// entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel
// so native can ask us to (a) decrypt a push event and update its notification,
// or (b) send an encrypted reply. Building a Matrix client here is safe ONLY
// because native gates this on the main app being dead never two clients on
// the same olm DB at once.
const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine';
@pragma('vm:entry-point')
void notificationEngineMain() {
WidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel(_kBgEngineChannel);
channel.setMethodCallHandler((call) async {
try {
final args = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
switch (call.method) {
case 'decryptAndShow':
return await _bgDecryptAndShow(
args['room_id'] as String?,
args['event_id'] as String?,
(args['notif_id'] as num?)?.toInt(),
);
case 'sendReply':
return await _bgSendReply(
args['room_id'] as String?,
args['text'] as String?,
);
}
} catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e');
}
return false;
});
// Signal native that the channel handler is installed and tasks can be sent.
channel.invokeMethod('bgEngineReady');
}
Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async {
if (roomId == null || eventId == null) return false;
final client = await _buildClient();
if (client == null) return false;
try {
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null) return false;
// Bad-encrypted body still starts with "**" keep the native placeholder.
if (event.body.startsWith('**')) return false;
final room = client.getRoomById(roomId);
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = (room?.isDirectChat ?? true)
? sender
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
// Hand the decrypted content back to native so it re-shows the SAME
// notification via NotificationHelper that keeps the reliable
// MainActivity-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed).
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
'title': title,
'body': _buildBody(event),
'room_id': roomId,
'notif_id': notifId ?? _stableId(roomId),
});
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
return false;
} finally {
// Let the async key-backup upload settle before closing the DB otherwise
// it logs a harmless "database_closed" error mid-flight.
await Future.delayed(const Duration(seconds: 2));
await client.dispose().catchError((_) {});
}
}
Future<bool> _bgSendReply(String? roomId, String? text) async {
if (roomId == null || text == null || text.trim().isEmpty) return false;
final client = await _buildClient();
if (client == null) return false;
try {
// A one-shot sync ensures the room and recipient device keys are present so
// the message can be encrypted. Safe here: main app is dead (native-gated).
await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null);
final room = client.getRoomById(roomId);
if (room == null) {
// Fall back to the prefs queue so the main app sends it on next launch.
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
return false;
}
await room.sendTextEvent(text.trim());
debugPrint('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] sendReply error: $e');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
return false;
} finally {
// Allow the encrypted send + any key upload to flush before closing the DB.
await Future.delayed(const Duration(seconds: 2));
await client.dispose().catchError((_) {});
}
}
/// Stores the raw callback handle of [notificationEngineMain] so native code can
/// bootstrap the background engine. Call once at app startup.
Future<void> registerBgEngineHandle() async {
if (!Platform.isAndroid) return;
try {
final handle = PluginUtilities.getCallbackHandle(notificationEngineMain);
if (handle == null) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
} catch (e) {
debugPrint('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
}
}
// Build a short-lived Matrix client for background work
// Uses NativeImplementationsDummy so Vodozemac runs synchronously in this
// isolate NativeImplementationsIsolate(compute) would try to spawn a
// sub-isolate, which fails inside a Firebase background isolate.
Future<Client?> _buildClient() async {
try {
// vodozemac may already be initialised in this isolate (e.g. a decrypt task
// ran before this reply task reused the same engine). Calling init() twice
// throws swallow it so it doesn't fail the whole client build.
try {
await vod.init();
} catch (_) {}
final appDir = await getApplicationSupportDirectory();
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
final db = await sqflite_native.databaseFactory
.openDatabase(dbPath)
.timeout(const Duration(seconds: 5));
// Prevent "database is locked" if the main process still has the DB open.
try {
await db.execute('PRAGMA busy_timeout = 5000');
await db.execute('PRAGMA journal_mode = WAL');
} catch (_) {}
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
final client = Client(
'Pyramid',
database: sdkDb,
nativeImplementations: NativeImplementationsDummy(),
logLevel: Level.error,
// Kritisch: getEventByPushNotification() ruft als Erstes
// ensureNotSoftLoggedOut() auf. Ohne eigenen Handler würde ein
// fehlgeschlagener Token-Refresh AUS DEM HINTERGRUND-ISOLATE heraus
// logout()+clear() auf der geteilten pyramid.sqlite auslösen und die
// Session der gesamten App zerstören. Siehe soft_logout_guard.dart.
onSoftLogout: guardedSoftLogoutRefresh,
);
await client
.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
)
.timeout(const Duration(seconds: 15));
return client;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] _buildClient failed: $e');
return null;
}
}
String _buildBody(Event event) {
final msgType = event.messageType;
final rawBody = event.body;
return switch (msgType) {
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}' : rawBody,
'm.image' => '📷 Bild',
'm.video' => '🎥 Video',
'm.audio' => '🎵 Audio',
'm.file' => '📄 Datei',
'm.sticker' => '🏷️ Sticker',
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}' : rawBody),
};
}
// Notification action handler
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly Android keeps the notification's "sending" spinner
// visible until this function returns.
@pragma('vm:entry-point')
Future<void> handleBackgroundNotificationResponse(
NotificationResponse details) async {
WidgetsFlutterBinding.ensureInitialized();
debugPrint('[NOTIF-BG] handleBackgroundNotificationResponse fired');
debugPrint('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
// Fast path: main isolate is running
// If the Pyramid UI process is alive (app is backgrounded but not killed),
// forward the action directly via IsolateNameServer. This avoids starting
// any new engine and works silently without bringing the app to foreground.
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
if (sendPort != null) {
debugPrint('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
sendPort.send(<String, dynamic>{
'actionId': details.actionId,
'payload': details.payload,
'input': details.input,
'id': details.id,
});
return;
}
// Slow path: app was killed
// Main isolate is gone. Dismiss the notification and, for reply actions,
// persist the text in SharedPreferences so _sendPendingReply picks it up
// the next time the app opens (via getInitialReply cold-start flow).
debugPrint('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification')),
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
);
final roomId = details.payload;
if (roomId != null) {
await plugin.cancel(_stableId(roomId));
debugPrint('[NOTIF-BG] cancelled notification for room $roomId');
} else if (details.id != null) {
await plugin.cancel(details.id!);
debugPrint('[NOTIF-BG] cancelled notification id ${details.id}');
}
if (details.actionId != 'reply') {
debugPrint('[NOTIF-BG] not a reply action, done');
return;
}
final text = details.input?.trim() ?? '';
if (text.isEmpty || roomId == null) {
debugPrint('[NOTIF-BG] text empty or roomId null, aborting');
return;
}
// App killed send the encrypted reply directly from this background isolate.
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
// delivers it on next launch).
final sent = await _bgSendReply(roomId, text);
debugPrint('[NOTIF-BG] background reply sent=$sent room=$roomId');
}
+83
View File
@@ -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,
);
+183
View File
@@ -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();
}
+65
View File
@@ -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;
}
}
+603
View File
@@ -0,0 +1,603 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kVoicePresenceType = 'io.pyramid.voice.presence';
class LiveKitCallManager with ChangeNotifier {
static final LiveKitCallManager instance = LiveKitCallManager._();
LiveKitCallManager._();
// Publish quality presets (Streamer Server)
static final Map<String, VideoParameters> qualityPresets = {
'sd': VideoParametersPresets.h360_169,
'hd': VideoParametersPresets.h720_169,
'fhd': VideoParametersPresets.h1080_169,
'4k': VideoParametersPresets.h2160_169,
};
static const Map<String, VideoEncoding> _screenShareEncodings = {
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
};
Room? _room;
EventsListener<RoomEvent>? _roomEventListener;
String _displayName = '';
bool isConnecting = false;
String? error;
bool isMuted = false;
bool isCameraOff = false;
bool isScreenSharing = false;
String _localQualityKey = 'hd';
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
String? _currentScreenShareSourceId;
LocalVideoTrack? _screenShareTrack;
bool isDeafened = false;
bool _mutedBeforeDeafen = false;
bool isVoiceChannel = false;
String? _matrixRoomId;
Client? _matrixClient;
Timer? _presenceHeartbeat;
Timer? _statsTimer;
bool get isActive => _room != null || isConnecting;
Room? get room => _room;
String? get currentRoomId => _matrixRoomId;
String get displayName => _displayName;
bool get noiseSuppression => false;
String get currentQualityKey => _localQualityKey;
String get subscribeQualityKey => _subscribeQualityKey;
VideoParameters get _currentQuality =>
qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169;
void _onRoomChanged() => notifyListeners();
void _cleanUpAfterDisconnect() {
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
if (_matrixClient != null && _matrixRoomId != null) {
_writePresence(false).catchError((_) {});
}
_roomEventListener?.dispose();
_roomEventListener = null;
_room?.removeListener(_onRoomChanged);
_room = null;
_displayName = '';
isConnecting = false;
error = null;
isMuted = false;
isCameraOff = false;
isScreenSharing = false;
isDeafened = false;
isVoiceChannel = false;
_mutedBeforeDeafen = false;
_screenShareTrack = null;
_currentScreenShareSourceId = null;
_matrixRoomId = null;
_matrixClient = null;
notifyListeners();
}
Future<void> _writePresence(bool active) async {
final client = _matrixClient;
final roomId = _matrixRoomId;
final userId = client?.userID;
if (client == null || roomId == null || userId == null) return;
try {
Map<String, dynamic> content;
if (active) {
// Resolve display name + avatar from any joined room's member state
User? member;
for (final r in client.rooms) {
final participants = r.getParticipants();
try {
member = participants.firstWhere((u) => u.id == userId);
if (member.displayName != null || member.avatarUrl != null) break;
} catch (_) {}
}
content = {
'active': true,
'joined_at': DateTime.now().millisecondsSinceEpoch,
'display_name': member?.displayName ?? userId,
if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(),
};
} else {
content = {'active': false};
}
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
} catch (e) {
debugPrint('[VoicePresence] $e');
}
}
Future<void> startCall({
required String roomName,
required String roomDisplayName,
required String identity,
bool audioOnly = false,
bool voiceChannel = false,
Client? matrixClient,
String? matrixRoomId,
}) async {
if (isActive) return;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true;
isVoiceChannel = voiceChannel;
error = null;
_displayName = roomDisplayName;
isCameraOff = audioOnly;
isMuted = false;
isScreenSharing = false;
isDeafened = false;
_mutedBeforeDeafen = false;
_localQualityKey = 'hd';
_subscribeQualityKey = 'auto';
_matrixClient = matrixClient;
_matrixRoomId = matrixRoomId;
notifyListeners();
try {
final token = LiveKitTokenGenerator.generate(
roomName: roomName,
identity: identity,
displayName: identity,
ttlSeconds: 21600,
);
final room = Room(
roomOptions: const RoomOptions(
adaptiveStream: false, // Matches FluffyChat fix
dynacast: true,
),
);
room.addListener(_onRoomChanged);
// Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige
// iceServers ERSETZEN die vom LiveKit-Server gelieferten die zeigten
// bisher auf einen hinter CGNAT unerreichbaren coturn.
RTCConfiguration rtcConfig = const RTCConfiguration();
try {
final ice = await IceServers.get();
rtcConfig = RTCConfiguration(
iceServers: ice
.map((m) => RTCIceServer(
urls: m['urls'] is List
? List<String>.from(m['urls'] as List)
: [m['urls'] as String],
username: m['username'] as String?,
credential: m['credential'] as String?,
))
.toList(),
);
} catch (e) {
debugPrint('[LiveKit] ICE server fetch error: $e');
}
await room.connect(
url,
token,
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
);
_room = room;
isConnecting = false;
notifyListeners();
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
await _writePresence(true);
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
_writePresence(true).catchError((_) {});
});
}
_roomEventListener = room.createListener()
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
..on<LocalTrackUnpublishedEvent>((event) {
if (event.publication.source == TrackSource.screenShareVideo) {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
}
notifyListeners();
})
..on<TrackSubscribedEvent>((event) {
final pub = event.publication;
if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) {
pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey));
}
// Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks.
if (pub.kind == TrackType.AUDIO) {
try {
rtc.Helper.setVolume(
_outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack);
} catch (_) {}
}
notifyListeners();
})
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
..on<TrackPublishedEvent>((_) => notifyListeners())
..on<TrackUnpublishedEvent>((_) => notifyListeners())
..on<ParticipantConnectedEvent>((_) => notifyListeners())
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
..on<RoomDisconnectedEvent>((_) {
_cleanUpAfterDisconnect();
});
// In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs).
// Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell
// vorhandenen Geräte validiert und sonst ignoriert.
String? prefMic, prefSpeaker, prefCam;
var deviceIds = <String>{};
try {
final prefs = await SharedPreferences.getInstance();
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0;
prefMic = prefs.getString('voice_mic_device');
prefSpeaker = prefs.getString('voice_speaker_device');
prefCam = prefs.getString('voice_camera_device');
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
deviceIds = devices.map((d) => d.deviceId).toSet();
} catch (e) {
debugPrint('[LiveKit] Device prefs/enumerate error: $e');
}
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null;
await room.localParticipant?.setMicrophoneEnabled(
true,
audioCaptureOptions:
prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null,
);
if (prefSpeaker != null) {
try {
await rtc.Helper.selectAudioOutput(prefSpeaker);
} catch (e) {
debugPrint('[LiveKit] selectAudioOutput error: $e');
}
}
if (!audioOnly) {
try {
await room.localParticipant?.setCameraEnabled(
true,
cameraCaptureOptions: CameraCaptureOptions(
deviceId: prefCam,
params: _currentQuality,
),
);
} catch (e) {
debugPrint('[LiveKit] Failed to enable camera: $e');
}
}
_startStatsLogging();
} catch (e) {
debugPrint('[VOIP] Start call error: $e');
isConnecting = false;
error = e.toString();
_cleanUpAfterDisconnect();
}
}
VideoQuality _videoQualityFromKey(String key) {
switch (key) {
case 'low': return VideoQuality.LOW;
case 'medium': return VideoQuality.MEDIUM;
case 'high': return VideoQuality.HIGH;
default: return VideoQuality.HIGH; // auto defaults to high
}
}
// Ausgabelautstärke (Settings-Slider "voice_output_volume")
double _outputVolume = 1.0;
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
Future<void> setOutputVolume(double v) async {
_outputVolume = v;
final room = _room;
if (room == null) return;
for (final p in room.remoteParticipants.values) {
for (final pub in p.audioTrackPublications) {
final t = pub.track;
if (t != null) {
try {
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack);
} catch (_) {}
}
}
}
}
Future<void> setSubscribeQuality(String key) async {
debugPrint('[LiveKit] Viewer changing subscribe quality to: $key');
_subscribeQualityKey = key;
if (_room == null) { notifyListeners(); return; }
final quality = _videoQualityFromKey(key);
for (final p in _room!.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
if (pub.kind == TrackType.VIDEO) {
debugPrint('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
pub.setVideoQuality(quality);
}
}
}
notifyListeners();
}
Future<void> toggleMute() async {
isMuted = !isMuted;
notifyListeners();
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
}
Future<void> toggleDeafen() async {
if (!isDeafened) {
_mutedBeforeDeafen = isMuted;
isDeafened = true;
if (!isMuted) {
await _room?.localParticipant?.setMicrophoneEnabled(false);
isMuted = true;
}
} else {
isDeafened = false;
if (!_mutedBeforeDeafen) {
await _room?.localParticipant?.setMicrophoneEnabled(true);
isMuted = false;
}
}
_applyDeafen();
notifyListeners();
}
void _applyDeafen() {
if (_room == null) return;
for (final p in _room!.remoteParticipants.values) {
for (final trackPub in p.audioTrackPublications) {
final track = trackPub.track;
if (track != null) {
track.mediaStreamTrack.enabled = !isDeafened;
}
}
}
}
Future<void> toggleCamera() async {
if (isCameraOff) {
try {
await _room?.localParticipant?.setCameraEnabled(
true,
cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality),
);
isCameraOff = false;
} catch (e) {
debugPrint('[LiveKit] toggleCamera ON error: $e');
}
} else {
try {
await _room?.localParticipant?.setCameraEnabled(false);
} catch (e) {
debugPrint('[LiveKit] toggleCamera OFF error: $e');
}
isCameraOff = true;
}
notifyListeners();
}
Future<void> toggleScreenShare() async {
if (isScreenSharing) {
await stopScreenShare();
} else {
try {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) {
final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]);
if (sources.isNotEmpty) {
await startScreenShare(sources.first.id);
return;
}
}
await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true;
notifyListeners();
} catch (e) {
debugPrint('[ScreenShare] Toggle error: $e');
}
}
}
VideoEncoding _screenShareEncoding() {
return _screenShareEncodings[_localQualityKey] ??
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
}
Future<void> startScreenShare(String sourceId) async {
if (isScreenSharing) return;
try {
final encoding = _screenShareEncoding();
final track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions(
sourceId: sourceId,
maxFrameRate: encoding.maxFramerate.toDouble(),
),
);
if (_room == null || _room!.localParticipant == null) return;
await _room!.localParticipant!.publishVideoTrack(
track,
publishOptions: VideoPublishOptions(
simulcast: true,
degradationPreference: DegradationPreference.balanced,
screenShareEncoding: encoding,
),
);
isScreenSharing = true;
_currentScreenShareSourceId = sourceId;
_screenShareTrack = track;
notifyListeners();
unawaited(_applyFpsToAllSimulcastLayers(track));
} catch (e) {
debugPrint('[ScreenShare] ERROR: $e');
}
}
Future<void> _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async {
final targetFps = _screenShareEncoding().maxFramerate;
for (var attempt = 1; attempt <= 5; attempt++) {
await Future.delayed(const Duration(milliseconds: 600));
if (!isScreenSharing || _screenShareTrack != track) return;
final sender = track.transceiver?.sender;
if (sender == null) continue;
final params = sender.parameters;
final encodings = params.encodings;
if (encodings == null || encodings.isEmpty) continue;
var anyUpdated = false;
for (final enc in encodings) {
if ((enc.maxFramerate ?? 0) < targetFps) {
enc.maxFramerate = targetFps;
anyUpdated = true;
}
}
if (anyUpdated) await sender.setParameters(params);
return;
}
}
Future<void> stopScreenShare() async {
final local = _room?.localParticipant;
if (local != null) {
final toStop = <String>[];
for (final pub in local.videoTrackPublications) {
if (pub.source == TrackSource.screenShareVideo) {
toStop.add(pub.sid);
await pub.track?.stop();
}
}
for (final sid in toStop) {
try { await local.removePublishedTrack(sid); } catch (_) {}
}
}
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
notifyListeners();
}
Future<void> hangUp() async {
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
if (_matrixClient != null && _matrixRoomId != null) {
await _writePresence(false).catchError((_) {});
}
_roomEventListener?.dispose();
_roomEventListener = null;
_room?.removeListener(_onRoomChanged);
await _room?.disconnect();
_room = null;
_displayName = '';
isConnecting = false;
error = null;
isMuted = false;
isCameraOff = false;
isScreenSharing = false;
isDeafened = false;
isVoiceChannel = false;
_mutedBeforeDeafen = false;
_screenShareTrack = null;
_currentScreenShareSourceId = null;
_matrixRoomId = null;
_matrixClient = null;
notifyListeners();
}
Future<void> changeQuality(String qualityKey) async {
debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey');
_localQualityKey = qualityKey;
final params = qualityPresets[qualityKey];
if (params == null) { notifyListeners(); return; }
if (_room == null) { notifyListeners(); return; }
final local = _room!.localParticipant;
if (local == null) { notifyListeners(); return; }
if (!isCameraOff) {
await local.setCameraEnabled(false);
await Future.delayed(const Duration(milliseconds: 400));
try {
await local.setCameraEnabled(
true,
cameraCaptureOptions: CameraCaptureOptions(params: params),
);
} catch (e) {
debugPrint('[LiveKit] Camera restart error: $e');
isCameraOff = true;
}
}
if (isScreenSharing && _currentScreenShareSourceId != null) {
final sid = _currentScreenShareSourceId!;
await stopScreenShare();
await Future.delayed(const Duration(milliseconds: 400));
await startScreenShare(sid);
}
notifyListeners();
}
Future<void> changeAudioInput(String deviceId) async {
final participant = _room?.localParticipant;
if (participant == null) return;
try {
final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId));
for (final pub in List.of(participant.audioTrackPublications)) {
await participant.removePublishedTrack(pub.sid);
}
await participant.publishAudioTrack(track);
if (isMuted) await participant.setMicrophoneEnabled(false);
} catch (e) { debugPrint('[Audio] error: $e'); }
}
void setRemoteVolume(double volume) {
if (_room == null) return;
final mute = volume < 0.05;
for (final p in _room!.remoteParticipants.values) {
for (final pub in p.audioTrackPublications) {
pub.track?.mediaStreamTrack.enabled = !mute;
}
}
}
void _startStatsLogging() {
_stopStatsLogging();
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
}
void _stopStatsLogging() {
_statsTimer?.cancel();
_statsTimer = null;
}
Future<void> _logStats() async {
if (_room == null) return;
// Basic stats logging...
debugPrint('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
}
}
+37
View File
@@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More