Compare commits

...
54 Commits
Author SHA1 Message Date
Bernd SteckmeisterandClaude Opus 5.5 a30d57ba69 docs: PROGRESS/ROADMAP – Runde 4 (UI, Animationen, Lesebestätigung, LiveKit, Markdown)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:15:00 +02:00
Bernd SteckmeisterandClaude Opus 5.5 df377d7c4b chore: Testversion 0.1.38
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:13:57 +02:00
Bernd SteckmeisterandClaude Opus 5.5 0ad06e3238 chore: flutter_markdown (eingestellt) -> flutter_markdown_plus
Gleiche API, gepflegter Nachfolger. Rendertest für die Markdown-Vorschau
(test/document_markdown_test.dart).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:13:27 +02:00
Bernd SteckmeisterandClaude Opus 5.5 756486d1fe security: LiveKit-Token vom Server statt Secret in der App
Die App signierte LiveKit-Tokens selbst – dafür steckte das SFU-Secret im
App-Code (jeder mit der APK konnte sich Tokens für beliebige Räume und
Identitäten ausstellen). Jetzt holt die App das Token von der bereits
vorhandenen Server-Route /api/livekit-token (whoami-Prüfung, Identität =
geprüfte Matrix-ID). Secret und JWT-Signieren sind aus dem Client entfernt.

- Lesbare Fehlermeldungen (offline, 401, Serverfehler) statt stiller Fehler.
- Gruppenanruf aus dem Chat-Kopf übergibt jetzt den Matrix-Client.
- Tests: Unit-Test mit Mock-HTTP + echter LiveKit-Beitritt (Testkonto).
- Offen: Handy-Anruftest; danach Secret-Rotation durch Bernd (Doku).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:12:15 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ae6f651f4c feat: federnde Übergänge und Animationen, Entwürfe je Raum
- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut,
  Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während
  des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein.
- Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen
  oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen,
  Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer),
  Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht
  zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus).
- Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am
  neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt
  eigenes Eingabefeld je Raum mit Entwurf im Speicher.
- Antwort-Leiste: größeres Schließen-X auf dem Handy.
- Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:07:17 +02:00
Bernd SteckmeisterandClaude Opus 5.5 1d0ccfde8b fix: Lesebestätigung nur im Vordergrund, ohne Dauerfeuer, privat statt gar nicht
- Offener Chat meldete bei jedem Neuaufbau erneut "gelesen" – auch mit
  App im Hintergrund; die gerade gezeigte Benachrichtigung verschwand dann
  sofort wieder. Jetzt nur bei Fokus, je Event nur einmal.
- Zurück in der App: "Neue Nachrichten"-Trenner hinter dem zuletzt
  gelesenen Stand, danach gelesen melden.
- "Lesebestätigungen senden" aus: nur noch private Bestätigung
  (m.read.private) statt gar keiner – Ungelesen-Zähler stimmen wieder.
  Gilt auch für "Gelesen" aus der Benachrichtigung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:32:36 +02:00
Bernd SteckmeisterandClaude Opus 5.5 223c9448f6 feat: schwebende Eingabeleiste - Chat laeuft darunter weiter (Milchglas), Flaeche faengt Beruehrungen ab
Bernds Wunsch 2026-10-07: Die Nachrichtenliste reicht jetzt bis ganz unten;
Tipp-Anzeige und Eingabefeld schweben darueber. Um das Feld herum ist die
Flaeche klar (Chat sichtbar), aber beruehrungsdicht - dort reagiert nur die
Eingabe. Hinter dem Feld selbst Milchglas (Unschaerfe + 78 % Flaeche), Rahmen
und Fokus-Ring aussen. Die Liste bekommt unten genau die gemessene Hoehe der
Leiste als Abstand (MeasureSize; waechst mit Antwort-Leiste/mehrzeiligem
Text), damit die neueste Nachricht ganz ueber die Leiste scrollt; der
Nach-unten-Knopf sitzt darueber.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:26:33 +02:00
Bernd SteckmeisterandClaude Opus 5.5 7063ebfceb feat: Antwort-Zitat antippen springt zur zitierten Nachricht (inkl. Aufleuchten)
_ReplyPreview nutzt den Sprung-Mechanismus der Suche (pendingJumpEventProvider).
Der Sprung scrollt jetzt schrittweise Richtung aeltere Nachrichten, wenn das
Ziel zwar geladen, aber in der lazy ListView noch nicht gezeichnet ist (vorher
gab er dann nach 40 Versuchen auf); dasselbe Zitat darf erneut angetippt
werden. UI-Durchlauf prueft den Sprung per Screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:17:35 +02:00
Bernd SteckmeisterandClaude Opus 5.5 382747cdb1 feat: Handy - groessere Symbole, Suche/Mitglieder im Menue, Kontakt-Banner repariert, Schrift 110 %, federnde Knoepfe
Bernds Feedback 2026-10-07:
- Schrift-Standard Handy 110 % (statt 115 %).
- PyrIconBtn: im Handy-Layout Symbol 22 px (vorher fest 16), Flaeche 40,
  Tippflaeche 44; Farben aus dem Farbmodus statt fest verdrahtet.
- Eingabeleiste: Anhaengen/GIF/Emoji/Senden auf dem Handy 22-23 px statt
  12-13 px.
- Chat-Kopf (Handy): Anrufen + Videoanruf sichtbar, Suchen + Mitglieder im
  Drei-Punkte-Menue.
- Aufklappbarer DM-Kopf als Ebenen: Zurueck + Symbole bleiben oben,
  Profilbereich gleitet darunter und nutzt die volle Breite (vorher Name zu
  "p..." gekuerzt, Symbole rutschten in die Mitte); Kuerzel im Avatar
  mittig, skaliert mit.
- SpringPress (widgets/spring_press.dart): federnde Druck-Rueckmeldung,
  aus bei "Animationen 0 %".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:13:32 +02:00
Bernd SteckmeisterandClaude Opus 5.5 36875ef9cd fix: Android-Backup und Geraeteumzug fuer App-Daten abschalten (Aussperr-Schutz) + Gradle 8.14.3
Ohne allowBackup=false/dataExtractionRules zog die SQLCipher-Datenbank beim
Handywechsel ohne ihren Keystore-Schluessel mit - AppDatabase bricht dann
bewusst ab, die App waere unbenutzbar. Ausserdem lag der Push-Zugangsschluessel
(SharedPreferences) im Cloud-Backup. Neues Geraet = neu anmelden + Schluessel
per Wiederherstellungsschluessel (Matrix-Weg). Gradle-Wrapper auf 8.14.3
(Flutter kuendigte Ende der 8.13-Unterstuetzung an).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:44:58 +02:00
Bernd SteckmeisterandClaude Opus 5.5 e90b954348 fix: Abmelden ohne verspaetete Sync-Antwort (SDK-Wettlauf) + Live-Regressionstest
logoutWithoutStraySync (lib/core/session_logout.dart): Sync-Schleife
anhalten, laufenden Long-Poll per Account-Daten wecken und auslaufen lassen,
erst dann abmelden - es ist keine Anfrage mit altem Token mehr unterwegs,
die per 401 ein zweites clear() ausloesen koennte. Genutzt an allen drei
Abmelde-Stellen (Einstellungen, Ueber, Konto geloescht) und im E2E-Live-Test.
Live gemessen: die verspaetete 401 tritt real auf, kam aber stets vor dem
Abschluss einer Neuanmeldung an - Risiko war klein, ist jetzt ausgeschlossen.
test/live_logout_race_test.dart in scripts/test.ps1 -Live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:39:37 +02:00
Bernd SteckmeisterandClaude Opus 5.5 f09e4875c6 chore: Version 0.1.37+38 (Test-Build Beta-Feedback) + PROGRESS/ROADMAP nachgezogen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:25:23 +02:00
Bernd SteckmeisterandClaude Opus 5.5 c38e9c18b0 feat: Handy-Chatliste als Messenger-Kachel (Vorschau, Uhrzeit, Ungelesen) + deutsche Beschriftungen
- rooms_mobile_tile.dart: auf dem Handy grosser Avatar, Name + Uhrzeit der
  letzten Nachricht, Vorschau (gleiche Texte wie Push: Medien-Labels,
  Antwort-Zitat entfernt; "Du:" bzw. Absender in Gruppen), Ungelesen-Zaehler.
  PC behaelt die kompakte Liste, Voice-Kanaele ihre Darstellung.
- utils/date_labels.dart: chatListTime (heute Uhrzeit, Gestern, Wochentag,
  Datum; sicher an Zeitumstellungs-Tagen) + 5 Tests.
- Sichtbare englische Texte eingedeutscht (Chats durchsuchen,
  Direktnachrichten, Raeume, Nachricht..., Mitglieder, Suchen, ...).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:21:33 +02:00
Bernd SteckmeisterandClaude Opus 5.5 c5f10901e6 feat: UI-Umbau - weiches Dunkel, Schriftgroessen-Regler, Sprechblasen umschaltbar, Einstellungen als Vollbild
Bernds Entscheidungen 2026-10-07:
- theme.dart: ThemeVariant (Dunkel = neues weiches Dunkelgrau als Standard,
  Schwarz = bisheriges Fast-Schwarz, Hell) mit PyramidPalette; Nebentexte
  kontrastreicher. Alte Einstellung theme_dark wird uebernommen
  (dunkel -> weiches Dunkel). Fest verdrahtete Dunkelfarben im Profil-Popup
  und GIF-Picker folgen jetzt dem Farbmodus.
- appearance.dart: Schriftgroesse = Pyramid-Regler x Systemeinstellung
  (Handy-Standard 115 %, PC 100 %), MessageStyle Automatisch/Sprechblasen/
  Liste, gemeinsame isMobileLayout-Regel.
- message_bubbles.dart: Sprechblasen-Darstellung (eigene rechts in
  Akzentfarbe, Gruppen mit Name + Avatar, Bilder ohne Blase in voller Groesse);
  Listen-Darstellung unveraendert, Weiche in MessageGroup.
- Formatierte Nachrichten (u. a. Antworten) nutzten RichText ohne
  Schriftskalierung -> erschienen kleiner; jetzt Text.rich.
- Einstellungen auf dem Handy als ganze Seite, Schalter unter dem Text.
- UI-Durchlauf um Liste/Hell/Gruppe/Einstellungen erweitert; Tests fuer
  Theme-Varianten, Uebernahme alter Werte, Schriftfaktor, Darstellung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:16:07 +02:00
Bernd SteckmeisterandClaude Opus 5.5 a1d64ec813 feat: Handy - Platz fuer Statusleiste, randlose Bild-Vollansicht mit Gesten, groessere Medien im Chat
Bernds Beta-Feedback:
- main.dart: edgeToEdge statt immersiveSticky (Statusleiste schob sich ueber
  den Inhalt); MobileShell mit SafeArea + Statusleisten-Symbolen passend
  zum Theme, Hintergrund bg1 = Kopfzeilenfarbe.
- MediaViewer: Handy randlos auf Schwarz (vorher 24-px-Rand, halbtransparent);
  Tippen neben das Bild schliesst, aufs Bild blendet Leiste ein/aus; Wischen
  hoch/runter schliesst (nur ohne Zoom, ueber InteractiveViewer-Callbacks,
  kein Gestenkonflikt mit Zwei-Finger-Zoom); Doppeltipp zoomt an der Stelle.
- Chat: Bilder auf dem Handy bis halbe Bildschirmhoehe statt fest 320 px;
  Videos im echten Seitenverhaeltnis statt fest 16:9.
- UI-Durchlauf integration_test/mobile_ui_screens_test.dart (Handy-Format,
  Testprofil, Screenshots nach build/ui_screens/) + Testinhalte-Helfer
  test/live_ui_fixture_test.dart; scripts/test.ps1 -Ui.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:52:10 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ef1d881967 feat: Push als Chat-Verlauf - mehrere Nachrichten, voller Text, Bildvorschau, echtes "Gelesen"
Bernds Beta-Feedback: pro Raum wurde jede Nachricht durch die naechste ersetzt
und auf 100 Zeichen gekuerzt, ohne Bild. Jetzt:
- NotificationHelper: MessagingStyle mit den letzten 8 Nachrichten pro Raum
  (Verlauf in SharedPreferences, nur solange die Benachrichtigung sichtbar ist),
  gleiche eventId ersetzt (Platzhalter -> Klartext), nur Neues klingelt.
- notification_content.dart: Text bis 1000 Zeichen, Antwort-Zitat entfernt,
  Medien-Labels, Vorschau-Einstellung auch bei gekillter App; Bild (E2EE
  entschluesselt) + Avatar per FileProvider an die System-UI.
- "Gelesen" setzt eine echte Lesebestaetigung (Haupt-Engine bzw. BgEngine).
- Laufende App holt genau das gepushte Event statt room.lastEvent.
- Debug-only DebugNotifyReceiver zum Testen per adb ohne Login.
Emulator (API 36): Verlauf, Ersetzen, Bild, Avatar, Gelesen geprueft;
9 neue Tests. Echtes Geraet (Bernd) steht aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:27:56 +02:00
Bernd SteckmeisterandClaude Opus 5.5 460477204b docs: Bernds Beta-Feedback analysiert - Ursachen (Push, Statusleiste, Vollansicht) + Plan + UI-Entscheidungen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 12:32:36 +02:00
Bernd SteckmeisterandClaude Opus 5.5 dbc0340eeb docs: Wiederaufnahme 2026-10-07 - Tests/Benchmarks dokumentiert, SDK-Wettlauf + Abhaengigkeiten in ROADMAP
Neue Punkte: SDK-Sync-Wettlauf (Logout-Pfad pruefen), Abhaengigkeits-Updates
(sqlcipher_flutter_libs EOL - nie auf 0.7.0 heben, matrix 6.2 -> 13,
flutter_markdown eingestellt; WebRTC-Audio-Fix weiterhin nicht upstream).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:32 +02:00
Bernd SteckmeisterandClaude Opus 5.5 df1b392ed8 feat: Benchmarks (DB Klartext vs. SQLCipher, Migration) + scripts/test.ps1
benchmark/ misst Sync-Schreiben, App-Start, Chat oeffnen, lokale Suche und die
Migrationsdauer (A-B-B-A, Aufwaermlauf, Median), schreibt build/benchmarks/*.json
und vergleicht mit benchmark/baseline/. scripts/test.ps1 fuehrt Analyse,
Unit-Tests, optional Live-Test (-Live) und Benchmarks (-Bench) mit einem Befehl aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:30 +02:00
Bernd SteckmeisterandClaude Opus 5.5 f4d334a60b test: Live-E2E-Test gegen SDK-Sync-Wettlauf abgesichert (sporadisch rot)
matrix 6.2 wirft den Fehler einer veralteten Sync-Antwort, bevor es die
Request-ID prueft: die 401 eines alten Long-Polls nach dem Logout loeste ein
zweites clear() aus (nach dispose -> "databaseFactory not initialized").
_logoutWithoutStraySync haelt die Sync-Schleife an und laesst den Long-Poll
auslaufen. Danach 3/3 Laeufe gruen, 0 verspaetete Logout-Meldungen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:29 +02:00
Bernd SteckmeisterandClaude Opus 5.5 d5ff7982a0 refactor: lokale Nachrichtensuche als eigenes Modul (lib/features/search) + 5 Vertragstests
Verhaltensgleich aus search_modal.dart herausgeloest (gleiche Deckel 1500/60/500,
gleiche Schleife; Medien-Filter jetzt Parameter). Tests laufen ueber die echte
SDK-DB-Schicht mit synthetischen Daten (test/support/synthetic_matrix_data.dart).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:28 +02:00
Bernd Steckmeister b2413ef627 chore: Version 0.1.36+37 (Beta-Release) 2026-07-07 06:22:30 +02:00
Bernd Steckmeister a4f9fd70e6 chore: Anzeigename gross geschrieben -> 'Pyramid' (Launcher-Label, Fenstertitel, Windows-Produktname); Paket-ID/Binaername unveraendert 2026-07-07 06:22:29 +02:00
Bernd Steckmeister bf552ee194 feat: Windows-Audiogeraete-Erkennung reparieren - Dummy-PeerConnection weckt das Audio-Device-Modul (M3) 2026-07-07 06:22:28 +02:00
Bernd SteckmeisterandClaude Fable 5 e0179ad907 chore: auth_log-Check 23:12-03:06 - Mehr-Stunden-Fenster erfuellt, Langzeit-Punkt abgehakt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-07 03:07:40 +02:00
Bernd SteckmeisterandClaude Fable 5 8b36d16cdd chore: auth_log-Check 18:29-22:49 + 22:49-23:12 im PC_TESTPLAN dokumentiert
Dritter Netzausfall (18:41-18:46, ~4,5 min) selbst erholt ohne Logout;
8. Start mit verschluesselter DB sauber, Beobachtung laeuft weiter.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 23:13:40 +02:00
Bernd SteckmeisterandClaude Fable 5 f8f5332d90 feat: Call-Robustheit + Standalone-Voice-Channel per Klick beitretbar (M3)
Mit zwei echten Test-Accounts (zwei GUI-Instanzen via PYRAMID_PROFILE_DIR)
durchgeklickt: Beitreten, Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitaetswechsel (Full HD 60fps), sauberes Verlassen.

- rooms_list.dart: Voice-Channel in der normalen Raumliste war per Klick
  nicht beitretbar (oeffnete nur Chat) -> _toggleVoiceChannel wie Space-Liste.
- livekit_call_manager.dart: _callGeneration entwertet laufenden connect
  bei Auflegen/Wechsel (keine Geisterverbindung mit offenem Mikro);
  Fehlerpfade setzen error sichtbar statt still zu verschwinden.
- rooms_panel_core.dart: raeumt activeVoiceRoomIdProvider auf, wenn der
  Voice-Service inaktiv wird, + Fehler-SnackBar.
- test/live_call_setup_test.dart: gegateter Setup-Helfer fuer den Testraum.

UNGETESTET: echtes 2-Peer-Audio, Netz-Abbruch mitten im Call.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 22:50:58 +02:00
Bernd SteckmeisterandClaude Fable 5 ffd70caffd feat: 60-FPS-Streaming-Presets (hd60/fhd60) fuer Bildschirm-Streams (M4)
Nach docs/STREAMING_60FPS.md: neue Presets 720p/1080p mit 60 FPS und
hoher Bitrate (5/8 Mbit/s), Preset-Aufloesung+FPS werden jetzt bis in
die Capture-Constraints durchgereicht (vorher blieb der LiveKit-Default
1080p/15 aktiv), 60-FPS-Presets publishen ohne Simulcast mit
DegradationPreference.maintainFramerate (unter Last Aufloesung statt
Frames opfern). contentHint 'motion' ist in flutter_webrtc 1.4.1 aus
Dart nicht setzbar (geprueft) - im Code dokumentiert.
4 neue Vertragstests fuer die Preset-Tabellen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 22:19:25 +02:00
Bernd SteckmeisterandClaude Fable 5 9f797279eb test: Live-E2E-Verifikation SQLCipher mit echtem Login (Test-Account)
Neuer gegateter Test (PYRAMID_LIVE_TEST=1, Windows): echter Login als
pyramidtest1, Byte-Pruefung der Platte (kein SQLite-Header, Token +
Nachricht nicht im Klartext, Positiv-Kontrolle in Klartext-DB),
Neustart aus verschluesselter DB, Logout/Re-Login, Migration einer
Klartext-DB mit echter eingeloggter Session. Zugangsdaten bleiben
ausserhalb des Repos.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 22:11:48 +02:00
Bernd SteckmeisterandClaude Fable 5 dba4b64df8 feat: PYRAMID_PROFILE_DIR-Override fuer Desktop-Testprofile (app_dirs.dart)
DB, auth_log und Media-Cache laufen jetzt ueber appSupportDir(); nur auf
Desktop und nur wenn PYRAMID_PROFILE_DIR gesetzt ist, landet alles in einem
eigenen Profilverzeichnis - fuer Autopilot-Testlogins parallel zur echten
Session. Ohne Variable exakt das bisherige Verhalten. secure_storage/
shared_preferences bewusst nicht umgeleitet (Begruendung im Dateikopf).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:55:30 +02:00
Bernd Steckmeister fdc16afa70 docs: Streaming-60fps-Task fuer Autopilot (M4) - hohe Aufloesung + 60 FPS, Ist-Analyse + Umsetzungsschritte 2026-07-06 18:51:59 +02:00
Bernd SteckmeisterandClaude Fable 5 9950e1eb29 chore: CHANGES-Hook-Log nachgezogen
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:29:49 +02:00
Bernd SteckmeisterandClaude Fable 5 17b3798dc0 docs: zweiter Netz-Blip 18:25 ebenfalls ohne Logout ueberstanden; Frage an Bernd (Test-Account fuer Emulator-Tests) notiert
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:29:36 +02:00
Bernd SteckmeisterandClaude Fable 5 f2b9afbfea test: SQLCipher auf Android-Emulator verifiziert (Neuanlage verschluesselt, Klartext-Migration, Neustart ohne Re-Migration)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:28:25 +02:00
Bernd SteckmeisterandClaude Fable 5 9693174de9 docs: auth_log-Check 17:43-Fenster - echter 7-min-Netzausfall ohne Logout ueberstanden, Sync selbst erholt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:20:24 +02:00
Bernd SteckmeisterandClaude Fable 5 bca7abb206 docs: Session-Abschluss protokolliert (Analyse-Korrektur, VoicePrefs-Tests, 32 Tests im Testplan, Beobachtung laeuft seit 17:43)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:55:03 +02:00
Bernd SteckmeisterandClaude Fable 5 3698048b26 test: VoicePrefs-Fassade abgesichert - historische Keys, null/leer-Loeschen, Fallback 1.0 (3 neue Tests, gesamt 32)
Nagelt den dokumentierten Vertrag der M2-Fassade (d22f9c2) fest, damit
bestehende Nutzer-Einstellungen (Utas Geraet) bei weiteren Umbauten der
Call-Schicht nachweislich erhalten bleiben.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:54:06 +02:00
Bernd SteckmeisterandClaude Fable 5 3300a44c4a docs: State-Management-Doc korrigiert - MediaCache-Umstellung ist NICHT risikoarm (Chat-Timeline haengt dran), zurueckgestellt
Der erste Befund beruhte auf einem durch head-limit abgeschnittenen grep;
message_media.dart (am wenigsten geprueft laut PC_TESTPLAN 3) nutzt den
Cache ebenfalls, inkl. statischem Wrapper ohne ref-Zugang.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:52:09 +02:00
Bernd SteckmeisterandClaude Fable 5 6b0ae21f2b docs: State-Management-Ist-Analyse + Umbauplan (M2), ROADMAP-Punkt annotiert
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:50:05 +02:00
Bernd SteckmeisterandClaude Fable 5 41bcaf2a88 chore: toten incomingCallProvider aus app_state.dart entfernt (seit M2-Call-Pilot unreferenziert)
Repo-weit verifiziert: einzige Fundstelle war die Definition selbst -
der Pilot hat die Call-Provider ersatzlos in die Module verlegt
(callSignalingProvider/voiceChannelProvider), dieser StateProvider
blieb ungenutzt zurueck. analyze sauber, 29 Tests gruen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:48:16 +02:00
Bernd SteckmeisterandClaude Fable 5 52b0ae975a docs: settings_encryption-Split + auth_log-Check 17:14-Fenster protokolliert, Beobachtung auf 17:43 umgestellt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:45:36 +02:00
Bernd SteckmeisterandClaude Fable 5 6128953e28 refactor: settings_encryption.dart (1470 Zeilen) in 4 thematische part-Dateien unter encryption/ geteilt (M2, zeichenidentisch verifiziert)
Vorbereitung fuer Schnitt-Doc Schritt 3 (verification-Modul), ohne den
Verschluesselungs-heiligen Code anzufassen: megolm_crypto (Export/Import-
Krypto-Helfer), encryption_section (Enums + _EncryptionSection),
recovery_key_dialogs (_ChangeRecoveryKeyDialog/_ShowKeyBody/_ConfirmKeyBody),
fingerprint_passphrase (_FingerprintGroup/_PassphraseDialog).
Rekonstruktion zeichenidentisch (1468 Code-Zeilen, ordinaler Vergleich),
analyze sauber, 29 Tests gruen, App-Start ok (auth_log 17:43:14 loggedIn).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:43:53 +02:00
Bernd Steckmeister c8cc1480a8 docs: PC_TESTPLAN - Beobachtungsfenster auf 17:14 aktualisiert (Neustarts durch Refactoring-Praxistests) 2026-07-06 17:16:20 +02:00
Bernd Steckmeister 643ba662dd docs: voice_channel-Split protokolliert, ROADMAP-Nachtrag (app_shell bleibt bewusst ungeteilt) 2026-07-06 17:15:46 +02:00
Bernd Steckmeister 95d9b07dbd refactor: call_ui/voice_channel.dart (1123 Zeilen) in 5 thematische part-Dateien unter view/ geteilt (M2, zeichenidentisch verifiziert) 2026-07-06 17:15:12 +02:00
Bernd Steckmeister b131f56b72 docs: M2-Teilschritte call_ui-Modul + VoicePrefs-Fassade protokolliert, ROADMAP/Schnitt-Doc nachgezogen 2026-07-06 17:10:37 +02:00
Bernd Steckmeister d22f9c2961 refactor: voice_*-Prefs hinter VoicePrefs-Fassade in settings_prefs (M2 storage-Leitplanke, gleiche Keys/Semantik) 2026-07-06 17:09:41 +02:00
Bernd Steckmeister b2335da2fb refactor: Call-UI als Modul lib/features/call_ui (M2, Pilot-Schritt vervollstaendigt - reine Umbenennung) 2026-07-06 17:09:40 +02:00
Bernd Steckmeister b84d31ca87 docs: auth_log-Langzeit-Check - 12:43-Lauf endete frueh (kein Logout), Beobachtung 16:57 neu gestartet 2026-07-06 16:59:34 +02:00
Bernd Steckmeister f87db183ea docs: Haertetest-Stand in PC_TESTPLAN/ROADMAP/PROGRESS - automatisierbarer Teil erledigt
- flutter test gruen (29), Session ueber 3 Starts + DB-Migration stabil,
  E2EE-Empfang belegt, auth_log unauffaellig
- Langzeit-Beobachtung laeuft (App entkoppelt gestartet 12:43); Klick-Teile
  (Senden, Logout/Re-Login, Splits 2-8, Voice-Join) fuer Bernd abgegrenzt
2026-07-06 12:45:20 +02:00
Bernd Steckmeister 3c7eb9ddca refactor: M2-Call-Pilot – Module call_signaling + voice_channel mit Fassaden (Entscheidung 2)
- CallSignalingService/callSignalingProvider und VoiceChannelService/
  voiceChannelProvider als einzige oeffentliche Schnittstellen
- voip_manager.dart / livekit_call_manager.dart in ihre Module verschoben,
  Logik unangetastet (nur Klassen-Koepfe, @override, Imports, Provider-Namen)
- Alle Konsumenten entkoppelt; Kompositions-Punkt matrix_client.dart als
  dokumentierte Ausnahme. analyze sauber, 29 Tests gruen, Windows-Start ok.
  Call-Klick-/Geraetetest steht aus (PROGRESS.md)
2026-07-06 12:42:59 +02:00
Bernd Steckmeister f64398d3c6 feat: SQLCipher-Verschluesselung der pyramid.sqlite (M1) via neue Storage-Fassade app_database.dart
- AppDatabase.open()/openForPush() kapseln Factory, Schluessel (Keystore),
  Klartext->SQLCipher-Migration (Backup/Verify/atomarer Tausch) und PRAGMAs
- Android/Windows/Linux verschluesselt, iOS/macOS bewusst Klartext
- 5 neue Tests mit echter SQLCipher-DLL; Windows-Praxistest mit echter DB
  erfolgreich (23 Tabellen verifiziert, E2EE intakt); Android UNGETESTET
2026-07-06 12:31:38 +02:00
Bernd SteckmeisterandClaude Fable 5 cead62ccb2 fix: Autopilot fest auf nordmann-Konto (Bernd 2026-07-06) – Review (aa): private E-Mail vor Publikation entfernt, *.bak-* gitignored
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 11:58:32 +02:00
Bernd SteckmeisterandClaude Fable 5 4d16d49012 docs: Fable-5-Review (z) – (y)-Protokoll geprüft (kein Befund), Ende-Kriterium der Review-Kette geschärft
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 19:03:56 +02:00
127 changed files with 12717 additions and 3556 deletions
+3
View File
@@ -46,3 +46,6 @@ app.*.map.json
autopilot.log autopilot.log
.claude/worktrees/ .claude/worktrees/
*.bak *.bak
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
*.bak-*
+183
View File
@@ -1309,3 +1309,186 @@
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 19:03 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-06 11:56 [Edit] /home/steggi/pyramid/autopilot.sh
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-06 12:16 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml
2026-07-06 12:22 [Write] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
2026-07-06 12:30 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 12:31 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 12:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 16:58 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 16:59 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:12 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
2026-07-06 17:13 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 17:41 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\0954ef5a-3f8c-4521-ac42-f4ee83e4873b\scratchpad\split_encryption.ps1
2026-07-06 17:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
2026-07-06 17:49 [Write] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:53 [Edit] C:\Users\nordm\pyramid - Kopie\test\settings_prefs_test.dart
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:19 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:22 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\b2380513-3ca6-4879-b9df-23f8ba907354\scratchpad\make_plain_db.dart
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 18:28 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:48 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_dirs.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1
2026-07-06 22:04 [Write] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_ui\view\quality_menu.dart
2026-07-06 22:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
2026-07-06 22:16 [Write] C:\Users\nordm\pyramid - Kopie\test\streaming_presets_test.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_panel_core.dart
2026-07-06 22:26 [Write] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:26 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:31 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\6aa736cb-f5e2-471d-9167-7695c1c38655\scratchpad\win_tools.ps1
2026-07-06 22:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_list.dart
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\test-accounts-gui.md
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md
2026-07-06 23:13 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 23:23 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-07 03:16 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\windows\runner\main.cpp
2026-07-07 03:19 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\mmprobe.cpp
2026-07-07 03:23 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\lib\main.dart
2026-07-07 03:24 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
2026-07-07 03:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-07 03:29 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\win_tools.ps1
+945
View File
@@ -13,6 +13,924 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
--- ---
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
**Erledigt (je ein Commit, alles gepusht):**
- **Handy-UI** (382747c): größere Symbole (Anruf, Video, Anhang, GIF, Emoji),
Suche/Mitglieder im ⋮-Menü, aufgeklapptes Kontakt-Banner (Kürzel mittig,
Symbole bleiben an ihrem Platz), Schrift-Standard 110 %, federnde Knöpfe.
- **Antwort-Zitat antippen** springt zur Nachricht (7063ebf).
- **Schwebende Eingabeleiste** (223c944): Chat läuft darunter weiter,
Milchglas nur im Eingabefeld, Fläche fängt Berührungen ab.
- **Lesebestätigung** (1d0ccfd): nur bei Fokus/Vordergrund, je Event einmal
(vorher bei jedem Neuaufbau, auch im Hintergrund – die gerade gezeigte
Benachrichtigung verschwand sofort); „Neue Nachrichten“-Trenner nach
Rückkehr in die App; „Lesebestätigungen senden“ aus = private Bestätigung
statt gar keiner (Ungelesen-Zähler stimmen). Haken-Logik geprüft: Uhr =
wird gesendet, ✓ = beim Server, goldenes ✓✓ = gelesen. Matrix kennt kein
„auf dem Gerät angekommen“ – daher keine zweistufigen grauen Haken.
- **Animationen** (ae6f651): Chat gleitet auf dem Handy federnd über die
Liste (Liste bleibt aufgebaut), neue Nachrichten/Reaktionen ploppen auf,
Zähler hüpfen, Scroll-Knopf/Antwort-Leiste/Senden-Pfeil federn, Vollansicht
zoomt auf; alles mit Einstellung „Animationen“ skaliert. Fix dabei: beim
Raumwechsel blieben Antwort-Ziel und getippter Text am neuen Raum hängen –
jetzt Entwurf je Raum.
- **LiveKit-Secret aus der App** (756486d): Token kommt von
`/api/livekit-token`; echter Beitritt mit Testkonto grün.
- **flutter_markdown_plus** statt eingestelltem flutter_markdown (0ad06e3).
- **Version 0.1.38+39** (df377d7).
**Offen/Nächster Schritt:** Bernd testet 0.1.38 auf dem Handy (Push, neue
Animationen, schwebende Leiste, Anruf/Sprachkanal wegen LiveKit-Umbau).
Danach: LiveKit-Secret rotieren (Bernd, wenn alle Geräte aktualisiert),
SQLCipher-Nachfolger (sqlite3 3.x), matrix-SDK-Sprung.
**Stolperfallen:** (1) Wischen-zum-Antworten gibt es nur auf echten Handys
(Platform-Check) – im Windows-UI-Test über Lange-Drücken → Antworten, und
NEBEN die Sprechblase drücken (im Text greift am PC die Textauswahl und
der Test bricht undurchsichtig ab). (2) `Matrix4.getMaxScaleOnAxis` liefert
bei 2D-Skalierung < 1 immer 1 (z-Achse) – `storage[0]` lesen.
(3) LiveKit protokolliert auf dem Pi nur Warnungen – Beitritte sind dort
nicht sichtbar; Nachweis über die Identität aus der Beitrittsantwort.
---
## 2026-10-07 (Teil 3) – Beta-Feedback umgesetzt: Push-Verlauf, Handy-UI, neues Aussehen → Test-Version 0.1.37
**Erledigt (je ein Commit, alles gepusht):**
- **Push als Chat-Verlauf** (ef1d881): MessagingStyle mit den letzten 8
Nachrichten pro Raum, voller Text (Deckel 1000 statt 100), Antwort-Zitat
entfernt, Medien-Labels, Bildvorschau (E2EE entschlüsselt, FileProvider),
Avatare, „Gelesen“ setzt echte Lesebestätigung. Emulator: Verlauf,
Platzhalter-Ersetzen, Bild, Avatar, Gelesen geprüft (DebugNotifyReceiver,
nur Debug-Build).
- **Handy** (a1d64ec): edge-to-edge statt immersiveSticky + SafeArea
(Statusleiste überdeckt nichts mehr); Bild-Vollansicht randlos, Tippen
daneben/Wischen schließt, Doppeltipp zoomt; Bilder im Chat bis halbe
Bildschirmhöhe, Videos im echten Seitenverhältnis.
- **UI-Umbau** (c5f1090): Farbmodus Dunkel (neues weiches Dunkelgrau,
Standard) / Schwarz (alt) / Hell; Schriftgrößen-Regler (Handy-Standard
115 % × Systemgröße); Nachrichten Automatisch/Sprechblasen/Liste;
Einstellungen auf dem Handy als Vollbild; formatierte Nachrichten
(Antworten!) ignorierten die Schriftgröße (RichText → Text.rich).
- **Chatliste** (c38e9c1): Handy-Kachel mit Avatar, Uhrzeit, Vorschau der
letzten Nachricht, Ungelesen-Zähler; sichtbare englische Texte eingedeutscht.
- **Werkzeuge:** `integration_test/mobile_ui_screens_test.dart` (echte App im
Handy-Format mit Testprofil, prüft Vollansicht-Gesten, Screenshots nach
`build/ui_screens/`), `test/live_ui_fixture_test.dart` (Testinhalte),
`scripts/test.ps1 -Ui`. 60 Unit-Tests grün.
- **Version 0.1.37+38**, release-signierte APKs lokal gebaut
(`build/app/outputs/flutter-apk/`), Signatur-Zertifikat identisch mit
v0.1.36 (SHA-256 c3de66be…), Release-APK startet auf dem Emulator ohne
Fehler. NICHT auf Gitea veröffentlicht (Bernd testet zuerst).
- **SDK-Logout-Wettlauf** geschlossen: `lib/core/session_logout.dart`
(`logoutWithoutStraySync`) an allen drei Abmelde-Stellen; Live-Test
`test/live_logout_race_test.dart` (Leerlauf + direkt nach Login, je 45 s
beobachtet). Ehrliche Einordnung: verspätete 401 tritt real auf, kam aber
stets vor Abschluss der Neuanmeldung – Risiko war klein. E2E-Live-Test nutzt
jetzt denselben App-Baustein.
- **Android-Backup aus** (allowBackup=false, fullBackupContent=false,
dataExtractionRules schließt Cloud-Backup UND Geräteumzug aus): verhindert
den Aussperr-Fall „verschlüsselte DB ohne Schlüssel auf neuem Handy“ und
den Push-Token im Cloud-Backup. Gradle 8.13 → 8.14.3.
**Offen/Nächster Schritt:** Bernd testet 0.1.37 auf dem Handy (Push mit
mehreren Nachrichten + Bild, „Gelesen“, Statusleiste, Vollansicht-Gesten,
Sprechblasen, Schriftgröße). Danach ROADMAP: SDK-Logout-Wettlauf im
App-Pfad, Abhängigkeiten (sqlcipher_flutter_libs EOL, Gradle 8.13→8.14,
Kotlin-Plugin-Migration laut Flutter-Warnung).
**Stolperfallen:** (1) Simulierte Fensterklicks (PostMessage) erreichen
Flutter unter Windows nicht → UI-Prüfung über integration_test. (2) PowerShell
ohne DPI-Awareness bekommt bei 125 % Skalierung verkleinerte Fenstermaße →
abgeschnittene Screenshots (SetProcessDPIAware). (3) SharedPreferences sind
unter Windows NICHT pro PYRAMID_PROFILE_DIR getrennt – Testläufe teilen Bernds
Einstellungen (nur lesen, im UI-Test nichts speichern). (4) RichText
skaliert nicht mit MediaQuery.textScaler – immer Text.rich verwenden.
(5) apksigner braucht JAVA_HOME = Android-Studio-JBR (aus `flutter doctor -v`).
(6) Kein `shrinkImageMaxDimension: 0` an sendFileEvent (SDK verkleinert auf 0).
---
## 2026-10-07 (Teil 2) – Bernds Beta-Feedback analysiert, Plan steht (NOCH KEIN CODE)
**Bernds Feedback (Beta v0.1.36, Android):** Push kommt an, aber (a) beim
Aufklappen viel weniger Text als bei Element/WhatsApp, (b) immer nur EINE
Nachricht statt mehrerer, (c) keine Bildvorschau. Handy-UI gefällt nicht:
dunkle Farbe, Schrift zu klein, oben kein Platz (Statusleiste überlagert
Text), Bild-/Video-Vollansicht unnötig klein (durchsichtiger Rahmen), lässt
sich nicht durch Tippen ins Leere schließen. Wunsch: genereller UI-Overhaul;
danach Liste weiter, Fokus Stabilität + Flexibilität. Härtetest (PC_TESTPLAN 1)
und Call-Tests (3) kann er noch nicht machen.
**Bernds Entscheidungen (AskUserQuestion, 2026-10-07):** Farben = „Weiches
Dunkel“ (Dunkelgrau statt #0A0A0C, Hell bleibt Option); Nachrichten-
Darstellung = UMSCHALTBAR (Sprechblasen / Liste, Einstellung); Schrift =
größerer Standard (≈16 statt 14 px) + Regler unter Erscheinungsbild, System-
Schriftgröße weiter beachten.
**Ursachen (im Code verifiziert):**
- Push (b): `NotificationHelper.show` nutzt EINE notifId pro Raum
(`stableId(roomId)`) und ersetzt die alte Benachrichtigung komplett.
- Push (a): kein Style (nur `setContentText`) UND Dart kürzt hart auf 100
Zeichen (`_buildBody` in background_push.dart, `_watchEvents` +
`_decryptAndUpdateNotification` in notification_service.dart).
- Push (c): kein Bild-Pfad vorhanden.
- Statusleiste: `main.dart` setzt `SystemUiMode.immersiveSticky`, und
`_MobileShell` (app_shell.dart) hat kein SafeArea → Leiste liegt über
Kopfzeile (auch auf April-Screenshots sichtbar).
- Vollansicht: `media_viewer.dart` Inhalt = Bildschirm minus 24 px,
Barriere 70 % transparent; leere Fläche neben dem Bild gehört zum
GestureDetector (`_toggle`) → Tippen blendet nur Knöpfe aus.
- Zusatzbefunde: Knopf „Gelesen“ schließt nur, sendet KEINE Lesebestätigung;
`_decryptAndUpdateNotification` nimmt `room.lastEvent` statt des gepushten
Events; Access-Token liegt für PushService.kt im Klartext in
FlutterSharedPreferences (`notif_access_token`) – Sicherheitsnotiz.
**Plan / nächster konkreter Handgriff (je ein Commit):**
1. Push: Dart-Modul für Benachrichtigungs-Inhalt (Text ohne 100er-Kürzung,
Antwort-Zitat entfernt via `calcUnlocalizedBody(hideReply: true)`,
Medien-Labels, `notif_preview` auch im Hintergrund beachten) + Tests;
native `NotificationHelper` auf `MessagingStyle` (Verlauf pro Raum in
SharedPreferences, nur fortführen solange Benachrichtigung aktiv ist;
Ersetzen per eventId statt Anhängen beim Platzhalter→Klartext; neue
Nachricht = Alarm, Update = still); Bild: Dart lädt Thumbnail
(`downloadAndDecryptAttachment(getThumbnail: true)`) nach
`cacheDir/downloads/notif/`, native per FileProvider (`.fileprovider`,
Pfad `downloads/` existiert) + `grantUriPermission("com.android.systemui")`;
„Gelesen“ → echte Lesebestätigung (Haupt-Engine bzw. BgEngine `markRead`).
Test: Debug-only Receiver zum Auslösen auf dem Emulator (kein Login nötig),
dann Bernd auf echtem Gerät.
2. Handy: `edgeToEdge` statt immersiveSticky + SafeArea im MobileShell;
Vollansicht randlos/deckend schwarz, Tippen außerhalb des Bildes schließt,
Wischen nach unten schließt, Doppeltipp-Zoom.
3. UI-Overhaul nach Bernds Entscheidungen (Palette in theme.dart, globaler
TextScaler × Nutzer-Regler, Sprechblasen-Variante in chat/message/).
4. Danach ROADMAP-Liste (SDK-Logout-Wettlauf, Abhängigkeiten, …).
**Stolperfallen:** Android-Toolchain + Emulator `Medium_Phone_API_36.1`
laufen auf diesem PC (flutter doctor sauber). In die App auf dem Emulator
KEINE Zugangsdaten eintippen (echter Server) – UI im eingeloggten Zustand
per Windows-Build mit `PYRAMID_PROFILE_DIR=…\.pyramid-autopilot\profile1`
und schmalem Fenster (<900 px = Handy-Layout) prüfen.
---
## 2026-10-07 – Wiederaufnahme nach Pause: Bestandsaufnahme, Test-Runner, Benchmarks, Live-Test stabilisiert
**Erledigt:**
- **Bestandsaufnahme:** Repo synchron mit Gitea (0 vor/0 hinter), Arbeitsbaum
war sauber, Toolchain unverändert (Flutter 3.44.4 / Dart 3.12.2).
`flutter analyze` sauber (nur bekannter onUpdate-Hinweis), 36 Unit-Tests
grün. Autopilot (PC + Pi) weiterhin pausiert.
- **Lokale Suche als Modul:** `_localMessageSearch` aus `search_modal.dart`
nach `lib/features/search/local_message_search.dart` verschoben – gleiche
Deckel (1500/Raum, 60 Treffer, 500er-Blöcke), gleiche Schleife, nur der
Medien-Filter kommt jetzt als Parameter. 5 Vertragstests
(`test/local_message_search_test.dart`) auf der echten SDK-DB-Schicht.
- **Benchmarks** (`benchmark/`, `docs/TESTS_UND_BENCHMARKS.md`): Klartext vs.
SQLCipher (Sync-Schreiben, App-Start, Chat öffnen, Suche) + Migrationsdauer;
JSON-Berichte, Baseline in `benchmark/baseline/`, Regressionswarnung >25 %.
Ergebnis: SQLCipher-Aufschlag im Rauschen (±5–10 %), Migration ≈ 42 ms/MB,
Suche (Worst Case) 64 ms für 10 000 Nachrichten.
- **`scripts/test.ps1`:** Analyse + Unit-Tests (+ `-Live`, `-Bench`, `-All`,
`-UpdateBaseline`) mit Zusammenfassung; baut die SQLCipher-DLL bei Bedarf.
- **Live-Test sporadisch rot → Ursache gefunden, Test stabilisiert:** siehe
Befund unten. `live_encryption_e2e_test.dart` meldet sich jetzt über
`_logoutWithoutStraySync` ab (Sync anhalten, laufenden Long-Poll
auslaufen lassen, dann Logout). Danach 3 Läufe in Folge grün (Ergebnis
siehe unten).
**Befund (neu in ROADMAP, M1-Bereich):** matrix 6.2 `_innerSync` wirft den
Fehler einer Sync-Antwort, bevor geprüft wird, ob die Anfrage noch aktuell
ist. Die 401 eines alten Long-Polls nach einem Logout löst ein erneutes
`clear()` aus – nach einem schnellen Re-Login auf demselben Client würde das
die neue Session löschen. Im Test trat es als „databaseFactory not
initialized" nach `dispose()` auf. Upstream `main` unverändert (geprüft).
App-Logout-Pfad noch NICHT angefasst (heilig, braucht Gerätetest).
**Offen/Nächster Schritt:** Bernds Rückmeldungen aus der Beta-Testphase
(v0.1.36) einsammeln, dann Prioritäten setzen: Klick-Tests aus
`docs/PC_TESTPLAN.md`, App-Logout-Pfad gegen den SDK-Wettlauf prüfen,
Abhängigkeits-Updates (ROADMAP M2, Achtung `sqlcipher_flutter_libs` EOL).
**Stolperfallen:** (1) sqflite liefert für denselben Pfad dieselbe
Verbindung zurück – ein zweites Öffnen+Schließen kappt die erste
(Benchmark-„database_closed"). (2) Benchmark-Reihenfolge: der Dart-JIT wird
während des Laufs schneller, der zuerst gemessene Modus verlor ±20 % →
großer Aufwärm-Durchlauf + A-B-B-A. (3) `dart format` NUR auf neue Dateien
anwenden – Bestandsdateien sind nicht formatiert, sonst riesiger Diff.
(4) Vor einer Aussage nachmessen: Schreibkosten hängen NICHT nennenswert von
der Raumgröße ab (250 vs. 4000 Nachrichten/Raum ≈ +15 %).
---
## 2026-07-06 – Nacht-Session (C+B): 60-FPS-Streaming + Call mit ZWEI echten Test-Accounts real durchgeklickt
**Erledigt:** Fokus (C) Streaming und (B) Calls – beides mit zwei echten
Test-Logins (pyramidtest1 GUI-Fenster links, pyramidtest2 rechts, zwei
getrennte Profile über `PYRAMID_PROFILE_DIR`) am laufenden Windows-Build
verifiziert, nicht nur analysiert.
**(C) 60-FPS-Streaming (`docs/STREAMING_60FPS.md`, Commit ffd70ca):**
- Neue Presets `hd60` (720p·60, 5 Mbit/s) und `fhd60` (1080p·60, 8 Mbit/s)
in `qualityPresets` + `screenShareEncodings`; im SENDEN-Menü als
„HD 60fps"/„Full HD 60fps" ergänzt. Preset-Auflösung UND -FPS werden
jetzt bis in `ScreenShareCaptureOptions.params` durchgereicht (vorher
blieb der LiveKit-Default 1080p/15 aktiv, egal welches Preset). 60-FPS-
Modi publishen ohne Simulcast mit
`DegradationPreference.maintainFramerate` (unter Last Auflösung statt
Frames opfern). `contentHint='motion'` ist in flutter_webrtc 1.4.1 aus
Dart NICHT setzbar (Pub-Cache geprüft) – im Code ehrlich dokumentiert.
4 Vertragstests für die Preset-Tabellen (grün).
- **Real getestet:** pyramidtest1 teilt Bildschirm → pyramidtest2 sieht den
Stream (und umgekehrt, beide Richtungen). Über das SENDEN-Menü live auf
„Full HD 60fps" umgeschaltet: Screenshare-Track wird mit dem neuen
Encoding neu published, kein Absturz, Empfänger bekommt weiter Bild.
- **UNGETESTET (ehrlich):** subjektives „flüssiges 60-FPS-Gefühl" –
Sender und Empfänger liefen auf DEMSELBEN PC (Selbst-Streaming =
Hall-of-Mirrors), das taugt nicht zur Ruckel-Beurteilung. Ob
`sender.parameters.encodings` real `maxFramerate==60` trägt, ist nur am
Preset-Vertrag belegt, nicht am Live-RTP-Report. Braucht zwei echte
Geräte + Netz.
**(B) Call-Robustheit (Commit siehe git log):**
- **Bug gefunden + gefixt:** Standalone-Voice-Channels (nicht in einem
Space, sondern in der normalen Raumliste) waren per Klick GAR NICHT
beitretbar – `rooms_list.dart` öffnete für sie nur die Chat-Ansicht.
Jetzt tritt ein Klick dem Kanal bei/verlässt ihn (Toggle), exakt wie in
der Space-Liste (`_toggleVoiceChannel`). Real verifiziert: beide
Test-User beigetreten, Teilnehmerliste zeigt beide, Zähler „2".
- **Zustands-Härtung `livekit_call_manager.dart`:** (1) `_callGeneration`
entwertet einen noch laufenden `connect`, wenn zwischendurch aufgelegt/
gewechselt wird – vorher konnte eine Geisterverbindung mit offenem Mikro
im alten Kanal zurückbleiben, während die UI „nicht verbunden" zeigte.
(2) Fehlerpfade setzen `error` sichtbar (unerwarteter
`RoomDisconnectedEvent` mit Grund, fehlgeschlagener Beitritt) statt
still aus dem Kanal zu verschwinden – CLAUDE.md „keine stillen
Fehlerpfade". (3) `rooms_panel_core.dart` hört auf den Voice-Service und
räumt `activeVoiceRoomIdProvider` auf, sobald der Service inaktiv wird
(Kanal-Highlight/eigener Teilnehmer-Eintrag blieben sonst hängen),
und zeigt den Fehler als SnackBar.
- **Real getestet:** Beitreten (beide), Teilnehmerliste, Screenshare
bidirektional, Qualitätswechsel live, sauberes Verlassen (pyramidtest1
legt auf → bei test1 Voice-Widget weg + Kanal nicht mehr aktiv, bei
test2 Teilnehmerzähler auf 1, test1-Stream verschwunden).
- **Setup-Helfer `test/live_call_setup_test.dart`** (gegated wie die
Encryption-Tests): legt den Voice-Testraum an und bringt beide Test-User
rein, damit die zwei GUI-Instanzen sofort joinen können.
- **UNGETESTET (ehrlich):** echtes 2-Peer-AUDIO (beide auf einem PC =
Rückkopplung, nicht sinnvoll prüfbar). VerbindungsABBRUCH mitten im Call
(Netz weg → `RoomDisconnectedEvent`-Pfad mit meiner Fehlermeldung) nur
im Code abgesichert, nicht live simuliert – braucht kontrollierten
Netzausfall. M3-Punkt „Voice-Channel-Logik robust" daher nur annotiert,
nicht abgehakt.
**Offen/Nächster Schritt:** auth_log-Check des Beobachtungslaufs (Bernds
echtes Profil neu gestartet 22:49:22 `loggedIn`, keine Re-Migration) –
Einträge seit 2026-07-06 22:49 prüfen. Für Bernd: 60-FPS-Streaming auf zwei
ECHTEN Geräten übers Netz gegentesten (Ruckeln?), echtes 2-Peer-Audio.
**Stolperfallen:** (1) `PYRAMID_PROFILE_DIR` leitet DB/Media um, aber NICHT
flutter_secure_storage – zwei Profile teilen sich denselben
`db_cipher_key`. Für reine Login-Tests unkritisch (Schlüssel wird nur
gelesen). (2) Der SENDEN-Qualitäts-Zahnrad taucht erst beim Hovern über den
Hero-Videobereich auf (Bottom-Bar slidet ein). (3) Eine per
`flutter build` neu gebaute exe lässt sich nicht überschreiben, solange die
Beobachtungs-exe läuft – vorher beenden.
---
## 2026-07-06 – Nacht-Session (A): SQLCipher mit ECHTEM Login live verifiziert (Test-Account, 2 neue E2E-Tests grün)
**Erledigt:** Bernds Fokus (A) für die Nacht – die Verschlüsselung nicht nur
behaupten, sondern mit einem echten Konto belegen. Bernd hat dafür
Test-Accounts bereitgestellt (`~/.pyramid-autopilot/test-accounts.txt`,
pyramidtest1/pyramidtest2 auf steggi-matrix.work – NICHT Bernds/Utas Konten).
Neuer Live-Test `test/live_encryption_e2e_test.dart` (nur auf Windows, nur
mit `PYRAMID_LIVE_TEST=1`, Zugangsdaten bleiben AUSSERHALB des Repos):
- **Test 1 – frische Installation (grün, echter Lauf gegen den Homeserver):**
DB verschlüsselt angelegt (Schlüssel-Injektion wie in den Bestands-Tests),
echter Passwort-Login als pyramidtest1, Testraum angelegt, Nachricht mit
Zufalls-Marker gesendet. Danach Byte-Prüfung der Platte: Header ist NICHT
„SQLite format 3", und weder der echte Access-Token noch der
Nachrichtentext sind in `pyramid.sqlite`/`-wal` im Klartext auffindbar.
Gegenprobe: Raw-Query MIT Schlüssel findet beides in der Datei → die Daten
stecken wirklich drin, nur verschlüsselt. „Neustart" (DB neu geöffnet,
neuer Client): Session inkl. identischem Access-Token kommt aus der
verschlüsselten DB, ohne Passwort. Dann Logout → erneuter Login → alte
Nachricht wieder lesbar (Timeline inkl. History-Nachladen).
- **Test 2 – Utas Update-Fall mit ECHTER Session (grün):** Klartext-DB wie
vor der SQLCipher-Einführung, echter Login, Nachricht. POSITIV-KONTROLLE:
in der Klartext-DB findet die Byte-Suche Token UND Nachricht (beweist,
dass die Suche Leaks erkennen würde). Dann `migratePlaintextToEncrypted`
(exakt der App-Codepfad): Migration grün, `.premigration`-Backup da,
danach ist auf der Platte nichts mehr im Klartext, Session (gleicher
Access-Token) und Nachricht überleben die Migration. Das war bisher der
große UNGETESTET-Punkt „Migration mit echter eingeloggter Session".
- Beide Tests räumen serverseitig auf (Logout am Ende). Gesamtsuite: 32
Tests grün, die 2 Live-Tests skippen ohne Gate sauber. analyze sauber
(nur bekannter onUpdate-Hinweis).
**Ehrlich abgegrenzt (bleibt UNGETESTET):** flutter_secure_storage wird im
Host-Test umgangen (Schlüssel direkt injiziert; der Keystore-Pfad ist durch
die echten App-Läufe Windows + Android-Emulator belegt). E2EE-verschlüsselte
Räume kann der Host-Test nicht (kein vodozemac) – der Testraum ist
unverschlüsselt, was für die Klartext-Suche der härtere Fall ist. Echtes
ARM-Gerät + FCM-Push-Isolate bleiben offen (ROADMAP-Punkt weiter nicht
abgehakt).
**Offen/Nächster Schritt:** Fokus (C) Streaming 60 FPS
(`docs/STREAMING_60FPS.md`), danach Fokus (B) Call-Logik mit beiden
Test-Accounts.
**Stolperfallen:** `Client.init(waitUntilLoadCompletedLoaded: false)` lädt
die Raumliste ASYNCHRON – wer direkt nach dem Init `client.rooms`/
`getRoomById` prüft, sieht eine leere Liste und meldet fälschlich
Datenverlust. Für DB-Restaurations-Checks `waitUntilLoadCompletedLoaded:
true` verwenden.
---
## 2026-07-06 – SQLCipher auf ANDROID getestet (Emulator API 36): Neuanlage, Migration und Neustart grün
**Erledigt:** Der größte offene Teil von Testplan Punkt 4
(`docs/SQLCIPHER_MIGRATION.md`) ist jetzt real auf Android belegt – kein
Gerät angeschlossen, aber der vorhandene Emulator (Medium Phone API 36.1,
x86_64, MIT Play Services) reicht für die DB-Schicht:
- **Szenario A – frische Installation:** Debug-APK installiert, App
gestartet → `pyramid.sqlite` wird von Anfang an VERSCHLÜSSELT angelegt
(Header = Zufallsbytes statt „SQLite format 3", per `run-as`/`od`
verifiziert); auth_log ohne Cipher-Fallback oder Fehler.
- **Szenario B – Update-Szenario (Utas Fall):** `pm clear` (auch der
Keystore-Eintrag weg) + synthetische Klartext-DB (2 Tabellen, 550
Zeilen) VOR dem ersten Start in `files/` gelegt → beim Start lief die
echte Migration: auth_log „Klartext-DB erfolgreich nach SQLCipher
migriert (2 Tabellen verifiziert)", `.premigration`-Backup liegt da,
DB danach verschlüsselt, App lief normal weiter (Login-Screen,
Screenshot geprüft). **Bewusst NICHT mit Bernds echter DB getestet** –
die enthält seine Session/Olm-Account, ein Duplikat auf einem zweiten
„Gerät" würde den E2EE-State korrumpieren.
- **Szenario C – Neustart:** force-stop + Relaunch → verschlüsselte DB
öffnet mit dem Schlüssel aus dem Android-Keystore, KEINE zweite
Migration, kein Fehler. Damit sind `openCipherOnAndroid`,
`sqlcipher_export` und flutter_secure_storage auf Android
(Haupt-Isolate) praktisch belegt.
- Doku nachgezogen: Status-Kopf `docs/SQLCIPHER_MIGRATION.md`,
ROADMAP-M1-Vermerk. Emulator danach heruntergefahren. Nebenbei:
`android/gradle.properties` bekam beim APK-Build automatisch zwei
Flutter-Migrator-Flags (`builtInKotlin=false`, `newDsl=false` – pinnt
bestehendes Verhalten), mit committet.
**UNGETESTET bleibt (ROADMAP-Punkt weiter NICHT abgehakt):** echtes
ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
der Push-Test (FCM-Background-Isolate liest verschlüsselte DB,
secure_storage im Background-Isolate) – braucht ein Testgerät mit
eingeloggtem Account (Login-Daten hat nur Bernd). Erst danach Release
und Utas Gerät.
**Offen/Nächster Schritt:** auth_log-Check des weiterlaufenden
Windows-Beobachtungslaufs (Einträge seit 2026-07-06 18:29; Check 18:29
ergab einen zweiten kurzen Netz-Blip 18:25:56, ~40 s, wieder selbst
erholt ohne Logout – im PC_TESTPLAN eingetragen). Für Bernd unverändert:
PC_TESTPLAN 1 (Rest) + 2–8 durchklicken; fürs SQLCipher-Finale ein
echtes Android-Gerät anstecken (Debug-Build reicht, Ablauf wie hier im
Emulator). Falls Bernd dem Autopilot einen TEST-Account (eigener User
auf steggi-matrix.work, nicht Bernds/Utas) bereitstellt, könnte der
Emulator-Test zusätzlich echten Login + Session-Migration + FCM-Push
abdecken – als Frage notiert, nicht eigenmächtig anlegen.
**Fragen an Bernd:** Soll ich für künftige Emulator-/Gerätetests einen
eigenen Matrix-TEST-Account bekommen (Nutzername+Passwort, z. B.
@pyramidtest)? Damit wären Login-Flow, Migration mit echter Session und
Push auf Android ohne Risiko für echte Konten automatisiert testbar.
**Stolperfallen:** (1) Ein Dart-Skript außerhalb des Repos kann
`package:`-Imports nicht auflösen – Hilfsskripte kurz ins Repo legen
(`tool/`, untracked) und nach dem Lauf löschen. (2) Für Datei-Zugriff im
App-Sandbox-Verzeichnis braucht es eine DEBUG-APK (`run-as` geht nur bei
debuggable); das Emulator-Image hat kein `sqlite3`-Binary – Test-DBs
also am PC bauen und pushen.
---
## 2026-07-06 – auth_log-Check 17:43-Fenster: echter ~7-min-Netzausfall OHNE Logout überstanden (stärkster Positiv-Befund bisher)
**Erledigt:** Vierte PC-Session, zuerst der Pflicht-Check der Vorsession
(auth_log seit 17:43), Ergebnis im PC_TESTPLAN Abschnitt 1 eingetragen:
- **Fenster 17:43–18:19:** `loggedIn` um 17:43:14, dann um 18:09:57 ein
ECHTER Verbindungsabriss zum Homeserver – „Syncloop failed: Client has
not connection to the server" alle 3 s bis 18:16:39 (~7 Minuten;
Server war zum Check-Zeitpunkt von diesem PC wieder erreichbar,
HTTP 200). **Kein Logout, keine Eskalation** – der Sync erholte sich
von selbst (WAL-Write 18:18:13 NACH der letzten Fehlerzeile, Prozess
durchgehend am Leben). Das ist genau der Fehlerpfad, den die
Uta-Logout-Fixes (63e4919/9dc83f7/891f348) absichern sollen, erstmals
mit einem realen Netzausfall belegt.
- Beobachtungslauf (PID seit 17:43) läuft unangetastet weiter; kumulativ
heute 12:26–18:19 über 7 Starts ohne einen einzigen Random-Logout.
**Offen/Nächster Schritt:** SQLCipher-Android-Test per Emulator
(Medium Phone API 36.1 ist verfügbar, kein echtes Gerät angeschlossen) –
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`, so weit im Emulator
abbildbar; Rest (echtes Gerät, FCM-Push-Isolate) klar abgrenzen.
**Stolperfallen:** Erfolgreiche Syncs werden NICHT geloggt – das Ende
einer Fehlerserie im auth_log erkennt man am Ausbleiben weiterer
Fehlerzeilen plus einem WAL-Write danach, nicht an einem „recovered"-Eintrag.
---
## 2026-07-06 – Analyse-Korrektur (MediaCache NICHT risikoarm) + VoicePrefs-Vertragstests, Session-Ende sauber
**Erledigt:** Abschluss der dritten PC-Session:
- **Ehrlichkeits-Korrektur im State-Management-Doc (3300a44):** Die
Einstufung „MediaCache hinter Provider = risikoarmer Lückenfüller" war
FALSCH – sie beruhte auf einem durch `head_limit` abgeschnittenen grep.
Tatsächlich nutzt auch `message_media.dart` den Cache (7 Stellen, inkl.
statischem `_DocBytesCache`-Wrapper ohne ref-Zugang), und genau dieser
Chat-Timeline-Code ist laut PC_TESTPLAN 3 der am wenigsten geprüfte
Teil. Schritt als ZURÜCKGESTELLT markiert (gehört zum
chat_timeline-Modul, Schnitt-Doc 9); ROADMAP-Vermerk angepasst.
- **VoicePrefs-Vertragstests (3698048):** 3 neue Tests in
`test/settings_prefs_test.dart` nageln die dokumentierten Garantien der
gestrigen/heutigen Fassade fest (historische voice_*-Keys unverändert →
Utas Bestands-Einstellungen bleiben erhalten; null/leer LÖSCHT das Pref
statt leeren String zu speichern; Fallback-Lautstärke 1.0). Gesamt jetzt
**32 Tests grün**, analyze sauber.
- **auth_log-Endkontrolle 17:54:** Beobachtungslauf seit 17:43 unauffällig
(keine neuen Einträge, DB-Writes laufen, Prozess lebt) – App bleibt über
das Session-Ende hinaus als Langzeit-Beobachtung laufen.
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
17:43) in der nächsten Session – das Fenster wächst jetzt ungestört, kein
weiterer Neustart geplant. Ohne Bernds Klick-Tests (PC_TESTPLAN 1 Rest,
2–8) ist am PC nichts Risikoarmes mehr offen; danach: State-Management
Schritte 1–2 (docs/M2_STATE_MANAGEMENT.md) bzw. SQLCipher-Gerätetest
(Android, `docs/SQLCIPHER_MIGRATION.md` Punkt 4).
**Stolperfallen:** grep-Werkzeuge mit Treffer-Limit (`head_limit`)
schneiden still ab – vor Architektur-Aussagen („X wird nur von Y benutzt")
die Trefferliste IMMER ohne Limit bzw. mit Vollständigkeits-Check erheben.
Der Fehler stand schon committet im Doc und hätte die nächste Session in
einen „risikoarmen" Umbau am ungeprüftesten Code der App gelockt.
---
## 2026-07-06 – State-Management-Ist-Analyse (docs/M2_STATE_MANAGEMENT.md) + toter incomingCallProvider entfernt
**Erledigt:** Nächster offener M2-Punkt „Einheitliches State-Management" –
nach dem erprobten Vorgehen erst die Analyse, Umsetzung folgt gestaffelt:
- **`docs/M2_STATE_MANAGEMENT.md` geschrieben:** Repo-weite Bestandsaufnahme
(Riverpod schon dominant, ~50 Provider, keine ValueNotifier/eigenen
StreamController). 4 Misch-Patterns identifiziert und gewichtet:
(1) Call-Singletons + ChangeNotifier hinter den Pilot-Fassaden mit
UI-Zustand IN den Managern, (2) `activeVoiceRoomIdProvider`/
`voiceParticipantsProvider` liegen in app_state.dart statt im
voice_channel-Modul, (3) `MediaCache.instance`, (4) Push-Globals
(bewusst so, Isolate-Zwang – KEIN Umbau-Kandidat). Zielbild +
Schrittfolge mit klarem Gating: Schritte 1–2 (Call-Pfad) GESPERRT bis
Bernds Klick-Test des Piloten; frei ist nur MediaCache-hinter-Provider.
- **Toter `incomingCallProvider` entfernt (41bcaf2):** einzige Fundstelle
repo-weit war die Definition in app_state.dart – Überbleibsel des
Call-Piloten (Provider ersatzlos in die Module verlegt). analyze sauber,
29 Tests grün.
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
17:43). Umsetzbar ohne Bernd wäre noch Schritt 3 aus dem neuen Doc
(MediaCache hinter Provider); Schritte 1–2 erst nach dem Klick-Test.
**Stolperfallen:** Keine – Analyse + risikofreie Leichen-Entfernung.
---
## 2026-07-06 – auth_log-Check 17:14-Fenster sauber + settings_encryption.dart in 4 part-Dateien geteilt (6128953)
**Erledigt:** Dritte PC-Session des Tages. Zuerst der von der Vorsession
hinterlassene Pflicht-Check, dann der nächste risikoarme M2-Schritt:
- **auth_log-Check (PC_TESTPLAN Abschnitt 1 aktualisiert):** Fenster
2026-07-06 17:14–17:42 sauber – einziger Eintrag ist das `loggedIn` des
17:14-Starts, kein Logout, kein Fehler, DB-Writes liefen durchgehend
(WAL zuletzt 17:37 beim Check). Lauf um 17:42 vom Autopilot beendet
(Rebuild nötig). Kumulativ heute 12:26–17:42 über 6 Starts ohne einen
Random-Logout; für „mehrere Stunden am Stück" bleibt der Punkt offen.
- **`settings_encryption.dart` (1470 Zeilen, größte verbliebene
Settings-Teildatei) nach dem 7-fach erprobten Muster geteilt (6128953):**
4 part-Dateien unter `lib/widgets/settings/encryption/` –
`megolm_crypto` (Export/Import-Krypto-Helfer, PBKDF2/AES-CTR),
`encryption_section` (Enums + _EncryptionSection),
`recovery_key_dialogs` (_ChangeRecoveryKeyDialog/_ShowKeyBody/
_ConfirmKeyBody), `fingerprint_passphrase` (_FingerprintGroup/
_PassphraseDialog). Das ist Vorbereitung für Schnitt-Doc Schritt 3
(verification-Modul), OHNE den Verschlüsselungs-heiligen Code
anzufassen: Skript-Split mit Rekonstruktions-Verifikation, alle 1468
Code-Zeilen ZEICHENIDENTISCH (ordinaler Vergleich), keine Zeile geändert.
- **Geprüft:** analyze sauber (nur bekannter onUpdate-Hinweis), 29 Tests
grün, `flutter build windows --debug` + entkoppelter App-Start: auth_log
17:43:14 `loggedIn`, keine erneute Migration, kein Fehler. Beobachtung
läuft seit 17:43 mit dem neuen Code.
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
17:43). Klick-Tests (PC_TESTPLAN 1 Rest + 2–8) weiter bei Bernd – der
Verschlüsselungs-Reiter (Abschnitt 2) deckt jetzt zugleich diesen Split ab.
Verification-Modul (Schnitt-Doc 3) NICHT ohne Bernd anfangen
(2-Geräte-Test nötig); UI-State-Herauslösen wartet auf den Klick-Test.
**Stolperfallen:** Ein Debug-Rebuild kann die laufende exe nicht
überschreiben – den entkoppelten Beobachtungs-Lauf vorher gezielt beenden
(Stop-Process), das Fenster im PC_TESTPLAN sauber abschließen und nach dem
Build neu starten; sonst schlägt der Build mit gesperrter Datei fehl.
---
## 2026-07-06 – call_ui/voice_channel.dart in 5 part-Dateien geteilt (letzte Gott-Datei der Call-Schicht, 95d9b07)
**Erledigt:** Die beim M0-Aufräumen bewusst zurückgestellte Datei
`voice_channel.dart` (1123 Zeilen – wartete auf die Call-Fassaden-
Entscheidung, die seit dem Piloten gefallen ist) ist nach dem 6-fach
erprobten Muster geteilt: Owner (19 Zeilen, Imports + part-Direktiven) +
5 thematische part-Dateien unter `lib/features/call_ui/view/`
(`voice_channel_core` = VoiceChannelView/_ParticipantData, `call_view` =
_UnifiedCallView/Header, `participant_layout` = Grid/Hero/Strip/Tile,
`avatars_controls` = Avatare/_CallControls/_NoCall/Fullscreen,
`quality_menu` = ScreenShare-/Qualitäts-Menüs/_VoiceCtrlBtn).
- **Beweis:** Skript-Split mit Rekonstruktions-Verifikation – Header +
Teile sind ZEICHENIDENTISCH zum Original (1123 Zeilen, case-sensitiver
Zeilenvergleich). Keine einzige Code-Zeile geändert.
- **Geprüft:** analyze sauber (nur bekannter onUpdate-Hinweis), 29 Tests
grün, Build + App-Start ok (auth_log 17:14:31 `loggedIn`); App läuft
seit 17:14 wieder entkoppelt als Langzeit-Beobachtung.
- `app_shell.dart` (1073 Zeilen) bleibt bewusst ungeteilt –
Bootstrap/Login-Pfad ist heilig, kein Anlass.
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
17:14). Danach wartet die Call-Schicht auf Bernds Klick-Test
(PC_TESTPLAN Abschnitt 8); nächster risikoarmer M2-Kandidat wäre
Schnitt-Doc Schritt 3 (verification-Modul) – aber Verifikations-Flows
sind Verschlüsselungs-heilig und brauchen einen 2-Geräte-Test, daher
nicht ohne Bernd anfangen.
**Stolperfallen:** In PowerShell bindet das Komma stärker als Minus:
`@(999, $l.Count - 1)` ist `(999, $l.Count) - 1` (Array-Minus = Fehler) –
immer `($l.Count - 1)` klammern. Und `Measure-Object -Line` zählt
Leerzeilen NICHT mit – für echte Zeilenzahlen `$array.Count` nehmen.
---
## 2026-07-06 – M2-Call-Schicht, zwei weitere Teilschritte: call_ui-Modul + VoicePrefs-Fassade (verhaltensgleich)
**Erledigt:** Nach dem auth_log-Check die zwei risikoärmsten der offenen
inneren Aufräumarbeiten der Call-Schicht (ROADMAP „Call-Schicht
entwirren", `docs/M2_MODULE_SCHNITT.md`):
- **`call_ui` als Modul (b2335da):** `lib/features/call/` →
`lib/features/call_ui/` (git mv, 100 % rename), Importe in den einzigen
zwei Verwendern (`rooms_panel.dart`, `app_shell.dart`) angepasst. Die
beiden UI-Dateien konsumierten seit dem Pilot schon ausschließlich die
Service-Interfaces – damit ist Pilot-Schritt 1 aus dem Schnitt-Doc
(call_signaling + voice_channel + call_ui) strukturell komplett.
- **VoicePrefs-Fassade (d22f9c2):** Die 4 voice_*-Prefs (Mikro/Kamera/
Lautsprecher-Gerät, Ausgabelautstärke) laufen jetzt über eine neue
`VoicePrefs`-Kapsel in `core/settings_prefs.dart` – vorher griffen
`voip_manager.dart` (2 Stellen), `livekit_call_manager.dart` und
`settings_voice.dart` direkt auf SharedPreferences zu (die im
Schnitt-Doc Punkt 6 genannten Stellen). Keys und Semantik (null/leer =
Pref löschen, Fallback-Lautstärke 1.0) exakt beibehalten; der
shared_preferences-Import ist aus settings_modal.dart, beiden Managern
und damit aus der ganzen Call-Schicht verschwunden.
- **Geprüft:** `flutter analyze` sauber (nur bekannter onUpdate-Hinweis),
29 Tests grün, `flutter build windows --debug` + App-Start: Login/Sync
laufen (auth_log 17:08:57 `loggedIn`, keine Migration, kein Fehler).
App läuft seit 17:08 wieder ENTKOPPELT als Langzeit-Beobachtung – jetzt
mit dem NEUEN Code.
- **Bewusst zurückgestellt:** „UI-State aus den Managern herauslösen" –
das wäre ein echter Logik-Umbau auf dem heiligen Call-Pfad, während
Bernds Klick-Test des Piloten (PC_TESTPLAN Abschnitt 8) noch aussteht.
Kein zweiter ungetesteter Umbau obendrauf; steht als Nächstes an, SOBALD
der Klick-Test grün ist.
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
17:08) in der nächsten Session bzw. vor Session-Ende. Danach ist auf dem
PC ohne Bernds Klick-Tests nichts Risikoarmes mehr offen: M2-Schritt 2
(settings_modal weiter aufsplitten) wäre der nächste Kandidat.
**Stolperfallen:** Nach dem Umstellen einer part-Datei (settings_voice)
den dann ungenutzten Import (shared_preferences) beim OWNER
(settings_modal.dart) entfernen – analyze meldet ihn sonst als unused,
und die Fundstelle ist nicht die geänderte Datei.
---
## 2026-07-06 – Langzeit-Check auth_log: 12:43-Lauf endete früh (kein Logout im Fenster), Beobachtung 16:57 neu gestartet
**Erledigt:** Der von der Vorsession hinterlassene Pflicht-Check
(auth_log seit 12:43 prüfen) ist ausgeführt, Ergebnis im PC_TESTPLAN
(Abschnitt 1) eingetragen:
- **Befund:** Der entkoppelte 12:43-Lauf lief nur ~20 Minuten – letzter
DB-Write 13:03:21, danach Prozess weg. KEINE Absturzspur (Application-
Event-Log leer, keine WER-Reports), kein Logout-/Fehler-Eintrag im
auth_log → vermutlich wurde das Fenster manuell geschlossen (Bernds
PC). Im 20-Minuten-Fenster kein Random-Logout, aber das ist zu kurz
für „mehrere Stunden" – Punkt bleibt offen.
- **Positiv-Evidenz obendrauf:** Neustart der exe um 16:57 → auth_log
`LoginState -> loggedIn` (16:57:46), KEINE erneute Migration, kein
Fehler – die Session überlebt jetzt nachweislich den 4. Start mit
SQLCipher-verschlüsselter DB.
- **Beobachtung neu gestartet:** App läuft seit 16:57 wieder ENTKOPPELT
(Start-Process, überlebt Session-Ende). Die nächste Session (oder das
Ende dieser) muss `auth_log.txt` auf Einträge seit 2026-07-06 16:57
prüfen und im PC_TESTPLAN eintragen.
**Offen/Nächster Schritt:** Weiter mit dem obersten offenen, hier
bearbeitbaren ROADMAP-Punkt: innere M2-Aufräumarbeiten der Call-Schicht
(UI-State aus den Managern, `call_ui`-Modul, storage-Fassade für die
voice_*-Prefs). Vor Session-Ende auth_log erneut prüfen.
**Stolperfallen:** Ein entkoppelter Beobachtungs-Lauf auf Bernds PC kann
jederzeit von Bernd geschlossen werden – das ist KEIN Fehlerbefund
(keine Panik wegen „Prozess weg"): erst Event-Log/WER auf Absturz und
auth_log auf Logout prüfen, dann als „manuell beendet" einordnen. Die
Beobachtungsdauer misst man am letzten DB-Write (`pyramid.sqlite-wal`),
nicht an der Start-Zeit.
---
## 2026-07-06 – Härtetest (PC_TESTPLAN): automatisierbarer Teil erledigt, Klick-Teil vorbereitet, Langzeit-Lauf gestartet
**Erledigt:** Dritter Auftragspunkt der PC-Session (nach SQLCipher und
Call-Pilot): `docs/PC_TESTPLAN.md` so weit abgearbeitet, wie es ohne
UI-Klicks geht, Ergebnisse dort eingetragen:
- **Abschnitt 0 ✅:** `flutter test` grün – 29 Tests (24 Bestand + 5 neue
SQLCipher-Tests), mehrfach gelaufen (vor/nach jedem Umbau des Tages).
- **Abschnitt 1 (Teil-Evidenz, dokumentiert):** Bernds Bestands-Session
blieb über drei App-Starts UND die SQLCipher-Migration hinweg eingeloggt;
die App entschlüsselte live to_device-Events von @uta und forwardete
Room-Keys (E2EE-Empfangspfad belegt); `auth_log.txt` schreibt sauber
LoginState-Wechsel mit, keine Soft-Logout-/Fehler-Einträge.
- **Langzeit-Beobachtung GESTARTET:** Die App läuft seit 12:43 vom
Autopilot ENTKOPPELT (Start-Process, überlebt das Session-Ende) –
die NÄCHSTE Session muss `auth_log.txt` (App-Support-Verzeichnis
`%APPDATA%\chat.pyramid\pyramid\`) auf Random-Logout-Einträge seit
2026-07-06 12:43 prüfen und das Ergebnis im PC_TESTPLAN eintragen.
- **Klar abgegrenzt, was Bernd braucht:** Alles Klick-Interaktive
(Nachricht senden, Logout-Warnung, Re-Login → alte Nachrichten lesbar,
Abschnitte 2–8 der Splits, Voice-Channel-Join) kann der Autopilot nicht
ausführen – im PC_TESTPLAN als Grenze vermerkt. ROADMAP-Härtetest-Punkt
entsprechend NUR annotiert, nicht abgehakt.
**Offen/Nächster Schritt:** Nächste Session: auth_log-Langzeit-Check (s. o.).
Für Bernd: PC_TESTPLAN Abschnitte 1 (Rest) und 2–8 durchklicken – dabei
deckt „Voice-Channel beitreten/verlassen" (Abschnitt 8) zugleich den
Klick-Test des heutigen M2-Call-Piloten ab. Danach ggf. SQLCipher auf dem
Android-Testgerät (Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`).
**Stolperfallen:** Ein per `flutter run` gestarteter Lauf stirbt mit der
Session – für Beobachtungen über das Session-Ende hinaus die gebaute exe
direkt und entkoppelt starten (`Start-Process` auf
`build\windows\x64\runner\Debug\pyramid.exe`); das auth_log persistiert
die Ereignisse für die Folgesession.
---
## 2026-07-06 – M2-Call-Pilot: Module call_signaling + voice_channel mit Fassaden umgesetzt (Entscheidung 2)
**Erledigt:** Das erste M2-Umbau-Modul nach `docs/M2_MODULE_SCHNITT.md`
(Bernds Entscheidung 2: Call-Pilot zuerst, nicht settings_modal):
- **Zwei neue Module mit schmaler öffentlicher Schnittstelle:**
`lib/features/call_signaling/` (`CallSignalingService` +
`callSignalingProvider`; Implementierung `voip_manager.dart` hierher
verschoben) und `lib/features/voice_channel/` (`VoiceChannelService` +
`voiceChannelProvider`; Implementierung `livekit_call_manager.dart`
hierher verschoben). Die zwei Call-Mechanismen bleiben wie im Doc
begründet GETRENNT (Matrix-1:1-VoIP vs. LiveKit-SFU-Kanäle).
- **Alle Konsumenten entkoppelt:** app_shell, Call-UI (`voice_channel.dart`,
`mini_call_widget.dart`), Rooms-Panel (Join/Leave, Call-Dock),
Chat-Header (Call-Start), Settings (`_VoiceSection` ist jetzt
ConsumerStatefulWidget und nutzt die Provider statt der Singletons) –
niemand importiert mehr die Implementierungsdateien, repo-weit verifiziert.
Einzige dokumentierte Ausnahme: `matrix_client.dart` als Kompositions-
Punkt, der `PyramidVoipManager(client)` konstruiert. Die alten Provider
`callStateProvider`/`voipStateProvider` in app_state.dart sind ersatzlos
durch die Modul-Provider ersetzt (reine Umbenennung + Typ = Interface).
- **Bewusste Abweichungen vom Schnitt-Entwurf** (Verhaltensgleichheit vor
Schönheit, heiliger Call-Pfad; im Doc-Status-Kopf dokumentiert):
ChangeNotifier statt Streams, Methodennamen wie im Bestand
(`startCall`/`hangUp` statt `join`/`leave`), `CallSession` und
LiveKit-`Room` bleiben in den Schnittstellen sichtbar (UI rendert direkt
Renderer/Tracks – für die WebRTC-Renderer sieht das Doc das selbst vor).
Kein einziger Handler-/Logik-Block wurde verändert, nur Klassen-Köpfe
(`extends` + `@override`), Imports, Provider-Namen und Typ-Annotationen.
- **Geprüft:** `flutter analyze` sauber (nur bekannter onUpdate-Hinweis),
alle 29 Tests grün, `flutter run -d windows`: App startet, Login/Sync/E2EE
laufen, sauberer Quit. (Commit-Hash siehe git log dieses Eintrags.)
**UNGETESTET (interaktiv/Gerät – deshalb ROADMAP-Punkt „Call-Schicht
entwirren" nur annotiert, nicht abgehakt):** echter Voice-Channel-Join per
Klick auf Windows und echter 1:1-Call zwischen zwei Geräten. Der Umbau ist
bewusst rein mechanisch (Fassade davor, Logik unangetastet), aber die
CLAUDE.md-Regel „nach Refactoring Kernflows prüfen" ist für die Call-Flows
erst nach einem Klick-/Gerätetest voll erfüllt – steht im PC_TESTPLAN
(Abschnitt 8) bzw. braucht Bernd/Uta.
**Offen/Nächster Schritt:** Härtetest nach `docs/PC_TESTPLAN.md` (so weit
ohne Klick-Interaktion möglich); die inneren M2-Aufräumarbeiten der
Call-Schicht (UI-State aus den Managern, `call_ui`-Modul, storage-Fassade
für die voice_*-Prefs) folgen als eigene Schritte.
**Stolperfallen:** (1) Bei part-Dateien (settings, rooms_panel, chat_view)
hängen die Imports an der EINEN Owner-Datei – wer eine part-Datei umstellt,
muss die Imports beim Owner ändern und findet Verwender nur per Repo-grep
auf den Provider-Namen. (2) Ein `ChangeNotifierProvider<Interface>` braucht
ein Interface, das ChangeNotifier ERWEITERT (abstract class X extends
ChangeNotifier), sonst passt der Typparameter nicht.
---
## 2026-07-06 – SQLCipher-Migration UMGESETZT + auf Windows mit echter DB praxisgetestet (M1, Bernds Prio 1)
**Erledigt:** Der M1-Punkt „Sichere Speicherung von Access-Token & Krypto-DB"
ist nach `docs/SQLCIPHER_MIGRATION.md` umgesetzt (PC-Session laut
Entscheidung 1 in `ANTWORTEN_BERND.md`):
- **Neue Storage-Fassade `lib/core/app_database.dart`** (erster Baustein im
Sinne des M2-Modularitäts-Leitsatzes): kapselt Factory-Wahl, SQLCipher-
Schlüssel (32 Zufallsbytes, hex im Keystore via flutter_secure_storage,
Eintrag `db_cipher_key`, mit Read-Back-Verifikation gegen stilles
Write-Versagen), Klartext→SQLCipher-Migration und die vorher duplizierten
PRAGMAs. `matrix_client.dart` und `background_push.dart` rufen nur noch
`AppDatabase.open()` / `AppDatabase.openForPush()`.
- **Migration exakt nach Plan:** Marker gegen Push-Isolate-Race →
WAL-Checkpoint → Byte-Backup `.premigration` (nach 7 Tagen auto-gelöscht) →
`sqlcipher_export` in NEUE Datei → Verifikation (integrity_check +
Zeilenzahlen ALLER Tabellen) → atomarer rename-Tausch. Jeder Fehlpfad lässt
die Klartext-DB unangetastet (App läuft dann unverschlüsselt weiter,
Fehler im auth_log). Absturz zwischen Löschen und Umbenennen wird beim
nächsten Start aus dem Byte-Backup repariert.
- **Wichtige Plan-Korrektur:** Die Plan-Annahme „sqlcipher_flutter_libs hat
keine Desktop-Binaries" ist seit 0.6.x überholt – das Plugin baut SQLCipher
auf Windows als `sqlite3.dll` MIT (im Build-Output verifiziert, nur dieses
eine sqlite-Plugin ist im Build). Deshalb verschlüsseln Windows/Linux
ebenfalls (kein Test-Override nötig); iOS/macOS bleiben bewusst Klartext
(Pod-Kollision mit firebase_messaging, kein Release-Ziel). Laufzeit-Wächter
`PRAGMA cipher_version` verhindert, dass je stillschweigend die falsche
Bibliothek benutzt wird; verschlüsselte DB ohne Cipher-Lib/Schlüssel ist
ein harter Fehler mit Klartext-Meldung – NIE stille Neuanlage.
- **Tests `test/app_database_test.dart`** (5 neue, gesamt 29, alle grün):
fahren die ECHTE Migration mit der SQLCipher-DLL aus dem Build-Output –
cipher_version-Wächter, Header-Erkennung, Migration mit Zeilenzahl-
Verifikation, falscher Schlüssel scheitert sofort, erzwungener Fehlschlag
lässt das Klartext-Original intakt.
- **Praxistest Windows (`flutter run -d windows`), echte Daten:** Bernds
gewachsene 14-MB-DB (inkl. 4 MB WAL) wurde beim Start migriert
(auth_log: „23 Tabellen verifiziert"), Session blieb eingeloggt, E2EE
nachweislich intakt (App entschlüsselte live to_device-Events von
@uta, forwardete Room-Keys). Zweiter Start öffnet die verschlüsselte DB
ohne erneute Migration, sauberer Quit per `q`. DB-Header jetzt zufällig
(verschlüsselt), `.premigration` = Klartext-Original. Zusätzliche manuelle
Sicherung liegt als `pyramid.sqlite*.claude-vormigration-20260706` im
App-Verzeichnis (kann nach ein paar Tagen weg).
- `sqlite3` als direkte Dependency ergänzt (war schon transitiv im Lockfile –
kein neues externes Paket), für `open.overrideFor`/`openCipherOnAndroid`.
- `flutter analyze` sauber (nur der bekannte, bewusst zurückgestellte
onUpdate-Hinweis).
**UNGETESTET (Android – deshalb ROADMAP-Punkt NICHT abgehakt):** Migration
auf echtem Android-Gerät, Push-Isolate liest verschlüsselte DB
(`databaseFactoryFfiNoIsolate` + `openCipherOnAndroid`), flutter_secure_storage
aus dem Background-Isolate (bekanntes Risiko, Code steigt dann kontrolliert
mit Platzhalter-Notification aus). Testplan Punkt 4 in
`docs/SQLCIPHER_MIGRATION.md`; erst danach Release + Uta (vorher ihr
Key-Backup verifizieren).
**Offen/Nächster Schritt:** M2-Call-Pilot (Entscheidung 2: Call-Fassade nach
`docs/M2_MODULE_SCHNITT.md`), danach Härtetest nach `docs/PC_TESTPLAN.md`.
**Stolperfallen:** (1) Plan-Annahmen über Paket-Fähigkeiten VOR der Umsetzung
gegen den Pub-Cache/Build-Output prüfen – die „keine Desktop-Binaries"-Annahme
hätte unnötig einen Test-Override-Sonderpfad erzeugt. (2) `sqfliteFfiInit()`
war auf Windows faktisch wirkungslos fürs DB-Isolate (Override wirkt nur im
aufrufenden Isolate; das Worker-Isolate lädt die exe-nachbarschaftliche
`sqlite3.dll` – und die IST hier SQLCipher). Für eigene Overrides deshalb
`createDatabaseFactoryFfi(ffiInit: ...)` benutzen, das läuft IM DB-Isolate.
(3) Beim Schlüssel-Anlegen im Keystore IMMER read-back-verifizieren, bevor
damit verschlüsselt wird – ein still verlorener Schlüssel wäre Totalverlust.
(4) `flutter_test` findet keine `sqlite3.dll` neben dem Tester-Binary – die
DLL aus `build/windows/x64/runner/*/` explizit laden; Tests überspringen sich
selbst, wenn kein Build-Output da ist (Pi-tauglich).
---
## 2026-07-06 – Fable-5-Review (aa): autopilot.sh fest auf nordmann-Konto (uncommittete Änderung geprüft, E-Mail-Leak vor Publikation gefixt, committet)
**Erledigt:** Session-Start nach Arbeitszyklus zeigte echten neuen Anlass (also
kein reiner Protokoll-Commit, Ende-Kriterium aus (z) greift nicht): eine
uncommittete Änderung an `autopilot.sh` von heute plus Backup
`autopilot.sh.bak-20260706-vor-nordmann-pin`. Inhalt: Die Zwei-Konten-Rotation
(pyramid↔haupt) ist komplett entfernt, der Autopilot läuft laut Kommentar auf
Bernds Vorgabe (2026-07-06) nur noch mit dem nordmann-Konto
(Standard-Profil `~/.claude`). Der CHANGES-Hook hat dafür KEINEN Eintrag
(Log endet 2026-07-05 19:03) – die Änderung kam also von außerhalb einer
gehookten Claude-Session (Bernd/manuell). Als Punkt (aa) im
Fable-5-Review-Abschnitt abgehakt:
- **Umbau korrekt:** Rotation restlos raus (repo-weit kein
`get_account`/`switch_account`/`JUST_SWITCHED`/`ACCOUNT_FILE`-Rest),
`apply_account` macht nur noch `unset CLAUDE_CONFIG_DIR` – exakt die Lehre
aus Review (w) (Hauptprofil liegt unter `~/.claude.json`, die Variable darf
nicht gesetzt sein). `bash -n` sauber. Limit-Pfad: 20 min warten statt
Konto-Wechsel, PAUSE-/Notify-Logik unverändert.
- **Am lebenden System belegt:** tmux-Session „pyramid" wurde heute 11:54 neu
gestartet, `autopilot.log` zeigt das neue Log-Format, und DIESE Session
läuft nachweislich als nordmann-Konto mit leerem `CLAUDE_CONFIG_DIR`.
- **Befund (gefixt VOR dem Commit):** Die neue Fassung enthielt Bernds
private E-Mail-Adresse im Klartext (Kommentar + `ACCOUNT_NAME`-Log-Zeile).
Das Gitea-Repo ist ÖFFENTLICH, und bisher enthielt keine einzige getrackte
Datei diese Adresse – ein Commit hätte sie erstmals veröffentlicht
(PII/Spam-Harvesting, gleiche Familie wie der Secrets-Leitsatz in
CLAUDE.md). Funktional ist die Adresse unnötig (stand nur in Kommentar und
gitignoriertem `autopilot.log`) → durch neutrales „nordmann" ersetzt,
Begründungs-Kommentar dagelassen, damit sie niemand „zur Klarheit" wieder
einträgt.
- Aufräumen drumherum: `*.bak-*` in `.gitignore` aufgenommen (das heutige
Backup bleibt als Rollback auf der Platte, hält aber den Arbeitsbaum-
Detektor künftiger Sessions sauber). Hinweis: `~/.claude-accounts/active`
(Inhalt „haupt") liegt verwaist außerhalb des Repos – wird von nichts mehr
gelesen, bewusst nicht angefasst. Memory (Haupt-Profil) auf den neuen
Ein-Konto-Stand aktualisiert.
**ROADMAP-Check danach (Schritt 2 des Auftrags):** Unverändert kein offener
Punkt auf dem Pi sicher bearbeitbar – SQLCipher und Härtetest brauchen
PC/Gerät (Entscheidung 1, `docs/PC_TESTPLAN.md`), der M2-Call-Pilot braucht
Gerätetest (Entscheidung 2, heiliger Call-Pfad), Secrets/History ruhen
bewusst (Entscheidung 4). Keine Arbeit erfunden – Session endet sauber nach
diesem Commit.
**Offen/Nächster Schritt:** Unverändert – nächster PC-/Gerätelauf startet mit
SQLCipher (`docs/SQLCIPHER_MIGRATION.md`), danach Call-Pilot; Härtetest nach
`docs/PC_TESTPLAN.md`.
**Stolperfallen:** (1) Der CHANGES-Hook-Detektor sieht nur Claude-Edits –
Bernds manuelle Änderungen tauchen NUR in `git status` auf; deshalb gehört
`git status` (nicht nur das Hook-Log) an jeden Session-Start. (2) Vor dem
Committen fremder/manueller Änderungen in ein ÖFFENTLICHES Repo den Diff auf
PII/Secrets abklopfen – hier wäre sonst eine private E-Mail-Adresse
mitpubliziert worden.
---
## 2026-07-05 – Fable-5-Review (z): (y)-Protokoll-Commit geprüft (kein Befund) + Ende-Kriterium geschärft
**Erledigt:** Session-Start nach Arbeitszyklus: Arbeitsbaum sauber, Gitea nicht
voraus, CHANGES-Hook-Log endet 18:41 = exakt die (y)-PROGRESS-Edits → keine
unprotokollierte Pi-Arbeit. Einziger seit (y) ungeprüfter Rest war der
(y)-Protokoll-Commit 725f03a selbst; als Punkt (z) im Fable-5-Review-Abschnitt
abgehakt (kurzer Gegenlese-Pass, keine Volltiefen-Runde):
- Diff von 725f03a ist reine Doku und deckt sich exakt mit dem (y)-Eintrag:
PROGRESS.md +45 Zeilen ((y)-Eintrag + Checklisten-Häkchen), CHANGES.md
+2 Hook-Zeilen. Kein Code, nichts Halbfertiges.
- (y)-Kernaussagen stichprobenhaft gegengeprüft, beide korrekt: 15001ae ist
wirklich PROGRESS +46/CHANGES +2 (reine Doku), und
`git log 925aafb..HEAD -- lib/ test/ pubspec.*` ist leer – seit dem letzten
grünen Testlauf (24/24) ist kein Code angefasst, kein flutter-Lauf nötig.
**Ende-Kriterium geschärft (in der Review-Checkliste dokumentiert):** Die
Kette (a)–(z) reviewte zuletzt nur noch die Protokoll-Commits der jeweiligen
Vorsession – das terminiert nie von selbst. Ab jetzt gilt: Ein reiner,
befundloser Protokoll-Commit einer Review-Session ist KEIN neuer
Review-Anlass. Neue Review-Punkte gibt es nur noch für Arbeit mit Substanz
(Code- oder inhaltliche Doku-Änderungen, z. B. neue PC-Commits).
**ROADMAP-Check danach (Schritt 2 des Auftrags):** Unverändert kein offener
Punkt auf dem Pi sicher bearbeitbar – SQLCipher und Härtetest brauchen
PC/Gerät (Entscheidung 1, `docs/PC_TESTPLAN.md`), der M2-Call-Pilot braucht
Gerätetest (Entscheidung 2, heiliger Call-Pfad), Secrets/History ruhen
bewusst (Entscheidung 4, nicht anstoßen). Keine Arbeit erfunden – Session
endet sauber nach diesem Protokoll-Commit.
**Offen/Nächster Schritt:** Unverändert – nächster PC-/Gerätelauf startet mit
SQLCipher (`docs/SQLCIPHER_MIGRATION.md`), danach Call-Pilot; Härtetest nach
`docs/PC_TESTPLAN.md`.
**Stolperfallen:** Eine Review-Kette, die Protokoll-Commits reviewt, erzeugt
mit jedem Review einen neuen Protokoll-Commit – ohne explizites
„Protokoll-only zählt nicht"-Kriterium terminiert sie nie. Das Kriterium
gehört an die Checkliste selbst, nicht nur in einen Session-Eintrag.
---
## 2026-07-05 – Fable-5-Review (y): liegengebliebenen (x)-Protokoll-Commit der Vorsession geprüft und nachgeholt ## 2026-07-05 – Fable-5-Review (y): liegengebliebenen (x)-Protokoll-Commit der Vorsession geprüft und nachgeholt
**Erledigt:** Session-Start zeigte einen unsauberen Arbeitsbaum: Die **Erledigt:** Session-Start zeigte einen unsauberen Arbeitsbaum: Die
@@ -1180,6 +2098,33 @@ schließt den Review ab.
stichprobenhaft verifiziert (66bf54b-Umfang, keine `lib/`-Änderungen seit stichprobenhaft verifiziert (66bf54b-Umfang, keine `lib/`-Änderungen seit
925aafb, A/B-Frage überall geschlossen) und unverändert committet 925aafb, A/B-Frage überall geschlossen) und unverändert committet
(15001ae). Details im PROGRESS-Eintrag „(y)" oben. (15001ae). Details im PROGRESS-Eintrag „(y)" oben.
- [x] **(z) (y)-Protokoll-Commit 725f03a gegengelesen – korrekt, KEIN Befund;
Ende-Kriterium der Kette geschärft (2026-07-05).** Diff ist exakt der
(y)-Eintrag + Checklisten-Häkchen + 2 Hook-Zeilen (kein Code); Kernaussagen
stichprobenhaft verifiziert (15001ae-Umfang PROGRESS +46/CHANGES +2,
`git log 925aafb..HEAD -- lib/ test/ pubspec.*` leer). **Ab jetzt gilt:**
Ein reiner, befundloser Protokoll-Commit einer Review-Session ist KEIN
neuer Review-Anlass mehr – künftige Sessions prüfen nur noch Commits mit
Code-/Doku-Substanz (alles außer dem eigenen Review-Protokoll), sonst
reviewt die Kette ewig ihr eigenes Protokoll. Details im
PROGRESS-Eintrag „(z)" oben.
- [x] **(aa) Uncommittete autopilot.sh-Umstellung auf EIN Konto geprüft und
committet – 1 Befund gefixt (2026-07-06).** Die Umstellung (Konto-Rotation
raus, Autopilot fest auf dem nordmann-Konto = Standard-Profil `~/.claude`;
Bernds Vorgabe laut Kommentar, Backup
`autopilot.sh.bak-20260706-vor-nordmann-pin`) lag uncommitted im
Arbeitsbaum. Geprüft: Rotation restlos entfernt (kein
`get_account`/`switch_account`/`JUST_SWITCHED`/`ACCOUNT_FILE`-Rest, repo-weit
gegrept), `apply_account` = `unset CLAUDE_CONFIG_DIR` deckt sich mit dem
(w)-Befund, `bash -n` sauber, Limit-Pfad wartet jetzt 20 min statt zu
wechseln. Am lebenden System belegt: tmux-Session „pyramid" am 2026-07-06
11:54 neu gestartet, autopilot.log zeigt das neue Format, DIESE Session
läuft als nordmann-Konto mit leerem `CLAUDE_CONFIG_DIR`. **Befund (vor dem
Commit gefixt):** Die neue Fassung enthielt Bernds private E-Mail-Adresse
(Kommentar + `ACCOUNT_NAME`) – das Gitea-Repo ist ÖFFENTLICH, und bisher
enthielt keine getrackte Datei diese Adresse; ein Commit hätte sie erstmals
veröffentlicht. Durch neutrales „nordmann" ersetzt (funktionsgleich, die
Adresse stand nur in Kommentar und Log-Zeile).
**Fazit:** Die Sonnet-5-Arbeit war handwerklich sauber; die zwei echten Lücken **Fazit:** Die Sonnet-5-Arbeit war handwerklich sauber; die zwei echten Lücken
(verlorene Kommentare, fehlender Soft-Logout-Guard) hatte die abgebrochene (verlorene Kommentare, fehlender Soft-Logout-Guard) hatte die abgebrochene
+2
View File
@@ -25,6 +25,7 @@ für die unterstützte Dart-Version).
flutter pub get flutter pub get
flutter run -d windows # schnellster Praxistest während der Entwicklung flutter run -d windows # schnellster Praxistest während der Entwicklung
flutter analyze # muss sauber bleiben flutter analyze # muss sauber bleiben
pwsh scripts/test.ps1 # Analyse + Tests (-Live, -Bench, -All: siehe docs/TESTS_UND_BENCHMARKS.md)
``` ```
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
@@ -52,5 +53,6 @@ lib/
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude - `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte - `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
- `docs/TESTS_UND_BENCHMARKS.md` – Tests, Live-Tests, Benchmarks (ein Befehl)
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung) - `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK - `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
+161 -6
View File
@@ -42,6 +42,30 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3 + Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen. Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.** - [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
**Stand 2026-07-06: UMGESETZT + auf Windows praxisgetestet** – neue
Storage-Fassade `lib/core/app_database.dart` (SQLCipher auf
Android/Windows/Linux, Schlüssel im Keystore, Klartext-Migration mit
Backup/Verifikation/atomarem Tausch, Push-Isolate-Marker). Bernds echte
Windows-DB wurde erfolgreich migriert (PROGRESS.md 2026-07-06), Tests in
`test/app_database_test.dart` fahren die Migration mit echtem SQLCipher.
**Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün**
(API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start,
Neustart mit Keystore-Schlüssel ohne Re-Migration – Details im
Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`).
**Nachtrag 2026-07-06 (Nacht-Session): LIVE-E2E-Test mit ECHTEM Login grün**
(`test/live_encryption_e2e_test.dart`, Test-Account pyramidtest1 auf
steggi-matrix.work, Gate `PYRAMID_LIVE_TEST=1`): Platte nach echtem
Login/Nachricht byte-geprüft (kein SQLite-Header, Access-Token und
Nachrichtentext NICHT im Klartext auffindbar, Positiv-Kontrolle in
Klartext-DB findet beides), Neustart restauriert Session aus der
verschlüsselten DB, Logout→Re-Login→alte Nachricht lesbar, und die
Klartext-Migration mit einer ECHTEN eingeloggten Session erhält
Session+Daten. **Bleibt offen:**
echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test
(FCM liest verschlüsselte DB, secure_storage im Background-Isolate) –
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`;
erst nach grünem Gerätetest Release + Utas Gerät (vorher ihr Key-Backup
prüfen!), dann abhaken.
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends `sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über `pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
@@ -105,9 +129,11 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py` PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft (whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität = (401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/ geprüfte user_id). **Client umgestellt 2026-10-07 (756486d):** kein Secret
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger mehr in der App, echter LiveKit-Beitritt mit Testkonto grün
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`. (`integration_test/livekit_token_connect_test.dart`). **Offen:** Anruftest
auf dem Handy, danach Secret-Rotation durch Bernd (erst wenn alle Geräte die
neue Version haben), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k), - [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber: vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
@@ -116,6 +142,7 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
`apiSecret`, Giphy-Key. Alte jeweils widerrufen. `apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit - **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT). `apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
**Client-Teil ERLEDIGT 2026-10-07** (Secret aus dem Code; Rotation steht noch aus).
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04, **Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät. gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi - **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
@@ -136,10 +163,44 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern. verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
- [x] **Android-Backup/Geräteumzug abgeschaltet** (2026-10-07): ohne
`allowBackup=false` + `dataExtractionRules` wäre die SQLCipher-DB beim
Handywechsel ohne ihren Keystore-Schlüssel mitgezogen → App startet
nicht (Aussperr-Fall), und der Push-Zugangsschlüssel aus den
SharedPreferences läge im Cloud-Backup. Neues Gerät = neu anmelden +
Schlüssel per Wiederherstellungsschlüssel. Im APK geprüft (aapt2,
`dumpsys package`: kein ALLOW_BACKUP mehr).
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar - [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben; (gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken) jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
- [x] **SDK-Wettlauf: verspätete 401 einer alten Sync-Anfrage löscht ggf. die
NEUE Session** – ERLEDIGT 2026-10-07: alle App-Abmeldungen laufen über
`logoutWithoutStraySync` (lib/core/session_logout.dart: Sync anhalten,
Long-Poll per Account-Daten wecken, auslaufen lassen, dann abmelden).
Live gemessen (test/live_logout_race_test.dart): die verspätete 401 tritt
real auf, kam aber in allen Läufen an, BEVOR die Neuanmeldung fertig
war – reales Risiko in der App klein (Neuanmeldung binnen ~1 s nötig),
jetzt ausgeschlossen; Regressionstest in `scripts/test.ps1 -Live`.
Ursprüngliche Analyse: (gefunden 2026-10-07 über einen sporadisch roten
Live-Test, siehe PROGRESS.md). matrix 6.2 `Client._innerSync` wirft den
Fehler einer Sync-Antwort, BEVOR geprüft wird, ob die Anfrage noch
aktuell ist (`_currentSyncId`). Läuft beim Logout noch ein Long-Poll
(bis 30 s) mit dem alten Token, kommt dessen M_UNKNOWN_TOKEN danach an
und das SDK ruft `clear()` – nach einem schnellen Re-Login auf
demselben Client wäre das ein „Random-Logout" direkt nach dem Login.
Upstream (famedly/matrix-dart-sdk `main`, 2026-10-07) unverändert;
neuere SDKs versuchen bei vorhandenem Refresh-Token aber zuerst einen
Refresh statt `clear()` (entschärft). Im Live-Test per
`_logoutWithoutStraySync` umgangen. **Offen:** App-Logout-Pfad prüfen
(läuft der Re-Login auf demselben Client-Objekt? dann vor
`client.logout()` Sync anhalten + laufenden Long-Poll abwarten) –
heiliger Pfad, nur mit Gerätetest; ggf. Upstream-Issue melden.
## M2 – Das große Refactoring (erst nach M0/M1!) ## M2 – Das große Refactoring (erst nach M0/M1!)
@@ -162,7 +223,33 @@ lassen, ohne dass andere Module angefasst werden müssen.
Bernds Go für den Call-Pilot (Frage in PROGRESS.md). Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung / - [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
LiveKit-Verbindung / UI-State sauber getrennt) LiveKit-Verbindung / UI-State sauber getrennt)
**Teilschritt erledigt (2026-07-06, M2-Pilot laut Entscheidung 2):**
Module `call_signaling` und `voice_channel` mit Fassaden-Interfaces +
Providern existieren (`lib/features/call_signaling/`,
`lib/features/voice_channel/`), alle Konsumenten gehen nur noch über
`callSignalingProvider`/`voiceChannelProvider` – kein UI-Import der
Implementierungen mehr (Details/Abweichungen: Status-Kopf in
`docs/M2_MODULE_SCHNITT.md`). App startet, 29 Tests grün, Sync/E2EE
laufen. **UNGETESTET:** echter 1:1-Call und Voice-Channel-Join per
Klick (braucht Gerät bzw. interaktiven Test) – deshalb nicht abgehakt.
**Weitere Teilschritte erledigt (2026-07-06, zweite PC-Session):**
`call_ui` ist jetzt ein eigenes Modul (`lib/features/call_ui/`,
b2335da) und die voice_*-Prefs laufen über die `VoicePrefs`-Fassade
in `core/settings_prefs.dart` statt direktem SharedPreferences-Zugriff
(d22f9c2) – beides verhaltensgleich, analyze/Tests grün, App-Start
geprüft. **Bewusst zurückgestellt bis Bernds Klick-Test des Piloten
(PC_TESTPLAN Abschnitt 8) grün ist:** UI-State aus den Managern
herauslösen – kein zweiter ungetesteter Logik-Umbau auf dem heiligen
Call-Pfad.
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns) - [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
**Ist-Analyse + Umbauplan liegen in `docs/M2_STATE_MANAGEMENT.md`**
(2026-07-06): Riverpod ist schon dominant (~50 Provider); 4
Misch-Patterns identifiziert, Schrittfolge definiert. Schritte 1–2
(Call-Manager-UI-State, Voice-Provider-Umzug) GESPERRT bis Bernds
Klick-Test (PC_TESTPLAN Abschnitt 8); MediaCache-Umstellung
zurückgestellt (hängt an der Chat-Timeline, Korrektur im Doc).
Push bleibt bewusst wie er ist (Isolate-Zwang). Toter
`incomingCallProvider` bereits entfernt (41bcaf2).
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen - [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte (PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der `_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
@@ -179,9 +266,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
`features/chat/document/`) – jeweils verifiziert per automatisiertem `features/chat/document/`) – jeweils verifiziert per automatisiertem
Blockvergleich gegen das Original und `flutter analyze`, siehe Blockvergleich gegen das Original und `flutter analyze`, siehe
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen) PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst – und `layout/app_shell.dart` (1073 Zeilen) waren zunächst bewusst NICHT
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem mit angefasst – beide hängen eng mit der damals unentschiedenen
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten. Call-Fassade bzw. dem Bootstrap/Login-Pfad zusammen.
**Nachtrag 2026-07-06:** Nach dem Call-Piloten ist
`call_ui/voice_channel.dart` jetzt ebenfalls geteilt (5 part-Dateien
unter `call_ui/view/`, zeichenidentisch verifiziert, 95d9b07);
`app_shell.dart` bleibt wegen des Bootstrap/Login-Pfads (heilig)
weiter unangetastet. Ebenso `settings/settings_encryption.dart`
(1470 Zeilen, größte verbliebene Teildatei) in 4 part-Dateien unter
`settings/encryption/` geteilt (zeichenidentisch verifiziert,
6128953) – Vorbereitung für das spätere verification-Modul
(Schnitt-Doc Schritt 3), Code selbst unangetastet.
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat (zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
@@ -189,11 +285,52 @@ lassen, ohne dass andere Module angefasst werden müssen.
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
PROGRESS.md-Einträgen) PROGRESS.md-Einträgen)
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
- [x] Test-/Benchmark-Gerüst: `scripts/test.ps1` (Analyse, Unit-, Live-Tests,
Benchmarks mit einem Befehl), `benchmark/` mit Baseline, lokale Suche
als testbares Modul (`lib/features/search/`). Siehe
`docs/TESTS_UND_BENCHMARKS.md`, PROGRESS.md 2026-10-07.
- [ ] **Abhängigkeiten aktualisieren (3 Monate Stand, 119 Pakete veraltet)** –
einzeln, jeweils mit `scripts/test.ps1 -All` abgesichert:
- **`sqlcipher_flutter_libs` ist EOL:** 0.7.0 ist ein leerer Platzhalter
(pub.dev). Unsere Bindung `^0.6.8` schließt ihn aus – NIEMALS per
`pub upgrade --major-versions` mitziehen, sonst fehlt SQLCipher und
die App kann die verschlüsselte DB nicht mehr öffnen
(`AppDatabase` wirft dann bewusst, kein Datenverlust, aber Aussperren).
Nachfolger: `sqlite3` 3.x mit `hooks: user_defines: sqlite3: source:
sqlcipher` – Kompatibilität mit `sqflite_common_ffi` und Android
vorher klären; Migrations-/DB-Tests + Gerätetest Pflicht.
- `matrix` 6.2 → 13.x (großer Sprung, Breaking Changes; heiliger
Login-/Krypto-Pfad), `flutter_vodozemac` 0.5 → 0.8 gehört dazu.
- `livekit_client` 2.7 → 2.13 (innerhalb der Bindung möglich),
`flutter_webrtc`-Override 1.4.1 → 1.6.2: der Windows-Audiogeräte-Fix
(libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
bleibt nötig.
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). „Built-in Kotlin“
**bewusst noch nicht** (Entscheidung 2026-10-07): AGP bleibt 8.11; die vom
Flutter-Migrator gesetzten Flags `android.builtInKotlin=false` /
`android.newDsl=false` halten den Build stabil. Umstieg erst zusammen mit
AGP 9 und wenn flutter_webrtc, livekit_client, media_kit & Co. ihn
unterstützen – vorher Release-APK + Anruftest.
- ERLEDIGT 2026-10-07 (0ad06e3): `flutter_markdown` → `flutter_markdown_plus`
(Rendertest `test/document_markdown_test.dart`).
## M3 – Calls: Logik, UI & Audio (Discord als Referenz) ## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger, - [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
Code abgesichert). Nicht abgehakt.
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht) - [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den - [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
Einstellungen auswählbar machen – geht aktuell gar nicht) Einstellungen auswählbar machen – geht aktuell gar nicht)
@@ -207,7 +344,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings? - [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
Software- statt Hardware-Encoding?), dann gezielt fixen Software- statt Hardware-Encoding?), dann gezielt fixen
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
durchgereicht), degradationPreference (60-FPS-Modi
maintainFramerate, kein Simulcast). contentHint='motion' in
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl) - [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen) - [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos - [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
@@ -228,6 +377,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
## M7 – Design & Feinschliff ## M7 – Design & Feinschliff
- [x] **Beta-Feedback 2026-10-07 umgesetzt (UNGETESTET auf echtem Handy):**
Push als Chat-Verlauf mit Bildvorschau + echtem „Gelesen“, Statusleiste
überdeckt nichts mehr, Bild-Vollansicht mit Gesten, weiches Dunkel,
Schriftgrößen-Regler, Sprechblasen umschaltbar, Messenger-Chatliste,
deutsche Beschriftungen. Test-Version 0.1.37 (siehe PROGRESS.md).
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons - [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons - [ ] Design-Konsistenz-Pass über alle Icons/Buttons
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich - [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application>
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
<receiver
android:name=".DebugNotifyReceiver"
android:exported="true" />
</application>
</manifest> </manifest>
@@ -0,0 +1,55 @@
package chat.pyramid.pyramid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import java.io.File
/**
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
* Ist nicht im Release-APK enthalten.
*
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
*/
class DebugNotifyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
val roomName = intent.getStringExtra("roomName")
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
val args = mapOf(
"roomId" to roomId,
"notifId" to NotificationHelper.stableId(roomId),
"eventId" to eventId,
"senderId" to "@${sender.lowercase()}:pyramid.local",
"senderName" to sender,
"roomName" to roomName,
"isGroup" to (roomName != null),
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
"timestamp" to System.currentTimeMillis(),
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
)
NotificationHelper.showFromArgs(context, args)
}
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
val file = File(dir, name)
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
return file.absolutePath
}
}
+7 -2
View File
@@ -17,10 +17,15 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Kein Backup/Umzug der App-Daten: siehe res/xml/data_extraction_rules.xml
(verschlüsselte DB ohne Keystore-Schlüssel = App startet nicht). -->
<application <application
android:label="pyramid" android:label="Pyramid"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<service <service
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService" android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
android:enabled="true" android:enabled="true"
@@ -35,14 +35,17 @@ object BgEngine {
private val fallbacks = mutableMapOf<Int, Runnable>() private val fallbacks = mutableMapOf<Int, Runnable>()
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */ /** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) { fun scheduleFallback(
context: Context, meta: PushService.PushMeta, roomId: String, notifId: Int,
eventId: String?, delayMs: Long,
) {
val ctx = context.applicationContext val ctx = context.applicationContext
appContext = ctx appContext = ctx
fallbackHandler.post { fallbackHandler.post {
cancelFallback(notifId) cancelFallback(notifId)
val r = Runnable { val r = Runnable {
fallbacks.remove(notifId) fallbacks.remove(notifId)
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(ctx, roomId, notifId, eventId)
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)") Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
} }
fallbacks[notifId] = r fallbacks[notifId] = r
@@ -114,16 +117,19 @@ object BgEngine {
} }
// Dart finished decrypting — show the real content using the SAME // Dart finished decrypting — show the real content using the SAME
// native notification (keeps the reliable MainActivity reply action). // native notification (keeps the reliable MainActivity reply action).
// May arrive twice per event: first the text, then silently
// the same event with avatar/image (NotificationHelper merges).
"showDecrypted" -> { "showDecrypted" -> {
val title = call.argument<String>("title") ?: "" @Suppress("UNCHECKED_CAST")
val body = call.argument<String>("body") ?: "Neue Nachricht" val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = call.argument<String>("room_id") ?: "" val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId) val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: NotificationHelper.stableId(roomId)
val ctx = appContext val ctx = appContext
if (ctx != null && roomId.isNotEmpty()) { if (ctx != null && roomId.isNotEmpty()) {
// Beat the fallback placeholder to it — show decrypted directly. // Beat the fallback placeholder to it — show decrypted directly.
cancelFallback(notifId) cancelFallback(notifId)
NotificationHelper.show(ctx, title, body, roomId, notifId) NotificationHelper.showFromArgs(ctx, args)
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId") Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
} }
result.success(null) result.success(null)
@@ -206,12 +206,11 @@ class MainActivity : FlutterActivity() {
result.success(null) result.success(null)
} }
"showNativeNotification" -> { "showNativeNotification" -> {
val title = call.argument<String>("title") ?: "" @Suppress("UNCHECKED_CAST")
val body = call.argument<String>("body") ?: "" val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = call.argument<String>("roomId") ?: "" val roomId = args["roomId"] as? String ?: ""
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
if (roomId.isNotEmpty()) { if (roomId.isNotEmpty()) {
NotificationHelper.show(this, title, body, roomId, notifId) NotificationHelper.showFromArgs(this, args)
result.success(null) result.success(null)
} else { } else {
result.error("INVALID_ARG", "roomId missing", null) result.error("INVALID_ARG", "roomId missing", null)
@@ -5,13 +5,79 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.IconCompat
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Native Nachrichten-Benachrichtigungen (alle Pfade: Platzhalter aus
* PushService, Klartext aus der Haupt-Engine oder dem BgEngine-Isolate).
*
* Pro Raum gibt es EINE Benachrichtigung (notifId = stableId(roomId)), die als
* Chat-Verlauf (MessagingStyle) die letzten [MAX_MESSAGES] Nachrichten zeigt –
* wie WhatsApp/Element. Der Verlauf liegt in SharedPreferences, weil bei
* beendeter App jeder Push in einem neuen Prozess ankommt. Er wird nur
* fortgeführt, solange die Benachrichtigung noch sichtbar ist: hat der Nutzer
* sie weggewischt oder den Raum in der App geöffnet (Dart cancelt), beginnt
* der nächste Push mit einem leeren Verlauf.
*
* Gleiche eventId = Aktualisierung (Platzhalter → Klartext, Text → Text+Bild):
* die Nachricht wird ersetzt statt doppelt angehängt, und es klingelt nicht
* erneut. Nur eine wirklich neue Nachricht alarmiert.
*/
object NotificationHelper { object NotificationHelper {
const val CHANNEL_ID = "pyramid_messages" const val CHANNEL_ID = "pyramid_messages"
const val PLACEHOLDER_BODY = "Neue Nachricht"
private const val TAG = "PYRAMID-NOTIF"
private const val STORE = "pyramid_notif_history"
private const val MAX_MESSAGES = 8
private const val SYSTEM_UI = "com.android.systemui"
/** Eine Nachricht im Verlauf einer Raum-Benachrichtigung. */
data class Msg(
val eventId: String?,
val senderKey: String,
val senderName: String,
val body: String,
val timestamp: Long,
val imagePath: String? = null,
val avatarPath: String? = null,
) {
val isPlaceholder get() = body == PLACEHOLDER_BODY
fun toJson(): JSONObject = JSONObject().apply {
put("eventId", eventId ?: JSONObject.NULL)
put("senderKey", senderKey)
put("senderName", senderName)
put("body", body)
put("timestamp", timestamp)
put("imagePath", imagePath ?: JSONObject.NULL)
put("avatarPath", avatarPath ?: JSONObject.NULL)
}
companion object {
fun fromJson(o: JSONObject) = Msg(
eventId = o.optStringOrNull("eventId"),
senderKey = o.optString("senderKey"),
senderName = o.optString("senderName"),
body = o.optString("body"),
timestamp = o.optLong("timestamp"),
imagePath = o.optStringOrNull("imagePath"),
avatarPath = o.optStringOrNull("avatarPath"),
)
}
}
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */ /** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
fun stableId(roomId: String): Int { fun stableId(roomId: String): Int {
@@ -32,15 +98,226 @@ object NotificationHelper {
} }
} }
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) { /**
* Platzhalter/Alt-Aufrufer mit fertigem Titel „Absender · Raum“ (bzw. nur
* „Absender“ bei Direktnachrichten).
*/
fun show(
context: Context,
title: String,
body: String,
roomId: String,
notifId: Int,
eventId: String? = null,
) {
val parts = title.split(" · ", limit = 2)
val sender = parts[0].ifBlank { "Pyramid" }
val roomName = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName, isGroup = roomName != null,
msg = Msg(eventId, sender, sender, body, System.currentTimeMillis()),
)
}
/** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
fun showFromArgs(context: Context, args: Map<String, Any?>) {
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
if (roomId.isEmpty()) return
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: stableId(roomId)
val senderName = args["senderName"] as? String
if (senderName == null) {
// Alter Aufrufer ohne strukturierte Felder.
show(
context, args["title"] as? String ?: "",
args["body"] as? String ?: PLACEHOLDER_BODY,
roomId, notifId, args["eventId"] as? String,
)
return
}
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName,
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
msg = Msg(
eventId = args["eventId"] as? String,
senderKey = args["senderId"] as? String ?: senderName,
senderName = senderName,
body = args["body"] as? String ?: PLACEHOLDER_BODY,
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
imagePath = args["imagePath"] as? String,
avatarPath = args["avatarPath"] as? String,
),
)
}
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
fun showMessage(
context: Context,
roomId: String,
notifId: Int,
roomName: String?,
isGroup: Boolean,
msg: Msg,
) {
ensureChannel(context) ensureChannel(context)
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
val latest = history.last()
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) val style = NotificationCompat.MessagingStyle(
PendingIntent.FLAG_MUTABLE else 0 Person.Builder().setName("Du").setKey("me").build()
)
if (isGroup && roomName != null) {
style.conversationTitle = roomName
style.isGroupConversation = true
}
for (m in history) {
val person = Person.Builder()
.setName(m.senderName)
.setKey(m.senderKey)
.apply {
contentUri(context, m.avatarPath)?.let { setIcon(IconCompat.createWithContentUri(it)) }
}
.build()
val message = NotificationCompat.MessagingStyle.Message(m.body, m.timestamp, person)
contentUri(context, m.imagePath)?.let { message.setData("image/jpeg", it) }
style.addMessage(message)
}
// Reply action — BroadcastReceiver so replying NEVER opens the app. val builder = NotificationCompat.Builder(context, CHANNEL_ID)
// ReplyReceiver decrypts/sends via the background engine when killed. .setSmallIcon(R.drawable.ic_notification)
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.) .setColor(Color.rgb(0x7B, 0x61, 0xFF))
.setStyle(style)
// Fallback-Texte für Geräte/Ansichten ohne MessagingStyle.
.setContentTitle(if (isGroup && roomName != null) roomName else latest.senderName)
.setContentText(latest.body)
.setWhen(latest.timestamp)
.setShowWhen(true)
.setNumber(history.size)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Nur eine wirklich neue Nachricht klingelt; Aktualisierungen
// (Platzhalter → Klartext, Bild nachgeladen) ersetzen still.
.setOnlyAlertOnce(!isNew)
.setContentIntent(tapIntent(context, roomId, notifId))
.setAutoCancel(true)
.addAction(replyAction(context, roomId, notifId))
.addAction(markReadAction(context, roomId, notifId, latest.eventId))
.setGroup(CHANNEL_ID)
if (!isGroup) {
avatarBitmap(latest.avatarPath)?.let { builder.setLargeIcon(it) }
}
try {
nm(context).notify(notifId, builder.build())
} catch (e: Exception) {
// Sicherheitsnetz: lieber ohne Bilder/Avatare als gar nicht.
Log.w(TAG, "notify failed (${e.message}) — retrying as plain text")
builder.setLargeIcon(null as Bitmap?)
.setStyle(NotificationCompat.BigTextStyle().bigText(latest.body))
nm(context).notify(notifId, builder.build())
}
}
/** Vergisst den Verlauf eines Raums (nach Antworten/„Gelesen“). */
@Synchronized
fun clearHistory(context: Context, roomId: String) {
prefs(context).edit().remove(roomId).apply()
}
@Synchronized
private fun mergeIntoHistory(
context: Context, roomId: String, notifId: Int, msg: Msg,
): Pair<List<Msg>, Boolean> {
val prefs = prefs(context)
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
val isNew = index < 0
if (isNew) {
list.add(msg)
} else {
val old = list[index]
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
val keepOld = msg.isPlaceholder && !old.isPlaceholder
list[index] = Msg(
eventId = old.eventId,
senderKey = if (keepOld) old.senderKey else msg.senderKey,
senderName = if (keepOld) old.senderName else msg.senderName,
body = if (keepOld) old.body else msg.body,
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
imagePath = msg.imagePath ?: old.imagePath,
avatarPath = msg.avatarPath ?: old.avatarPath,
)
}
list.sortBy { it.timestamp }
while (list.size > MAX_MESSAGES) list.removeAt(0)
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
return list to isNew
}
private fun load(json: String?): MutableList<Msg> {
if (json == null) return mutableListOf()
return try {
val arr = JSONArray(json)
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
} catch (_: Exception) {
mutableListOf()
}
}
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
private fun isActive(context: Context, notifId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
nm(context).activeNotifications.any { it.id == notifId }
} catch (_: Exception) {
true
}
}
/**
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
*/
private fun contentUri(context: Context, path: String?): Uri? {
if (path.isNullOrEmpty()) return null
val file = File(path)
if (!file.exists()) return null
return try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
uri
} catch (e: Exception) {
Log.w(TAG, "contentUri failed for $path: ${e.message}")
null
}
}
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
private fun avatarBitmap(path: String?): Bitmap? {
if (path.isNullOrEmpty() || !File(path).exists()) return null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
sample *= 2
}
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
}
// ── Aktionen ──────────────────────────────────────────────────────────────
private fun mutableFlag() =
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.)
private fun replyAction(context: Context, roomId: String, notifId: Int): NotificationCompat.Action {
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY) val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
.setLabel("Antworten…").build() .setLabel("Antworten…").build()
val replyIntent = Intent(context, ReplyReceiver::class.java).apply { val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
@@ -49,59 +326,57 @@ object NotificationHelper {
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
} }
val replyPi = PendingIntent.getBroadcast( val replyPi = PendingIntent.getBroadcast(
context, notifId, context, notifId, replyIntent,
replyIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
) )
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi) return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
.addRemoteInput(replyInput) .addRemoteInput(replyInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build() .build()
}
// Dismiss action — also points to ReplyReceiver so notification is cancelled. // „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply { // bis zur neuesten Nachricht (ReplyReceiver → Haupt-Engine bzw. BgEngine).
private fun markReadAction(
context: Context, roomId: String, notifId: Int, eventId: String?,
): NotificationCompat.Action {
val intent = Intent(context, ReplyReceiver::class.java).apply {
action = ReplyReceiver.ACTION_DISMISS action = ReplyReceiver.ACTION_DISMISS
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId) putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
} }
val dismissPi = PendingIntent.getBroadcast( val pi = PendingIntent.getBroadcast(
context, notifId + 1_000_000, context, notifId + 1_000_000, intent,
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Action.Builder(0, "Gelesen", pi)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
// Tap action — opens MainActivity to the right room. // Tap action — opens MainActivity to the right room.
private fun tapIntent(context: Context, roomId: String, notifId: Int): PendingIntent {
val tapIntent = Intent(context, MainActivity::class.java).apply { val tapIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_MAIN action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER) addCategory(Intent.CATEGORY_LAUNCHER)
putExtra("room_id", roomId) putExtra("room_id", roomId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
val tapPi = PendingIntent.getActivity( return PendingIntent.getActivity(
context, notifId + 2_000_000, context, notifId + 2_000_000, tapIntent,
tapIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
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 prefs(context: Context) =
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
private fun nm(context: Context) = private fun nm(context: Context) =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
} }
private fun JSONObject.optStringOrNull(key: String): String? =
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
val token = (prefs.getString("flutter.notif_access_token", "") ?: "") val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
// Fetch sender + room name on a background thread (main thread forbids I/O). // Fetch sender + room name on a background thread (main thread forbids I/O).
var title = "Pyramid" var meta = PushMeta()
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) { if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
Thread { Thread {
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" } try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
finally { latch.countDown() } finally { latch.countDown() }
}.start() }.start()
latch.await(12L, TimeUnit.SECONDS) latch.await(12L, TimeUnit.SECONDS)
} }
Log.d(TAG, "handling push: title=$title roomId=$roomId") Log.d(TAG, "handling push: sender=${meta.senderName} room=${meta.roomName} roomId=$roomId")
val engine = FlutterEngineCache.getInstance().get("main") val engine = FlutterEngineCache.getInstance().get("main")
if (engine != null) { if (engine != null) {
// App backgrounded but alive — show placeholder immediately, then the // App backgrounded but alive — show placeholder immediately, then the
// warm main client decrypts and updates it (fast, barely visible). // warm main client decrypts and updates it (fast, barely visible).
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(this, roomId, notifId, eventId)
if (eventId != null) { if (eventId != null) {
val capturedRoomId = roomId val capturedRoomId = roomId
val capturedEventId = eventId val capturedEventId = eventId
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
// real content directly. The "Neue Nachricht" placeholder is only a // real content directly. The "Neue Nachricht" placeholder is only a
// fallback if decryption takes too long or fails. // fallback if decryption takes too long or fails.
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId") Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L) BgEngine.scheduleFallback(this, meta, roomId, notifId, eventId, 6_000L)
BgEngine.run(this, "decryptAndShow", mapOf( BgEngine.run(this, "decryptAndShow", mapOf(
"room_id" to roomId, "room_id" to roomId,
"event_id" to eventId, "event_id" to eventId,
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
)) ))
} else { } else {
// No event id to decrypt — just show the placeholder. // No event id to decrypt — just show the placeholder.
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(this, roomId, notifId, null)
} }
} }
@@ -102,11 +102,31 @@ class PushService : FirebaseMessagingService() {
.apply() .apply()
} }
/** Absender + Raumname aus unverschlüsselten Room-States (für den Platzhalter). */
data class PushMeta(
val senderId: String = "",
val senderName: String = "Pyramid",
val roomName: String? = null,
) {
fun showPlaceholder(context: android.content.Context, roomId: String, notifId: Int, eventId: String?) {
NotificationHelper.showMessage(
context, roomId, notifId, roomName, isGroup = roomName != null,
msg = NotificationHelper.Msg(
eventId = eventId,
senderKey = senderId.ifEmpty { senderName },
senderName = senderName,
body = NotificationHelper.PLACEHOLDER_BODY,
timestamp = System.currentTimeMillis(),
),
)
}
}
// ─── HTTP helpers ───────────────────────────────────────────────────────── // ─── HTTP helpers ─────────────────────────────────────────────────────────
private fun fetchTitle( private fun fetchMeta(
hs: String, token: String, roomId: String, eventId: String hs: String, token: String, roomId: String, eventId: String
): String? { ): PushMeta? {
return try { return try {
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8") val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8") val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
@@ -128,8 +148,11 @@ class PushService : FirebaseMessagingService() {
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token) val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
val roomName = nameState?.optString("name") val roomName = nameState?.optString("name")
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName" return PushMeta(
else senderName.ifEmpty { null } senderId = senderId,
senderName = senderName.ifEmpty { "Pyramid" },
roomName = roomName?.takeIf { it.isNotEmpty() },
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "fetchTitle failed: ${e.message}") Log.w(TAG, "fetchTitle failed: ${e.message}")
null null
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS" const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
const val EXTRA_ROOM_ID = "room_id" const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_NOTIF_ID = "notif_id" const val EXTRA_NOTIF_ID = "notif_id"
const val EXTRA_EVENT_ID = "event_id"
const val REMOTE_INPUT_KEY = "reply_text" const val REMOTE_INPUT_KEY = "reply_text"
} }
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0) val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
// Dismiss the notification immediately. // Dismiss the notification immediately; its message history goes too.
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.cancel(notifId) .cancel(notifId)
NotificationHelper.clearHistory(context, roomId)
if (intent.action == ACTION_DISMISS) { if (intent.action == ACTION_DISMISS) {
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId") markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
return return
} }
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
try { pending.finish() } catch (_: Exception) {} try { pending.finish() } catch (_: Exception) {}
}, 15_000L) }, 15_000L)
} }
/**
* „Gelesen“: Lesebestätigung bis [eventId] setzen – über die laufende
* Haupt-Engine oder (App beendet) still über die BgEngine. Ohne eventId
* (alter Platzhalter) bleibt es beim Schließen.
*/
private fun markRead(context: Context, roomId: String, eventId: String?) {
Log.d("PYRAMID-REPLY", "mark-read action for room $roomId event=$eventId")
if (eventId.isNullOrEmpty()) return
val args = mapOf("room_id" to roomId, "event_id" to eventId)
val engine = FlutterEngineCache.getInstance().get("main")
if (engine != null) {
try {
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
.invokeMethod("markReadFromNotification", args)
return
} catch (e: Exception) {
Log.w("PYRAMID-REPLY", "markRead via main engine failed: ${e.message}")
}
}
val pending = goAsync()
BgEngine.run(context, "markRead", args)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try { pending.finish() } catch (_: Exception) {}
}, 10_000L)
}
} }
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Kein Cloud-Backup und keine Gerät-zu-Gerät-Übertragung der App-Daten
(Android 12+). Grund: Die lokale Datenbank ist mit SQLCipher verschlüsselt,
ihr Schlüssel liegt im Android-Keystore und zieht NICHT mit um – eine
übertragene Datenbank ließe sich auf dem neuen Gerät nicht öffnen
(AppDatabase bricht dann bewusst ab, statt Daten zu überschreiben).
Außerdem enthalten die SharedPreferences den Zugangsschlüssel für den
Push-Dienst. Matrix-Weg auf neuem Gerät: neu anmelden + Schlüssel mit dem
Wiederherstellungsschlüssel aus dem Server-Backup holen.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
+14 -38
View File
@@ -1,40 +1,23 @@
#!/bin/bash #!/bin/bash
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife. # Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht. # Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh # Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D) # Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session) # PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
# FORTSETZEN: rm ~/pyramid/PAUSE # FORTSETZEN: rm ~/pyramid/PAUSE
# GANZ STOPPEN: tmux kill-session -t pyramid # GANZ STOPPEN: tmux kill-session -t pyramid
cd "$(dirname "$0")" cd "$(dirname "$0")"
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH" export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
# --- Zwei Claude-Konten mit automatischem Wechsel bei 5h-Limit --- # --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
# Konto "pyramid" = Profil ~/.claude-pyramid (Standard fuer diese Arbeit), # Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
# Konto "haupt" = Profil ~/.claude (springt ein, wenn pyramid am Limit ist). # ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
ACCOUNT_FILE="$HOME/.claude-accounts/active" # ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
get_account() { cat "$ACCOUNT_FILE" 2>/dev/null || echo pyramid; } # die Config faelschlich unter ~/.claude/.claude.json).
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
apply_account() { # ist oeffentlich, PII gehoert nicht hinein.)
if [ "$(get_account)" = "haupt" ]; then apply_account() { unset CLAUDE_CONFIG_DIR; }
# Hauptkonto = Standard-Profil -> Variable NICHT setzen ACCOUNT_NAME="nordmann"
# (sonst sucht Claude die Config unter ~/.claude/.claude.json statt ~/.claude.json)
unset CLAUDE_CONFIG_DIR
else
export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid"
fi
}
switch_account() {
local cur next
cur=$(get_account)
if [ "$cur" = "pyramid" ]; then next=haupt; else next=pyramid; fi
mkdir -p "$HOME/.claude-accounts"
echo "$next" > "$ACCOUNT_FILE"
notify_matrix "🔺 Autopilot: 5h-Limit auf Konto '$cur' erreicht – wechsle auf Konto '$next' und arbeite direkt weiter."
}
JUST_SWITCHED=0
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) --- # --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
MATRIX_URL="http://127.0.0.1:6167" MATRIX_URL="http://127.0.0.1:6167"
@@ -60,7 +43,7 @@ while true; do
continue continue
fi fi
apply_account apply_account
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $(get_account)) =====" | tee -a autopilot.log echo "===== $(date '+%F %T') Neue Claude-Session (Konto $ACCOUNT_NAME) =====" | tee -a autopilot.log
BEFORE=$(git rev-parse HEAD 2>/dev/null) BEFORE=$(git rev-parse HEAD 2>/dev/null)
SESSION_OUT=$(mktemp) SESSION_OUT=$(mktemp)
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT" claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
@@ -75,21 +58,14 @@ $COMMITS
$NEXT" $NEXT"
fi fi
# 5h-Limit erkannt? -> sofort aufs andere Konto wechseln statt zu warten # Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
rm -f "$SESSION_OUT" rm -f "$SESSION_OUT"
if [ "$JUST_SWITCHED" != "1" ]; then echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
switch_account
JUST_SWITCHED=1
continue
fi
JUST_SWITCHED=0
echo "===== $(date '+%F %T') Beide Konten am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
sleep 1200 sleep 1200
continue continue
fi fi
rm -f "$SESSION_OUT" rm -f "$SESSION_OUT"
JUST_SWITCHED=0
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
sleep 1200 sleep 1200
+151
View File
@@ -0,0 +1,151 @@
{
"suite": "db",
"timestamp": "2026-10-07T11:35:13.322832",
"git": "b2413ef",
"host": {
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
"cpus": 12
},
"params": {
"rooms": 20,
"eventsPerRoom": 500
},
"metrics": {
"klartext.write_total_ms": {
"label": "[klartext] Sync-Schreiben, 10000 Nachrichten",
"unit": "ms",
"value": 2513.443,
"min": 2469.054,
"runs": 2,
"compare": true
},
"klartext.write_per_event_us": {
"label": "[klartext] Sync-Schreiben je Nachricht",
"unit": "µs",
"value": 251.344,
"runs": 1,
"compare": true
},
"klartext.startup_ms": {
"label": "[klartext] App-Start: DB + Raumliste",
"unit": "ms",
"value": 4.121,
"min": 3.254,
"runs": 6,
"compare": true
},
"klartext.open_chat_ms": {
"label": "[klartext] Chat öffnen (50 Nachr.)",
"unit": "ms",
"value": 0.328,
"min": 0.286,
"runs": 6,
"compare": true
},
"klartext.search_miss_ms": {
"label": "[klartext] Suche ohne Treffer (Worst Case)",
"unit": "ms",
"value": 63.897,
"min": 63.716,
"runs": 4,
"compare": true
},
"klartext.search_hit_ms": {
"label": "[klartext] Suche mit Treffern",
"unit": "ms",
"value": 31.777,
"min": 31.202,
"runs": 4,
"compare": true
},
"klartext.size_mb": {
"label": "[klartext] DB-Größe inkl. WAL",
"unit": "MB",
"value": 4.008,
"runs": 1,
"compare": false
},
"sqlcipher.write_total_ms": {
"label": "[sqlcipher] Sync-Schreiben, 10000 Nachrichten",
"unit": "ms",
"value": 2615.633,
"min": 2456.356,
"runs": 2,
"compare": true
},
"sqlcipher.write_per_event_us": {
"label": "[sqlcipher] Sync-Schreiben je Nachricht",
"unit": "µs",
"value": 261.563,
"runs": 1,
"compare": true
},
"sqlcipher.startup_ms": {
"label": "[sqlcipher] App-Start: DB + Raumliste",
"unit": "ms",
"value": 4.313,
"min": 3.718,
"runs": 6,
"compare": true
},
"sqlcipher.open_chat_ms": {
"label": "[sqlcipher] Chat öffnen (50 Nachr.)",
"unit": "ms",
"value": 0.323,
"min": 0.309,
"runs": 6,
"compare": true
},
"sqlcipher.search_miss_ms": {
"label": "[sqlcipher] Suche ohne Treffer (Worst Case)",
"unit": "ms",
"value": 63.748,
"min": 62.8,
"runs": 4,
"compare": true
},
"sqlcipher.search_hit_ms": {
"label": "[sqlcipher] Suche mit Treffern",
"unit": "ms",
"value": 31.676,
"min": 31.296,
"runs": 4,
"compare": true
},
"sqlcipher.size_mb": {
"label": "[sqlcipher] DB-Größe inkl. WAL",
"unit": "MB",
"value": 4.098,
"runs": 1,
"compare": false
},
"cipher_overhead.write_total_ms": {
"label": "Aufschlag Verschlüsselung: Sync-Schreiben",
"unit": "%",
"value": 4.066,
"runs": 1,
"compare": false
},
"cipher_overhead.startup_ms": {
"label": "Aufschlag Verschlüsselung: App-Start",
"unit": "%",
"value": 4.634,
"runs": 1,
"compare": false
},
"cipher_overhead.open_chat_ms": {
"label": "Aufschlag Verschlüsselung: Chat öffnen",
"unit": "%",
"value": -1.388,
"runs": 1,
"compare": false
},
"cipher_overhead.search_miss_ms": {
"label": "Aufschlag Verschlüsselung: Suche ohne Treffer",
"unit": "%",
"value": -0.233,
"runs": 1,
"compare": false
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"suite": "migration",
"timestamp": "2026-10-07T11:35:21.106818",
"git": "b2413ef",
"host": {
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
"cpus": 12
},
"params": {
"rooms": 20,
"eventsPerRoom": 1000
},
"metrics": {
"db_size_mb": {
"label": "DB-Größe vor Migration",
"unit": "MB",
"value": 7.793,
"runs": 1,
"compare": false
},
"migrate_ms": {
"label": "Migration gesamt",
"unit": "ms",
"value": 325.058,
"min": 322.058,
"runs": 3,
"compare": true
},
"migrate_ms_per_mb": {
"label": "Migration je MB",
"unit": "ms/MB",
"value": 41.712,
"runs": 1,
"compare": true
}
}
}
+257
View File
@@ -0,0 +1,257 @@
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
// der Analyzer sie nicht als Tests.
// ignore_for_file: invalid_use_of_visible_for_testing_member
@TestOn('windows')
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/features/search/local_message_search.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../test/support/synthetic_matrix_data.dart';
import 'support/bench.dart';
/// Datenbank-Benchmark: Was kostet die SQLCipher-Verschlüsselung im Alltag?
///
/// Misst – jeweils für eine Klartext-DB und eine SQLCipher-DB mit DERSELBEN
/// Bibliothek aus dem App-Build – die vier Wege, auf denen Nutzer die DB
/// spüren:
/// 1. Sync-Schreiben (Nachrichten landen in Transaktionen wie beim Sync),
/// 2. App-Start (DB öffnen + SDK-Schema + Raumliste laden),
/// 3. Chat öffnen (50 neueste Nachrichten eines Raums),
/// 4. lokale Suche (Strg+K) – ohne Treffer = Worst Case, scannt alles.
///
/// Daten sind synthetisch (`test/support/synthetic_matrix_data.dart`), laufen
/// aber über die echte SDK-Speicherschicht. Lauf:
/// flutter test benchmark/db_benchmark_test.dart
/// oder alles zusammen über `scripts/test.ps1 -Bench`.
const _modes = ['klartext', 'sqlcipher'];
const _benchKey =
'0b1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e';
void main() {
final skip = findCipherDll() == null
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
: null;
test(
'DB: Klartext vs. SQLCipher',
() async {
final rooms = scaled(20);
final eventsPerRoom = scaled(500);
final totalEvents = rooms * eventsPerRoom;
final report = BenchReport(
'db',
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
);
final factory = cipherFactory();
final tempDir = await Directory.systemTemp.createTemp('pyramid_bench_db');
Future<Database> openBenchDb(String mode, String path) async {
final db = mode == 'sqlcipher'
? await AppDatabase.openEncrypted(factory, path, _benchKey)
: await factory.openDatabase(path);
await applyAppPragmas(db);
return db;
}
try {
// Ungemessener Aufwärm-Durchlauf (JIT, DB-Isolate, SQLCipher-Init).
// Bewusst mit voller Raumgröße: Der JIT optimiert die SDK-Pfade erst
// nach vielen Aufrufen mit wachsender Fragment-Liste – mit einem
// Mini-Durchlauf war die erste Messrunde noch ~45 % langsamer.
for (final mode in _modes) {
final path = p.join(tempDir.path, 'warmup_$mode.sqlite');
final client = await SyntheticMatrixData.openClient(
await openBenchDb(mode, path),
);
await SyntheticMatrixData.populate(
client,
rooms: (rooms ~/ 4).clamp(1, rooms),
eventsPerRoom: eventsPerRoom,
);
await SyntheticMatrixData.loadRooms(client);
await localMessageSearch(client, 'aufwärmen');
await client.dispose(closeDatabase: true);
}
// Gemessen wird abwechselnd A-B-B-A: Die Schreibkosten sind von
// Dart-Code (SDK-JSON) dominiert, und der JIT wird während des Laufs
// weiter schneller. Ohne das Muster gewinnt einfach der später
// gemessene Modus (2026-10-07 nachgemessen: ±20 % je nach Reihenfolge).
const order = ['klartext', 'sqlcipher', 'sqlcipher', 'klartext'];
final timings = {
for (final mode in _modes) mode: <String, List<double>>{},
};
void record(String mode, String metric, Iterable<double> values) =>
timings[mode]!.putIfAbsent(metric, () => []).addAll(values);
// 1. Sync-Schreiben – je Runde eine frische DB.
final paths = <String, String>{};
for (var round = 0; round < order.length; round++) {
final mode = order[round];
final path = p.join(tempDir.path, '${mode}_$round.sqlite');
final client = await SyntheticMatrixData.openClient(
await openBenchDb(mode, path),
);
final watch = Stopwatch()..start();
await SyntheticMatrixData.populate(
client,
rooms: rooms,
eventsPerRoom: eventsPerRoom,
needleEvery: 97,
);
watch.stop();
await client.dispose(closeDatabase: true);
record(mode, 'write', [watch.elapsedMicroseconds / 1000]);
paths[mode] = path;
}
// 2. App-Start: DB öffnen + SDK-Schema + Raumliste, danach wieder
// schließen – wie ein Neustart. Eigene Runde VOR den Lese-Messungen:
// sqflite liefert für denselben Pfad dieselbe Verbindung zurück,
// ein Schließen hier würde sonst deren offene Verbindung kappen.
for (final mode in order) {
record(
mode,
'startup',
await sample(() async {
final c = await SyntheticMatrixData.openClient(
await openBenchDb(mode, paths[mode]!),
);
await SyntheticMatrixData.loadRooms(c);
await c.dispose(closeDatabase: true);
}, runs: 3),
);
}
// 3.–4. Lesen auf der jeweils zuletzt geschriebenen DB.
final clients = <String, Client>{};
final roomLists = <String, List<Room>>{};
for (final mode in _modes) {
final client = await SyntheticMatrixData.openClient(
await openBenchDb(mode, paths[mode]!),
);
clients[mode] = client;
roomLists[mode] = await SyntheticMatrixData.loadRooms(client);
expect(
roomLists[mode],
hasLength(rooms),
reason: 'Raumliste vollständig ($mode)',
);
}
for (final mode in order) {
final client = clients[mode]!;
record(
mode,
'open_chat',
(await sample(() async {
for (final room in roomLists[mode]!) {
final page = await client.database.getEventList(
room,
limit: 50,
);
if (page.length != 50) throw StateError('Seite unvollständig');
}
}, runs: 3)).map((t) => t / rooms),
);
record(
mode,
'search_miss',
await sample(
() => localMessageSearch(client, 'gibtsnichtwort'),
runs: 2,
),
);
record(
mode,
'search_hit',
await sample(
() => localMessageSearch(client, 'nadelwort'),
runs: 2,
),
);
}
for (final client in clients.values) {
await client.dispose(closeDatabase: true);
}
for (final mode in _modes) {
final t = timings[mode]!;
final writeMs = median(t['write']!);
report.addTimings(
'$mode.write_total_ms',
'[$mode] Sync-Schreiben, $totalEvents Nachrichten',
t['write']!,
);
report.addValue(
'$mode.write_per_event_us',
'[$mode] Sync-Schreiben je Nachricht',
writeMs * 1000 / totalEvents,
'µs',
compare: true,
);
report.addTimings(
'$mode.startup_ms',
'[$mode] App-Start: DB + Raumliste',
t['startup']!,
);
report.addTimings(
'$mode.open_chat_ms',
'[$mode] Chat öffnen (50 Nachr.)',
t['open_chat']!,
);
report.addTimings(
'$mode.search_miss_ms',
'[$mode] Suche ohne Treffer (Worst Case)',
t['search_miss']!,
);
report.addTimings(
'$mode.search_hit_ms',
'[$mode] Suche mit Treffern',
t['search_hit']!,
);
report.addValue(
'$mode.size_mb',
'[$mode] DB-Größe inkl. WAL',
dbSizeMb(paths[mode]!),
'MB',
);
}
for (final (id, label) in [
('write_total_ms', 'Sync-Schreiben'),
('startup_ms', 'App-Start'),
('open_chat_ms', 'Chat öffnen'),
('search_miss_ms', 'Suche ohne Treffer'),
]) {
final plain = report.valueOf('klartext.$id');
final enc = report.valueOf('sqlcipher.$id');
report.addValue(
'cipher_overhead.$id',
'Aufschlag Verschlüsselung: $label',
(enc / plain - 1) * 100,
'%',
);
}
final regressions = await report.finish();
if (strictBench) expect(regressions, isEmpty);
} finally {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
},
skip: skip,
timeout: const Timeout(Duration(minutes: 20)),
);
}
+124
View File
@@ -0,0 +1,124 @@
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
// der Analyzer sie nicht als Tests.
// ignore_for_file: invalid_use_of_visible_for_testing_member
@TestOn('windows')
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:pyramid/core/app_database.dart';
import '../test/support/synthetic_matrix_data.dart';
import 'support/bench.dart';
/// Migrations-Benchmark: Wie lange dauert die einmalige Umstellung
/// Klartext → SQLCipher beim ersten Start nach dem Update (Utas Fall)?
///
/// Baut eine Klartext-DB über die echte SDK-Speicherschicht, kopiert sie vor
/// jedem Durchlauf frisch und misst `AppDatabase.migratePlaintextToEncrypted`
/// (exakt der App-Codepfad: Checkpoint, Backup, Export, Verifikation, Tausch).
/// Danach wird geprüft, dass die Raumliste aus der verschlüsselten DB wieder
/// vollständig lädt. Ergebnis zusätzlich als ms pro MB – damit lässt sich die
/// Dauer für eine echte DB-Größe hochrechnen.
const _benchKey =
'5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b';
void main() {
final skip = findCipherDll() == null
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
: null;
test(
'Migration Klartext → SQLCipher',
() async {
final rooms = scaled(20);
final eventsPerRoom = scaled(1000);
final report = BenchReport(
'migration',
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
);
final factory = cipherFactory();
final tempDir = await Directory.systemTemp.createTemp(
'pyramid_bench_migration',
);
try {
// Vorlage: Klartext-DB wie vor der SQLCipher-Einführung.
final templatePath = p.join(tempDir.path, 'template.sqlite');
var db = await factory.openDatabase(templatePath);
await applyAppPragmas(db);
var client = await SyntheticMatrixData.openClient(db);
await SyntheticMatrixData.populate(
client,
rooms: rooms,
eventsPerRoom: eventsPerRoom,
);
await client.dispose(closeDatabase: true);
// WAL in die Hauptdatei schreiben, damit die Kopie vollständig ist.
db = await factory.openDatabase(templatePath);
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
await db.close();
final sizeMb = dbSizeMb(templatePath);
final workPath = p.join(tempDir.path, 'pyramid.sqlite');
Future<void> freshCopy() async {
for (final suffix in ['', '-wal', '-shm', '.enc', '.premigration']) {
final f = File('$workPath$suffix');
if (f.existsSync()) f.deleteSync();
}
await File(templatePath).copy(workPath);
}
final times = await sample(
() async {
final ok = await AppDatabase.migratePlaintextToEncrypted(
factory: factory,
dbPath: workPath,
hexKey: _benchKey,
);
if (!ok) throw StateError('Migration fehlgeschlagen');
},
setup: freshCopy,
warmup: 0,
runs: 3,
);
// Ergebnis der letzten Migration prüfen: verschlüsselt + vollständig.
expect(await AppDatabase.isPlaintextSqlite(File(workPath)), isFalse);
db = await AppDatabase.openEncrypted(factory, workPath, _benchKey);
client = await SyntheticMatrixData.openClient(db);
final loaded = await SyntheticMatrixData.loadRooms(client);
final firstPage = await client.database.getEventList(
loaded.first,
limit: 50,
);
await client.dispose(closeDatabase: true);
expect(loaded, hasLength(rooms));
expect(firstPage, hasLength(50));
report.addValue('db_size_mb', 'DB-Größe vor Migration', sizeMb, 'MB');
report.addTimings('migrate_ms', 'Migration gesamt', times);
report.addValue(
'migrate_ms_per_mb',
'Migration je MB',
median(times) / sizeMb,
'ms/MB',
compare: true,
);
final regressions = await report.finish();
if (strictBench) expect(regressions, isEmpty);
} finally {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
},
skip: skip,
timeout: const Timeout(Duration(minutes: 20)),
);
}
+271
View File
@@ -0,0 +1,271 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart';
/// Kleines Benchmark-Gerüst für `benchmark/` (bewusst ohne Zusatzpaket).
///
/// Jede Suite sammelt Messwerte in einem [BenchReport]. Am Ende:
/// * Tabelle in der Konsole (inkl. Vergleich mit der Baseline),
/// * JSON nach `build/benchmarks/<suite>.json` (für spätere Vergleiche),
/// * Vergleich mit `benchmark/baseline/<suite>.json`, falls vorhanden.
///
/// Umgebungsvariablen:
/// * `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als neue Baseline
/// speichern (nach einer bewussten Änderung / auf neuer Hardware).
/// * `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern
/// (sonst nur Warnung, weil Messrauschen normal ist).
/// * `PYRAMID_BENCH_SCALE` – Datenmenge skalieren (Standard 1.0;
/// z. B. 0.2 für einen schnellen Probelauf, 3 für „Power-User").
/// Ab so viel Prozent langsamer als die Baseline gilt ein Wert als Regression.
const regressionThresholdPercent = 25.0;
/// Unterhalb dieser absoluten Differenz (ms) wird nie gewarnt – bei sehr
/// kleinen Werten ist das reines Rauschen.
const regressionFloorMs = 2.0;
double get benchScale =>
double.tryParse(Platform.environment['PYRAMID_BENCH_SCALE'] ?? '') ?? 1.0;
int scaled(int value) => (value * benchScale).round().clamp(1, 1 << 30);
/// Führt [body] erst [warmup]-mal ungemessen, dann [runs]-mal gemessen aus
/// und liefert die Laufzeiten in Millisekunden. [setup] läuft vor JEDEM
/// Durchlauf und wird nicht mitgemessen.
Future<List<double>> sample(
Future<void> Function() body, {
int warmup = 1,
int runs = 5,
Future<void> Function()? setup,
}) async {
for (var i = 0; i < warmup; i++) {
if (setup != null) await setup();
await body();
}
final out = <double>[];
for (var i = 0; i < runs; i++) {
if (setup != null) await setup();
final sw = Stopwatch()..start();
await body();
sw.stop();
out.add(sw.elapsedMicroseconds / 1000.0);
}
return out;
}
double median(List<double> values) {
final sorted = [...values]..sort();
final mid = sorted.length ~/ 2;
return sorted.length.isOdd
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
class BenchMetric {
BenchMetric({
required this.id,
required this.label,
required this.unit,
required this.value,
this.min,
this.runs = 1,
this.compare = true,
});
final String id;
final String label;
final String unit;
/// Median (bzw. Einzelwert) – der Wert, der verglichen wird.
final double value;
final double? min;
final int runs;
/// `false` für reine Info-Werte (z. B. Dateigröße), die nicht als
/// Regression gewertet werden.
final bool compare;
Map<String, Object?> toJson() => {
'label': label,
'unit': unit,
'value': double.parse(value.toStringAsFixed(3)),
if (min != null) 'min': double.parse(min!.toStringAsFixed(3)),
'runs': runs,
'compare': compare,
};
}
class BenchReport {
BenchReport(this.suite, {this.params = const {}});
final String suite;
final Map<String, Object?> params;
final _metrics = <BenchMetric>[];
/// Laufzeit-Messreihe (ms) als Metrik ablegen.
void addTimings(String id, String label, List<double> timingsMs) {
_metrics.add(
BenchMetric(
id: id,
label: label,
unit: 'ms',
value: median(timingsMs),
min: timingsMs.reduce((a, b) => a < b ? a : b),
runs: timingsMs.length,
),
);
}
void addValue(
String id,
String label,
double value,
String unit, {
bool compare = false,
}) {
_metrics.add(
BenchMetric(
id: id,
label: label,
unit: unit,
value: value,
compare: compare,
),
);
}
double valueOf(String id) => _metrics.firstWhere((m) => m.id == id).value;
/// Schreibt Bericht + ggf. Baseline und liefert die Liste der Regressionen
/// (leer = alles im Rahmen).
Future<List<String>> finish() async {
final baselineFile = File('benchmark/baseline/$suite.json');
Map<String, Object?>? baseline;
if (baselineFile.existsSync()) {
final json =
jsonDecode(baselineFile.readAsStringSync()) as Map<String, Object?>;
baseline = (json['metrics'] as Map).cast<String, Object?>();
}
final regressions = <String>[];
final buf = StringBuffer()
..writeln()
..writeln(
'══ Benchmark "$suite" '
'${params.isEmpty ? '' : params.entries.map((e) => '${e.key}=${e.value}').join(', ')}',
)
..writeln(
'${'Messung'.padRight(52)}${'Wert'.padLeft(12)} ${'Baseline'.padLeft(10)} Δ',
);
for (final m in _metrics) {
final base = (baseline?[m.id] as Map?)?['value'] as num?;
var delta = '';
// Für Prozent-Werte (z. B. Verschlüsselungs-Aufschlag) wäre ein
// relatives Δ irreführend (4 % → 11 % = „+150 %“) – dort nur die Werte.
if (base != null && base > 0 && m.unit != '%') {
final pct = (m.value - base) / base * 100;
delta = '${pct >= 0 ? '+' : ''}${pct.toStringAsFixed(0)} %';
if (m.compare &&
pct > regressionThresholdPercent &&
(m.unit != 'ms' || m.value - base > regressionFloorMs)) {
delta += ' ⚠ LANGSAMER';
regressions.add(
'${m.label}: ${_fmt(m.value)} ${m.unit} '
'(Baseline ${_fmt(base.toDouble())} ${m.unit}, $delta)',
);
}
}
buf.writeln(
'${m.label.padRight(52)}'
'${'${_fmt(m.value)} ${m.unit}'.padLeft(12)} '
'${base == null ? '–'.padLeft(10) : _fmt(base.toDouble()).padLeft(10)} $delta',
);
}
// ignore: avoid_print
print(buf);
final report = {
'suite': suite,
'timestamp': DateTime.now().toIso8601String(),
'git': await _gitRevision(),
'host': {
'os': Platform.operatingSystemVersion,
'cpus': Platform.numberOfProcessors,
},
'params': params,
'metrics': {for (final m in _metrics) m.id: m.toJson()},
};
const encoder = JsonEncoder.withIndent(' ');
final outFile = File('build/benchmarks/$suite.json');
outFile.parent.createSync(recursive: true);
outFile.writeAsStringSync(encoder.convert(report));
if (Platform.environment['PYRAMID_BENCH_UPDATE_BASELINE'] == '1') {
baselineFile.parent.createSync(recursive: true);
baselineFile.writeAsStringSync('${encoder.convert(report)}\n');
// ignore: avoid_print
print('Baseline aktualisiert: ${baselineFile.path}');
}
return regressions;
}
static String _fmt(double v) =>
v >= 100 ? v.toStringAsFixed(0) : v.toStringAsFixed(v >= 10 ? 1 : 2);
static Future<String?> _gitRevision() async {
try {
final r = await Process.run('git', ['rev-parse', '--short', 'HEAD']);
return r.exitCode == 0 ? (r.stdout as String).trim() : null;
} catch (_) {
return null;
}
}
}
bool get strictBench => Platform.environment['PYRAMID_BENCH_STRICT'] == '1';
// ── SQLCipher aus dem App-Build ────────────────────────────────────────────
/// Die vom sqlcipher_flutter_libs-Plugin gebaute `sqlite3.dll` – dieselbe
/// Bibliothek, die die App benutzt. `null`, wenn noch nie gebaut wurde.
String? findCipherDll() {
for (final config in ['Release', 'Profile', 'Debug']) {
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
if (file.existsSync()) return file.absolute.path;
}
return null;
}
/// Läuft (wie in der App) im Hintergrund-Isolate von sqflite_common_ffi –
/// deshalb top-level und ohne eingefangene Variablen.
void cipherFfiInit() {
final dll = findCipherDll();
if (dll == null) throw StateError('SQLCipher-DLL fehlt');
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open(dll));
}
/// Factory wie in der App: eigenes DB-Isolate, SQLCipher-Bibliothek.
/// Ohne `PRAGMA key` verhält sich SQLCipher wie normales SQLite – so laufen
/// Klartext- und verschlüsselte Messung mit DERSELBEN Bibliothek und sind
/// fair vergleichbar.
DatabaseFactory cipherFactory() =>
createDatabaseFactoryFfi(ffiInit: cipherFfiInit);
/// Dieselben PRAGMAs wie `AppDatabase._applyPragmas` (dort privat).
Future<void> applyAppPragmas(Database db) async {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
}
/// Gesamtgröße der DB inkl. WAL-Sidecar in MB.
double dbSizeMb(String path) {
var bytes = 0;
for (final suffix in ['', '-wal']) {
final f = File('$path$suffix');
if (f.existsSync()) bytes += f.lengthSync();
}
return bytes / (1024 * 1024);
}
+27 -4
View File
@@ -1,9 +1,32 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen) # LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät – **Status (2026-10-07):** Server-Route UND Client umgestellt.
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten - Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`. (`/home/steggi/matrix/server.py`) – whoami-Prüfung, Identität = geprüfte
Matrix-ID, Secret aus `livekit.yaml`. Geprüft: ungültiger Token → 401
(öffentlich über `dashboard.steggi-matrix.work`), gültiger Token → JWT mit
`sub` = Matrix-ID und denselben Grants wie früher.
- Client: `lib/core/livekit_token.dart` holt das Token per
`LiveKitTokenService.fetch` (kein Secret, kein JWT-Signieren mehr in der App);
`livekit_call_manager.dart` nutzt es für alle LiveKit-Beitritte. Fehler
(offline, 401, Serverfehler) erscheinen als lesbare Meldung im Anruf.
- Tests: `test/livekit_token_test.dart` (Anfrage, Fehlerpfade, kein Secret im
Code) und `integration_test/livekit_token_connect_test.dart` (ECHTER
LiveKit-Beitritt mit Server-Token, Testkonto pyramidtest1, Wegwerf-Raum) –
grün am 2026-10-07.
**Offen:**
1. Anruf/Sprachkanal auf dem echten Handy prüfen (Ton, Video, Bildschirm
teilen, zweiter Teilnehmer, Verlassen/Wiederbeitreten).
2. Erst wenn ALLE genutzten Geräte (Bernd + Uta) die neue Version haben:
Secret rotieren (siehe „Rotation“ unten) – das macht Bernd, nicht Claude.
Bis dahin funktionieren alte und neue App-Versionen parallel.
3. Neue Abhängigkeit beachten: Anrufe brauchen jetzt zusätzlich den
Dashboard-Server (`matrix-stats.service`). Ist er aus, meldet die App
„Anrufserver nicht erreichbar“.
Die ursprüngliche Planung (2026-07-04) folgt unverändert.
## Problem ## Problem
+27
View File
@@ -1,5 +1,32 @@
# M2 – Modul-Schnitt (Entwurf) # M2 – Modul-Schnitt (Entwurf)
> **STATUS 2026-07-06: Pilot (Schritt 1) UMGESETZT.** Die Module
> `call_signaling` (`lib/features/call_signaling/`: `CallSignalingService`
> + `callSignalingProvider` + Implementierung `voip_manager.dart`) und
> `voice_channel` (`lib/features/voice_channel/`: `VoiceChannelService` +
> `voiceChannelProvider` + Implementierung `livekit_call_manager.dart`)
> existieren; ALLE Konsumenten (Shell, Call-UI, Rooms-Panel, Chat-Header,
> Settings) gehen nur noch über die Provider/Interfaces, kein direkter
> Implementierungs-Import mehr (einzige dokumentierte Ausnahme:
> Kompositions-Punkt `matrix_client.dart`). Bewusste Abweichungen vom
> Entwurf unten (Verhaltensgleichheit vor Schönheit, heiliger Call-Pfad):
> ChangeNotifier statt Streams, Methodennamen wie im Bestand
> (`startCall`/`hangUp` statt `join`/`leave`), `CallSession`/LiveKit-`Room`
> bleiben in den Schnittstellen sichtbar, weil die UI direkt rendert.
> Echter Call-/Voice-Channel-Test steht aus (Gerät bzw. Klick-Test) –
> siehe PROGRESS.md 2026-07-06. Schritte 2–5 unten sind weiter offen.
>
> **Nachtrag 2026-07-06 (zweite PC-Session):** `call_ui` ist jetzt ein
> eigenes Modul (`lib/features/call_ui/`, reine Umbenennung von
> `features/call/`; konsumiert weiterhin nur die zwei Service-Interfaces).
> Außerdem ist der storage-Teilschritt für die Call-Module erledigt: die
> voice_*-Prefs (Geräte + Ausgabelautstärke) laufen über die neue
> `VoicePrefs`-Kapsel in `core/settings_prefs.dart` (Punkt 6 unten) –
> kein direkter SharedPreferences-Zugriff mehr in `voip_manager.dart`/
> `livekit_call_manager.dart`/`settings_voice.dart`, Keys unverändert.
> „UI-State aus den Managern herauslösen" wartet bewusst auf Bernds
> Klick-Test des Piloten (heiliger Call-Pfad).
Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage: Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage:
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
+95
View File
@@ -0,0 +1,95 @@
# M2 – Einheitliches State-Management: Ist-Analyse + Umbauplan
> **STATUS 2026-07-06: reine Analyse, noch KEIN Umbau.** Grundlage für den
> ROADMAP-M2-Punkt „Einheitliches State-Management (Riverpod konsequent,
> keine Misch-Patterns)". Die Schritte 1–2 unten betreffen den heiligen
> Call-Pfad und sind GESPERRT, bis Bernds Klick-Test des Call-Piloten
> (`docs/PC_TESTPLAN.md` Abschnitt 8) grün ist – vorher nur Schritt 3.
## Ist-Stand (repo-weit erhoben, 2026-07-06)
Riverpod ist bereits das dominante Muster: rund 50 Provider –
`StateProvider` für UI-Zustand, `StreamProvider` auf `client.onSync`,
`FutureProvider` (Client, Update-Info), `StateNotifierProvider` für die
Prefs-Notifier (`BoolPref`/`StringSetPref` in `core/settings_prefs.dart`).
`ValueNotifier`/eigene `StreamController` gibt es NICHT. Lokales
`setState` in Widgets für rein ephemeren Zustand (Hover, Dialog-Schritte,
Textfelder) ist normales Flutter und zählt hier bewusst NICHT als
Misch-Pattern.
Gefundene Misch-Patterns, nach Gewicht:
1. **Call-Schicht: Singletons + ChangeNotifier hinter den Fassaden.**
`LiveKitCallManager.instance` (static final Singleton,
`features/voice_channel/livekit_call_manager.dart:17`) und
`PyramidVoipManager._instance` (nullable static,
`features/call_signaling/voip_manager.dart:17`; konstruiert im
Kompositions-Punkt `core/matrix_client.dart:66–71`). Die Modul-Provider
(`voiceChannelProvider`/`callSignalingProvider`) geben nur die
Singletons zurück. Das ist die im Pilot BEWUSST gewählte Zwischenform
(Verhaltensgleichheit vor Schönheit). Eigentliches Problem: UI-Zustand
(Mute-Flags, Teilnehmer, Renderer, Qualitätswahl) lebt mutierbar IN den
Managern und wird per `notifyListeners()` grob-granular publiziert.
2. **Voice-UI-Zustand außerhalb des Moduls:** `activeVoiceRoomIdProvider`
und `voiceParticipantsProvider` liegen in `core/app_state.dart`
(Kopplungspunkt laut Ist-Analyse), werden aber von Rooms-Panel und
Mini-Call-Widget gesetzt/gelesen und gehören fachlich ins
`voice_channel`-Modul (Join-Zustand + Presence sind Modulsache).
3. **`MediaCache.instance`** (`core/media_cache.dart:19`): reines
Singleton, kein UI-Zustand, nur Cache. Nutzer sind `widgets/mxc_image.dart`
(7 Stellen, Widgets – leicht umstellbar) UND
`features/chat/message/message_media.dart` (7 Stellen, darunter der
statische Wrapper `_DocBytesCache` für Dokument-Vorschauen, dessen
Aufrufer keinen `ref`-Zugang haben). Eine Umstellung müsste `ref`/die
Cache-Instanz durch die statischen Helfer der Chat-Timeline fädeln –
und genau dieser Code (Download-/Entschlüsselungs-Fehlerpfade) ist
laut PC_TESTPLAN Abschnitt 3 der am wenigsten geprüfte Teil der App.
4. **Push/Notifications:** modul-globale Mutable-Zustände in
`core/notification_service.dart` (`_windowsNotifs`-Map, Plugin-Globals)
und direkte `SharedPreferences`-Zugriffe in `background_push.dart`/
`fcm_push_service.dart`. Das ist dem Background-Isolate-Zwang
geschuldet (siehe Schnitt-Doc Punkt 8 + Memory „Pyramid Push") –
**bewusst KEIN Umbau-Kandidat**, nur hier dokumentiert.
Bereits erledigt im Zuge dieser Analyse: der seit dem Call-Piloten tote
`incomingCallProvider` ist aus `app_state.dart` entfernt (41bcaf2).
## Zielbild
- `core/app_state.dart` enthält nur noch ECHTEN App-weiten UI-Zustand
(Theme, Layout/Panels, Navigation, Toast, Space-Auswahl) – kein
Feature-Fachzustand.
- Feature-Zustand lebt im jeweiligen Modul hinter dessen Provider;
andere Module lesen ihn NUR über die öffentliche Schnittstelle.
- Kein `.instance`-Zugriff über Modulgrenzen; Konstruktion ausschließlich
im Kompositions-Punkt bzw. im Provider des Moduls.
- ChangeNotifier ist als Übergangsform ok; Endzustand der Call-Module ist
ein immutables Zustandsobjekt (z. B. `VoiceChannelState`) pro Modul,
das der Provider publiziert, plus Kommando-Methoden auf dem Service
(entspricht dem Schnitt-Doc-Entwurf, dort als Stream skizziert).
## Schrittfolge (Reihenfolge = Priorität)
1. **[GESPERRT bis Klick-Test] UI-State aus den Managern herauslösen:**
je Call-Modul ein immutables Zustandsobjekt + `Notifier`/
`StateNotifier`, Manager wird interne Implementierung ohne
UI-Zustand. Das ist der bereits in PROGRESS.md zurückgestellte
Logik-Umbau – NICHT vorher anfangen (heiliger Call-Pfad, ein
ungetesteter Umbau liegt schon auf dem Stapel).
2. **[GESPERRT bis Klick-Test] `activeVoiceRoomIdProvider` +
`voiceParticipantsProvider` ins `voice_channel`-Modul umziehen**
(zusammen mit `kVoicePresenceType`). Mechanisch klein, aber
Call-Pfad-nah – sinnvoll im selben Zug wie Schritt 1.
3. **[ZURÜCKGESTELLT] `MediaCache` hinter `Provider<MediaCache>`:**
Erst als „risikoarm, jederzeit" eingestuft – das stimmte NICHT
(Korrektur 2026-07-06, siehe Ist-Stand Punkt 3: auch die Chat-Timeline
hängt dran, inkl. statischer Helfer ohne `ref`). Gehört in den Zug des
`chat_timeline`-Moduls (Schnitt-Doc Punkt 9) bzw. frühestens nach dem
Klick-Test von PC_TESTPLAN Abschnitt 3 – nicht als Lückenfüller.
4. **[kein Umbau] Push/Notifications:** bleibt wie es ist
(Isolate-Zwang); nur bei ohnehin anstehenden Push-Arbeiten die
Prefs-Zugriffe auf die `Prefs`-Fassade ziehen (Schnitt-Doc Punkt 6).
Abhak-Kriterium für den ROADMAP-Punkt: Schritte 1–3 umgesetzt und die
Kernflows (Login, Raum, Nachricht, Voice-Join) nach jedem Schritt
praktisch geprüft; Punkt 4 braucht nur den Doku-Vermerk (steht hiermit).
+35 -1
View File
@@ -70,6 +70,39 @@ Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s). Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
## Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)
Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):
- **Pro Raum ein Chat-Verlauf** (Android `MessagingStyle`) mit den letzten 8
Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel.
Der Verlauf liegt in SharedPreferences (`pyramid_notif_history`), weil bei
gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur
fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt
oder Raum geöffnet → nächster Push beginnt neu).
- **Gleiche eventId = Aktualisierung**, nicht neue Nachricht: der
„Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen
lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln
(`setOnlyAlertOnce(!isNew)`).
- **Text:** nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat
entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw.
Logik in `lib/core/notification_content.dart` (Tests:
`test/notification_content_test.dart`). Einstellung „Nachrichtenvorschau“
gilt jetzt auch bei gekillter App.
- **Bilder/Avatare:** Dart legt sie unter `<cache>/downloads/notif/` ab
(Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native
reicht sie per FileProvider-URI an die System-UI weiter
(`grantUriPermission("com.android.systemui")`). Dateien älter als 2 Tage
werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt
`NotificationHelper` auf reinen Text zurück.
- **„Gelesen“** setzt jetzt eine echte Lesebestätigung bis zur neuesten
Nachricht (Haupt-Engine `markReadFromNotification` bzw. BgEngine
`markRead`), statt nur zu schließen.
- **Testen ohne Login (nur Debug-Build):** `DebugNotifyReceiver`
(`android/app/src/debug/`), z. B.
`adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'"`
(`--ez image true` für ein Testbild). Nicht im Release-APK.
## Verschlüsselt antworten (gekillt, vollständig still) ## Verschlüsselt antworten (gekillt, vollständig still)
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`), - Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
@@ -103,7 +136,8 @@ Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing | | `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter | | `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) | | `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) | | `android/.../NotificationHelper.kt` | Native Benachrichtigung: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen |
| `lib/core/notification_content.dart` | Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien) |
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback | | `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply | | `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal | | `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
+78 -10
View File
@@ -1,6 +1,7 @@
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte # PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
Stand: 2026-07-03. Alle Pi-Sessions seit dem 2026-07-03 konnten nur Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi). `flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
@@ -8,25 +9,92 @@ Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
Schnellster Weg: `flutter run -d windows`. Schnellster Weg: `flutter run -d windows`.
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
## 0. Vorab (1 Minute) ## 0. Vorab (1 Minute)
- [ ] `flutter test` – die Regressionstests unter `test/` (Soft-Logout-Guard, - [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
Power-Levels-Schutz, AuthLog, Storage-Fassade `settings_prefs` – SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
Stand 2026-07-03: 24 Tests) müssen grün sein, bevor irgendetwas VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
anderes geprüft wird. mehrfach gelaufen (vor/nach jedem Umbau des Tages).
- [x] **Seit 2026-10-07: alles Automatisierbare mit EINEM Befehl** –
`pwsh scripts/test.ps1 -All` (Analyse, 41 Unit-Tests, Live-Test mit
Test-Account, Benchmarks). Details: `docs/TESTS_UND_BENCHMARKS.md`.
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!) ## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard). Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen - [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
- [ ] `auth_log.txt` (App-Support-Verzeichnis) prüfen: LoginState-Wechsel (Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
werden mitgeschrieben, keine unerwarteten Warnungen und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
- [ ] Mehrere Stunden laufen lassen: kein Random-Logout, kein - [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
Soft-Logout-Eintrag.
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt` Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet" zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
Migration). Danach zwei geplante Neustarts für die Refactoring-
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
echten Mehr-Stunden-Fenster abhaken.
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
läuft weiter, nächstes Fenster ab 23:12.
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
Fenster ab 03:06.
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
Stück.)
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren - [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken) (= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt - [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
+25 -3
View File
@@ -1,8 +1,30 @@
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin) # SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
Stand: 2026-07-03, geschrieben auf dem Pi (Plan gegen den echten Code verifiziert, > **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
Umsetzung braucht PC + Android-Testgerät). Gehört zum offenen M1-Punkt > Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
„Sichere Speicherung von Access-Token & Krypto-DB". > 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die
> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem
> SQLCipher ab).
> **Android-Emulator-Test 2026-07-06 (API 36, x86_64, Debug-APK) GRÜN:**
> (a) frische Installation legt die DB direkt verschlüsselt an
> (Header = Zufallsbytes, per run-as verifiziert), kein Cipher-Fallback im
> auth_log; (b) Update-Szenario nach `pm clear` + untergeschobener
> Klartext-DB (synthetisch, 2 Tabellen/550 Zeilen): Migration lief beim
> ersten Start („2 Tabellen verifiziert" im auth_log), `.premigration`
> liegt da, DB danach verschlüsselt; (c) Neustart öffnet mit dem
> Keystore-Schlüssel OHNE Re-Migration, App rendert normal den
> Login-Screen. Damit sind openCipherOnAndroid, sqlcipher_export und
> flutter_secure_storage auf Android (Haupt-Isolate) real belegt.
> **Offen bleibt (deshalb Punkt 4 NICHT voll abgehakt):** echtes
> ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
> der Push-Test (FCM-Isolate liest verschlüsselte DB, secure_storage im
> Background-Isolate) – braucht ein Testgerät mit eingeloggtem Account.
> Erst danach Release und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries"
> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher
> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output
> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux
> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben
> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel).
## Für Bernd in einfach ## Für Bernd in einfach
+50
View File
@@ -0,0 +1,50 @@
# Streaming: hohe Auflösung + 60 FPS (M4)
**Ziel (Bernd):** Bildschirm-Streams ruckeln aktuell. Es soll eine hohe Streaming-Einstellung
geben – hohe Auflösung UND hohe FPS, idealerweise **60 FPS**, flüssig.
## Ist-Zustand (im Code gefunden, 2026-07-06)
Datei `lib/features/voice_channel/livekit_call_manager.dart`:
- `qualityPresets` (~Z. 21): Auflösungs-Presets `sd`(360p) / `hd`(720p) / `fhd`(1080p) / `4k`(2160p).
- `_screenShareEncodings` (~Z. 28): **FPS hart gedeckelt** – `sd`=10, `hd`=15, `fhd`=20, `4k`=30.
**Es gibt KEIN 60-FPS-Preset.** Bitraten für hohe FPS zu niedrig (fhd nur 2,5 Mbit/s @20,
4k nur 4 Mbit/s @30).
- `startScreenShare` (~Z. 453): erstellt `ScreenShareCaptureOptions(maxFrameRate: …)` und
published mit `simulcast: true`, `degradationPreference: DegradationPreference.balanced`.
- **`contentHint` wird NICHT gesetzt** → der Encoder weiß nicht, dass es bewegtes Video ist,
und priorisiert Detail/Auflösung statt Framerate → Ruckeln.
- `_applyFpsToAllSimulcastLayers` (~Z. 485) hebt die FPS nachträglich auf allen Simulcast-Layern an.
UI `lib/features/call_ui/view/quality_menu.dart` (~Z. 21): „SENDEN"-Menü listet sd/hd/fhd/4k.
## Wahrscheinliche Ruckel-Ursachen
1. `maxFramerate` hart auf 20–30 begrenzt (nie 60).
2. Zu niedrige Bitrate für hohe FPS → Encoder verwirft/verwischt Frames.
3. `contentHint` nicht auf `motion` → Encoder opfert FPS für Detail.
4. `degradationPreference: balanced` → unter Last sinkt eher die FPS als die Auflösung.
## Aufgabe
1. **Neue High-FPS-Presets** für die Send-Qualität, z. B.:
- `hd60` → 720p, `VideoEncoding(maxBitrate ~4–5 Mbit/s, maxFramerate 60)`
- `fhd60` → 1080p, `VideoEncoding(maxBitrate ~6–8 Mbit/s, maxFramerate 60)`
Sowohl in `qualityPresets` (Auflösung) als auch `_screenShareEncodings` (Bitrate/FPS) eintragen
und im „SENDEN"-Menü in `quality_menu.dart` als „HD 60fps" / „Full HD 60fps" ergänzen.
2. **`contentHint = 'motion'`** auf dem Screenshare-Track setzen (flutter_webrtc/LiveKit-Feld) –
wichtigster Einzelhebel gegen Ruckeln bei Bewegung.
3. Für die 60-FPS-Modi **`degradationPreference: DegradationPreference.maintainFramerate`**
statt `balanced` (unter Last lieber Auflösung senken als FPS).
4. Sicherstellen, dass 60 wirklich durchgereicht wird – auch in `ScreenShareCaptureOptions.maxFrameRate`
und `_applyFpsToAllSimulcastLayers`.
5. Für den flüssigen 60-FPS-Modus **`simulcast: false` erwägen** (volle Bitrate auf einen Layer),
A/B gegen simulcast=true testen.
## Testen (mit den Test-Accounts)
- Streamen als pyramidtest1, zuschauen als pyramidtest2 (zwei Logins/Fenster).
- Prüfen, dass `sender.parameters.encodings` real `maxFramerate == 60` und die hohe `maxBitrate` tragen.
- Subjektiv: flüssiger? Bei Bewegung kein Ruckeln?
- EHRLICH protokollieren, was wirklich getestet wurde. Echtes flüssiges 60-FPS-Empfinden ist ggf.
nur mit echtem Gerät/Netz voll beurteilbar → dann klar als UNGETESTET markieren.
## Nicht vergessen
`flutter analyze` sauber, kleiner Commit + Push, PROGRESS.md + ROADMAP (M4) pflegen.
+137
View File
@@ -0,0 +1,137 @@
# Tests & Benchmarks
Stand: 2026-10-07. Alles läuft mit **einem Befehl** aus dem Projektordner:
```
pwsh scripts/test.ps1 # Analyse + Unit-Tests (~1 min) – vor jedem Commit
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
pwsh scripts/test.ps1 -Ui # + UI-Durchlauf im Handy-Format mit Screenshots
pwsh scripts/test.ps1 -All # alles (~4 min)
```
Am Ende steht eine Zusammenfassung mit OK/FEHLER je Schritt. Fehlt die
SQLCipher-Bibliothek (frischer Checkout), baut das Skript einmalig
`flutter build windows --debug`.
**Für Bernd in einem Satz:** Die Tests prüfen automatisch die heiklen Stellen
(Login-/Logout-Schutz, Verschlüsselung der Datenbank, Suche, Einstellungen),
die Benchmarks messen, ob die App dabei langsamer wird. Was man nur mit Klicken
oder zwei echten Geräten prüfen kann, steht weiterhin in `docs/PC_TESTPLAN.md`.
## Tests (`test/`)
| Datei | Prüft | Läuft wann |
|---|---|---|
| `app_database_test.dart` | SQLCipher: Bibliothek echt, Header-Erkennung, Klartext→verschlüsselt-Migration, falscher Schlüssel, Fehlschlag lässt Daten unangetastet | immer (braucht die DLL aus dem Windows-Build) |
| `soft_logout_guard_test.dart` | Uta-Random-Logout-Fix: Token-Refresh-Fehler enden nie im stillen Logout | immer |
| `auth_log_test.dart` | Login-Protokoll (Diagnose für Logout-Bugs) | immer |
| `power_levels_safety_test.dart` | Space-Admin kann sich nicht selbst aussperren | immer |
| `settings_prefs_test.dart` | VoicePrefs-Fassade (gespeicherte Spracheinstellungen) | immer |
| `streaming_presets_test.dart` | 60-FPS-Streaming-Presets (Auflösung/FPS/Bitrate) | immer |
| `local_message_search_test.dart` | Lokale Nachrichtensuche (Strg+K): Treffer, Sortierung, Groß-/Kleinschreibung inkl. Umlaute, Medien-Filter, Trefferdeckel | immer (neu 2026-10-07) |
| `live_encryption_e2e_test.dart` | Echter Login (pyramidtest1) → Platte verschlüsselt → Neustart → Logout → Re-Login → alte Nachricht lesbar; Utas Update-Fall mit echter Session | nur mit `-Live` |
| `live_logout_race_test.dart` | Abmelden + sofortige Neuanmeldung auf demselben Client: neue Session muss bleiben (SDK-Wettlauf, `logoutWithoutStraySync`); mit `PYRAMID_LIVE_RACE_PROOF=1` zusätzlich Nachweis mit altem Weg | nur mit `-Live` |
| `live_ui_fixture_test.dart` | Kein Test im engeren Sinn: legt Testinhalte (DM + Gruppe mit Bildern) für den UI-Durchlauf an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
| `live_call_setup_test.dart` | Kein Test im engeren Sinn: legt den Voice-Testraum für GUI-Call-Tests mit zwei Test-Accounts an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
`test/support/synthetic_matrix_data.dart` erzeugt synthetische Räume und
Nachrichten über die **echte SDK-Speicherschicht** – komplett offline, ohne
Konten. Tests und Benchmarks nutzen sie gemeinsam.
Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
## UI-Durchlauf (`integration_test/`, seit 2026-10-07)
`integration_test/mobile_ui_screens_test.dart` startet die ECHTE App
(Windows-Build) mit dem Testprofil `pyramidtest1`
(`PYRAMID_PROFILE_DIR=%USERPROFILE%\.pyramid-autopilot\profile1`) in
simulierter Handy-Größe (1080×2340, Statusleiste 24 dp, Gestenleiste 16 dp),
tippt sich durch Chatliste → Chat → Bild und prüft dabei: Vollansicht öffnet,
Tippen daneben schließt, Tippen aufs Bild schließt nicht, Doppeltipp zoomt,
Wischen nach unten schließt. Screenshots landen in `build/ui_screens/` – damit
lassen sich UI-Änderungen ohne Handy ansehen.
- Testinhalte (DM von pyramidtest2 mit langem Text, Antwort, Hoch- und
Querformat-Bild) legt `test/live_ui_fixture_test.dart` an
(`PYRAMID_LIVE_TEST=1`).
- Läuft nur, wenn keine Pyramid-Instanz offen ist (nie zwei Clients auf
einer Datenbank) – das Skript prüft das.
- Simulierte Fensterklicks (PostMessage) erreichen Flutter unter Windows
NICHT; deshalb dieser Weg über `integration_test`.
## Benchmarks (`benchmark/`)
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
`flutter test benchmark --concurrency=1`).
| Suite | Misst |
|---|---|
| `db_benchmark_test.dart` | Klartext- vs. SQLCipher-DB mit derselben Bibliothek: Sync-Schreiben (Transaktionen wie beim echten Sync), App-Start (DB öffnen + Raumliste), Chat öffnen (50 Nachrichten), lokale Suche (ohne Treffer = Worst Case, mit Treffern) |
| `migration_benchmark_test.dart` | Dauer der einmaligen Klartext→SQLCipher-Migration beim ersten Start nach dem Update (exakt der App-Codepfad), danach Vollständigkeitsprüfung |
**Wie gemessen wird:** DB im eigenen Isolate wie in der App; ungemessener
Aufwärm-Durchlauf; Modi abwechselnd A-B-B-A (ohne das gewann einfach der
später gemessene Modus um ±20 %, weil der Dart-JIT während des Laufs schneller
wird); Median mehrerer Durchläufe.
**Baseline & Regressionen:** Jeder Lauf schreibt `build/benchmarks/<suite>.json`
und vergleicht mit `benchmark/baseline/<suite>.json`. Mehr als 25 % langsamer
(und bei Zeiten mehr als 2 ms absolut) wird als „⚠ LANGSAMER" markiert.
Umgebungsvariablen:
- `-UpdateBaseline` bzw. `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als
neue Baseline speichern (nach bewusster Änderung oder auf neuer Hardware).
Die Baseline gilt nur für den Rechner, auf dem sie entstand.
- `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern.
- `PYRAMID_BENCH_SCALE=0.2` – kleinere Datenmenge für einen schnellen Probelauf
(andere Mengen sind mit der Baseline nicht vergleichbar).
### Erste Ergebnisse (2026-10-07, Bernds PC, 12 Kerne, Commit b2413ef)
20 Räume × 500 Nachrichten (DB-Suite), 20 × 1000 (Migration):
| Messung | Klartext | SQLCipher |
|---|---|---|
| Sync-Schreiben je Nachricht | 0,25 ms | 0,26 ms |
| App-Start (DB + Raumliste) | 4,1 ms | 4,3 ms |
| Chat öffnen (50 Nachrichten) | 0,33 ms | 0,32 ms |
| Suche ohne Treffer (10 000 Nachrichten) | 64 ms | 64 ms |
| Suche mit Treffern | 32 ms | 32 ms |
| Migration Klartext → SQLCipher | – | 325 ms für 7,8 MB (≈ 42 ms/MB) |
Was das heißt:
- **Die Verschlüsselung kostet praktisch nichts.** Alle Unterschiede liegen im
Messrauschen (±5–10 %, zwei Läufe hintereinander). Die Kosten stecken im
Matrix-SDK selbst (JSON-Verarbeitung), nicht in SQLCipher.
- **Sync-Schreiben** liegt bei ~0,26 ms pro Nachricht und hängt kaum von der
Raumgröße ab (nachgemessen: 250 vs. 4 000 Nachrichten pro Raum ≈ +15 %).
- **Suche** wächst linear mit der Zahl der gespeicherten Nachrichten
(gedeckelt auf 1 500 pro Raum). Hochrechnung, nicht gemessen: 100 aktive
Räume am Deckel ≈ 150 000 Nachrichten ≈ 1 s pro Suche auf diesem PC –
auf Handys entsprechend länger. Kandidat für einen Suchindex (FTS), falls
das in der Praxis stört.
- **Migration**: Hochrechnung für eine 100-MB-DB ≈ 4 s einmalig auf diesem
PC; auf Utas Handy vermutlich ein Mehrfaches davon (nicht gemessen).
## Bekannte Grenzen (ehrlich)
- Synthetische Daten: unverschlüsselte Räume, keine Olm/Megolm-Sessions
(vodozemac läuft im Host-Test nicht). Die DB-Struktur ist aber die echte.
- Gemessen auf Windows im JIT-Modus von `flutter test`; die Release-App läuft
AOT-kompiliert und ist eher schneller. Für Vergleiche über die Zeit taugt
das, für absolute Aussagen über Handys nicht.
- Nicht automatisiert: alles mit Klicks, echte Calls/Audio, 60-FPS-Streaming
zwischen zwei Geräten, Android-Push → `docs/PC_TESTPLAN.md`.
## Nächste sinnvolle Messungen
- **Streaming (M4 „Ursache messen"):** WebRTC-`getStats()` des Senders
auswerten (`framesPerSecond`, `qualityLimitationReason`, Encoder-Name) –
zeigt direkt, ob CPU, Bandbreite oder Encoder bremst. Braucht eine kleine
Diagnose-Anzeige im Call und zwei echte Geräte.
- **UI-Flüssigkeit:** `integration_test` mit Frame-Timing beim Scrollen der
Raumliste/Timeline (Windows, Test-Account-Profil über `PYRAMID_PROFILE_DIR`).
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/main.dart' as app;
/// Echter Beitritt zu LiveKit mit einem vom SERVER ausgestellten Token
/// (docs/LIVEKIT_TOKEN_MIGRATION.md) – über denselben Weg wie die App
/// (voiceChannelProvider.startCall), nur mit dem TEST-Konto.
///
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil,
/// z. B. pyramidtest1) – nie gegen Bernds echtes Profil:
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/livekit_token_connect_test.dart -d windows
///
/// Betritt einen eigenen Wegwerf-Raum („pyramid-tokentest“), nur Ton, und
/// legt sofort wieder auf. Ersetzt NICHT den Anruftest auf dem Handy.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
(tester) async {
app.main();
final end = DateTime.now().add(const Duration(seconds: 90));
while (find.byType(MaterialApp).evaluate().isEmpty &&
DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
}
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final client = await tester.runAsync(
() => container.read(matrixClientProvider.future),
);
expect(client!.isLogged(), isTrue, reason: 'Testprofil nicht angemeldet');
final call = container.read(voiceChannelProvider);
await tester.runAsync(() => call.startCall(
roomName: 'pyramid-tokentest',
roomDisplayName: 'Token-Test',
identity: 'wird-vom-server-ignoriert',
audioOnly: true,
matrixClient: client,
));
await tester.pump(const Duration(milliseconds: 500));
expect(call.error, isNull, reason: 'Beitritt fehlgeschlagen');
expect(call.room, isNotNull, reason: 'keine LiveKit-Verbindung');
// Der Server bestimmt die Identität aus dem geprüften Matrix-Login –
// der vom Client übergebene Wert spielt keine Rolle mehr.
expect(call.room!.localParticipant?.identity, client.userID);
await tester.runAsync(() => call.hangUp());
await tester.pump(const Duration(milliseconds: 500));
expect(call.room, isNull);
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 3)),
);
}
@@ -0,0 +1,312 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/main.dart' as app;
import 'package:pyramid/widgets/spring_press.dart';
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
///
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
/// Bernds echtes Profil. Testinhalte vorher mit
/// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2).
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
///
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
/// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum)
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
///
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
const _otherUser = 'pyramidtest2';
const _groupName = 'UI-Test Gruppe';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
(tester) async {
tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 63, bottom: 42)
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
addTearDown(tester.view.reset);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
app.main();
await _pumpUntil(
tester,
find.text(_otherUser),
timeout: const Duration(seconds: 90),
);
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
await _shot(tester, '01_chatliste');
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
await tester.tap(find.text(_otherUser).first);
// Chat gleitet federnd über die Liste.
await tester.pump(const Duration(milliseconds: 90));
await _shot(tester, '01b_chat_gleitet_herein');
await _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester);
await _shot(tester, '02_chat');
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '02c_dm_kopf_aufgeklappt');
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
// Antwort-Zitat antippen → springt zur zitierten Nachricht.
final quote = find.byWidgetPredicate(
(w) => w is SpringPress && w.pressedScale == 0.97,
);
await tester.scrollUntilVisible(
quote.first,
300,
scrollable: find.byType(Scrollable).first,
maxScrolls: 40,
);
await _pumpFor(tester, const Duration(milliseconds: 600));
await _shot(tester, '02e_zitat_vor_dem_tippen');
await tester.tap(quote.first, warnIfMissed: false);
await _pumpFor(tester, const Duration(milliseconds: 900));
await _shot(tester, '02f_sprung_zum_zitat');
await _scrollToNewest(tester);
// ⋮-Menü mit Suchen/Mitglieder.
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
await _pumpFor(tester, const Duration(seconds: 1));
expect(find.text('Mitglieder'), findsWidgets);
await _shot(tester, '02d_menue');
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
// Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
// greift am PC die Textauswahl).
final ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').first);
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
await _pumpFor(tester, const Duration(milliseconds: 500));
await tester.tap(find.byIcon(Icons.reply_rounded).first);
await tester.pump(const Duration(milliseconds: 60));
await _shot(tester, '02g_antwort_leiste_federt');
await _pumpFor(tester, const Duration(milliseconds: 800));
await _shot(tester, '02h_antwort_leiste');
final closeReply = find.byIcon(Icons.close_rounded);
if (closeReply.evaluate().isNotEmpty) {
await tester.tap(closeReply.first);
await _pumpFor(tester, const Duration(milliseconds: 600));
}
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '02b_chat_liste');
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
await _pumpFor(tester, const Duration(seconds: 2));
// ── Bild-Vollansicht ──
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
Future<void> openViewer() async {
final image = _largestImage(tester);
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
await tester.ensureVisible(image!);
await _pumpFor(tester, const Duration(milliseconds: 600));
await tester.tap(image);
await _pumpFor(tester, const Duration(seconds: 2));
expect(
find.byType(InteractiveViewer),
findsOneWidget,
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
);
}
await openViewer();
await _shot(tester, '03_vollansicht');
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
await tester.tapAt(Offset(size.width / 2, size.height - 30));
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '04_nach_tippen_daneben');
expect(
find.byType(InteractiveViewer),
findsNothing,
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
);
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
await openViewer();
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
expect(
find.byType(InteractiveViewer),
findsOneWidget,
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
);
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
Future<void> doubleTapCenter() async {
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await tester.pump(const Duration(milliseconds: 80));
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
}
await doubleTapCenter();
final viewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
expect(
viewer.transformationController!.value.getMaxScaleOnAxis(),
greaterThan(1.5),
reason: 'Doppeltipp hat nicht gezoomt',
);
await _shot(tester, '05_doppeltipp_zoom');
await doubleTapCenter();
// Wischen nach unten schließt.
await tester.dragFrom(
Offset(size.width / 2, size.height / 2),
const Offset(0, 320),
);
await _pumpFor(tester, const Duration(seconds: 2));
expect(
find.byType(InteractiveViewer),
findsNothing,
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
);
await _shot(tester, '06_nach_wischen');
// ── Farbmodus Hell (nur Zustand) ──
final savedVariant = container.read(themeVariantProvider);
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '07_chat_hell');
container.read(themeVariantProvider.notifier).state = savedVariant;
await _pumpFor(tester, const Duration(seconds: 1));
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await _pumpUntil(
tester,
find.text(_groupName),
timeout: const Duration(seconds: 30),
);
await tester.tap(find.text(_groupName).first);
await _pumpFor(tester, const Duration(seconds: 5));
await _scrollToNewest(tester);
await _shot(tester, '08_gruppe');
// ── Einstellungen → Erscheinungsbild ──
container.read(activeModalProvider.notifier).state = ModalKind.settings;
await _pumpFor(tester, const Duration(seconds: 2));
await tester.tap(find.text('Erscheinungsbild').first);
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '09_einstellungen_erscheinungsbild');
await tester.drag(
find.text('Schriftgröße').first,
const Offset(0, -500),
warnIfMissed: false,
);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '10_einstellungen_text_chat');
container.read(activeModalProvider.notifier).state = ModalKind.none;
await _pumpFor(tester, const Duration(seconds: 1));
// ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ──
container.read(activeRoomIdProvider.notifier).state = null;
await tester.pump(const Duration(milliseconds: 90));
await _shot(tester, '11_zurueck_gleitet_hinaus');
await _pumpFor(tester, const Duration(seconds: 1));
expect(find.text(_groupName), findsWidgets);
await _shot(tester, '12_liste_nach_zurueck');
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 6)),
);
}
Finder? _largestImage(WidgetTester tester) {
Element? best;
var bestArea = 150.0 * 150.0;
for (final element in find.byType(Image).evaluate()) {
final box = element.renderObject;
if (box is! RenderBox || !box.hasSize) continue;
final area = box.size.width * box.size.height;
if (area > bestArea) {
bestArea = area;
best = element;
}
}
return best == null ? null : find.byElementPredicate((e) => e == best);
}
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
Future<void> _scrollToNewest(WidgetTester tester) async {
for (var i = 0; i < 6; i++) {
final lists = find.byType(Scrollable);
if (lists.evaluate().isEmpty) return;
await tester.fling(lists.first, const Offset(0, -1500), 3000,
warnIfMissed: false);
await _pumpFor(tester, const Duration(milliseconds: 500));
}
await _pumpFor(tester, const Duration(seconds: 2));
}
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
final end = DateTime.now().add(duration);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
required Duration timeout,
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return;
}
fail('Zeitüberschreitung: $finder nicht gefunden');
}
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
Future<void> _shot(WidgetTester tester, String name) async {
await tester.pump();
final renderView = tester.binding.renderViews.first;
final layer = renderView.debugLayer! as OffsetLayer;
final physical = tester.view.physicalSize;
final image = await tester.binding.runAsync(
() => layer.toImage(Offset.zero & physical),
);
final bytes = await tester.binding.runAsync(
() => image!.toByteData(format: ui.ImageByteFormat.png),
);
final file = File('build/ui_screens/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
print('Screenshot: ${file.path}');
}
+24 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground; import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
import 'package:pyramid/core/router.dart'; import 'package:pyramid/core/router.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
Future<void> _loadPersistedPrefs() async { Future<void> _loadPersistedPrefs() async {
final saved = await loadThemePrefs(); final saved = await loadThemePrefs();
if (!mounted) return; if (!mounted) return;
ref.read(themeModeProvider.notifier).state = saved.isDark; ref.read(themeVariantProvider.notifier).state = saved.variant;
ref.read(textScaleProvider.notifier).state = saved.textScale;
ref.read(messageStyleProvider.notifier).state = saved.messageStyle;
ref.read(accentProvider.notifier).state = saved.accentIdx; ref.read(accentProvider.notifier).state = saved.accentIdx;
ref.read(radiusProvider.notifier).state = saved.radius; ref.read(radiusProvider.notifier).state = saved.radius;
ref.read(motionProvider.notifier).state = saved.motion; ref.read(motionProvider.notifier).state = saved.motion;
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
} }
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
final isDark = ref.watch(themeModeProvider); final variant = ref.watch(themeVariantProvider);
final textScale = ref.watch(textScaleProvider);
final accentIdx = ref.watch(accentProvider); final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider); final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider); final density = ref.watch(densityProvider);
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
final accent = accentPresets[accentIdx].color; final accent = accentPresets[accentIdx].color;
return PyramidTheme( return PyramidTheme(
isDark: isDark, variant: variant,
accent: accent, accent: accent,
radius: radius, radius: radius,
density: density, density: density,
motion: motion, motion: motion,
child: MaterialApp.router( child: MaterialApp.router(
title: 'Pyramid', title: 'Pyramid',
theme: buildPyramidThemeData(false), theme: buildPyramidThemeData(ThemeVariant.light),
darkTheme: buildPyramidThemeData(true), darkTheme: buildPyramidThemeData(
themeMode: isDark ? ThemeMode.dark : ThemeMode.light, variant.isDark ? variant : ThemeVariant.soft),
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
routerConfig: router, routerConfig: router,
// Schriftgröße: Pyramid-Regler × Handy-/Systemeinstellung, für alle
// Seiten und Dialoge (liegt über dem Navigator).
builder: (context, child) {
final mq = MediaQuery.of(context);
final factor = textScale ??
defaultTextScale(mobile: isMobileLayout(mq.size));
return MediaQuery(
data: mq.copyWith(
textScaler: PyramidTextScaler(mq.textScaler, factor),
),
child: child!,
);
},
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
+484
View File
@@ -0,0 +1,484 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart' as sqflite_native;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'package:pyramid/core/app_dirs.dart';
import 'package:pyramid/core/auth_log.dart';
/// Läuft im DB-Isolate der FFI-Factory (muss deshalb top-level sein):
/// Auf Android liefert das sqlcipher_flutter_libs-Plugin die Bibliothek als
/// `libsqlcipher.so` – dem sqlite3-Paket muss man das dort sagen. Auf
/// Windows/Linux bündelt dasselbe Plugin SQLCipher bereits UNTER DEM
/// STANDARDNAMEN (`sqlite3.dll` neben der exe, verifiziert im Build-Output),
/// der Default-Loader findet sie also ohne Override.
void _sqlcipherFfiInit() {
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
}
/// Gemeinsamer Öffner für die `pyramid.sqlite` – der eine Storage-Baustein,
/// hinter dem Factory-Wahl, Verschlüsselung und Migration stecken (Plan:
/// `docs/SQLCIPHER_MIGRATION.md`, Modularitäts-Leitsatz aus CLAUDE.md).
///
/// Kapselt an EINER Stelle, was vorher in `matrix_client.dart` und
/// `background_push.dart` dupliziert war:
/// * Factory-Wahl pro Plattform (FFI + SQLCipher wo verfügbar),
/// * Schlüsselbeschaffung: 32 zufällige Bytes, hex-kodiert im
/// Plattform-Keystore (`flutter_secure_storage`, Eintrag `db_cipher_key`),
/// * einmalige Migration bestehender Klartext-DBs nach SQLCipher,
/// * die PRAGMAs (WAL, busy_timeout).
///
/// Oberste Regel (CLAUDE.md „Kein Datenverlust"): KEIN Codepfad darf eine
/// bestehende DB beschädigen oder still eine leere neue anlegen. Im Zweifel
/// läuft die App unverschlüsselt weiter und der Fehler steht im auth_log.
class AppDatabase {
AppDatabase._();
static const _cipherKeyStorageKey = 'db_cipher_key';
/// Klartext-Backup `*.premigration` nach so vielen Tagen automatisch
/// aufräumen (Kompromiss aus dem Migrationsplan: Rollback-Fenster vs.
/// dauerhaft herumliegende Klartext-Kopie).
static const _premigrationMaxAge = Duration(days: 7);
/// Plattformen, auf denen sqlcipher_flutter_libs die Cipher-Bibliothek
/// mitliefert. iOS/macOS bleiben bewusst beim nativen sqflite-Plugin
/// (Klartext): der SQLCipher-Pod kollidiert dort mit Paketen, die das
/// System-sqlite3 linken (z. B. firebase_messaging, siehe Paket-README),
/// und iOS ist kein Release-Ziel von Pyramid.
static bool get _isCipherPlatform =>
!kIsWeb && (Platform.isAndroid || Platform.isWindows || Platform.isLinux);
static Future<String> _dbPath() async {
final appDir = await appSupportDir();
return p.join(appDir.path, 'pyramid.sqlite');
}
// ── Öffnen: Haupt-App ────────────────────────────────────────────────────
/// Öffnet die Datenbank für die Haupt-App. Nur dieser Pfad migriert eine
/// bestehende Klartext-DB (das Push-Isolate migriert NIE selbst).
static Future<Database> open() async {
final dbPath = await _dbPath();
if (!_isCipherPlatform) {
// iOS/macOS: unverändert das native sqflite-Plugin, Klartext.
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
if (Platform.isAndroid) {
// Lade-Workaround für Android 6 (Paket-README) – im Main-Isolate
// ausführen, BEVOR irgendein Isolate sqlite benutzt.
try {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
} catch (_) {}
}
final factory = createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit);
await _recoverFromInterruptedSwap(dbPath);
await _clearStaleMarker(dbPath);
final dbFile = File(dbPath);
final exists = await dbFile.exists();
final isPlaintext = exists && await isPlaintextSqlite(dbFile);
if (!await _cipherAvailable(factory)) {
// Sicherheitsnetz: sollte auf Cipher-Plattformen nie passieren
// (Bibliothek wird als Plugin mitgebaut). Eine VERSCHLÜSSELTE DB ohne
// Cipher-Lib ist ein harter Fehler – niemals still neu anlegen.
if (exists && !isPlaintext) {
throw StateError(
'pyramid.sqlite ist verschlüsselt, aber die SQLCipher-Bibliothek '
'fehlt in diesem Build. Kein Zugriff möglich – Daten bleiben '
'unangetastet. Bitte App-Build prüfen (sqlcipher_flutter_libs).',
);
}
await AuthLog.write(
'[DB] SQLCipher nicht verfügbar – Datenbank bleibt unverschlüsselt.',
);
final db = await factory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
if (exists && !isPlaintext) {
// Bereits verschlüsselt: der Schlüssel MUSS im Keystore liegen.
final key = await _readStoredKey();
if (key == null || key.isEmpty) {
throw StateError(
'pyramid.sqlite ist verschlüsselt, aber im Schlüsselspeicher fehlt '
'"$_cipherKeyStorageKey". Es wird KEINE neue Datenbank angelegt, '
'damit keine Daten verloren gehen. Schlüsselspeicher prüfen '
'(flutter_secure_storage) oder Backup einspielen.',
);
}
final db = await _openWithKey(factory, dbPath, key);
await _applyPragmas(db);
await _cleanupOldBackup(dbPath);
return db;
}
final key = await _getOrCreateKey();
if (exists && isPlaintext) {
final migrated = await migratePlaintextToEncrypted(
factory: factory,
dbPath: dbPath,
hexKey: key,
);
if (!migrated) {
// Migration fehlgeschlagen → Klartext-DB ist unversehrt, App läuft
// wie bisher weiter (Fehlerdetails stehen im auth_log).
final db = await factory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
}
// Frische Installation (legt verschlüsselt neu an) oder frisch migriert.
final db = await _openWithKey(factory, dbPath, key);
await _applyPragmas(db);
return db;
}
// ── Öffnen: Android-Push-Isolate ─────────────────────────────────────────
/// Öffnet die Datenbank für das Push-/Hintergrund-Isolate. Gibt `null`
/// zurück, wenn gerade kein sicherer Zugriff möglich ist (Migration läuft,
/// DB fehlt, Schlüssel fehlt) – die Notification zeigt dann einmalig den
/// nativen „Neue Nachricht"-Platzhalter, das ist der akzeptierte Preis.
/// Dieser Pfad migriert NIE und legt NIE eine neue Datei an.
static Future<Database?> openForPush() async {
final dbPath = await _dbPath();
if (await File('$dbPath.migrating').exists()) {
debugPrint('[DB] Migration läuft – Push-Zugriff übersprungen.');
return null;
}
final dbFile = File(dbPath);
if (!await dbFile.exists()) return null;
if (!_isCipherPlatform) {
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
// noIsolate: databaseFactoryFfi würde ein Unter-Isolate spawnen – genau
// die Fallenklasse, an der im Firebase-Background-Isolate schon
// NativeImplementationsIsolate scheiterte (siehe background_push.dart).
final factory =
createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit, noIsolate: true);
if (await isPlaintextSqlite(dbFile)) {
// Noch nicht migriert (macht beim nächsten Start die Haupt-App).
final db = await factory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
final key = await _readStoredKey();
if (key == null || key.isEmpty) {
// Bekanntes Risiko aus dem Plan: secure_storage aus dem
// Background-Isolate. Niemals raten oder neu anlegen – nur aussteigen.
debugPrint('[DB] Kein Cipher-Schlüssel im Push-Isolate lesbar.');
return null;
}
final db = await _openWithKey(factory, dbPath, key);
await _applyPragmas(db);
return db;
}
// ── Migration Klartext → SQLCipher ───────────────────────────────────────
/// Wandelt eine bestehende Klartext-DB in eine SQLCipher-DB um – der heikle
/// Teil des Plans. Reihenfolge exakt wie in `docs/SQLCIPHER_MIGRATION.md`:
/// Marker → WAL-Checkpoint → Byte-Backup → Export in NEUE Datei →
/// Verifikation (integrity_check + Zeilenzahlen ALLER Tabellen) → atomarer
/// Tausch. Schlägt irgendetwas fehl, bleibt die Klartext-DB unangetastet
/// und die Funktion gibt `false` zurück.
@visibleForTesting
static Future<bool> migratePlaintextToEncrypted({
required DatabaseFactory factory,
required String dbPath,
required String hexKey,
}) async {
final marker = File('$dbPath.migrating');
final encPath = '$dbPath.enc';
final backupPath = '$dbPath.premigration';
try {
// Marker zuerst: hält das Push-Isolate fern (Race aus dem Plan).
await marker.writeAsString(DateTime.now().toIso8601String());
// 1. WAL eindampfen – sonst fehlen im Export Daten, die noch in
// pyramid.sqlite-wal liegen.
var db = await factory.openDatabase(dbPath);
try {
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
} finally {
await db.close();
}
// 2. Byte-Backup (Kopie, kein rename) – bleibt bis zum verifizierten
// Erfolg + Aufräumfrist liegen.
await File(dbPath).copy(backupPath);
// 3. Export in NEUE Datei; das Original wird nie beschrieben.
final encFile = File(encPath);
if (await encFile.exists()) await encFile.delete();
final Map<String, int> sourceCounts;
db = await factory.openDatabase(dbPath);
try {
sourceCounts = await _tableRowCounts(db);
await db.rawQuery(
'ATTACH DATABASE \'${_escapeSqlString(encPath)}\' AS enc '
'KEY "x\'$hexKey\'"',
);
await db.rawQuery("SELECT sqlcipher_export('enc')");
await db.rawQuery('DETACH DATABASE enc');
} finally {
await db.close();
}
// 4. Verifikation der NEUEN Datei, bevor irgendetwas getauscht wird.
final encDb = await _openWithKey(factory, encPath, hexKey);
try {
final integrity = await encDb.rawQuery('PRAGMA integrity_check');
final integrityOk = integrity.length == 1 &&
integrity.first.values.first.toString() == 'ok';
if (!integrityOk) {
throw StateError('integrity_check fehlgeschlagen: $integrity');
}
final encCounts = await _tableRowCounts(encDb);
for (final entry in sourceCounts.entries) {
if (encCounts[entry.key] != entry.value) {
throw StateError(
'Zeilenzahl weicht ab in "${entry.key}": '
'${entry.value} (Original) != ${encCounts[entry.key]} (Export)',
);
}
}
} finally {
await encDb.close();
}
// 5. Tausch. rename auf derselben Partition ist atomar; für das
// Zeitfenster zwischen delete und rename existiert das Byte-Backup
// (Wiederanlauf repariert das, siehe _recoverFromInterruptedSwap).
for (final suffix in ['-wal', '-shm']) {
final sidecar = File('$dbPath$suffix');
if (await sidecar.exists()) await sidecar.delete();
}
await File(dbPath).delete();
await File(encPath).rename(dbPath);
await AuthLog.write(
'[DB] Klartext-DB erfolgreich nach SQLCipher migriert '
'(${sourceCounts.length} Tabellen verifiziert).',
);
return true;
} catch (e) {
// Aufräumen: halbfertigen Export löschen, Original bleibt maßgeblich.
try {
final encFile = File(encPath);
if (await encFile.exists()) await encFile.delete();
} catch (_) {}
await AuthLog.write(
'[DB] SQLCipher-Migration fehlgeschlagen – Klartext-DB bleibt '
'unangetastet: $e',
);
return false;
} finally {
try {
if (await marker.exists()) await marker.delete();
} catch (_) {}
}
}
/// Absturz exakt zwischen „Original gelöscht" und „Export umbenannt":
/// dann fehlt `pyramid.sqlite`, aber das Byte-Backup existiert. Backup
/// zurückkopieren (die Migration läuft danach einfach erneut). Ein evtl.
/// liegengebliebener Export ist nicht als vollständig beweisbar → löschen.
static Future<void> _recoverFromInterruptedSwap(String dbPath) async {
final backup = File('$dbPath.premigration');
if (await File(dbPath).exists() || !await backup.exists()) return;
try {
final enc = File('$dbPath.enc');
if (await enc.exists()) await enc.delete();
await backup.copy(dbPath);
await AuthLog.write(
'[DB] Unterbrochene Migration erkannt – Klartext-Backup '
'wiederhergestellt, Migration läuft erneut.',
);
} catch (e) {
await AuthLog.write('[DB] Wiederherstellung aus .premigration fehlgeschlagen: $e');
}
}
/// Ein Marker ohne laufende Migration ist ein Absturz-Überbleibsel – beim
/// Start der Haupt-App (es gibt nur eine) gefahrlos entfernbar.
static Future<void> _clearStaleMarker(String dbPath) async {
try {
final marker = File('$dbPath.migrating');
if (await marker.exists()) {
await marker.delete();
await AuthLog.write('[DB] Verwaisten Migrations-Marker entfernt.');
}
} catch (_) {}
}
static Future<void> _cleanupOldBackup(String dbPath) async {
try {
final backup = File('$dbPath.premigration');
if (!await backup.exists()) return;
final age = DateTime.now().difference((await backup.stat()).modified);
if (age > _premigrationMaxAge) {
await backup.delete();
await AuthLog.write(
'[DB] Klartext-Backup .premigration nach '
'${_premigrationMaxAge.inDays} Tagen gelöscht.',
);
}
} catch (_) {}
}
// ── Bausteine ────────────────────────────────────────────────────────────
/// Klartext-SQLite-Dateien beginnen mit dem 16-Byte-Header
/// `SQLite format 3\0`; SQLCipher-Dateien haben keinen lesbaren Header.
/// Leere/winzige Dateien zählen als Klartext (die Migration exportiert sie
/// dann schlicht leer).
@visibleForTesting
static Future<bool> isPlaintextSqlite(File file) async {
final raf = await file.open();
try {
final header = await raf.read(16);
if (header.length < 16) return true;
// 16-Byte-Magic: "SQLite format 3" gefolgt von einem NUL-Byte.
const magic = 'SQLite format 3';
for (var i = 0; i < magic.length; i++) {
if (header[i] != magic.codeUnitAt(i)) return false;
}
return header[15] == 0x00;
} finally {
await raf.close();
}
}
/// Öffnet eine Datei mit Schlüssel. `PRAGMA key` MUSS das allererste
/// Statement der Verbindung sein → onConfigure. Der sqlite_master-Zugriff
/// danach lässt einen falschen/fehlenden Schlüssel sofort hier scheitern
/// statt irgendwann später mitten im Betrieb.
static Future<Database> _openWithKey(
DatabaseFactory factory,
String path,
String hexKey,
) async {
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
onConfigure: (db) async {
await db.rawQuery('PRAGMA key = "x\'$hexKey\'"');
},
),
);
try {
await db.rawQuery('SELECT count(*) FROM sqlite_master');
} catch (e) {
await db.close();
rethrow;
}
return db;
}
@visibleForTesting
static Future<Database> openEncrypted(
DatabaseFactory factory,
String path,
String hexKey,
) =>
_openWithKey(factory, path, hexKey);
/// `PRAGMA cipher_version` liefert bei normalem sqlite3 KEINE Zeile – der
/// vom Paket-README empfohlene Wächter dagegen, versehentlich unverschlüsselt
/// weiterzulaufen, weil die falsche Bibliothek geladen wurde.
static Future<bool> _cipherAvailable(DatabaseFactory factory) async {
final db = await factory.openDatabase(inMemoryDatabasePath);
try {
final rows = await db.rawQuery('PRAGMA cipher_version');
if (rows.isEmpty) return false;
final value = rows.first.values.first;
return value != null && value.toString().isNotEmpty;
} finally {
await db.close();
}
}
/// Wie bisher in matrix_client.dart/background_push.dart: WAL gegen
/// "database is locked" zwischen Haupt-App und Push-Isolate.
static Future<void> _applyPragmas(Database db) async {
try {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
} catch (e) {
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
}
}
static Future<String?> _readStoredKey() =>
const FlutterSecureStorage().read(key: _cipherKeyStorageKey);
/// Erzeugt beim allerersten Mal 32 kryptografisch zufällige Bytes und legt
/// sie hex-kodiert im Keystore ab. Der Read-Back danach ist Pflicht: würde
/// das Schreiben still scheitern und wir trotzdem damit verschlüsseln,
/// wäre die DB beim nächsten Start unlesbar (Totalverlust).
static Future<String> _getOrCreateKey() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _cipherKeyStorageKey);
if (existing != null && existing.isNotEmpty) return existing;
final rnd = Random.secure();
final key = List.generate(
32,
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
await storage.write(key: _cipherKeyStorageKey, value: key);
final readBack = await storage.read(key: _cipherKeyStorageKey);
if (readBack != key) {
throw StateError(
'Schlüsselspeicher-Verifikation fehlgeschlagen: '
'"$_cipherKeyStorageKey" konnte nicht zuverlässig abgelegt werden. '
'Es wird NICHT verschlüsselt (sonst wäre die DB später unlesbar).',
);
}
return key;
}
static Future<Map<String, int>> _tableRowCounts(Database db) async {
final tables = await db.rawQuery(
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
);
final result = <String, int>{};
for (final row in tables) {
final name = row['name'] as String;
final count = await db.rawQuery('SELECT count(*) AS c FROM "$name"');
result[name] = (count.first['c'] as num).toInt();
}
return result;
}
/// SQL-String-Literal: einfache Anführungszeichen verdoppeln (der Pfad
/// kommt aus dem App-Verzeichnis, aber sicher ist sicher).
static String _escapeSqlString(String value) => value.replaceAll("'", "''");
}
+31
View File
@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
/// Basisverzeichnis für alle lokalen App-Daten (pyramid.sqlite, auth_log.txt,
/// Media-Cache).
///
/// Normalfall: das Application-Support-Verzeichnis der Plattform – exakt wie
/// bisher, es ändert sich NICHTS.
///
/// Nur auf dem Desktop kann die Umgebungsvariable `PYRAMID_PROFILE_DIR` ein
/// abweichendes Verzeichnis erzwingen. Zweck: Testläufe mit den
/// Autopilot-Testkonten in einem eigenen Profil, parallel zur echten
/// Session desselben Windows-Nutzers (die echte Session sieht die Variable
/// nie und bleibt unberührt). Bewusst NICHT umgeleitet werden
/// shared_preferences und flutter_secure_storage – deren Plugins lösen ihre
/// Pfade selbst auf; ein Testprofil teilt sich also den DB-Schlüssel
/// (`db_cipher_key`) und die UI-Prefs mit dem Hauptprofil. Für Tests ist das
/// unkritisch (der Schlüssel wird nur GELESEN, nie überschrieben), echte
/// Zweitkonten bräuchten mehr.
Future<Directory> appSupportDir() async {
final override = Platform.isWindows || Platform.isLinux || Platform.isMacOS
? Platform.environment['PYRAMID_PROFILE_DIR']
: null;
if (override != null && override.trim().isNotEmpty) {
final dir = Directory(override.trim());
await dir.create(recursive: true);
return dir;
}
return getApplicationSupportDirectory();
}
+12 -13
View File
@@ -1,15 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/matrix_client.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'; import 'package:pyramid/features/rooms/rooms_provider.dart';
// ─── Theme state ────────────────────────────────────────────────────────────── // ─── Theme state ──────────────────────────────────────────────────────────────
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark final themeVariantProvider =
StateProvider<ThemeVariant>((ref) => ThemeVariant.soft);
/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0).
final textScaleProvider = StateProvider<double?>((ref) => null);
final messageStyleProvider =
StateProvider<MessageStyle>((ref) => MessageStyle.auto);
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
const accentPresets = [ const accentPresets = [
@@ -64,16 +70,9 @@ enum ModalKind { none, settings, search }
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none); final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
// ─── Call state ─────────────────────────────────────────────────────────────── // ─── Call state ───────────────────────────────────────────────────────────────
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) { // `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
return LiveKitCallManager.instance; // und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
});
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
return PyramidVoipManager.instance;
});
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
// ─── View mode (chat / voice) ───────────────────────────────────────────────── // ─── View mode (chat / voice) ─────────────────────────────────────────────────
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/widgets.dart';
/// Darstellungs-Einstellungen, die nicht reine Farben sind (die stehen in
/// theme.dart): Handy-Layout-Erkennung, Schriftgröße, Nachrichten-Darstellung.
/// Unterhalb dieser Breite (oder sehr flach, z. B. Handy quer) zeigt Pyramid
/// das Handy-Layout (eine Spalte, Chat als eigene Seite).
bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
// ── Schriftgröße ───────────────────────────────────────────────────────────
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
/// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard
/// gewünscht), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0;
const minTextScale = 0.85;
const maxTextScale = 1.5;
/// Kombiniert die Schriftgröße des Systems (Handy-Einstellung) mit dem
/// Pyramid-eigenen Regler: erst Pyramid-Faktor, dann die (ggf. nichtlineare)
/// Systemskalierung.
class PyramidTextScaler extends TextScaler {
const PyramidTextScaler(this.system, this.factor);
final TextScaler system;
final double factor;
@override
double scale(double fontSize) => system.scale(fontSize * factor);
@override
// ignore: deprecated_member_use
double get textScaleFactor => system.textScaleFactor * factor;
@override
bool operator ==(Object other) =>
other is PyramidTextScaler &&
other.system == system &&
other.factor == factor;
@override
int get hashCode => Object.hash(system, factor);
}
// ── Nachrichten-Darstellung ────────────────────────────────────────────────
/// Wie Nachrichten im Chat aussehen (Einstellungen → Erscheinungsbild).
enum MessageStyle {
/// Handy: Sprechblasen, PC: Liste.
auto,
/// Sprechblasen wie WhatsApp/Signal (eigene rechts, andere links).
bubbles,
/// Liste wie Discord/Element (Name + Text untereinander).
list;
String get label => switch (this) {
MessageStyle.auto => 'Automatisch',
MessageStyle.bubbles => 'Sprechblasen',
MessageStyle.list => 'Liste',
};
/// Tatsächlich anzuwendende Darstellung.
bool useBubbles({required bool mobile}) => switch (this) {
MessageStyle.auto => mobile,
MessageStyle.bubbles => true,
MessageStyle.list => false,
};
static MessageStyle fromName(String? name) => MessageStyle.values
.firstWhere((v) => v.name == name, orElse: () => MessageStyle.auto);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/app_dirs.dart';
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen /// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen /// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
@@ -15,7 +15,7 @@ class AuthLog {
static Future<File> _ensureFile() async { static Future<File> _ensureFile() async {
final existing = _file; final existing = _file;
if (existing != null) return existing; if (existing != null) return existing;
final dir = await getApplicationSupportDirectory(); final dir = await appSupportDir();
final file = File(p.join(dir.path, 'auth_log.txt')); final file = File(p.join(dir.path, 'auth_log.txt'));
return _file = file; return _file = file;
} }
+55 -45
View File
@@ -7,11 +7,11 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p; import 'package:pyramid/core/app_database.dart';
import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.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. /// Port name used to locate the main isolate from background isolates.
/// Registered by notification_service.dart when the app starts. /// Registered by notification_service.dart when the app starts.
@@ -66,6 +66,11 @@ void notificationEngineMain() {
args['room_id'] as String?, args['room_id'] as String?,
args['text'] as String?, args['text'] as String?,
); );
case 'markRead':
return await _bgMarkRead(
args['room_id'] as String?,
args['event_id'] as String?,
);
} }
} catch (e) { } catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e'); debugPrint('[NOTIF-BGENGINE] handler error: $e');
@@ -90,26 +95,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
) )
.timeout(const Duration(seconds: 20)); .timeout(const Duration(seconds: 20));
if (event == null) return false; 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 prefs = await SharedPreferences.getInstance();
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ?? final content = MessageNotificationContent.fromEvent(
event.senderId.split(':').first.replaceFirst('@', ''); event,
final title = (room?.isDirectChat ?? true) showPreview: prefs.getBool('notif_preview') ?? true,
? sender );
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}'; // Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
if (content == null) return false;
// Hand the decrypted content back to native so it re-shows the SAME // Hand the decrypted content back to native so it re-shows the SAME
// notification via NotificationHelper — that keeps the reliable // notification via NotificationHelper — that keeps the reliable
// MainActivity-based reply action (the flutter_local_notifications reply // ReplyReceiver-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed). // path doesn't fire dependably when the app is killed). Erst der Text,
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', { // dann lautlos Avatar/Bild (deliverMessageNotification).
'title': title, await deliverMessageNotification(
'body': _buildBody(event), event,
'room_id': roomId, content,
'notif_id': notifId ?? _stableId(roomId), notifId: notifId ?? _stableId(roomId),
}); send: (a) => const MethodChannel(_kBgEngineChannel)
.invokeMethod('showDecrypted', a),
);
return true; return true;
} catch (e) { } catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e'); debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
@@ -154,6 +160,29 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
} }
} }
/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
/// HTTP-Aufruf, keine Verschlüsselung nötig).
Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
if (roomId == null || eventId == null) return false;
final client = await _buildClient();
if (client == null) return false;
try {
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
await markRoomReadUpTo(
room,
eventId,
publicReceipt: await loadReadReceiptsPublic(),
).timeout(const Duration(seconds: 15));
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] markRead error: $e');
return false;
} finally {
await client.dispose().catchError((_) {});
}
}
/// Stores the raw callback handle of [notificationEngineMain] so native code can /// Stores the raw callback handle of [notificationEngineMain] so native code can
/// bootstrap the background engine. Call once at app startup. /// bootstrap the background engine. Call once at app startup.
Future<void> registerBgEngineHandle() async { Future<void> registerBgEngineHandle() async {
@@ -182,18 +211,13 @@ Future<Client?> _buildClient() async {
await vod.init(); await vod.init();
} catch (_) {} } catch (_) {}
final appDir = await getApplicationSupportDirectory(); // Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
final dbPath = p.join(appDir.path, 'pyramid.sqlite'); // Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
final db = await sqflite_native.databaseFactory // Schlüssel fehlt) → Notification behält den nativen Platzhalter.
.openDatabase(dbPath) final db =
.timeout(const Duration(seconds: 5)); await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
if (db == null) return null;
// 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 sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
@@ -224,20 +248,6 @@ Future<Client?> _buildClient() async {
} }
} }
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 ─────────────────────────────────────────────── // ── Notification action handler ───────────────────────────────────────────────
// Called in a fresh Dart isolate when the user presses Reply or Gelesen. // Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly — Android keeps the notification's "sending" spinner // Must return quickly — Android keeps the notification's "sending" spinner
+84 -32
View File
@@ -1,37 +1,89 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
class LiveKitTokenGenerator { import 'package:http/http.dart' as http;
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
static String generate({ /// LiveKit-Beitritts-Token vom Pyramid-Server holen.
required String roomName, ///
required String identity, /// Früher hat die App die Tokens selbst signiert – dafür steckte das
String? displayName, /// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
int ttlSeconds = 3600, // Valid for 1 hour by default /// Raum und jede Identität ein Token ausstellen. Jetzt signiert nur noch der
}) { /// Server (`/api/livekit-token` im Dashboard-Server auf dem Pi): Er prüft den
final jwt = JWT( /// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
{ /// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
'video': { class LiveKitTokenService {
'roomJoin': true, static const endpoint =
'room': roomName, 'https://dashboard.steggi-matrix.work/api/livekit-token';
'canPublish': true, static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
'canSubscribe': true, static const _timeout = Duration(seconds: 15);
'canPublishData': true,
},
'metadata': displayName ?? identity,
},
issuer: apiKey,
subject: identity,
);
// Sign the token with your secret /// Liefert Token und LiveKit-Adresse für [room]. Wirft
final token = jwt.sign( /// [LiveKitTokenException] mit einer verständlichen Meldung.
SecretKey(apiSecret), static Future<({String token, String url})> fetch({
expiresIn: Duration(seconds: ttlSeconds), required String room,
); required String matrixToken,
http.Client? httpClient,
return token; }) async {
final client = httpClient ?? http.Client();
try {
final res = await client
.post(
Uri.parse(endpoint),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
)
.timeout(_timeout);
if (res.statusCode == 401) {
throw const LiveKitTokenException(
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
);
}
if (res.statusCode != 200) {
throw LiveKitTokenException(
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
);
}
final data = jsonDecode(res.body);
final token = data is Map ? data['token'] : null;
if (token is! String || token.isEmpty) {
throw const LiveKitTokenException(
'Anrufserver hat kein Zugangstoken geliefert.',
);
}
final url = (data as Map)['url'];
return (
token: token,
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
);
} on LiveKitTokenException {
rethrow;
} on TimeoutException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
);
} on SocketException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on http.ClientException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on FormatException {
throw const LiveKitTokenException(
'Anrufserver hat eine unlesbare Antwort geschickt.',
);
} finally {
if (httpClient == null) client.close();
}
} }
} }
class LiveKitTokenException implements Exception {
final String message;
const LiveKitTokenException(this.message);
@override
String toString() => message;
}
+7 -31
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -7,40 +6,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/encryption/utils/key_verification.dart'; import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p; import 'package:pyramid/core/app_database.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:pyramid/core/auth_log.dart'; import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:pyramid/core/voip_manager.dart'; // Kompositions-Punkt: Einzige Stelle außerhalb des call_signaling-Moduls,
// die die Implementierung (statt der Fassade) kennen darf – sie erzeugt sie.
import 'package:pyramid/features/call_signaling/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async { final matrixClientProvider = FutureProvider<Client>((ref) async {
final DatabaseFactory factory; // Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
// komplett in der Storage-Fassade — siehe docs/SQLCIPHER_MIGRATION.md.
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { final db = await AppDatabase.open();
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
// no .so loading required.
factory = sqflite_native.databaseFactory;
} else if (!kIsWeb) {
sqfliteFfiInit();
factory = databaseFactoryFfi;
} else {
throw UnsupportedError('Web not supported');
}
final appDir = await getApplicationSupportDirectory();
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
final db = await factory.openDatabase(dbPath);
// Optimization for Windows/FFI to prevent "database is locked" errors
try {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
} catch (e) {
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
}
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
+2 -2
View File
@@ -4,7 +4,7 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/app_dirs.dart';
/// Shared, bounded LRU cache for media bytes (chat images, document previews, /// Shared, bounded LRU cache for media bytes (chat images, document previews,
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap, /// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
@@ -89,7 +89,7 @@ class MediaCache {
} }
Future<Directory> _initDiskDir() async { Future<Directory> _initDiskDir() async {
final support = await getApplicationSupportDirectory(); final support = await appSupportDir();
final dir = Directory( final dir = Directory(
'${support.path}${Platform.pathSeparator}media_cache'); '${support.path}${Platform.pathSeparator}media_cache');
await dir.create(recursive: true); await dir.create(recursive: true);
+293
View File
@@ -0,0 +1,293 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/media_cache.dart';
/// Was eine Nachrichten-Benachrichtigung zeigt – egal, ob sie aus der
/// laufenden App (`notification_service.dart`) oder dem Hintergrund-Isolate
/// bei beendeter App (`background_push.dart`) kommt. Beide Wege übergeben
/// [toNativeArgs] an `NotificationHelper.kt`, das daraus pro Raum einen
/// Chat-Verlauf (Android MessagingStyle) baut.
class MessageNotificationContent {
const MessageNotificationContent({
required this.roomId,
required this.eventId,
required this.senderId,
required this.senderName,
required this.roomName,
required this.body,
required this.timestamp,
this.isImage = false,
});
final String roomId;
final String eventId;
final String senderId;
final String senderName;
/// `null` bei Direktnachrichten – dann zeigt Android nur den Absender.
final String? roomName;
final String body;
final DateTime timestamp;
/// Bild-Nachricht: Vorschaubild wird nachgeladen ([cacheNotificationImage]).
final bool isImage;
bool get isGroup => roomName != null;
/// Einzeiliger Titel für Plattformen ohne Chat-Verlauf (Windows).
String get title => roomName == null ? senderName : '$senderName · $roomName';
/// Liest den Inhalt aus einem (entschlüsselten) Event. `null`, wenn es
/// nichts Anzeigbares ist (nicht entschlüsselt, gelöscht, kein
/// Nachrichten-Event) – dann bleibt ein evtl. Platzhalter stehen.
/// Ist [showPreview] aus (Einstellung „Vorschau“), steht nur
/// [notificationPlaceholderBody] da und es wird kein Bild geladen.
static MessageNotificationContent? fromEvent(
Event event, {
bool showPreview = true,
}) {
final body = notificationBody(event);
if (body == null) return null;
final room = event.room;
final displayName = room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.displayName
?.trim();
return MessageNotificationContent(
roomId: room.id,
eventId: event.eventId,
senderId: event.senderId,
senderName: (displayName == null || displayName.isEmpty)
? _localpart(event.senderId)
: displayName,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
body: showPreview ? body : notificationPlaceholderBody,
timestamp: event.originServerTs,
isImage: showPreview && event.messageType == MessageTypes.Image,
);
}
/// Argumente für den nativen Kanal (`showNativeNotification` bzw.
/// `showDecrypted`). `title`/`body` bleiben für ältere Native-Pfade drin.
Map<String, Object?> toNativeArgs({
required int notifId,
String? imagePath,
String? avatarPath,
}) => {
'title': title,
'body': body,
'roomId': roomId,
'notifId': notifId,
'eventId': eventId,
'senderId': senderId,
'senderName': senderName,
'roomName': roomName,
'isGroup': isGroup,
'timestamp': timestamp.millisecondsSinceEpoch,
'imagePath': imagePath,
'avatarPath': avatarPath,
};
}
/// Text, den Android anzeigt, solange der echte Inhalt fehlt.
const notificationPlaceholderBody = 'Neue Nachricht';
/// Längere Texte werden gekürzt (aufgeklappt zeigt Android ohnehin nicht mehr).
const notificationBodyMaxChars = 1000;
/// Anzeigetext einer Nachricht für die Benachrichtigung, oder `null`, wenn
/// das Event nichts Anzeigbares enthält.
String? notificationBody(Event event) {
if (event.redacted) return null;
if (event.type == EventTypes.Sticker) return '🏷️ Sticker';
if (event.type == _pollStartType) return '📊 Umfrage';
if (event.type != EventTypes.Message) return null;
final content = event.content;
final caption = _caption(event);
final String? text = switch (event.messageType) {
MessageTypes.Text || MessageTypes.Notice => _plainText(event),
MessageTypes.Emote => '* ${_plainText(event)}',
MessageTypes.Image => caption == null ? '📷 Foto' : '📷 $caption',
MessageTypes.Video => caption == null ? '🎥 Video' : '🎥 $caption',
MessageTypes.Audio =>
content.containsKey(_voiceKey) ? '🎤 Sprachnachricht' : '🎵 Audio',
MessageTypes.File =>
'📎 ${content.tryGet<String>('filename') ?? event.body}',
MessageTypes.Location => '📍 Standort',
MessageTypes.Sticker => '🏷️ Sticker',
MessageTypes.BadEncrypted => null,
_ => _plainText(event),
};
final trimmed = text?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed.length > notificationBodyMaxChars
? '${trimmed.substring(0, notificationBodyMaxChars)}…'
: trimmed;
}
const _pollStartType = 'org.matrix.msc3381.poll.start';
const _voiceKey = 'org.matrix.msc3245.voice';
/// Klartext ohne Antwort-Zitat („> <@x> …“) und ohne HTML.
String _plainText(Event event) => event.calcUnlocalizedBody(
hideReply: true,
hideEdit: true,
plaintextBody: true,
);
/// MSC2530: Steht neben `filename` ein abweichender `body`, ist das die
/// Bildunterschrift.
String? _caption(Event event) {
final filename = event.content.tryGet<String>('filename');
final body = event.content.tryGet<String>('body')?.trim();
if (filename == null || body == null || body.isEmpty || body == filename) {
return null;
}
return body;
}
String _localpart(String userId) =>
userId.split(':').first.replaceFirst('@', '');
// ── Bilder für die Benachrichtigung ─────────────────────────────────────────
// Android zeigt Bilder in Benachrichtigungen nur über eine content://-URI.
// Die Dateien liegen deshalb unter <cache>/downloads/notif/ – genau dort gibt
// der FileProvider der App frei (res/xml: cache-path "downloads/").
const _maxImageBytes = 8 * 1024 * 1024;
const _maxAvatarBytes = 2 * 1024 * 1024;
const _keepFilesFor = Duration(days: 2);
/// Lädt das Vorschaubild einer Bild-Nachricht (bei E2EE entschlüsselt) in
/// den Cache und liefert den Dateipfad – oder `null`, wenn es nicht klappt.
Future<String?> cacheNotificationImage(Event event) async {
if (event.messageType != MessageTypes.Image) return null;
try {
final file = await event
.downloadAndDecryptAttachment(getThumbnail: event.hasThumbnail)
.timeout(const Duration(seconds: 20));
return await _writeNotifFile(
'img_${_hash(event.eventId)}',
file.bytes,
maxBytes: _maxImageBytes,
);
} catch (_) {
return null;
}
}
/// Avatar des Absenders als Datei (zuerst aus dem Bild-Cache der App).
Future<String?> cacheNotificationAvatar(Event event) async {
try {
final client = event.room.client;
final mxc = event.room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.avatarUrl;
if (mxc == null) return null;
final name = 'avatar_${_hash(mxc.toString())}';
final existing = File(p.join((await _notifDir()).path, name));
if (await existing.exists()) return existing.path;
final cacheKey = 'img:$mxc';
var bytes = await MediaCache.instance.getPersistent(cacheKey);
if (bytes == null) {
final headers = {'authorization': 'Bearer ${client.accessToken}'};
try {
final thumb = await mxc.getThumbnailUri(
client,
width: 128,
height: 128,
method: ThumbnailMethod.crop,
);
final res = await client.httpClient
.get(thumb, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
} catch (_) {}
if (bytes == null) {
// Continuwuity kann keine Thumbnails → Original (Avatare sind klein).
final full = await mxc.getDownloadUri(client);
final res = await client.httpClient
.get(full, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
}
if (bytes != null) {
await MediaCache.instance.putPersistent(cacheKey, bytes);
}
}
if (bytes == null) return null;
return await _writeNotifFile(name, bytes, maxBytes: _maxAvatarBytes);
} catch (_) {
return null;
}
}
Future<Directory> _notifDir() async {
final dir = Directory(
p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
);
await dir.create(recursive: true);
return dir;
}
Future<String?> _writeNotifFile(
String name,
Uint8List bytes, {
required int maxBytes,
}) async {
if (bytes.isEmpty || bytes.length > maxBytes) return null;
final dir = await _notifDir();
await _pruneOldFiles(dir);
final file = File(p.join(dir.path, name));
await file.writeAsBytes(bytes, flush: true);
return file.path;
}
Future<void> _pruneOldFiles(Directory dir) async {
try {
final cutoff = DateTime.now().subtract(_keepFilesFor);
await for (final entity in dir.list()) {
if (entity is File && (await entity.lastModified()).isBefore(cutoff)) {
await entity.delete();
}
}
} catch (_) {}
}
/// Dateiname aus Event-ID/MXC (enthält `$`, `:` und `/`).
String _hash(String input) =>
sha1.convert(utf8.encode(input)).toString().substring(0, 20);
/// Zeigt eine Nachricht auf Android in zwei Schritten: erst sofort den Text
/// (damit nichts wartet), dann – nur falls es etwas gibt – lautlos dasselbe
/// mit Avatar und Bild. Native erkennt die Aktualisierung an der eventId und
/// ersetzt die Nachricht im Verlauf, statt sie doppelt anzuhängen.
Future<void> deliverMessageNotification(
Event event,
MessageNotificationContent content, {
required int notifId,
required Future<void> Function(Map<String, Object?> args) send,
}) async {
await send(content.toNativeArgs(notifId: notifId));
final avatarPath = await cacheNotificationAvatar(event);
final imagePath = content.isImage
? await cacheNotificationImage(event)
: null;
if (avatarPath == null && imagePath == null) return;
await send(
content.toNativeArgs(
notifId: notifId,
avatarPath: avatarPath,
imagePath: imagePath,
),
);
}
+72 -77
View File
@@ -12,6 +12,8 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart'; import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart'; import 'package:windows_taskbar/windows_taskbar.dart';
@@ -197,6 +199,26 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (!share.isEmpty) { if (!share.isEmpty) {
ref.read(pendingShareProvider.notifier).state = share; ref.read(pendingShareProvider.notifier).state = share;
} }
} else if (call.method == 'markReadFromNotification') {
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
if (roomId != null && eventId != null) {
try {
final client = await ref.read(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room != null) {
await markRoomReadUpTo(
room,
eventId,
publicReceipt: ref.read(privacyReadReceiptsProvider),
);
}
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
}
}
} else if (call.method == 'decryptAndUpdateNotification') { } else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder. // Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite // We decrypt the event here (the Matrix client has the keys) and overwrite
@@ -458,57 +480,37 @@ Future<void> _decryptAndUpdateNotification(
final client = await ref final client = await ref
.read(matrixClientProvider.future) .read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
var room = client.getRoomById(roomId); if (eventId == null) return;
if (room == null) {
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found'); // Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null || event.senderId == client.userID) return;
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) {
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
return; return;
} }
// The event might not have synced yet — wait for the next sync (max 12 s). // Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
// We use lastEvent as a proxy: after sync it will be the event we want. // Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
Event? event = room.lastEvent; await deliverMessageNotification(
final bool alreadyHave = event,
eventId == null || event?.eventId == eventId; content,
if (!alreadyHave || event == null) { notifId: notifId ?? _stableRoomId(roomId),
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId'); send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
await client.onSync.stream.first.timeout(const Duration(seconds: 12)); );
room = client.getRoomById(roomId); debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
if (room == null) return;
event = room.lastEvent;
}
if (event == null) {
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
return;
}
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
final rawBody = event.body;
// If decryption failed the body starts with "** Unable to decrypt"
if (rawBody.isEmpty || rawBody.startsWith('**')) {
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
return;
}
final notifBody =
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = room.isDirectChat
? displayName
: '$displayName · ${room.getLocalizedDisplayname()}';
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
// replaces the existing notification rather than stacking a new one.
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': notifBody,
'roomId': roomId,
'notifId': notifId ?? _stableRoomId(roomId),
});
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
} catch (e) { } catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e'); debugPrint('[NOTIF] decryptAndUpdate error: $e');
} }
@@ -516,27 +518,30 @@ Future<void> _decryptAndUpdateNotification(
// ─── Show notification ──────────────────────────────────────────────────────── // ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification({ Future<void> showMessageNotification(
required String roomId, Event event,
String? roomName, MessageNotificationContent content,
required String senderName, ) async {
required String body, final roomId = content.roomId;
}) async {
final title = roomName != null ? '$senderName · $roomName' : senderName;
if (Platform.isAndroid && _androidInitialized) { if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets // Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies. // ReplyReceiver directly — the app never opens when the user replies.
try { try {
await _kInstallChannel.invokeMethod('showNativeNotification', { await deliverMessageNotification(
'title': title, event,
'body': body, content,
'roomId': roomId, notifId: _stableRoomId(roomId),
'notifId': _stableRoomId(roomId), send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
}); );
} catch (e) { } catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e'); debugPrint('[NOTIF] showNativeNotification failed: $e');
} }
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
final title = content.title;
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
final body = content.body.length > 300
? '${content.body.substring(0, 300)}…'
: content.body;
// Close the previous notification for this room before showing a new one. // Close the previous notification for this room before showing a new one.
_windowsNotifs.remove(roomId)?.close(); _windowsNotifs.remove(roomId)?.close();
@@ -624,23 +629,13 @@ void _watchEvents(Ref ref, Client client) {
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return; if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
} }
// ── Build notification body ─────────────────────────────────────────── // ── Build notification content ────────────────────────────────────────
final rawBody = event.body; final content = MessageNotificationContent.fromEvent(
final showPreview = ref.read(notifPreviewProvider); event,
final notifBody = showPreview showPreview: ref.read(notifPreviewProvider),
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
: 'Neue Nachricht';
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
showMessageNotification(
roomId: roomId,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
senderName: displayName,
body: notifBody,
); );
if (content == null) return;
showMessageNotification(event, content);
}); });
} }
+25
View File
@@ -0,0 +1,25 @@
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// SharedPreferences-Schlüssel der Einstellung „Lesebestätigungen senden“
/// (siehe privacyReadReceiptsProvider). Auch der Hintergrund-Isolate der
/// Benachrichtigungen liest ihn, dort gibt es keine Provider.
const readReceiptsPrefKey = 'privacy_read_receipts';
Future<bool> loadReadReceiptsPublic() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(readReceiptsPrefKey) ?? true;
}
/// Setzt Lesemarker und Lesebestätigung bis [eventId].
///
/// Mit [publicReceipt] = false (Einstellung „Lesebestätigungen senden“ aus)
/// geht nur die private Bestätigung (m.read.private) raus: Der Server zählt
/// Ungelesenes trotzdem herunter und andere eigene Geräte kennen den Stand,
/// Chatpartner sehen aber kein „gelesen“. Früher wurde in diesem Fall gar
/// nichts gesendet – Ungelesen-Zähler blieben dann stehen.
Future<void> markRoomReadUpTo(
Room room,
String eventId, {
required bool publicReceipt,
}) => room.setReadMarker(eventId, mRead: eventId, public: publicReceipt);
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
/// Kennung der Account-Daten, mit denen wir den laufenden Sync aufwecken.
const logoutWakeAccountDataType = 'io.pyramid.logout_wake';
/// Meldet [client] ab, ohne dass eine verspätete Server-Antwort die NÄCHSTE
/// Anmeldung wieder löscht.
///
/// Hintergrund (SDK-Wettlauf, ROADMAP M1, gefunden 2026-10-07): matrix 6.2
/// (und Upstream `main`) prüft bei einer FEHLER-Antwort des Sync-Long-Polls
/// nicht, ob die Anfrage noch aktuell ist. Läuft beim Abmelden noch ein
/// Long-Poll (bis 30 s) mit dem alten Token, kommt dessen 401
/// (M_UNKNOWN_TOKEN) später zurück und das SDK ruft `clear()` – meldet sich
/// der Nutzer in der Zwischenzeit auf demselben Client neu an (Pyramid
/// benutzt nach dem Logout dasselbe Client-Objekt weiter), wird die frische
/// Session gelöscht: ein „Random-Logout“ direkt nach dem Login.
///
/// Ablauf hier:
/// 1. Sync-Schleife anhalten (nach der laufenden Anfrage keine neue).
/// 2. Laufenden Long-Poll WECKEN: geänderte Account-Daten lässt der Server
/// sofort antworten – statt bis zu 30 s zu warten.
/// 3. Die laufende Anfrage regulär (mit gültigem Token) zu Ende gehen lassen.
/// 4. Erst dann serverseitig abmelden – es ist keine Anfrage mehr unterwegs.
///
/// Schritt 2/3 sind „best effort“: scheitern sie (offline), wird nach
/// höchstens ~8 s trotzdem abgemeldet – nie schlechter als das bisherige
/// direkte `logout()`.
///
/// Live gemessen (test/live_logout_race_test.dart, 2026-10-07): die
/// verspätete 401 tritt real auf, kommt aber meist an, BEVOR ein Mensch sich
/// neu angemeldet hat – das Risiko ist klein, dieser Weg schließt es aus.
Future<void> logoutWithoutStraySync(
Client client, {
Duration settleTimeout = const Duration(seconds: 5),
}) async {
client.backgroundSync = false;
// Liefert den laufenden Sync (oder startet einen kurzen ohne Long-Poll).
final inFlight = client.oneShotSync(timeout: Duration.zero);
final userId = client.userID;
if (userId != null) {
try {
await client
.setAccountData(userId, logoutWakeAccountDataType, {
'ts': DateTime.now().millisecondsSinceEpoch,
})
.timeout(const Duration(seconds: 3));
} catch (e) {
debugPrint('[LOGOUT] Sync-Wecken fehlgeschlagen (egal): $e');
}
}
try {
await inFlight.timeout(settleTimeout);
} catch (e) {
debugPrint('[LOGOUT] laufender Sync nicht rechtzeitig fertig: $e');
}
await client.logout();
}
+91 -6
View File
@@ -2,6 +2,9 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/theme.dart';
// ── Generic SharedPreferences-backed bool notifier ────────────────────────── // ── Generic SharedPreferences-backed bool notifier ──────────────────────────
@@ -85,34 +88,116 @@ final privacyPresenceProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
final privacyReadReceiptsProvider = final privacyReadReceiptsProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
final privacyTypingProvider = final privacyTypingProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
// ── Voice preferences ────────────────────────────────────────────────────────
// Geschrieben von der Settings-Voice-Sektion, gelesen von den Call-Modulen
// (call_signaling, voice_channel). Die historischen voice_*-Keys bleiben
// unverändert, damit bestehende Nutzer-Einstellungen erhalten bleiben.
class VoicePrefs {
VoicePrefs._();
static const _kMic = 'voice_mic_device';
static const _kSpeaker = 'voice_speaker_device';
static const _kCamera = 'voice_camera_device';
static const _kOutputVolume = 'voice_output_volume';
static Future<double> loadOutputVolume() async {
final p = await SharedPreferences.getInstance();
return p.getDouble(_kOutputVolume) ?? 1.0;
}
static Future<void> saveOutputVolume(double v) async {
final p = await SharedPreferences.getInstance();
await p.setDouble(_kOutputVolume, v);
}
/// Gewählte Geräte-IDs; null = kein Pref gesetzt (System-Standard nutzen).
static Future<({String? mic, String? speaker, String? camera})>
loadDevices() async {
final p = await SharedPreferences.getInstance();
return (
mic: p.getString(_kMic),
speaker: p.getString(_kSpeaker),
camera: p.getString(_kCamera),
);
}
static Future<void> saveMic(String? deviceId) => _saveDevice(_kMic, deviceId);
static Future<void> saveSpeaker(String? deviceId) =>
_saveDevice(_kSpeaker, deviceId);
static Future<void> saveCamera(String? deviceId) =>
_saveDevice(_kCamera, deviceId);
static Future<void> _saveDevice(String key, String? deviceId) async {
final p = await SharedPreferences.getInstance();
if (deviceId == null || deviceId.isEmpty) {
await p.remove(key);
} else {
await p.setString(key, deviceId);
}
}
}
// ── Theme persistence helpers ──────────────────────────────────────────────── // ── Theme persistence helpers ────────────────────────────────────────────────
Future<void> saveThemePrefs({ Future<void> saveThemePrefs({
bool? isDark, ThemeVariant? variant,
int? accentIdx, int? accentIdx,
double? radius, double? radius,
double? motion, double? motion,
double? textScale,
bool resetTextScale = false,
MessageStyle? messageStyle,
}) async { }) async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
if (isDark != null) await p.setBool('theme_dark', isDark); if (variant != null) {
await p.setString('theme_variant', variant.name);
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
await p.setBool('theme_dark', variant.isDark);
}
if (accentIdx != null) await p.setInt('theme_accent', accentIdx); if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
if (radius != null) await p.setDouble('theme_radius', radius); if (radius != null) await p.setDouble('theme_radius', radius);
if (motion != null) await p.setDouble('theme_motion', motion); if (motion != null) await p.setDouble('theme_motion', motion);
if (resetTextScale) {
await p.remove('ui_text_scale');
} else if (textScale != null) {
await p.setDouble('ui_text_scale', textScale);
}
if (messageStyle != null) {
await p.setString('chat_message_style', messageStyle.name);
}
} }
Future<({bool isDark, int accentIdx, double radius, double motion})> Future<
loadThemePrefs() async { ({
ThemeVariant variant,
int accentIdx,
double radius,
double motion,
double? textScale,
MessageStyle messageStyle,
})> loadThemePrefs() async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
final storedVariant = p.getString('theme_variant');
final variant = storedVariant != null
? ThemeVariant.fromName(storedVariant)
: (p.getBool('theme_dark') ?? true)
? ThemeVariant.soft
: ThemeVariant.light;
return ( return (
isDark: p.getBool('theme_dark') ?? true, variant: variant,
accentIdx: p.getInt('theme_accent') ?? 0, accentIdx: p.getInt('theme_accent') ?? 0,
radius: p.getDouble('theme_radius') ?? 12.0, radius: p.getDouble('theme_radius') ?? 12.0,
motion: p.getDouble('theme_motion') ?? 1.0, motion: p.getDouble('theme_motion') ?? 1.0,
textScale: p.getDouble('ui_text_scale'),
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
); );
} }
+123 -21
View File
@@ -1,6 +1,103 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Pyramid design tokens — matching mockup CSS variables exactly /// Farbschema der App (Einstellungen → Erscheinungsbild → Farbmodus).
enum ThemeVariant {
/// „Weiches Dunkel“ – Dunkelgrau statt Fast-Schwarz, mehr Kontrast bei
/// Text. Standard seit 2026-10-07 (Bernds Entscheidung).
soft,
/// Das bisherige Fast-Schwarz (spart auf OLED-Displays etwas Akku).
black,
/// Hell.
light;
bool get isDark => this != ThemeVariant.light;
String get label => switch (this) {
ThemeVariant.soft => 'Dunkel',
ThemeVariant.black => 'Schwarz',
ThemeVariant.light => 'Hell',
};
static ThemeVariant fromName(String? name) =>
ThemeVariant.values.firstWhere((v) => v.name == name,
orElse: () => ThemeVariant.soft);
}
/// Die Flächen- und Textfarben eines [ThemeVariant].
class PyramidPalette {
const PyramidPalette({
required this.bg0,
required this.bg1,
required this.bg2,
required this.bg3,
required this.bgHover,
required this.bgActive,
required this.border,
required this.borderStrong,
required this.fg,
required this.fgMuted,
required this.fgDim,
});
final Color bg0, bg1, bg2, bg3, bgHover, bgActive, border, borderStrong;
final Color fg, fgMuted, fgDim;
/// Weiches Dunkel: alle Flächen ~10 % heller als „Schwarz“, Nebentexte
/// deutlich kontrastreicher (fgDim auf bg1 ≈ 5:1 statt ≈ 3,8:1).
static const soft = PyramidPalette(
bg0: Color(0xFF15161A),
bg1: Color(0xFF1B1C21),
bg2: Color(0xFF212228),
bg3: Color(0xFF282A31),
bgHover: Color(0xFF2F3139),
bgActive: Color(0xFF373945),
border: Color(0xFF2C2E36),
borderStrong: Color(0xFF3A3C46),
fg: Color(0xFFF2F2F5),
fgMuted: Color(0xFFB7B7C2),
fgDim: Color(0xFF8B8B99),
);
static const black = PyramidPalette(
bg0: PyramidColors.bg0,
bg1: PyramidColors.bg1,
bg2: PyramidColors.bg2,
bg3: PyramidColors.bg3,
bgHover: PyramidColors.bgHover,
bgActive: PyramidColors.bgActive,
border: PyramidColors.border,
borderStrong: PyramidColors.borderStrong,
fg: PyramidColors.fg,
fgMuted: PyramidColors.fgMuted,
fgDim: PyramidColors.fgDim,
);
static const light = PyramidPalette(
bg0: PyramidColors.bg0Light,
bg1: PyramidColors.bg1Light,
bg2: PyramidColors.bg2Light,
bg3: PyramidColors.bg3Light,
bgHover: PyramidColors.bgHoverLight,
bgActive: PyramidColors.bgActiveLight,
border: PyramidColors.borderLight,
borderStrong: PyramidColors.borderStrongLight,
fg: PyramidColors.fgLight,
fgMuted: PyramidColors.fgMutedLight,
fgDim: PyramidColors.fgDimLight,
);
static PyramidPalette of(ThemeVariant variant) => switch (variant) {
ThemeVariant.soft => soft,
ThemeVariant.black => black,
ThemeVariant.light => light,
};
}
// Pyramid design tokens — matching mockup CSS variables exactly.
// Die dunklen Werte hier sind die Variante „Schwarz“; Widgets sollten die
// Farben über PyramidTheme.of(context) holen (folgt dem gewählten Schema).
class PyramidColors { class PyramidColors {
const PyramidColors._(); const PyramidColors._();
@@ -49,7 +146,7 @@ class PyramidColors {
} }
class PyramidTheme extends InheritedWidget { class PyramidTheme extends InheritedWidget {
final bool isDark; final ThemeVariant variant;
final Color accent; final Color accent;
final double radius; final double radius;
final double density; final double density;
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
const PyramidTheme({ const PyramidTheme({
super.key, super.key,
required super.child, required super.child,
this.isDark = true, this.variant = ThemeVariant.soft,
this.accent = PyramidColors.accentAmber, this.accent = PyramidColors.accentAmber,
this.radius = 12, this.radius = 12,
this.density = 1, this.density = 1,
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!; return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
} }
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light; bool get isDark => variant.isDark;
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light; PyramidPalette get palette => PyramidPalette.of(variant);
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light; Color get bg0 => palette.bg0;
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight; Color get bg1 => palette.bg1;
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight; Color get bg2 => palette.bg2;
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight; Color get bg3 => palette.bg3;
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight; Color get bgHover => palette.bgHover;
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight; Color get bgActive => palette.bgActive;
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight; Color get border => palette.border;
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight; Color get borderStrong => palette.borderStrong;
Color get fg => palette.fg;
Color get fgMuted => palette.fgMuted;
Color get fgDim => palette.fgDim;
Color get danger => PyramidColors.danger; Color get danger => PyramidColors.danger;
Color get online => PyramidColors.online; Color get online => PyramidColors.online;
Color get away => PyramidColors.away; Color get away => PyramidColors.away;
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
@override @override
bool updateShouldNotify(PyramidTheme oldWidget) => bool updateShouldNotify(PyramidTheme oldWidget) =>
isDark != oldWidget.isDark || variant != oldWidget.variant ||
accent != oldWidget.accent || accent != oldWidget.accent ||
radius != oldWidget.radius || radius != oldWidget.radius ||
density != oldWidget.density || density != oldWidget.density ||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download. // Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color); TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
ThemeData buildPyramidThemeData(bool isDark) { ThemeData buildPyramidThemeData(ThemeVariant variant) {
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight; final palette = PyramidPalette.of(variant);
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light; final isDark = variant.isDark;
final fg = palette.fg;
final bg1 = palette.bg1;
final accent = PyramidColors.accentAmber; final accent = PyramidColors.accentAmber;
return ThemeData( return ThemeData(
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
), ),
textTheme: TextTheme( textTheme: TextTheme(
bodyMedium: _base(fg).copyWith(fontSize: 14), bodyMedium: _base(fg).copyWith(fontSize: 14),
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight), bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight), labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
), ),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: isDark ? PyramidColors.border : PyramidColors.borderLight, color: palette.border,
thickness: 1, thickness: 1,
space: 1, space: 1,
), ),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/call_signaling/voip_manager.dart';
/// Öffentliche Schnittstelle des Moduls `call_signaling` – natives
/// Matrix-1:1-VoIP (`m.call.*`-Events, direkte WebRTC-Peer-Connection,
/// kein SFU). Schnitt nach `docs/M2_MODULE_SCHNITT.md`.
///
/// Andere Module (UI, Settings, Shell) konsumieren AUSSCHLIESSLICH diese
/// Schnittstelle über [callSignalingProvider] – nie `voip_manager.dart`
/// direkt. Einzige Ausnahme ist der Kompositions-Punkt in
/// `matrix_client.dart`, der die Implementierung konstruiert.
///
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
/// Schönheit, heiliger Call-Pfad):
/// * ChangeNotifier statt Streams – die gesamte UI hängt heute an
/// `ref.watch(...)` mit notifyListeners; ein Stream-Umbau wäre eine
/// Verhaltensänderung ohne Not.
/// * [currentCall] bleibt als matrix-SDK-`CallSession` sichtbar (die UI
/// braucht state/room/answer/reject), ebenso die WebRTC-Renderer –
/// das Doc sieht Letzteres ausdrücklich vor.
abstract class CallSignalingService extends ChangeNotifier {
/// Der laufende 1:1-Call (null = keiner). UI liest state/room und
/// beantwortet eingehende Calls über [safeAction] + answer()/reject().
CallSession? get currentCall;
rtc.RTCVideoRenderer get localRenderer;
rtc.RTCVideoRenderer get remoteRenderer;
bool get isMicMuted;
bool get isCameraMuted;
bool get isScreensharing;
bool get isRemoteScreensharing;
bool get isDeafened;
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
String get currentQualityKey;
Future<void> startCall(String roomId, {bool video});
Future<void> hangup();
Future<void> toggleMic();
Future<void> toggleCamera();
Future<void> toggleDeafen();
/// Auf Windows öffnet ein [context] den Quellen-Picker fürs Screensharing.
Future<void> toggleScreenSharing([BuildContext? context]);
Future<void> changeQuality(String key);
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]);
/// Führt eine Call-Aktion (answer/reject/…) fehlertolerant aus und stößt
/// danach die interne Zustands-/Renderer-Aktualisierung an.
Future<void> safeAction(Future<dynamic> Function() action);
}
/// Einziger Zugriffsweg der UI auf das 1:1-VoIP-Modul. Die Instanz wird beim
/// Client-Aufbau in `matrix_client.dart` erzeugt (existiert also, sobald die
/// eingeloggte Shell steht).
final callSignalingProvider =
ChangeNotifierProvider<CallSignalingService>((ref) {
return PyramidVoipManager.instance;
});
@@ -3,20 +3,28 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:pyramid/core/ice_servers.dart'; import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/widgets/screen_share_picker.dart'; import 'package:pyramid/widgets/screen_share_picker.dart';
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate { /// Implementierung von [CallSignalingService] (Modul `call_signaling`).
/// Wird ausschließlich vom Kompositions-Punkt (`matrix_client.dart`)
/// konstruiert; alle anderen gehen über `callSignalingProvider`.
class PyramidVoipManager extends CallSignalingService
implements WebRTCDelegate {
static PyramidVoipManager? _instance; static PyramidVoipManager? _instance;
static PyramidVoipManager get instance => _instance!; static PyramidVoipManager get instance => _instance!;
final Client client; final Client client;
late final VoIP voip; late final VoIP voip;
@override
CallSession? currentCall; CallSession? currentCall;
@override
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer(); final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
@override
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer(); final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
bool _renderersInitialized = false; bool _renderersInitialized = false;
@@ -29,11 +37,17 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
String? _selectedSourceId; String? _selectedSourceId;
String _currentQualityKey = 'hd'; String _currentQualityKey = 'hd';
@override
bool get isMicMuted => _isMicMuted; bool get isMicMuted => _isMicMuted;
@override
bool get isCameraMuted => _isCameraMuted; bool get isCameraMuted => _isCameraMuted;
@override
bool get isScreensharing => _isScreensharing; bool get isScreensharing => _isScreensharing;
@override
bool get isRemoteScreensharing => _isRemoteScreensharing; bool get isRemoteScreensharing => _isRemoteScreensharing;
@override
bool get isDeafened => _isDeafened; bool get isDeafened => _isDeafened;
@override
String get currentQualityKey => _currentQualityKey; String get currentQualityKey => _currentQualityKey;
PyramidVoipManager(this.client) { PyramidVoipManager(this.client) {
@@ -253,12 +267,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den /// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen. /// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
@override
Future<void> applyOutputVolume([double? value]) async { Future<void> applyOutputVolume([double? value]) async {
var v = value; var v = value;
if (v == null) { if (v == null) {
try { try {
final prefs = await SharedPreferences.getInstance(); v = await VoicePrefs.loadOutputVolume();
v = prefs.getDouble('voice_output_volume') ?? 1.0;
} catch (_) { } catch (_) {
v = 1.0; v = 1.0;
} }
@@ -295,6 +309,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
@override @override
Future<void> registerListeners(CallSession session) async {} Future<void> registerListeners(CallSession session) async {}
@override
Future<void> safeAction(Future<dynamic> Function() action) async { Future<void> safeAction(Future<dynamic> Function() action) async {
try { try {
await action(); await action();
@@ -306,6 +321,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
_updateRenderers(); _updateRenderers();
} }
@override
Future<void> toggleMic() async { Future<void> toggleMic() async {
if (currentCall == null) return; if (currentCall == null) return;
_isMicMuted = !_isMicMuted; _isMicMuted = !_isMicMuted;
@@ -318,6 +334,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
} }
} }
@override
Future<void> toggleCamera() async { Future<void> toggleCamera() async {
if (currentCall == null) return; if (currentCall == null) return;
_isCameraMuted = !_isCameraMuted; _isCameraMuted = !_isCameraMuted;
@@ -337,6 +354,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
await _updateRenderers(); await _updateRenderers();
} }
@override
Future<void> toggleScreenSharing([BuildContext? context]) async { Future<void> toggleScreenSharing([BuildContext? context]) async {
if (currentCall == null) return; if (currentCall == null) return;
final call = currentCall!; final call = currentCall!;
@@ -359,11 +377,13 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
}); });
} }
@override
Future<void> toggleDeafen() async { Future<void> toggleDeafen() async {
_isDeafened = !_isDeafened; _isDeafened = !_isDeafened;
notifyListeners(); notifyListeners();
} }
@override
Future<void> hangup() async { Future<void> hangup() async {
if (currentCall == null) return; if (currentCall == null) return;
await safeAction(() async { await safeAction(() async {
@@ -372,6 +392,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
}); });
} }
@override
Future<void> startCall(String roomId, {bool video = false}) async { Future<void> startCall(String roomId, {bool video = false}) async {
final room = client.getRoomById(roomId); final room = client.getRoomById(roomId);
if (room == null) return; if (room == null) return;
@@ -394,6 +415,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
notifyListeners(); notifyListeners();
} }
@override
Future<void> changeQuality(String key) async { Future<void> changeQuality(String key) async {
_currentQualityKey = key; _currentQualityKey = key;
if (currentCall == null) { if (currentCall == null) {
@@ -444,10 +466,10 @@ class MediaDevicesWrapper extends MediaDevices {
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden — // In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen). // nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
try { try {
final prefs = await SharedPreferences.getInstance(); final devicePrefs = await VoicePrefs.loadDevices();
final mic = prefs.getString('voice_mic_device'); final mic = devicePrefs.mic;
final cam = prefs.getString('voice_camera_device'); final cam = devicePrefs.camera;
final speaker = prefs.getString('voice_speaker_device'); final speaker = devicePrefs.speaker;
if (mic != null && if (mic != null &&
improvedConstraints['audio'] != null && improvedConstraints['audio'] != null &&
improvedConstraints['audio'] != false) { improvedConstraints['audio'] != false) {
@@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/livekit_call_manager.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/core/voip_manager.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/widgets/screen_share_picker.dart'; import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
class MiniCallWidget extends ConsumerStatefulWidget { class MiniCallWidget extends ConsumerStatefulWidget {
final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager final dynamic call; // CallSignalingService (1:1) oder VoiceChannelService
const MiniCallWidget({super.key, required this.call}); const MiniCallWidget({super.key, required this.call});
@override @override
@@ -45,7 +45,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final isVoip = widget.call is PyramidVoipManager; final isVoip = widget.call is CallSignalingService;
final call = widget.call; final call = widget.call;
final String displayName = isVoip final String displayName = isVoip
@@ -95,7 +95,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
isStreaming ? call.localRenderer : call.remoteRenderer, isStreaming ? call.localRenderer : call.remoteRenderer,
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
) )
: _LiveKitPreview(manager: call as LiveKitCallManager), : _LiveKitPreview(manager: call as VoiceChannelService),
), ),
// Top bar of preview // Top bar of preview
@@ -259,9 +259,9 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
pt: pt, pt: pt,
onTap: () async { onTap: () async {
if (isVoip) { if (isVoip) {
(call as PyramidVoipManager).toggleScreenSharing(context); (call as CallSignalingService).toggleScreenSharing(context);
} else { } else {
final lk = call as LiveKitCallManager; final lk = call as VoiceChannelService;
if (lk.isScreenSharing) { if (lk.isScreenSharing) {
await lk.stopScreenShare(); await lk.stopScreenShare();
} else { } else {
@@ -351,7 +351,7 @@ class _ControlBtnState extends State<_ControlBtn> {
} }
class _LiveKitPreview extends StatelessWidget { class _LiveKitPreview extends StatelessWidget {
final LiveKitCallManager manager; final VoiceChannelService manager;
const _LiveKitPreview({required this.manager}); const _LiveKitPreview({required this.manager});
@override @override
@@ -0,0 +1,173 @@
part of '../voice_channel.dart';
class _BigAvatar extends StatelessWidget {
final _ParticipantData data;
final PyramidTheme pt;
const _BigAvatar({required this.data, required this.pt});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 100, height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
),
);
}
}
class _SmallAvatar extends StatelessWidget {
final _ParticipantData data;
final PyramidTheme pt;
const _SmallAvatar({required this.data, required this.pt});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 44, height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
),
);
}
}
class _CallControls extends StatelessWidget {
final bool isMuted;
final bool isDeafened;
final bool isCameraOff;
final bool isScreenSharing;
final VoidCallback onToggleMic;
final VoidCallback onToggleDeafen;
final VoidCallback onToggleCam;
final VoidCallback onToggleScreen;
final VoidCallback onHangUp;
final PyramidTheme pt;
const _CallControls({
required this.isMuted,
required this.isDeafened,
required this.isCameraOff,
required this.isScreenSharing,
required this.onToggleMic,
required this.onToggleDeafen,
required this.onToggleCam,
required this.onToggleScreen,
required this.onHangUp,
required this.pt,
});
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
decoration: BoxDecoration(color: pt.bg1, border: Border(top: BorderSide(color: pt.border))),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: Icons.screen_share_rounded, active: isScreenSharing, pt: pt, onTap: onToggleScreen),
const SizedBox(width: 24),
Container(width: 1, height: 32, color: pt.border),
const SizedBox(width: 24),
_VoiceCtrlBtn(icon: Icons.call_end_rounded, isDanger: true, pt: pt, onTap: onHangUp),
],
),
),
);
}
}
class _NoCall extends StatelessWidget {
final PyramidTheme pt;
final WidgetRef ref;
const _NoCall({required this.pt, required this.ref});
@override
Widget build(BuildContext context) {
return Container(
color: pt.bg1,
child: Column(
children: [
Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
),
const SizedBox(width: 8),
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
],
),
),
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
const SizedBox(height: 12),
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
],
),
),
),
],
),
);
}
}
class _FullscreenVideoPage extends StatelessWidget {
final _ParticipantData data;
const _FullscreenVideoPage({required this.data});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(children: [
Positioned.fill(
child: data.videoWidget != null
? _MirrorWrapper(
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
isScreenShare: data.isScreenShare,
child: data.videoWidget!,
)
: _BigAvatar(data: data, pt: PyramidTheme.of(context)),
),
Positioned(
top: 0, right: 0,
child: SafeArea(
child: IconButton(
icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white, size: 28),
onPressed: () => Navigator.of(context).pop(),
),
),
),
]),
);
}
}
+214
View File
@@ -0,0 +1,214 @@
part of '../voice_channel.dart';
class _UnifiedCallView extends StatefulWidget {
final List<_ParticipantData> participants;
final bool isLiveKit;
final String displayName;
final bool isConnecting;
final VoidCallback onHangUp;
final VoidCallback onMinimize;
final VoidCallback onToggleMic;
final VoidCallback onToggleDeafen;
final VoidCallback onToggleCam;
final VoidCallback onToggleScreen;
final bool isMuted;
final bool isDeafened;
final bool isCameraOff;
final bool isScreenSharing;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _UnifiedCallView({
required this.participants,
required this.isLiveKit,
required this.displayName,
required this.isConnecting,
required this.onHangUp,
required this.onMinimize,
required this.onToggleMic,
required this.onToggleDeafen,
required this.onToggleCam,
required this.onToggleScreen,
required this.isMuted,
required this.isDeafened,
required this.isCameraOff,
required this.isScreenSharing,
this.onQualityMenu,
required this.pt,
});
@override
State<_UnifiedCallView> createState() => _UnifiedCallViewState();
}
class _UnifiedCallViewState extends State<_UnifiedCallView> {
String? _heroId;
bool _isGridView = false;
bool _stripCollapsed = false;
@override
void initState() {
super.initState();
_autoSelectHero();
}
@override
void didUpdateWidget(_UnifiedCallView oldWidget) {
super.didUpdateWidget(oldWidget);
_autoSelectHero();
}
void _autoSelectHero() {
if (_isGridView) return;
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
_heroId = screenshare.id;
} else if (_heroId == null && widget.participants.isNotEmpty) {
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
if (firstVideo != null) _heroId = firstVideo.id;
}
}
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
int rank(_ParticipantData p) {
if (p.isScreenShare) return 0;
if (p.hasVideo) return 1;
if (p.isSpeaking) return 2;
return 3;
}
return [...all]..sort((a, b) => rank(a).compareTo(rank(b)));
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final all = _sorted(widget.participants);
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
if (widget.isConnecting) {
return Scaffold(
backgroundColor: pt.bg1,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const PyramidLoader(size: 80),
const SizedBox(height: 20),
Text('Verbinde…',
style: TextStyle(color: pt.fgMuted, fontSize: 14)),
const SizedBox(height: 16),
TextButton.icon(
style: TextButton.styleFrom(foregroundColor: pt.danger),
onPressed: widget.onHangUp,
icon: const Icon(Icons.call_end_rounded, size: 16),
label: const Text('Abbrechen'),
),
],
),
),
);
}
return Scaffold(
backgroundColor: pt.bg1,
body: Column(
children: [
_CallHeader(
displayName: widget.displayName,
isGridView: _isGridView,
onToggleView: () => setState(() => _isGridView = !_isGridView),
onMinimize: widget.onMinimize,
pt: pt,
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isGridView || hero == null
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
: _HeroLayout(
hero: hero,
others: all.where((p) => p.id != hero.id).toList(),
stripCollapsed: _stripCollapsed,
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
onSelectOther: (p) => setState(() => _heroId = p.id),
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
),
),
_CallControls(
isMuted: widget.isMuted,
isDeafened: widget.isDeafened,
isCameraOff: widget.isCameraOff,
isScreenSharing: widget.isScreenSharing,
onToggleMic: widget.onToggleMic,
onToggleDeafen: widget.onToggleDeafen,
onToggleCam: widget.onToggleCam,
onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp,
pt: pt,
),
],
),
);
}
}
class _CallHeader extends StatelessWidget {
final String displayName;
final bool isGridView;
final VoidCallback onToggleView;
final VoidCallback onMinimize;
final PyramidTheme pt;
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
_LiveBadge(pt: pt),
const Spacer(),
PyrIconBtn(
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
onPressed: onToggleView,
),
const SizedBox(width: 4),
PyrIconBtn(
icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg),
tooltip: 'Minimieren',
onPressed: onMinimize,
),
],
),
);
}
}
class _LiveBadge extends StatelessWidget {
final PyramidTheme pt;
const _LiveBadge({required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(999), color: pt.online.withAlpha(30)),
child: Row(children: [
Container(width: 6, height: 6, decoration: BoxDecoration(color: pt.online, shape: BoxShape.circle)),
const SizedBox(width: 4),
Text('Live', style: TextStyle(color: pt.online, fontSize: 11, fontWeight: FontWeight.w600)),
]),
);
}
}
@@ -0,0 +1,424 @@
part of '../voice_channel.dart';
class _ParticipantGrid extends StatelessWidget {
final List<_ParticipantData> participants;
final PyramidTheme pt;
final Function(_ParticipantData) onSelect;
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, box) {
final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2);
const spacing = 12.0;
final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols;
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: spacing,
runSpacing: spacing,
children: participants.map((p) => SizedBox(
width: tileWidth,
height: tileWidth / p.aspectRatio,
child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)),
)).toList(),
),
);
});
}
}
class _HeroLayout extends StatelessWidget {
final _ParticipantData hero;
final List<_ParticipantData> others;
final bool stripCollapsed;
final VoidCallback onToggleStrip;
final Function(bool) onSetStripCollapsed;
final Function(_ParticipantData) onSelectOther;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _HeroLayout({
required this.hero,
required this.others,
required this.stripCollapsed,
required this.onToggleStrip,
required this.onSetStripCollapsed,
required this.onSelectOther,
this.onQualityMenu,
required this.pt,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, box) {
final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600;
final heroView = _HeroSection(
data: hero,
onQualityMenu: onQualityMenu,
pt: pt,
);
final strip = _ParticipantStrip(
participants: others,
isVertical: isLandscape,
onSelect: onSelectOther,
pt: pt,
);
if (isLandscape) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: GestureDetector(
onHorizontalDragEnd: (details) {
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
},
child: heroView,
),
),
),
if (!stripCollapsed)
SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)),
_CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
],
);
} else {
return Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: GestureDetector(
onVerticalDragEnd: (details) {
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
},
child: heroView,
),
),
),
_CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
if (!stripCollapsed)
SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)),
],
);
}
});
}
}
class _HeroSection extends StatefulWidget {
final _ParticipantData data;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _HeroSection({required this.data, this.onQualityMenu, required this.pt});
@override
State<_HeroSection> createState() => _HeroSectionState();
}
class _HeroSectionState extends State<_HeroSection> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => setState(() => _hovered = !_hovered),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rLg),
child: Container(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: widget.data.videoWidget != null
? _MirrorWrapper(
isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant),
isScreenShare: widget.data.isScreenShare,
child: widget.data.videoWidget!,
)
: _BigAvatar(data: widget.data, pt: pt),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
top: _hovered ? 0 : -60, left: 0, right: 0,
child: _HeroTopBar(data: widget.data, pt: pt),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: _hovered ? 0 : -60, left: 0, right: 0,
child: _HeroBottomBar(
data: widget.data,
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
),
],
),
),
),
),
),
);
}
}
class _HeroTopBar extends StatelessWidget {
final _ParticipantData data;
final PyramidTheme pt;
const _HeroTopBar({required this.data, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]),
),
child: Row(
children: [
if (data.isScreenShare) _LivePill(pt: pt),
const SizedBox(width: 8),
Text(
data.isScreenShare ? "${data.name} präsentiert" : data.name,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14),
),
],
),
);
}
}
class _HeroBottomBar extends StatelessWidget {
final _ParticipantData data;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]),
),
child: Row(
children: [
const Spacer(),
if (onQualityMenu != null)
_HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)),
const SizedBox(width: 8),
_HeroActionBtn(
icon: Icons.fullscreen_rounded,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => _FullscreenVideoPage(data: data),
)),
),
],
),
);
}
}
class _HeroActionBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
final Function(TapDownDetails)? onTapDown;
const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onTapDown: onTapDown,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: Colors.white, size: 20),
),
);
}
}
class _LivePill extends StatelessWidget {
final PyramidTheme pt;
const _LivePill({required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
const SizedBox(width: 4),
const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)),
]),
);
}
}
class _ParticipantStrip extends StatelessWidget {
final List<_ParticipantData> participants;
final bool isVertical;
final Function(_ParticipantData) onSelect;
final PyramidTheme pt;
const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt});
@override
Widget build(BuildContext context) {
return ListView.separated(
scrollDirection: isVertical ? Axis.vertical : Axis.horizontal,
itemCount: participants.length,
separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12),
itemBuilder: (context, i) => SizedBox(
width: isVertical ? double.infinity : 101 * participants[i].aspectRatio,
height: 101,
child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])),
),
);
}
}
class _ParticipantTile extends StatefulWidget {
final _ParticipantData data;
final PyramidTheme pt;
final VoidCallback onTap;
const _ParticipantTile({required this.data, required this.pt, required this.onTap});
@override
State<_ParticipantTile> createState() => _ParticipantTileState();
}
class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin {
late AnimationController _speakingCtrl;
@override
void initState() {
super.initState();
_speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true);
}
@override
void dispose() {
_speakingCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final data = widget.data;
return GestureDetector(
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _speakingCtrl,
builder: (context, _) {
final isSpeaking = data.isSpeaking;
final pulse = isSpeaking ? _speakingCtrl.value : 0.0;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(
color: isSpeaking
? pt.accent.withAlpha((150 + pulse * 105).toInt())
: pt.border,
width: isSpeaking ? 2 : 1,
),
boxShadow: isSpeaking
? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase - 1),
child: Stack(
children: [
Positioned.fill(
child: data.videoWidget != null
? _MirrorWrapper(
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
isScreenShare: data.isScreenShare,
child: data.videoWidget!,
)
: _SmallAvatar(data: data, pt: pt),
),
Positioned(
left: 6, bottom: 6, right: 6,
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)],
Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)),
],
),
),
],
),
),
if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)),
],
),
),
);
},
),
);
}
}
class _CollapseHandle extends StatelessWidget {
final bool isVertical;
final bool collapsed;
final VoidCallback onToggle;
final PyramidTheme pt;
const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onToggle,
child: Container(
width: isVertical ? 24 : double.infinity,
height: isVertical ? double.infinity : 24,
color: Colors.transparent,
child: Center(
child: Icon(
isVertical
? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded)
: (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded),
color: pt.fgDim, size: 20,
),
),
),
);
}
}
+133
View File
@@ -0,0 +1,133 @@
part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) {
await call.stopScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id);
}
}
void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Offset pos) {
final pt = PyramidTheme.of(context);
showMenu(
context: context,
position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 350, pos.dx, pos.dy),
color: pt.bg2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
items: <PopupMenuEntry>[
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [
('sd', 'SD', '360p'),
('hd', 'HD', '720p'),
('hd60', 'HD 60fps', '720p·60'),
('fhd', 'Full HD', '1080p'),
('fhd60', 'Full HD 60fps', '1080p·60'),
('4k', '4K', '2160p'),
])
PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)),
const PopupMenuDivider(),
const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [('auto', 'Auto', ''), ('high', 'Hoch', 'FHD'), ('medium', 'Mittel', 'HD'), ('low', 'Niedrig', 'SD')])
PopupMenuItem(onTap: () => call.setSubscribeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.subscribeQualityKey == q.$1, pt: pt)),
],
);
}
void _showVoipQualityMenu(BuildContext context, CallSignalingService voip, Offset pos) {
final pt = PyramidTheme.of(context);
showMenu(
context: context,
position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 200, pos.dx, pos.dy),
color: pt.bg2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
items: <PopupMenuEntry>[
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')])
PopupMenuItem(onTap: () => voip.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: voip.currentQualityKey == q.$1, pt: pt)),
],
);
}
class _QualityItem extends StatelessWidget {
final String label;
final String? hint;
final bool active;
final PyramidTheme pt;
const _QualityItem({required this.label, required this.active, required this.pt, this.hint});
@override
Widget build(BuildContext context) {
return Row(children: [
if (active) Icon(Icons.check_rounded, size: 16, color: pt.accent) else const SizedBox(width: 16),
const SizedBox(width: 8),
Text(label, style: TextStyle(color: active ? pt.accent : pt.fg, fontSize: 13, fontWeight: active ? FontWeight.bold : FontWeight.normal)),
if (hint != null && hint!.isNotEmpty) ...[const Spacer(), Text(hint!, style: TextStyle(color: pt.fgMuted, fontSize: 11))],
]);
}
}
class _VoiceCtrlBtn extends StatefulWidget {
final IconData icon;
final PyramidTheme pt;
final bool active;
final bool danger;
final bool isDanger;
final VoidCallback? onTap;
const _VoiceCtrlBtn({required this.icon, required this.pt, this.onTap, this.active = false, this.danger = false, this.isDanger = false});
@override
State<_VoiceCtrlBtn> createState() => _VoiceCtrlBtnState();
}
class _VoiceCtrlBtnState extends State<_VoiceCtrlBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
Color bg;
Color iconColor;
if (widget.isDanger) { bg = pt.danger; iconColor = Colors.white; }
else if (widget.danger) { bg = pt.danger.withAlpha(30); iconColor = pt.danger; }
else if (widget.active) { bg = pt.accent.withAlpha(30); iconColor = pt.accent; }
else { bg = _hovered ? pt.bgHover : pt.bg3; iconColor = pt.fg; }
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 52, height: 52,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(26)),
child: Center(child: Icon(widget.icon, size: 20, color: iconColor)),
),
),
);
}
}
class _MirrorWrapper extends StatelessWidget {
final Widget child;
final bool isLocal;
final bool isScreenShare;
const _MirrorWrapper({required this.child, required this.isLocal, required this.isScreenShare});
@override
Widget build(BuildContext context) {
if (isLocal && !isScreenShare) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(3.14159),
child: child,
);
}
return child;
}
}
@@ -0,0 +1,178 @@
part of '../voice_channel.dart';
// Unified participant data to handle both LiveKit and VoIP
class _ParticipantData {
final String id;
final String name;
final Widget? videoWidget;
final bool isSpeaking;
final bool isMuted;
final bool hasVideo;
final bool isScreenShare;
final Color avatarColor;
final dynamic originalParticipant;
final double aspectRatio;
const _ParticipantData({
required this.id,
required this.name,
this.videoWidget,
this.isSpeaking = false,
this.isMuted = false,
this.hasVideo = false,
this.isScreenShare = false,
this.avatarColor = Colors.grey,
this.originalParticipant,
this.aspectRatio = 16.0 / 9.0,
});
}
class VoiceChannelView extends ConsumerWidget {
const VoiceChannelView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
return _UnifiedCallView(
participants: _getVoipParticipants(voip),
isLiveKit: false,
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
isConnecting: false,
onHangUp: () {
voip.hangup();
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
onMinimize: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.chat,
onToggleMic: voip.toggleMic,
onToggleDeafen: voip.toggleDeafen,
onToggleCam: voip.toggleCamera,
onToggleScreen: () => voip.toggleScreenSharing(context),
isMuted: voip.isMicMuted,
isDeafened: voip.isDeafened,
isCameraOff: voip.isCameraMuted,
isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt,
);
}
if (!call.isActive) {
return _NoCall(pt: pt, ref: ref);
}
return _UnifiedCallView(
participants: _getLiveKitParticipants(call),
isLiveKit: true,
displayName: call.displayName,
isConnecting: call.isConnecting,
onHangUp: () {
call.hangUp();
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
onMinimize: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.chat,
onToggleMic: call.toggleMute,
onToggleDeafen: call.toggleDeafen,
onToggleCam: call.toggleCamera,
onToggleScreen: () => _handleLiveKitScreenShare(context, call),
isMuted: call.isMuted,
isDeafened: call.isDeafened,
isCameraOff: call.isCameraOff,
isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt,
);
}
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
final room = call.room;
if (room == null) return [];
final all = <Participant>[
if (room.localParticipant != null) room.localParticipant!,
...room.remoteParticipants.values,
];
return all.mapIndexed((i, p) {
final screenPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.screenShareVideo);
final camPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.camera);
final videoTrack = (screenPub?.track ?? camPub?.track) as VideoTrack?;
final isScreen = screenPub != null;
double ar = 16.0 / 9.0;
if (videoTrack != null) {
try {
final dim = (videoTrack as dynamic).currentDimensions ?? (videoTrack as dynamic).dimensions;
if (dim != null) {
final w = (dim.width as num?)?.toDouble() ?? 0;
final h = (dim.height as num?)?.toDouble() ?? 0;
if (w > 0 && h > 0) ar = w / h;
}
} catch (_) {}
}
return _ParticipantData(
id: p.identity,
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
isSpeaking: p.isSpeaking,
isMuted: !p.isMicrophoneEnabled(),
hasVideo: videoTrack != null,
isScreenShare: isScreen,
avatarColor: _avatarColors[i % _avatarColors.length],
originalParticipant: p,
aspectRatio: ar,
);
}).toList();
}
List<_ParticipantData> _getVoipParticipants(CallSignalingService voip) {
final participants = <_ParticipantData>[];
double ar(rtc.RTCVideoRenderer r) {
final w = r.videoWidth.toDouble();
final h = r.videoHeight.toDouble();
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
}
// Remote
final isRemoteScreen = voip.isRemoteScreensharing;
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false,
hasVideo: hasRemoteVideo,
isScreenShare: isRemoteScreen,
avatarColor: _avatarColors[1],
aspectRatio: hasRemoteVideo ? ar(voip.remoteRenderer) : 16.0 / 9.0,
));
// Local
final isLocalScreen = voip.isScreensharing;
final hasLocalVideo = voip.localRenderer.srcObject != null && voip.localRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'local',
name: 'Du',
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted,
hasVideo: hasLocalVideo,
isScreenShare: isLocalScreen,
avatarColor: _avatarColors[0],
aspectRatio: hasLocalVideo ? ar(voip.localRenderer) : 16.0 / 9.0,
));
return participants;
}
static const _avatarColors = [
Color(0xFFFF6B9D), Color(0xFF06B6D4), Color(0xFFA855F7),
Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444),
Color(0xFF3B82F6), Color(0xFF84CC16),
];
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
part 'view/voice_channel_core.dart';
part 'view/call_view.dart';
part 'view/participant_layout.dart';
part 'view/avatars_controls.dart';
part 'view/quality_menu.dart';
+100 -25
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -7,7 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart'; import 'package:pyramid/features/chat/gif_sticker_picker.dart';
@@ -32,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
} }
class _ChatComposerState extends ConsumerState<ChatComposer> { class _ChatComposerState extends ConsumerState<ChatComposer> {
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
static final _drafts = <String, String>{};
final _ctrl = TextEditingController(); final _ctrl = TextEditingController();
late final FocusNode _focus; late final FocusNode _focus;
bool _canSend = false; bool _canSend = false;
@@ -44,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
void initState() { void initState() {
super.initState(); super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent); _focus = FocusNode(onKeyEvent: _handleKeyEvent);
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
final draft = _drafts.remove(widget.room.id);
if (draft != null) {
_ctrl.text = draft;
_canSend = draft.trim().isNotEmpty;
}
_ctrl.addListener(_onTextChanged); _ctrl.addListener(_onTextChanged);
} }
@@ -118,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
@override @override
void dispose() { void dispose() {
final text = _ctrl.text;
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
_typingTimer?.cancel(); _typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {}); if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove(); _gifOverlay?.remove();
@@ -300,22 +315,40 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400; final isSmall = size.width < 400;
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
final mobile = isMobileLayout(size);
final roomName = widget.room.getLocalizedDisplayname(); final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat final placeholder = widget.room.isDirectChat
? 'Message…' ? 'Nachricht…'
: 'Message #$roomName'; : 'Nachricht an #$roomName';
return SafeArea( return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.replyTo != null) // Antwort-Leiste federt auf und klappt beim Schließen weich zu.
_ReplyBar( AnimatedSwitcher(
event: widget.replyTo!, duration: motionDuration(context, 300),
onClear: widget.onClearReply ?? () {}, reverseDuration: motionDuration(context, 160),
pt: pt, switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, anim) => SizeTransition(
sizeFactor: anim,
alignment: Alignment.bottomCenter,
child: FadeTransition(opacity: anim, child: child),
), ),
child: widget.replyTo != null
? KeyedSubtree(
key: ValueKey('reply:${widget.replyTo!.eventId}'),
child: _ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
)
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
),
Padding( Padding(
// Bottom inset matches the profile block in the rooms list (8px) so // Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it. // the composer sits flush with it.
@@ -327,8 +360,10 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
// block in the rooms list (~89px on the user's display). Grows for // block in the rooms list (~89px on the user's display). Grows for
// multiline. // multiline.
constraints: const BoxConstraints(minHeight: 53), constraints: const BoxConstraints(minHeight: 53),
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
// schwebenden Feld durch und scheint unscharf durch.
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase + 4), borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all( border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border, color: _focus.hasFocus ? pt.accent : pt.border,
@@ -337,7 +372,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)] ? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null, : null,
), ),
child: Focus( child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase + 3),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: ColoredBox(
color: pt.bg2.withValues(alpha: 0.78),
child: Focus(
onFocusChange: (_) => setState(() {}), onFocusChange: (_) => setState(() {}),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -347,7 +388,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.add_rounded, icon: Icons.add_rounded,
tooltip: 'Datei anhängen', tooltip: 'Datei anhängen',
pt: pt, pt: pt,
size: size.width < 600 ? 24 : 30, size: mobile ? 42 : 30,
onTap: _pickAttachment, onTap: _pickAttachment,
), ),
// Text input // Text input
@@ -397,14 +438,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.gif_box_outlined, icon: Icons.gif_box_outlined,
tooltip: 'GIF', tooltip: 'GIF',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleGifPicker, onTap: _toggleGifPicker,
), ),
_ComposerIconBtn( _ComposerIconBtn(
icon: Icons.tag_faces_rounded, icon: Icons.tag_faces_rounded,
tooltip: 'Emoji', tooltip: 'Emoji',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleEmojiPicker, onTap: _toggleEmojiPicker,
), ),
if (size.width > 600) if (size.width > 600)
@@ -424,10 +465,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
canSend: _canSend, canSend: _canSend,
pt: pt, pt: pt,
onTap: _send, onTap: _send,
size: mobile ? 42 : 36,
), ),
), ),
], ],
), ),
),
),
),
), ),
), ),
), ),
@@ -461,9 +506,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final platform = Theme.of(context).platform; final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
final touchSize = isMobile ? 44.0 : widget.size; final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion( return MouseRegion(
@@ -475,7 +518,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
child: Tooltip( child: Tooltip(
message: widget.tooltip, message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500), waitDuration: const Duration(milliseconds: 500),
child: SizedBox( child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox(
width: touchSize, width: touchSize,
height: touchSize, height: touchSize,
child: Center( child: Center(
@@ -497,6 +542,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
), ),
), ),
), ),
),
), ),
), ),
); );
@@ -507,8 +553,14 @@ class _SendButton extends StatefulWidget {
final bool canSend; final bool canSend;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final double size;
const _SendButton({required this.canSend, required this.pt, required this.onTap}); const _SendButton({
required this.canSend,
required this.pt,
required this.onTap,
this.size = 36,
});
@override @override
State<_SendButton> createState() => _SendButtonState(); State<_SendButton> createState() => _SendButtonState();
@@ -528,11 +580,14 @@ class _SendButtonState extends State<_SendButton> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: active ? widget.onTap : null, onTap: active ? widget.onTap : null,
child: AnimatedContainer( child: SpringPress(
enabled: active,
pressedScale: 0.82,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
width: 36, width: widget.size,
height: 36, height: widget.size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3, color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm), borderRadius: BorderRadius.circular(pt.rSm),
@@ -547,13 +602,22 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(), : Matrix4.identity(),
transformAlignment: Alignment.center, transformAlignment: Alignment.center,
child: Center( child: Center(
child: Icon( // Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
Icons.send_rounded, child: AnimatedScale(
size: 16, scale: active ? 1 : 0.8,
color: active ? pt.accentFg : pt.fgDim, duration: active
? motionDuration(context, 520)
: const Duration(milliseconds: 140),
curve: active ? Curves.elasticOut : Curves.easeOut,
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
), ),
), ),
), ),
),
), ),
); );
} }
@@ -599,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
], ],
), ),
), ),
// Handy: größeres X mit fingergerechter Trefferfläche.
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear, onTap: onClear,
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim), child: SpringPress(
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
color: pt.fgDim,
),
),
),
), ),
], ],
), ),
+6 -1
View File
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
@@ -19,10 +20,14 @@ import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_group.dart'; import 'package:pyramid/features/chat/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart'; import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/measure_size.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/core/notification_service.dart';
import 'package:pyramid/core/voip_manager.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
part 'view/chat_view_core.dart'; part 'view/chat_view_core.dart';
+1 -1
View File
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pdfx/pdfx.dart'; import 'package:pdfx/pdfx.dart';
+1 -1
View File
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(title, child: Text(title,
style: TextStyle(color: PyramidColors.fgDim, style: TextStyle(color: PyramidTheme.of(context).fgDim,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.6)), letterSpacing: 0.6)),
+32 -2
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
// //
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide) // Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
double inlineMediaMaxHeight(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final isMobile = screen.width < 900 || screen.height < 500;
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
}
class PyramidVideoPlayer extends StatefulWidget { class PyramidVideoPlayer extends StatefulWidget {
final Uint8List bytes; final Uint8List bytes;
final String filename; final String filename;
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
late final Player _player; late final Player _player;
late final VideoController _controller; late final VideoController _controller;
bool _loading = true; bool _loading = true;
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
double? _aspect;
final _subs = <StreamSubscription<int?>>[];
double _speed = 1.0; double _speed = 1.0;
String _quality = 'Auto'; String _quality = 'Auto';
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
super.initState(); super.initState();
_player = Player(); _player = Player();
_controller = VideoController(_player); _controller = VideoController(_player);
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
_init(); _init();
} }
void _updateAspect() {
final w = _player.state.width;
final h = _player.state.height;
if (w == null || h == null || w <= 0 || h <= 0) return;
final aspect = w / h;
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
}
Future<void> _init() async { Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
@override @override
void dispose() { void dispose() {
for (final s in _subs) {
s.cancel();
}
_player.dispose(); _player.dispose();
super.dispose(); super.dispose();
} }
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
return Container( return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4), margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 420), constraints: BoxConstraints(
maxWidth: 420,
maxHeight: inlineMediaMaxHeight(context),
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black, color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg), borderRadius: BorderRadius.circular(pt.rLg),
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
), ),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: _aspect ?? 16 / 9,
child: StreamBuilder<bool>( child: StreamBuilder<bool>(
stream: _player.stream.playing, stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>( builder: (_, playSnap) => StreamBuilder<Duration>(
+196 -53
View File
@@ -6,9 +6,17 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart'; import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
/// Overlay viewer for images, GIFs and videos. /// Overlay viewer for images, GIFs and videos.
/// Uses a semi-transparent backdrop; tapping outside the content closes it. ///
/// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt):
/// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig).
/// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus.
/// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet
/// dabei aus.
/// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei
/// Fingern aufziehen geht weiterhin.
class MediaViewer extends StatefulWidget { class MediaViewer extends StatefulWidget {
final String senderName; final String senderName;
final Uint8List? bytes; final Uint8List? bytes;
@@ -40,10 +48,22 @@ class MediaViewer extends StatefulWidget {
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
barrierLabel: 'Schließen', barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178), // Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
transitionDuration: const Duration(milliseconds: 180), // ausblenden kann).
barrierColor: Colors.transparent,
transitionDuration: motionDuration(context, 300),
transitionBuilder: (context, animation, secondaryAnimation, child) => transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeIn,
)),
child: child,
),
),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer( pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName, senderName: senderName,
filename: filename, filename: filename,
@@ -59,21 +79,127 @@ class MediaViewer extends StatefulWidget {
State<MediaViewer> createState() => _MediaViewerState(); State<MediaViewer> createState() => _MediaViewerState();
} }
class _MediaViewerState extends State<MediaViewer> { class _MediaViewerState extends State<MediaViewer>
with SingleTickerProviderStateMixin {
/// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen.
static const _dismissDistance = 120.0;
static const _dismissVelocity = 1000.0;
static const _doubleTapScale = 2.5;
final _tc = TransformationController(); final _tc = TransformationController();
final _contentKey = GlobalKey();
late final AnimationController _zoomAnim;
Animation<Matrix4>? _zoomTween;
bool _controlsVisible = true; bool _controlsVisible = true;
bool _saving = false; bool _saving = false;
bool _savingSticker = false; bool _savingSticker = false;
bool _zoomed = false;
bool _dragging = false;
double _dragOffset = 0;
Offset? _doubleTapGlobal;
@override
void initState() {
super.initState();
_zoomAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..addListener(() {
final tween = _zoomTween;
if (tween != null) _tc.value = tween.value;
});
_tc.addListener(_onTransform);
}
@override @override
void dispose() { void dispose() {
_zoomAnim.dispose();
_tc.removeListener(_onTransform);
_tc.dispose(); _tc.dispose();
super.dispose(); super.dispose();
} }
void _onTransform() {
final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01;
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
}
void _close() => Navigator.of(context).maybePop();
void _toggle() => setState(() => _controlsVisible = !_controlsVisible); void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
void _resetZoom() => _tc.value = Matrix4.identity(); void _animateTo(Matrix4 target) {
_zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate(
CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic),
);
_zoomAnim.forward(from: 0);
}
void _resetZoom() => _animateTo(Matrix4.identity());
/// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus.
void _toggleZoom() {
final global = _doubleTapGlobal;
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
if (_zoomed || global == null || box == null) {
_resetZoom();
return;
}
final p = box.globalToLocal(global);
_animateTo(Matrix4.identity()
..translateByDouble(
-p.dx * (_doubleTapScale - 1),
-p.dy * (_doubleTapScale - 1),
0,
1,
)
..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1));
}
// ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es
// nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ─────────────
void _onInteractionStart(ScaleStartDetails d) {
_dragging = !_zoomed && d.pointerCount == 1;
}
void _onInteractionUpdate(ScaleUpdateDetails d) {
if (!_dragging) return;
if (d.pointerCount > 1) {
// Zweiter Finger dazu → das ist ein Zoom, kein Wischen.
setState(() {
_dragging = false;
_dragOffset = 0;
});
return;
}
setState(() => _dragOffset += d.focalPointDelta.dy);
}
void _onInteractionEnd(ScaleEndDetails d) {
if (!_dragging) return;
_dragging = false;
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
if (_dragOffset.abs() > _dismissDistance || fast) {
_close();
} else {
setState(() => _dragOffset = 0);
}
}
// Video: kein InteractiveViewer, also normales vertikales Wischen.
void _onVideoDragUpdate(DragUpdateDetails d) =>
setState(() => _dragOffset += d.delta.dy);
void _onVideoDragEnd(DragEndDetails d) {
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
if (_dragOffset.abs() > _dismissDistance || fast) {
_close();
} else {
setState(() => _dragOffset = 0);
}
}
Future<void> _saveAsSticker() async { Future<void> _saveAsSticker() async {
final event = widget.event; final event = widget.event;
@@ -127,9 +253,16 @@ class _MediaViewerState extends State<MediaViewer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final isVideo = widget.isVideo && widget.bytes != null; final isVideo = widget.isVideo && widget.bytes != null;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
// Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist.
final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0);
final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress);
// Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt.
final margin = isMobile ? 0.0 : 24.0;
final header = AnimatedOpacity( final header = AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0, opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
child: IgnorePointer( child: IgnorePointer(
ignoring: !_controlsVisible, ignoring: !_controlsVisible,
@@ -146,7 +279,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: _close,
icon: const Icon(Icons.close_rounded, color: Colors.white), icon: const Icon(Icons.close_rounded, color: Colors.white),
tooltip: 'Schließen', tooltip: 'Schließen',
), ),
@@ -181,7 +314,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download_rounded, color: Colors.white), : const Icon(Icons.download_rounded, color: Colors.white),
), ),
if (!isVideo) if (!isVideo && _zoomed)
IconButton( IconButton(
onPressed: _resetZoom, onPressed: _resetZoom,
tooltip: 'Zoom zurücksetzen', tooltip: 'Zoom zurücksetzen',
@@ -194,61 +327,71 @@ class _MediaViewerState extends State<MediaViewer> {
), ),
); );
// Content area — constrained so the transparent Material outside it lets final Widget content;
// pointer events through to the barrier (which dismisses on tap).
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
if (isVideo) { if (isVideo) {
return Material( content = GestureDetector(
type: MaterialType.transparency, onVerticalDragUpdate: _onVideoDragUpdate,
onVerticalDragEnd: _onVideoDragEnd,
child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
);
} else {
Widget img;
if (widget.bytes != null) {
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
} else if (widget.networkUrl != null) {
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
} else {
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
}
content = InteractiveViewer(
transformationController: _tc,
minScale: 1.0,
maxScale: 10.0,
// Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung
// gehört dann dem Wischen zum Schließen.
panEnabled: _zoomed,
onInteractionStart: _onInteractionStart,
onInteractionUpdate: _onInteractionUpdate,
onInteractionEnd: _onInteractionEnd,
child: Center( child: Center(
child: SizedBox( key: _contentKey,
width: contentMaxW, // Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
height: contentMaxH, child: GestureDetector(
child: Stack( onTap: _toggle,
fit: StackFit.expand, onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
children: [ onDoubleTap: _toggleZoom,
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename), child: img,
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
), ),
), ),
); );
} }
Widget img;
if (widget.bytes != null) {
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
} else if (widget.networkUrl != null) {
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
} else {
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
}
return Material( return Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: Center( child: Stack(
child: SizedBox( fit: StackFit.expand,
width: contentMaxW, children: [
height: contentMaxH, // Hintergrund + „daneben tippen schließt“.
child: GestureDetector( GestureDetector(
onTap: _toggle, behavior: HitTestBehavior.opaque,
child: Stack( onTap: _close,
fit: StackFit.expand, child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
children: [ ),
InteractiveViewer( Padding(
transformationController: _tc, padding: EdgeInsets.all(margin),
minScale: 0.5, child: Transform.translate(
maxScale: 10.0, offset: Offset(0, _dragOffset),
child: Center(child: img), child: GestureDetector(
), // Tipps auf die leere Fläche um das Bild (innerhalb des
Positioned(top: 0, left: 0, right: 0, child: header), // Inhaltsbereichs) schließen ebenfalls.
], behavior: HitTestBehavior.translucent,
onTap: _close,
child: content,
),
), ),
), ),
), Positioned(top: 0, left: 0, right: 0, child: header),
],
), ),
); );
} }
@@ -0,0 +1,261 @@
part of '../message_group.dart';
// Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem
// Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts
// in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in
// Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe.
// Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen
// und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste.
const _kBubbleMaxWidthFactor = 0.82;
const _kBubbleAvatarSize = 30.0;
const _kBubbleAvatarGap = 8.0;
extension _BubbleBuilder on _MessageGroupState {
Widget _buildBubbles(BuildContext context, PyramidTheme pt) {
final firstEvent = widget.events.first;
final isOwn = firstEvent.senderId == widget.currentUserId;
final isDm = widget.room?.isDirectChat ?? false;
// Name + Avatar nur bei fremden Nachrichten in Gruppenräumen.
final showSender = !isOwn && !isDm;
final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromId(firstEvent.senderId);
return LayoutBuilder(builder: (context, constraints) {
final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts
final senderSpace =
showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0;
final maxBubbleWidth =
((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0);
final rows = <Widget>[];
for (var j = 0; j < widget.events.length; j++) {
final event = widget.events[j];
final isFirst = j == 0;
final isLast = j == widget.events.length - 1;
final bubbleColumn = Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_Bubble(
event: event,
pt: pt,
isOwn: isOwn,
isFirst: isFirst,
isLast: isLast,
senderName: showSender && isFirst ? name : null,
senderColor: color,
maxWidth: maxBubbleWidth,
timeline: widget.timeline,
),
if (widget.timeline != null)
_ReactionsRow(
event: event,
timeline: widget.timeline!,
room: widget.room,
currentUserId: widget.currentUserId,
pt: pt,
),
],
);
final line = Row(
mainAxisAlignment:
isOwn ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSender) ...[
SizedBox(
width: _kBubbleAvatarSize,
child: isFirst
? _Avatar(
initial: initial,
color: color,
pt: pt,
size: _kBubbleAvatarSize,
avatarUri: sender.avatarUrl,
)
: null,
),
const SizedBox(width: _kBubbleAvatarGap),
],
Flexible(child: bubbleColumn),
],
);
rows.add(_selectableRow(
event,
pt,
Padding(
padding: EdgeInsets.only(
top: isFirst ? pt.msgGap / 2 : 3,
left: 12,
right: 12,
),
child: line,
),
));
if (widget.readEventIds.contains(event.eventId)) {
rows.add(_ReadMarker(pt: pt));
}
if (widget.fullyReadId == event.eventId && !isLast) {
rows.add(_InlineUnreadDivider(pt: pt));
}
}
if (widget.showStatusIndicator &&
isOwn &&
widget.room != null &&
!widget.readEventIds.contains(widget.events.last.eventId)) {
rows.add(Padding(
padding: const EdgeInsets.only(right: 16, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: _MessageStatus(lastEvent: widget.events.last, pt: pt),
),
));
}
rows.add(const SizedBox(height: 2));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rows,
);
});
}
}
/// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase).
class _Bubble extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final bool isOwn;
final bool isFirst;
final bool isLast;
final String? senderName;
final Color senderColor;
final double maxWidth;
final Timeline? timeline;
const _Bubble({
required this.event,
required this.pt,
required this.isOwn,
required this.isFirst,
required this.isLast,
required this.senderName,
required this.senderColor,
required this.maxWidth,
this.timeline,
});
Event get _displayEvent =>
timeline != null ? event.getDisplayEvent(timeline!) : event;
bool get _isVisualMedia {
final e = _displayEvent;
if (e.type == EventTypes.Sticker) return true;
return e.type == EventTypes.Message &&
(e.messageType == MessageTypes.Image ||
e.messageType == MessageTypes.Video);
}
/// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar).
static Color ownColor(PyramidTheme pt) => Color.alphaBlend(
pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24),
pt.bg1,
);
BorderRadius get _radius {
const big = Radius.circular(18);
const small = Radius.circular(6);
// Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen.
final topSide = isFirst ? big : small;
final bottomSide = isLast ? big : small;
return isOwn
? BorderRadius.only(
topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide)
: BorderRadius.only(
topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide);
}
@override
Widget build(BuildContext context) {
final time = Text(
_formatMessageTime(event.originServerTs),
style: TextStyle(color: pt.fgDim, fontSize: 11),
);
final failed = event.status.isError
? _SendFailedRow(event: event, pt: pt)
: null;
if (_isVisualMedia) {
// Ohne Blase: Bilder/Videos sollen so groß wie möglich sein.
final box = _reservedImageSize(
_displayEvent,
maxW: maxWidth.clamp(80.0, 420.0),
maxH: inlineMediaMaxHeight(context),
);
return Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (senderName != null) _senderLabel(),
SizedBox(
width: box?.width ?? maxWidth,
child: _MessageContent(event: event, pt: pt, timeline: timeline),
),
?failed,
Padding(padding: const EdgeInsets.only(top: 2), child: time),
],
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 6),
decoration: BoxDecoration(
color: isOwn ? ownColor(pt) : pt.bg3,
borderRadius: _radius,
),
// Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit
// sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen.
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (senderName != null) _senderLabel(),
_MessageContent(event: event, pt: pt, timeline: timeline),
?failed,
],
),
const SizedBox(height: 2),
time,
],
),
),
);
}
Widget _senderLabel() => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
senderName!,
style: TextStyle(
color: senderColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
}
@@ -1,5 +1,15 @@
part of '../message_group.dart'; part of '../message_group.dart';
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
final _poppedMessageIds = <String>{};
void _rememberPopped(String id) {
_poppedMessageIds.add(id);
if (_poppedMessageIds.length > 300) {
_poppedMessageIds.remove(_poppedMessageIds.first);
}
}
String _formatMessageTime(DateTime t) { String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0'); final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0'); final m = t.minute.toString().padLeft(2, '0');
@@ -28,6 +38,10 @@ class MessageGroup extends StatefulWidget {
final GlobalKey? jumpKey; final GlobalKey? jumpKey;
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; final bool highlightOn;
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
/// (z. B. solange der Chat gerade erst geöffnet wird).
final DateTime? popInAfter;
const MessageGroup({ const MessageGroup({
super.key, super.key,
@@ -45,6 +59,7 @@ class MessageGroup extends StatefulWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.popInAfter,
}); });
@override @override
@@ -64,7 +79,23 @@ class _MessageGroupState extends State<MessageGroup> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = PyramidTheme.of(context); // Darstellung laut Einstellung (Erscheinungsbild → Nachrichten):
// Sprechblasen (message_bubbles.dart) oder Liste wie bisher.
return Consumer(builder: (context, ref, _) {
final style = ref.watch(messageStyleProvider);
final pt = PyramidTheme.of(context);
final bubbles =
style.useBubbles(mobile: isMobileLayout(MediaQuery.sizeOf(context)));
return _withSwipeToReply(
pt,
bubbles ? _buildBubbles(context, pt) : _buildList(context, pt),
);
});
}
// Listen-Darstellung (Discord-Stil): Avatar + Name + Zeit, darunter die
// Nachrichten des Absenders bündig untereinander.
Widget _buildList(BuildContext context, PyramidTheme pt) {
final firstEvent = widget.events.first; final firstEvent = widget.events.first;
final sender = firstEvent.senderFromMemoryOrFallback; final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname(); final name = sender.calcDisplayname();
@@ -168,11 +199,14 @@ class _MessageGroupState extends State<MessageGroup> {
// Hover highlight + actions are now per-row (see _selectableRow), so the // Hover highlight + actions are now per-row (see _selectableRow), so the
// group is just the column of rows. // group is just the column of rows.
Widget content = Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: rows, children: rows,
); );
}
// Handy: nach links wischen = antworten (beide Darstellungen).
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
if (!_isMobile) return content; if (!_isMobile) return content;
return GestureDetector( return GestureDetector(
@@ -282,15 +316,39 @@ class _MessageGroupState extends State<MessageGroup> {
), ),
); );
if (_isMobile) return row; if (_isMobile) return _popIn(event, row);
// Desktop: per-row hover highlight + actions. // Desktop: per-row hover highlight + actions.
return MouseRegion( return _popIn(event, MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId), onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() { onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null; if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}), }),
child: row, child: row,
));
}
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
Widget _popIn(Event event, Widget row) {
final id = event.transactionId ?? event.eventId;
final after = widget.popInAfter;
final fresh = after != null &&
!_poppedMessageIds.contains(id) &&
(event.status.isSending ||
(event.originServerTs.isAfter(after) &&
DateTime.now().difference(event.originServerTs) <
const Duration(minutes: 2)));
if (fresh) _rememberPopped(id);
return PopIn(
key: ValueKey('row:$id'),
play: fresh,
alignment: event.senderId == widget.currentUserId
? Alignment.bottomRight
: Alignment.bottomLeft,
child: row,
); );
} }
@@ -159,7 +159,18 @@ class _ReplyPreview extends StatelessWidget {
// matching WhatsApp/Discord. Falls back to stripped text for m.text. // matching WhatsApp/Discord. Falls back to stripped text for m.text.
final (IconData? icon, String body) = _previewFor(replyEvent); final (IconData? icon, String body) = _previewFor(replyEvent);
return Padding( // Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten
// (gleicher Mechanismus wie der Sprung aus der Suche).
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => ProviderScope.containerOf(context, listen: false)
.read(pendingJumpEventProvider.notifier)
.state = replyEvent.eventId,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SpringPress(
pressedScale: 0.97,
child: Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: IntrinsicHeight( child: IntrinsicHeight(
child: ClipRRect( child: ClipRRect(
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
), ),
), ),
), ),
),
),
),
), ),
); );
} }
+2 -2
View File
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
final maxW = constraints.maxWidth.isFinite final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth.clamp(80.0, 420.0) ? constraints.maxWidth.clamp(80.0, 420.0)
: 420.0; : 420.0;
const maxH = 320.0; final maxH = inlineMediaMaxHeight(context);
// Reserve the final display box up-front from the event's info dimensions, // Reserve the final display box up-front from the event's info dimensions,
// so the layout doesn't shift (and yank the scroll position) when the image // so the layout doesn't shift (and yank the scroll position) when the image
// finishes loading. Null when dimensions are absent. // finishes loading. Null when dimensions are absent.
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
final pt = widget.pt; final pt = widget.pt;
final isSticker = widget.event.type == EventTypes.Sticker; final isSticker = widget.event.type == EventTypes.Sticker;
final maxW = isSticker ? 160.0 : 420.0; final maxW = isSticker ? 160.0 : 420.0;
final maxH = isSticker ? 160.0 : 320.0; final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
return FutureBuilder<_MediaResult>( return FutureBuilder<_MediaResult>(
future: _future, future: _future,
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
// Group by emoji key → list of senders // Group by emoji key → list of senders
final Map<String, List<String>> grouped = {}; final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) { for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?; final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue; if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId); (grouped[key] ??= []).add(r.senderId);
final ts = r.originServerTs;
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
} }
final now = DateTime.now();
if (grouped.isEmpty) return const SizedBox.shrink(); if (grouped.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
children: grouped.entries.map((e) { children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId); final myReaction = e.value.contains(currentUserId);
return _ReactionChip( return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key, emoji: e.key,
count: e.value.length, count: e.value.length,
mine: myReaction, mine: myReaction,
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
final bool mine; final bool mine;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final bool fresh;
const _ReactionChip({ const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji, required this.emoji,
required this.count, required this.count,
required this.mine, required this.mine,
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = widget.pt; final pt = widget.pt;
return MouseRegion( return PopIn(
play: widget.fresh,
fromScale: 0.3,
offsetY: 0,
alignment: Alignment.center,
milliseconds: 520,
child: SpringBump(
value: widget.count,
child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
], ],
), ),
), ),
),
),
),
), ),
); );
} }
+2 -2
View File
@@ -213,7 +213,7 @@ class _MatrixHtmlText extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spans = _parseHtml(html, baseStyle, linkColor); final spans = _parseHtml(html, baseStyle, linkColor);
return SelectionArea( return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)), child: Text.rich(TextSpan(children: spans, style: baseStyle)),
); );
} }
@@ -388,7 +388,7 @@ class _PlainLinkText extends StatelessWidget {
} }
return SelectionArea( return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)), child: Text.rich(TextSpan(children: spans, style: baseStyle)),
); );
} }
} }
+5
View File
@@ -9,6 +9,9 @@ import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'
show messageStyleProvider, pendingJumpEventProvider;
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart'; import 'package:pyramid/core/media_cache.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
@@ -19,9 +22,11 @@ import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.dart'; import 'package:pyramid/features/chat/reaction_service.dart';
import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
part 'message/message_group_core.dart'; part 'message/message_group_core.dart';
part 'message/message_bubbles.dart';
part 'message/message_text.dart'; part 'message/message_text.dart';
part 'message/message_link_preview.dart'; part 'message/message_link_preview.dart';
part 'message/message_media.dart'; part 'message/message_media.dart';
+312 -206
View File
@@ -42,11 +42,13 @@ class _ChatHeader extends ConsumerWidget {
children: [ children: [
// Back button on mobile // Back button on mobile
if (isMobile) ...[ if (isMobile) ...[
GestureDetector( PyrIconBtn(
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), const SizedBox(width: 4),
], ],
// Room icon / avatar // Room icon / avatar
if (isDm && client != null) ...[ if (isDm && client != null) ...[
@@ -141,19 +143,27 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onTogglePinned, onPressed: onTogglePinned,
), ),
], ],
PyrIconBtn( if (isMobile)
size: 28, _ChatOverflowMenu(
icon: const Icon(Icons.search_rounded), pt: pt,
tooltip: 'Search', onSearch: onOpenSearch,
onPressed: onOpenSearch, onMembers: onToggleMembers,
), )
PyrIconBtn( else ...[
size: 28, PyrIconBtn(
icon: const Icon(Icons.group_rounded), size: 28,
tooltip: 'Members', icon: const Icon(Icons.search_rounded),
active: membersOpen, tooltip: 'Suchen',
onPressed: onToggleMembers, onPressed: onOpenSearch,
), ),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: membersOpen,
onPressed: onToggleMembers,
),
],
], ],
), ),
], ],
@@ -167,17 +177,19 @@ class _ChatHeader extends ConsumerWidget {
if (room.isDirectChat) { if (room.isDirectChat) {
// Use native Matrix VoIP for 1:1 DMs // Use native Matrix VoIP for 1:1 DMs
ref.read(viewModeProvider.notifier).state = ViewMode.voice; ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await PyramidVoipManager.instance.startCall(room.id, video: cam); await ref.read(callSignalingProvider).startCall(room.id, video: cam);
return; return;
} }
// Use LiveKit for group voice rooms // Use LiveKit for group voice rooms
ref.read(viewModeProvider.notifier).state = ViewMode.voice; ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await ref.read(callStateProvider).startCall( await ref.read(voiceChannelProvider).startCall(
roomName: roomId, roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(), roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}', identity: room.client.userID ?? '',
audioOnly: !cam, audioOnly: !cam,
// Für das Beitritts-Token (Server prüft den Matrix-Login).
matrixClient: room.client,
); );
} }
} }
@@ -222,6 +234,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
Profile? _profile; Profile? _profile;
String? _bannerMxcUri; String? _bannerMxcUri;
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
// der Name genau bis davor.
final _actionsKey = GlobalKey();
double _actionsWidth = 140;
void _measureActions() {
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final w = box.size.width;
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
setState(() => _actionsWidth = w);
}
}
String? _statusMsg; String? _statusMsg;
bool _online = false; bool _online = false;
@@ -291,6 +317,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
final pt = widget.pt; final pt = widget.pt;
final expanded = widget.expanded; final expanded = widget.expanded;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@@ -361,198 +388,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
), ),
), ),
), ),
Padding( // Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
padding: const EdgeInsets.symmetric(horizontal: 16), // nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
child: Row( // aufgeklappt wurde der Name zu „p…“ gekürzt).
children: [ AnimatedPositioned(
if (isMobile) ...[ duration: _animDuration,
GestureDetector( curve: _animCurve,
onTap: () => left: expanded ? 16 : (isMobile ? 54 : 16),
ref.read(activeRoomIdProvider.notifier).state = null, right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
child: top: expanded ? 44 : 0,
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), bottom: 0,
), child: MouseRegion(
const SizedBox(width: 12), cursor: SystemMouseCursors.click,
], child: GestureDetector(
// Klickbarer Profilbereich: Avatar + Name + Details behavior: HitTestBehavior.opaque,
Expanded( onTap: widget.onToggle,
child: MouseRegion( child: Row(
cursor: SystemMouseCursors.click, children: [
child: GestureDetector( // Avatar — skaliert mit (rund, wie im Mockup)
behavior: HitTestBehavior.opaque, AnimatedContainer(
onTap: widget.onToggle, duration: _animDuration,
child: Row( curve: _animCurve,
children: [ width: avatarSize,
// Avatar — skaliert mit (rund, wie im Mockup) height: avatarSize,
AnimatedContainer( decoration: BoxDecoration(
duration: _animDuration, shape: BoxShape.circle,
curve: _animCurve, color: pt.bg3,
width: avatarSize, ),
height: avatarSize, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( child: avatarUrl != null && client != null
shape: BoxShape.circle, ? MxcImage(
color: pt.bg3, mxcUri: avatarUrl.toString(),
), client: client,
clipBehavior: Clip.antiAlias, fit: BoxFit.cover,
child: avatarUrl != null && client != null placeholder:
? MxcImage( _initialBox(initial, pt, avatarSize),
mxcUri: avatarUrl.toString(), )
client: client, : _initialBox(initial, pt, avatarSize),
fit: BoxFit.cover, ),
placeholder: _initialBox(initial, pt), AnimatedContainer(
) duration: _animDuration,
: _initialBox(initial, pt), curve: _animCurve,
), width: expanded ? 20 : 10,
AnimatedContainer( ),
duration: _animDuration, Expanded(
curve: _animCurve, child: Column(
width: expanded ? 20 : 10, mainAxisAlignment: MainAxisAlignment.center,
), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( // Name skaliert mit; Status blendet daneben ein
mainAxisAlignment: MainAxisAlignment.center, Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Name skaliert mit; Status blendet daneben ein Flexible(
Row( child: AnimatedDefaultTextStyle(
crossAxisAlignment: CrossAxisAlignment.center, duration: _animDuration,
children: [ curve: _animCurve,
Flexible( style: TextStyle(
child: AnimatedDefaultTextStyle( color: pt.fg,
duration: _animDuration, fontSize: expanded ? 24 : 15,
curve: _animCurve, fontWeight: expanded
style: TextStyle( ? FontWeight.w700
color: pt.fg, : FontWeight.w600,
fontSize: expanded ? 24 : 15,
fontWeight: expanded
? FontWeight.w700
: FontWeight.w600,
),
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
), ),
AnimatedOpacity( child: Text(
opacity: expanded ? 1 : 0, name,
duration: _animDuration, maxLines: 1,
child: Padding( overflow: TextOverflow.ellipsis,
padding:
const EdgeInsets.only(left: 8),
child: Text(
_online ? 'online' : 'offline',
style: TextStyle(
color: _online
? pt.online
: pt.fgMuted,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
), ),
], ),
), ),
// Eingeklappt: "zuletzt online vor…", AnimatedOpacity(
// aufgeklappt: Handle + Biographie. opacity: expanded ? 1 : 0,
AnimatedCrossFade(
duration: _animDuration, duration: _animDuration,
sizeCurve: _animCurve, child: Padding(
crossFadeState: expanded padding:
? CrossFadeState.showSecond const EdgeInsets.only(left: 8),
: CrossFadeState.showFirst, child: Text(
firstChild: client != null _online ? 'online' : 'offline',
? _PresenceLine( style: TextStyle(
userId: _partnerId, color: _online
client: client, ? pt.online
pt: pt, : pt.fgMuted,
) fontSize: 13,
: const SizedBox.shrink(), fontWeight: FontWeight.w500,
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
if (_statusMsg != null && ),
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
), ),
), ),
], ],
), ),
), // Eingeklappt: "zuletzt online vor…",
], // aufgeklappt: Handle + Biographie.
AnimatedCrossFade(
duration: _animDuration,
sizeCurve: _animCurve,
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: client != null
? _PresenceLine(
userId: _partnerId,
client: client,
pt: pt,
)
: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_statusMsg != null &&
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
), ),
), ],
), ),
),
),
),
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
if (isMobile)
Positioned(
left: 6,
top: 0,
height: 52,
child: Center(
child: PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), ),
// Aktionen — bleiben immer sichtbar ),
Row( Positioned(
right: isMobile ? 6 : 16,
top: 0,
height: 52,
child: KeyedSubtree(
key: _actionsKey,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.phone_rounded), icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call', tooltip: 'Voice call',
onPressed: () => _startCall(cam: false), onPressed: () => _startCall(cam: false),
), ),
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.videocam_rounded), icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call', tooltip: 'Video call',
onPressed: () => _startCall(cam: true), onPressed: () => _startCall(cam: true),
), ),
if (size.width > 700) if (size.width > 700)
Container( Container(
width: 1, width: 1,
height: 20, height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border, color: pt.border,
), ),
if (size.width > 800) if (size.width > 800)
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.push_pin_rounded), icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten', tooltip: 'Angepinnte Nachrichten',
active: widget.pinnedOpen, active: widget.pinnedOpen,
onPressed: widget.onTogglePinned, onPressed: widget.onTogglePinned,
), ),
PyrIconBtn( if (isMobile)
size: 28, _ChatOverflowMenu(
icon: const Icon(Icons.search_rounded), pt: pt,
tooltip: 'Search', onSearch: widget.onOpenSearch,
onPressed: widget.onOpenSearch, onMembers: widget.onToggleMembers,
), )
PyrIconBtn( else ...[
size: 28, PyrIconBtn(
icon: const Icon(Icons.group_rounded), size: 28,
tooltip: 'Members', icon: const Icon(Icons.search_rounded),
active: widget.membersOpen, tooltip: 'Suchen',
onPressed: widget.onToggleMembers, onPressed: widget.onOpenSearch,
), ),
], PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: widget.membersOpen,
onPressed: widget.onToggleMembers,
), ),
], ],
],
),
), ),
), ),
], ],
@@ -560,15 +611,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
); );
} }
Widget _initialBox(String initial, PyramidTheme pt) => Center( // Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
child: FittedBox( // 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
fit: BoxFit.scaleDown, Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
child: Padding( Center(
padding: const EdgeInsets.all(6), child: AnimatedDefaultTextStyle(
child: Text( duration: _animDuration,
initial, curve: _animCurve,
style: TextStyle( textAlign: TextAlign.center,
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), style: TextStyle(
color: pt.fg,
fontSize: avatarSize * 0.42,
fontWeight: FontWeight.w700,
height: 1.0,
),
child: Text(
initial,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
), ),
), ),
), ),
@@ -576,7 +637,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
void _startCall({required bool cam}) async { void _startCall({required bool cam}) async {
ref.read(viewModeProvider.notifier).state = ViewMode.voice; ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await PyramidVoipManager.instance.startCall(widget.room.id, video: cam); await ref.read(callSignalingProvider).startCall(widget.room.id, video: cam);
} }
} }
@@ -733,3 +794,48 @@ class _PresenceLineState extends State<_PresenceLine> {
return 'vor ${(diff.inDays / 365).floor()}j'; return 'vor ${(diff.inDays / 365).floor()}j';
} }
} }
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
class _ChatOverflowMenu extends StatelessWidget {
final PyramidTheme pt;
final VoidCallback onSearch;
final VoidCallback onMembers;
const _ChatOverflowMenu({
required this.pt,
required this.onSearch,
required this.onMembers,
});
@override
Widget build(BuildContext context) {
PopupMenuItem<VoidCallback> item(
IconData icon, String label, VoidCallback action) =>
PopupMenuItem(
value: action,
child: Row(
children: [
Icon(icon, size: 20, color: pt.fgMuted),
const SizedBox(width: 12),
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
],
),
);
return PopupMenuButton<VoidCallback>(
tooltip: 'Mehr',
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
color: pt.bg2,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase),
side: BorderSide(color: pt.border),
),
onSelected: (action) => action(),
itemBuilder: (_) => [
item(Icons.search_rounded, 'Suchen', onSearch),
item(Icons.group_rounded, 'Mitglieder', onMembers),
],
);
}
}
+11 -1
View File
@@ -19,6 +19,13 @@ class _MessageList extends StatelessWidget {
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; final bool highlightOn;
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset;
/// Siehe MessageGroup.popInAfter.
final DateTime? popInAfter;
const _MessageList({ const _MessageList({
required this.timeline, required this.timeline,
required this.scrollCtrl, required this.scrollCtrl,
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.bottomInset = 0,
this.popInAfter,
}); });
@override @override
@@ -241,7 +250,7 @@ class _MessageList extends StatelessWidget {
controller: scrollCtrl, controller: scrollCtrl,
reverse: true, reverse: true,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(bottom: 8 + bottomInset),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500), scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length, itemCount: itemKeys.length,
findChildIndexCallback: (Key key) { findChildIndexCallback: (Key key) {
@@ -287,6 +296,7 @@ class _MessageList extends StatelessWidget {
jumpKey: jumpKey, jumpKey: jumpKey,
highlightEventId: highlightEventId, highlightEventId: highlightEventId,
highlightOn: highlightOn, highlightOn: highlightOn,
popInAfter: popInAfter,
); );
}, },
); );
+23 -1
View File
@@ -115,7 +115,7 @@ class NoChatSelected extends StatelessWidget {
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Select a room to start chatting', 'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 16), style: TextStyle(color: pt.fgMuted, fontSize: 16),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -129,3 +129,25 @@ class NoChatSelected extends StatelessWidget {
); );
} }
} }
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
/// hinter dem Eingabefeld selbst (chat_composer.dart).
class _FloatingComposerArea extends StatelessWidget {
final PyramidTheme pt;
final Widget child;
const _FloatingComposerArea({required this.pt, required this.child});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: child,
),
);
}
}
+153 -28
View File
@@ -28,9 +28,20 @@ class _ChatViewState extends ConsumerState<ChatView> {
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
bool _unreadFading = false; bool _unreadFading = false;
Timer? _unreadFadeTimer; Timer? _unreadFadeTimer;
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
String? _lastMarkedEventId;
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
// schon vorhandenen Nachrichten ruhig erscheinen).
DateTime? _popInAfter;
// Jump-to-message (from search): scroll to + briefly highlight a target event. // Jump-to-message (from search): scroll to + briefly highlight a target event.
final _jumpKey = GlobalKey(); final _jumpKey = GlobalKey();
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
// oder mehrzeiligem Text).
double _composerHeight = 72;
String? _jumpEventId; String? _jumpEventId;
String? _highlightEventId; String? _highlightEventId;
bool _highlightOn = false; bool _highlightOn = false;
@@ -77,11 +88,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
return; return;
} }
// Not built yet — load older history toward the target every few tries. // Not built yet. Die Liste baut nur sichtbare Einträge (lazy, reverse):
// Ist das Ziel schon geladen, Richtung ältere Nachrichten scrollen, bis
// es gezeichnet ist (Antwort-Zitate zeigen fast immer nach oben); sonst
// zusätzlich ältere Historie nachladen.
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { final loaded =
timeline?.events.any((e) => e.eventId == _jumpEventId) ?? false;
if (!loaded &&
timeline != null &&
timeline.canRequestHistory &&
attempt % 3 == 0) {
timeline.requestHistory(historyCount: 100).catchError((_) {}); timeline.requestHistory(historyCount: 100).catchError((_) {});
} }
if (_scrollCtrl.hasClients && (loaded || attempt % 3 == 1)) {
final pos = _scrollCtrl.position;
final step = pos.viewportDimension * 0.8;
final target = (pos.pixels + step).clamp(0.0, pos.maxScrollExtent);
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
}
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
}); });
@@ -93,6 +118,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
bool get _inSelectionMode => _selectedIds.isNotEmpty; bool get _inSelectionMode => _selectedIds.isNotEmpty;
double get _scrollBtnSize =>
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
void _toggleSelect(String eventId) { void _toggleSelect(String eventId) {
setState(() { setState(() {
if (_selectedIds.contains(eventId)) { if (_selectedIds.contains(eventId)) {
@@ -109,12 +137,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollCtrl.addListener(_onScroll); _scrollCtrl.addListener(_onScroll);
// App/Fenster wieder im Vordergrund: was inzwischen kam, jetzt als
// gelesen melden – mit „Neue Nachrichten“-Trenner davor.
ref.listenManual<bool>(windowFocusedProvider, (prev, focused) {
if (focused && prev == false) _onFocusRegained();
});
} }
@override @override
void didUpdateWidget(ChatView oldWidget) { void didUpdateWidget(ChatView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) { if (oldWidget.roomId != widget.roomId) {
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
// auf eine Nachricht aus Raum A in Raum B raus.
_replyTo = null;
_selectedIds.clear();
_showScrollDown = false;
_profileExpanded = false; _profileExpanded = false;
_sessionFullyReadId = null; _sessionFullyReadId = null;
_sessionHasUnread = false; _sessionHasUnread = false;
@@ -124,6 +162,8 @@ class _ChatViewState extends ConsumerState<ChatView> {
_historyExhausted = false; _historyExhausted = false;
_unreadFading = false; _unreadFading = false;
_unreadFadeTimer?.cancel(); _unreadFadeTimer?.cancel();
_lastMarkedEventId = null;
_popInAfter = null;
_jumpEventId = null; _jumpEventId = null;
_highlightEventId = null; _highlightEventId = null;
_highlightOn = false; _highlightOn = false;
@@ -174,6 +214,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { setState(() {
_sessionFullyReadId = newFullyReadId; _sessionFullyReadId = newFullyReadId;
_sessionHasUnread = newHasUnread; _sessionHasUnread = newHasUnread;
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
}); });
if (newHasUnread) { if (newHasUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
@@ -224,21 +265,60 @@ class _ChatViewState extends ConsumerState<ChatView> {
} }
void _markRead([Timeline? tl]) { void _markRead([Timeline? tl]) {
// Nur als gelesen melden, was wirklich zu sehen ist: Läuft die App im
// Hintergrund (oder hat das Fenster keinen Fokus), bleibt alles ungelesen
// und die Benachrichtigung stehen – vorher wurde sie beim nächsten
// Neuaufbau sofort wieder entfernt und die Nachricht als gelesen gemeldet.
if (!ref.read(windowFocusedProvider)) return;
// Cancel any pending notification for this room (user is now reading it). // Cancel any pending notification for this room (user is now reading it).
cancelNotificationForRoom(widget.roomId); cancelNotificationForRoom(widget.roomId);
final sendReceipts = ref.read(privacyReadReceiptsProvider);
if (!sendReceipts) return;
final room = ref.read(roomProvider(widget.roomId)); final room = ref.read(roomProvider(widget.roomId));
if (room == null) return; if (room == null) return;
if (room.markedUnread) room.markUnread(false).catchError((_) {});
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
final latest = timeline?.events.firstOrNull; final latest = timeline?.events
if (latest != null && latest.status.isSynced) { .where((e) => e.status.isSynced)
room.setReadMarker(latest.eventId, mRead: latest.eventId) .firstOrNull;
.catchError((_) {}); if (latest == null || latest.eventId == _lastMarkedEventId) return;
} else { _lastMarkedEventId = latest.eventId;
room.markUnread(false).catchError((_) {}); markRoomReadUpTo(
room,
latest.eventId,
publicReceipt: ref.read(privacyReadReceiptsProvider),
).catchError((_) {
// Beim nächsten Anlass erneut versuchen.
if (_lastMarkedEventId == latest.eventId) _lastMarkedEventId = null;
});
}
/// Zurück in der App mit diesem Chat offen: Kamen inzwischen Nachrichten
/// von anderen, erscheint der „Neue Nachrichten“-Trenner hinter dem zuletzt
/// gelesenen Stand; danach wird bis zur neuesten Nachricht gelesen gemeldet.
void _onFocusRegained() {
if (!mounted || !_sessionCaptured) return;
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline == null) return;
final since = _lastMarkedEventId;
if (since != null && !_sessionHasUnread) {
final me = timeline.room.client.userID;
final events = timeline.events; // newest-first
final idx = events.indexWhere((e) => e.eventId == since);
final hasNew = idx > 0 &&
events
.take(idx)
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
if (hasNew) {
_unreadFadeTimer?.cancel();
setState(() {
_sessionFullyReadId = since;
_sessionHasUnread = true;
_unreadFading = false;
});
}
} }
_markRead(timeline);
} }
Future<void> _onScroll() async { Future<void> _onScroll() async {
@@ -313,7 +393,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
// listen) so it also fires when this ChatView is freshly mounted for a // listen) so it also fires when this ChatView is freshly mounted for a
// DIFFERENT room — listen would miss a value set before registration. // DIFFERENT room — listen would miss a value set before registration.
final pendingJump = ref.watch(pendingJumpEventProvider); final pendingJump = ref.watch(pendingJumpEventProvider);
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { // (Kein Vergleich mit _jumpEventId mehr: dasselbe Zitat darf zweimal
// hintereinander angetippt werden – _startJump verbraucht die Anfrage.)
if (pendingJump != null && !_jumpInProgress) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && if (mounted &&
!_jumpInProgress && !_jumpInProgress &&
@@ -429,28 +511,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
jumpKey: _jumpKey, jumpKey: _jumpKey,
highlightEventId: _highlightEventId, highlightEventId: _highlightEventId,
highlightOn: _highlightOn, highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
); );
}, },
), ),
if (_showScrollDown) Positioned(
Positioned(
right: 16, right: 16,
bottom: 12, bottom: (room != null ? _composerHeight : 0) + 12,
child: AnimatedOpacity( child: IgnorePointer(
opacity: _showScrollDown ? 1 : 0, ignoring: !_showScrollDown,
duration: const Duration(milliseconds: 150), child: AnimatedScale(
scale: _showScrollDown ? 1 : 0,
duration: motionDuration(
context, _showScrollDown ? 520 : 180),
curve: _showScrollDown
? Curves.elasticOut
: Curves.easeInBack,
child: GestureDetector( child: GestureDetector(
onTap: () => _scrollCtrl.animateTo( onTap: () => _scrollCtrl.animateTo(
0, 0,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOut, curve: Curves.easeOut,
), ),
child: SpringPress(
child: Container( child: Container(
width: 36, width: _scrollBtnSize,
height: 36, height: _scrollBtnSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg0, color: pt.bg0,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
border: Border.all(color: pt.border), border: Border.all(color: pt.border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -460,7 +550,49 @@ class _ChatViewState extends ConsumerState<ChatView> {
), ),
], ],
), ),
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted), child: Icon(Icons.keyboard_arrow_down_rounded,
size: _scrollBtnSize * 0.56, color: pt.fgMuted),
),
),
),
),
),
),
// Schwebende Eingabeleiste (Bernds Wunsch 2026-10-07):
// der Chat läuft optisch darunter weiter
// (durchscheinend + Unschärfe), die ganze Fläche
// fängt aber Berührungen ab – dort reagiert nur
// die Eingabe, nicht der Chat dahinter.
if (room != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MeasureSize(
onChange: (size) {
if (mounted &&
(size.height - _composerHeight).abs() >
0.5) {
setState(
() => _composerHeight = size.height);
}
},
child: _FloatingComposerArea(
pt: pt,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypingIndicator(room: room, pt: pt),
ChatComposer(
// Je Raum ein eigenes Eingabefeld
// (Text, „tippt…“, Entwurf).
key: ValueKey('composer:${room.id}'),
room: room,
replyTo: _replyTo,
onClearReply: () =>
setState(() => _replyTo = null),
),
],
), ),
), ),
), ),
@@ -468,13 +600,6 @@ class _ChatViewState extends ConsumerState<ChatView> {
], ],
), ),
), ),
if (room != null) _TypingIndicator(room: room, pt: pt),
if (room != null)
ChatComposer(
room: room,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
], ],
), ),
), ),
+1 -1
View File
@@ -43,7 +43,7 @@ class MembersPanel extends ConsumerWidget {
Icon(Icons.group_rounded, size: 16, color: pt.fg), Icon(Icons.group_rounded, size: 16, color: pt.fg),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Members', 'Mitglieder',
style: TextStyle( style: TextStyle(
color: pt.fg, color: pt.fg,
fontSize: 14, fontSize: 14,
+2 -2
View File
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
final name = isRealSpace final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space') ? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Rooms'); : (spaceId == 'dms' ? 'Chats' : 'Räume');
void handleMenu(_SpaceMenuItem item) { void handleMenu(_SpaceMenuItem item) {
switch (item) { switch (item) {
@@ -478,7 +478,7 @@ class _FilterInput extends StatelessWidget {
onChanged: onChanged, onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13), style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Filter rooms', hintText: 'Chats durchsuchen',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
filled: true, filled: true,
+57 -9
View File
@@ -99,10 +99,10 @@ class _RoomsList extends ConsumerWidget {
if (filtered.isEmpty && invites.isEmpty) { if (filtered.isEmpty && invites.isEmpty) {
return Center( return Center(
child: child:
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13))); Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
} }
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms'; final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms'; final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false; final isCollapsed = collapsed[sectionId] ?? false;
@@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget {
context: context, builder: (_) => const CreateJoinDialog()), context: context, builder: (_) => const CreateJoinDialog()),
), ),
if (!isCollapsed) if (!isCollapsed)
...filtered.map((room) => _RoomItem( ...filtered.map((room) {
room: room, final isVoice = _isVoiceRoom(room);
isActive: activeRoomId == room.id, return _RoomItem(
pt: pt, room: room,
onTap: () { isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: pt,
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste) –
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
onTap: () {
if (isVoice) {
_toggleVoiceChannel(ref, room);
} else {
ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat; ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}, }
)), },
);
}),
], ],
); );
} }
} }
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// aktiven Kanal, Wechsel legt vorher auf).
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
if (call.isVoiceChannel && activeVoice == room.id) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
await call.hangUp();
return;
}
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: room.client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: room.client,
matrixRoomId: room.id,
);
}
class _SectionHeader extends StatefulWidget { class _SectionHeader extends StatefulWidget {
final String title; final String title;
final int count; final int count;
@@ -477,6 +513,18 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[]; final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
final client = ref.watch(matrixClientProvider).valueOrNull; final client = ref.watch(matrixClientProvider).valueOrNull;
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileRoomTile(
room: room,
isActive: active,
pt: pt,
myUserId: client?.userID,
onTap: widget.onTap,
onLongPress: _showContextMenu,
);
}
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted; Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
return MouseRegion( return MouseRegion(
@@ -0,0 +1,167 @@
part of '../rooms_panel.dart';
// Chatlisten-Eintrag im Handy-Layout (Bernds Feedback 2026-10-07: die Handy-App
// soll sich wie ein gewohnter Messenger anfühlen): großer Avatar, Name mit
// Uhrzeit der letzten Nachricht, darunter die letzte Nachricht als Vorschau
// und der Ungelesen-Zähler. Am PC bleibt die kompakte Discord-Liste
// (_RoomItem); Voice-Kanäle behalten ihre eigene Darstellung.
const _kTileAvatar = 46.0;
class _MobileRoomTile extends StatelessWidget {
final Room room;
final bool isActive;
final PyramidTheme pt;
final String? myUserId;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _MobileRoomTile({
required this.room,
required this.isActive,
required this.pt,
required this.myUserId,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final name = room.getLocalizedDisplayname();
final unread = room.notificationCount;
final mention = room.highlightCount > 0;
final last = room.lastEvent;
final preview = _roomPreview(room, myUserId);
final time = last == null ? null : chatListTime(last.originServerTs);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Container(
color: isActive ? pt.accentSoft : Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Row(
children: [
room.isDirectChat
? _DmAvatar(room: room, pt: pt, size: _kTileAvatar)
: _RoomTileAvatar(room: room, pt: pt),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight:
unread > 0 ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (time != null) ...[
const SizedBox(width: 8),
Text(
time,
style: TextStyle(
color: unread > 0 ? pt.accent : pt.fgDim,
fontSize: 12,
fontWeight:
unread > 0 ? FontWeight.w600 : FontWeight.w400,
),
),
],
],
),
const SizedBox(height: 3),
Row(
children: [
Expanded(
child: Text(
preview ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: unread > 0 ? pt.fgMuted : pt.fgDim,
fontSize: 13.5,
),
),
),
// Immer im Baum, damit der Zähler beim Erscheinen und
// Hochzählen federnd aufploppen kann.
SpringBump(
value: unread,
child: unread > 0
? Padding(
padding: const EdgeInsets.only(left: 8),
child: _UnreadBadge(
count: unread, isMention: mention, pt: pt),
)
: const SizedBox.shrink(),
),
],
),
],
),
),
],
),
),
);
}
}
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
class _RoomTileAvatar extends ConsumerWidget {
final Room room;
final PyramidTheme pt;
const _RoomTileAvatar({required this.room, required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final fallback = Container(
width: _kTileAvatar,
height: _kTileAvatar,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 22,
color: pt.fgMuted,
),
);
if (client == null || room.avatar == null) return fallback;
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: _kTileAvatar,
borderRadius: BorderRadius.circular(14),
placeholder: (_) => fallback,
);
}
}
/// Vorschau der letzten Nachricht: gleiche Texte wie in Benachrichtigungen
/// (Medien als „📷 Foto“ usw., Antwort-Zitat entfernt), in Gruppen mit
/// Absender, eigene Nachrichten mit „Du:“.
String? _roomPreview(Room room, String? myUserId) {
final e = room.lastEvent;
if (e == null) return null;
if (e.type == EventTypes.Encrypted) return '🔒 Verschlüsselte Nachricht';
final body = notificationBody(e);
if (body == null) return null;
if (e.senderId == myUserId) return 'Du: $body';
if (room.isDirectChat) return body;
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
}
+27 -2
View File
@@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final _filterCtrl = TextEditingController(); final _filterCtrl = TextEditingController();
String _filter = ''; String _filter = '';
final Map<String, bool> _collapsed = {}; final Map<String, bool> _collapsed = {};
String? _lastShownCallError;
@override @override
void dispose() { void dispose() {
@@ -37,10 +38,34 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
// Voice-Zustand mit der UI synchron halten: Bei unerwartetem Abbruch
// oder fehlgeschlagenem Beitritt wird der Service inaktiv, aber
// activeVoiceRoomIdProvider zeigte vorher weiter auf den Kanal – das
// Kanal-Highlight und der eigene (optimistische) Teilnehmer-Eintrag
// blieben hängen. Außerdem den Fehler sichtbar machen (kein stiller
// Fehlerpfad, CLAUDE.md).
ref.listen<VoiceChannelService>(voiceChannelProvider, (_, call) {
if (!call.isActive && ref.read(activeVoiceRoomIdProvider) != null) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
}
final err = call.error;
if (err == null) {
_lastShownCallError = null;
} else if (err != _lastShownCallError) {
_lastShownCallError = err;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Voice: $err')),
);
});
}
});
final roomsAsync = ref.watch(roomListProvider); final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider); final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(callStateProvider); final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(voipStateProvider); final voip = ref.watch(callSignalingProvider);
final isRealSpace = activeSpaceId != null && final isRealSpace = activeSpaceId != null &&
activeSpaceId != 'dms' && activeSpaceId != 'dms' &&
+12 -11
View File
@@ -3,7 +3,8 @@ part of '../rooms_panel.dart';
class _DmAvatar extends ConsumerStatefulWidget { class _DmAvatar extends ConsumerStatefulWidget {
final Room room; final Room room;
final PyramidTheme pt; final PyramidTheme pt;
const _DmAvatar({required this.room, required this.pt}); final double size;
const _DmAvatar({required this.room, required this.pt, this.size = 22});
@override @override
ConsumerState<_DmAvatar> createState() => _DmAvatarState(); ConsumerState<_DmAvatar> createState() => _DmAvatarState();
@@ -69,13 +70,13 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
avatar = MxcAvatar( avatar = MxcAvatar(
mxcUri: widget.room.avatar, mxcUri: widget.room.avatar,
client: client, client: client,
size: 22, size: widget.size,
borderRadius: BorderRadius.circular(11), borderRadius: BorderRadius.circular(widget.size / 2),
placeholder: (_) => placeholder: (_) =>
_InitialCircle(initial: initial, color: color, size: 22), _InitialCircle(initial: initial, color: color, size: widget.size),
); );
} else { } else {
avatar = _InitialCircle(initial: initial, color: color, size: 22); avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
} }
final dot = _dotColor(); final dot = _dotColor();
@@ -83,11 +84,11 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
return Stack(clipBehavior: Clip.none, children: [ return Stack(clipBehavior: Clip.none, children: [
avatar, avatar,
Positioned( Positioned(
right: -2, right: widget.size > 30 ? 0 : -2,
bottom: -2, bottom: widget.size > 30 ? 0 : -2,
child: Container( child: Container(
width: 9, width: widget.size > 30 ? 13 : 9,
height: 9, height: widget.size > 30 ? 13 : 9,
decoration: BoxDecoration( decoration: BoxDecoration(
color: dot, color: dot,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -123,9 +124,9 @@ class _InitialCircle extends StatelessWidget {
decoration: BoxDecoration(color: color, shape: BoxShape.circle), decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Center( child: Center(
child: Text(initial, child: Text(initial,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: size * 0.45,
fontWeight: FontWeight.w600))), fontWeight: FontWeight.w600))),
); );
} }
@@ -95,7 +95,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
if (jr.isVoice) { if (jr.isVoice) {
// LiveKit only needs the room id + a display name, so connect even if the // LiveKit only needs the room id + a display name, so connect even if the
// matrix room object hasn't synced locally yet. // matrix room object hasn't synced locally yet.
final call = ref.read(callStateProvider); final call = ref.read(voiceChannelProvider);
if (call.isActive) await call.hangUp(); if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId; ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
await call.startCall( await call.startCall(
@@ -199,7 +199,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
} }
void joinVoiceChannel(Room room) async { void joinVoiceChannel(Room room) async {
final call = ref.read(callStateProvider); final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider); final activeVoice = ref.read(activeVoiceRoomIdProvider);
// Toggle: leave if already in this channel // Toggle: leave if already in this channel
if (call.isVoiceChannel && activeVoice == room.id) { if (call.isVoiceChannel && activeVoice == room.id) {
+8 -1
View File
@@ -7,16 +7,22 @@ import 'package:matrix/matrix.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:matrix/src/utils/space_child.dart'; import 'package:matrix/src/utils/space_child.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/call/mini_call_widget.dart'; import 'package:pyramid/features/call_ui/mini_call_widget.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/features/rooms/user_panel.dart'; import 'package:pyramid/features/rooms/user_panel.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart';
import 'package:pyramid/utils/date_labels.dart';
import 'package:pyramid/widgets/create_join_dialog.dart'; import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/new_dm_dialog.dart'; import 'package:pyramid/widgets/new_dm_dialog.dart';
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -29,3 +35,4 @@ part 'panel/rooms_list.dart';
part 'panel/rooms_voice.dart'; part 'panel/rooms_voice.dart';
part 'panel/rooms_invite_item.dart'; part 'panel/rooms_invite_item.dart';
part 'panel/rooms_shared.dart'; part 'panel/rooms_shared.dart';
part 'panel/rooms_mobile_tile.dart';
@@ -0,0 +1,62 @@
import 'package:matrix/matrix.dart';
/// Lokale Nachrichtensuche über die entschlüsselten Events in der Client-DB.
///
/// Die server-seitige `/search`-API sieht bei E2EE nur Chiffretext und liefert
/// für verschlüsselte Räume nichts – deshalb wird (wie bei Element) die lokale
/// Datenbank durchsucht. Aus `search_modal.dart` herausgelöst, damit Tests und
/// `benchmark/` genau diesen Codepfad prüfen/messen können.
///
/// Bei [mediaOnly] werden nur Medien-Nachrichten geliefert, deren
/// Dateiname/Beschriftung passt. Ergebnis: neueste zuerst, höchstens
/// [searchTotalCap] Treffer; pro Raum werden höchstens
/// [searchPerRoomScanCap] Events gescannt.
Future<List<Event>> localMessageSearch(
Client client,
String query, {
bool mediaOnly = false,
}) async {
final db = client.database;
final lower = query.toLowerCase();
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
final results = <Event>[];
for (final room in client.rooms.where((r) => !r.isSpace)) {
var start = 0;
var scanned = 0;
try {
while (scanned < searchPerRoomScanCap) {
final events = await db.getEventList(
room,
start: start,
limit: searchScanChunk,
);
if (events.isEmpty) break;
for (final e in events) {
if (e.type != EventTypes.Message || e.redacted) continue;
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
if (mediaOnly && !mediaTypes.contains(msgtype)) continue;
if (!e.body.toLowerCase().contains(lower)) continue;
results.add(e);
}
start += searchScanChunk;
scanned += events.length;
final roomExhausted = events.length < searchScanChunk;
if (roomExhausted || results.length >= searchTotalCap) break;
}
} catch (_) {}
if (results.length >= searchTotalCap) break;
}
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
return results.take(searchTotalCap).toList();
}
/// Höchstens so viele Events werden pro Raum gescannt.
const searchPerRoomScanCap = 1500;
/// Höchstens so viele Treffer liefert die Suche insgesamt.
const searchTotalCap = 60;
/// So viele Events werden pro DB-Abfrage geladen.
const searchScanChunk = 500;
+2 -2
View File
@@ -71,7 +71,7 @@ class SpacesRail extends ConsumerWidget {
id: 'rooms', id: 'rooms',
label: 'R', label: 'R',
color: const Color(0xFFF59E0B), color: const Color(0xFFF59E0B),
name: 'Rooms', name: 'Räume',
active: activeSpaceId == 'rooms', active: activeSpaceId == 'rooms',
unread: 0, unread: 0,
pt: pt, pt: pt,
@@ -179,7 +179,7 @@ class _HomeButtonState extends State<_HomeButton> {
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05)) ? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
: Matrix4.identity(), : Matrix4.identity(),
child: Tooltip( child: Tooltip(
message: 'Direct Messages', message: 'Direktnachrichten',
waitDuration: const Duration(milliseconds: 400), waitDuration: const Duration(milliseconds: 400),
preferBelow: false, preferBelow: false,
child: Stack( child: Stack(
@@ -6,11 +6,14 @@ import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room; import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/core/ice_servers.dart'; import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart'; import 'package:pyramid/core/livekit_token.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
const _kVoicePresenceType = 'io.pyramid.voice.presence'; const _kVoicePresenceType = 'io.pyramid.voice.presence';
class LiveKitCallManager with ChangeNotifier { /// Implementierung von [VoiceChannelService] (Modul `voice_channel`).
/// Andere Module gehen ausschließlich über `voiceChannelProvider`.
class LiveKitCallManager extends VoiceChannelService {
static final LiveKitCallManager instance = LiveKitCallManager._(); static final LiveKitCallManager instance = LiveKitCallManager._();
LiveKitCallManager._(); LiveKitCallManager._();
@@ -18,31 +21,51 @@ class LiveKitCallManager with ChangeNotifier {
static final Map<String, VideoParameters> qualityPresets = { static final Map<String, VideoParameters> qualityPresets = {
'sd': VideoParametersPresets.h360_169, 'sd': VideoParametersPresets.h360_169,
'hd': VideoParametersPresets.h720_169, 'hd': VideoParametersPresets.h720_169,
'hd60': VideoParametersPresets.h720_169,
'fhd': VideoParametersPresets.h1080_169, 'fhd': VideoParametersPresets.h1080_169,
'fhd60': VideoParametersPresets.h1080_169,
'4k': VideoParametersPresets.h2160_169, '4k': VideoParametersPresets.h2160_169,
}; };
static const Map<String, VideoEncoding> _screenShareEncodings = { /// FPS + Bitrate pro Screenshare-Preset. Die 60-FPS-Presets (M4,
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10), /// `docs/STREAMING_60FPS.md`) brauchen deutlich mehr Bitrate, sonst
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15), /// verwirft der Encoder Frames und es ruckelt trotz hoher Framerate.
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20), @visibleForTesting
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30), static const Map<String, VideoEncoding> screenShareEncodings = {
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
'hd60': VideoEncoding(maxBitrate: 5_000_000, maxFramerate: 60),
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
'fhd60': VideoEncoding(maxBitrate: 8_000_000, maxFramerate: 60),
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
}; };
/// 60-FPS-Presets bekommen beim Publish eine andere Strategie
/// (kein Simulcast, Framerate vor Auflösung halten).
@visibleForTesting
static bool isHighFpsPreset(String key) => key.endsWith('60');
Room? _room; Room? _room;
EventsListener<RoomEvent>? _roomEventListener; EventsListener<RoomEvent>? _roomEventListener;
String _displayName = ''; String _displayName = '';
@override
bool isConnecting = false; bool isConnecting = false;
@override
String? error; String? error;
@override
bool isMuted = false; bool isMuted = false;
@override
bool isCameraOff = false; bool isCameraOff = false;
@override
bool isScreenSharing = false; bool isScreenSharing = false;
String _localQualityKey = 'hd'; String _localQualityKey = 'hd';
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high' String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
String? _currentScreenShareSourceId; String? _currentScreenShareSourceId;
LocalVideoTrack? _screenShareTrack; LocalVideoTrack? _screenShareTrack;
@override
bool isDeafened = false; bool isDeafened = false;
bool _mutedBeforeDeafen = false; bool _mutedBeforeDeafen = false;
@override
bool isVoiceChannel = false; bool isVoiceChannel = false;
String? _matrixRoomId; String? _matrixRoomId;
Client? _matrixClient; Client? _matrixClient;
@@ -50,12 +73,24 @@ class LiveKitCallManager with ChangeNotifier {
Timer? _statsTimer; Timer? _statsTimer;
/// Entwertet laufende `startCall`-Verbindungsaufbauten: Wer während des
/// (mehrere Sekunden dauernden) `connect` auflegt oder den Kanal wechselt,
/// darf KEINE Geisterverbindung zurücklassen – vorher blieb in dem Fall
/// das Mikro im alten Kanal offen, während die UI „nicht verbunden" zeigte.
int _callGeneration = 0;
@override
bool get isActive => _room != null || isConnecting; bool get isActive => _room != null || isConnecting;
@override
Room? get room => _room; Room? get room => _room;
@override
String? get currentRoomId => _matrixRoomId; String? get currentRoomId => _matrixRoomId;
@override
String get displayName => _displayName; String get displayName => _displayName;
bool get noiseSuppression => false; bool get noiseSuppression => false;
@override
String get currentQualityKey => _localQualityKey; String get currentQualityKey => _localQualityKey;
@override
String get subscribeQualityKey => _subscribeQualityKey; String get subscribeQualityKey => _subscribeQualityKey;
VideoParameters get _currentQuality => VideoParameters get _currentQuality =>
@@ -64,6 +99,7 @@ class LiveKitCallManager with ChangeNotifier {
void _onRoomChanged() => notifyListeners(); void _onRoomChanged() => notifyListeners();
void _cleanUpAfterDisconnect() { void _cleanUpAfterDisconnect() {
_callGeneration++;
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -76,7 +112,9 @@ class LiveKitCallManager with ChangeNotifier {
_room = null; _room = null;
_displayName = ''; _displayName = '';
isConnecting = false; isConnecting = false;
error = null; // `error` bewusst NICHT löschen: Ein unerwarteter Abbruch/Join-Fehler
// muss für die UI sichtbar bleiben (kein stiller Fehlerpfad). Manuelles
// Auflegen (hangUp) setzt error selbst zurück.
isMuted = false; isMuted = false;
isCameraOff = false; isCameraOff = false;
isScreenSharing = false; isScreenSharing = false;
@@ -122,6 +160,7 @@ class LiveKitCallManager with ChangeNotifier {
} }
} }
@override
Future<void> startCall({ Future<void> startCall({
required String roomName, required String roomName,
required String roomDisplayName, required String roomDisplayName,
@@ -132,8 +171,7 @@ class LiveKitCallManager with ChangeNotifier {
String? matrixRoomId, String? matrixRoomId,
}) async { }) async {
if (isActive) return; if (isActive) return;
final gen = ++_callGeneration;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true; isConnecting = true;
isVoiceChannel = voiceChannel; isVoiceChannel = voiceChannel;
@@ -151,12 +189,21 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
final token = LiveKitTokenGenerator.generate( // Token holt der Server (prüft den Matrix-Login, Identität = eigene
roomName: roomName, // Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
identity: identity, // [identity] wird dafür nicht mehr gebraucht.
displayName: identity, final accessToken = matrixClient?.accessToken;
ttlSeconds: 21600, if (accessToken == null || accessToken.isEmpty) {
throw const LiveKitTokenException(
'Nicht angemeldet – Beitritt nicht möglich.');
}
final lk = await LiveKitTokenService.fetch(
room: roomName,
matrixToken: accessToken,
); );
if (gen != _callGeneration) return; // inzwischen aufgelegt/gewechselt
final token = lk.token;
final url = lk.url;
final room = Room( final room = Room(
roomOptions: const RoomOptions( roomOptions: const RoomOptions(
@@ -193,6 +240,17 @@ class LiveKitCallManager with ChangeNotifier {
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig), connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
); );
if (gen != _callGeneration) {
// Während des Verbindens wurde aufgelegt oder der Kanal gewechselt –
// diese Verbindung gehört niemandem mehr: sofort wieder trennen,
// sonst bleibt das Mikro unsichtbar im alten Kanal offen.
room.removeListener(_onRoomChanged);
try {
await room.disconnect();
} catch (_) {}
return;
}
_room = room; _room = room;
isConnecting = false; isConnecting = false;
notifyListeners(); notifyListeners();
@@ -233,7 +291,11 @@ class LiveKitCallManager with ChangeNotifier {
..on<TrackUnpublishedEvent>((_) => notifyListeners()) ..on<TrackUnpublishedEvent>((_) => notifyListeners())
..on<ParticipantConnectedEvent>((_) => notifyListeners()) ..on<ParticipantConnectedEvent>((_) => notifyListeners())
..on<ParticipantDisconnectedEvent>((_) => notifyListeners()) ..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
..on<RoomDisconnectedEvent>((_) { ..on<RoomDisconnectedEvent>((event) {
// Kommt nur bei UNERWARTETEN Abbrüchen an (manuelles hangUp
// entsorgt den Listener vorher) – Grund sichtbar machen statt
// still aus dem Kanal zu verschwinden.
error = 'Verbindung getrennt (${event.reason?.name ?? 'unbekannt'})';
_cleanUpAfterDisconnect(); _cleanUpAfterDisconnect();
}); });
@@ -243,11 +305,11 @@ class LiveKitCallManager with ChangeNotifier {
String? prefMic, prefSpeaker, prefCam; String? prefMic, prefSpeaker, prefCam;
var deviceIds = <String>{}; var deviceIds = <String>{};
try { try {
final prefs = await SharedPreferences.getInstance(); _outputVolume = await VoicePrefs.loadOutputVolume();
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0; final devicePrefs = await VoicePrefs.loadDevices();
prefMic = prefs.getString('voice_mic_device'); prefMic = devicePrefs.mic;
prefSpeaker = prefs.getString('voice_speaker_device'); prefSpeaker = devicePrefs.speaker;
prefCam = prefs.getString('voice_camera_device'); prefCam = devicePrefs.camera;
final devices = await rtc.navigator.mediaDevices.enumerateDevices(); final devices = await rtc.navigator.mediaDevices.enumerateDevices();
deviceIds = devices.map((d) => d.deviceId).toSet(); deviceIds = devices.map((d) => d.deviceId).toSet();
} catch (e) { } catch (e) {
@@ -288,9 +350,16 @@ class LiveKitCallManager with ChangeNotifier {
_startStatsLogging(); _startStatsLogging();
} catch (e) { } catch (e) {
debugPrint('[VOIP] Start call error: $e'); debugPrint('[VOIP] Start call error: $e');
if (gen != _callGeneration) {
// Inzwischen wurde aufgelegt/neu verbunden – der Zustand gehört
// schon dem neuen Aufbau, hier nichts mehr anfassen.
return;
}
isConnecting = false; isConnecting = false;
error = e.toString();
_cleanUpAfterDisconnect(); _cleanUpAfterDisconnect();
// NACH dem Aufräumen setzen, damit der Join-Fehler sichtbar bleibt.
error = 'Beitritt fehlgeschlagen: $e';
notifyListeners();
} }
} }
@@ -307,6 +376,7 @@ class LiveKitCallManager with ChangeNotifier {
double _outputVolume = 1.0; double _outputVolume = 1.0;
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks. /// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
@override
Future<void> setOutputVolume(double v) async { Future<void> setOutputVolume(double v) async {
_outputVolume = v; _outputVolume = v;
final room = _room; final room = _room;
@@ -323,6 +393,7 @@ class LiveKitCallManager with ChangeNotifier {
} }
} }
@override
Future<void> setSubscribeQuality(String key) async { Future<void> setSubscribeQuality(String key) async {
debugPrint('[LiveKit] Viewer changing subscribe quality to: $key'); debugPrint('[LiveKit] Viewer changing subscribe quality to: $key');
_subscribeQualityKey = key; _subscribeQualityKey = key;
@@ -340,12 +411,14 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
Future<void> toggleMute() async { Future<void> toggleMute() async {
isMuted = !isMuted; isMuted = !isMuted;
notifyListeners(); notifyListeners();
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted); await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
} }
@override
Future<void> toggleDeafen() async { Future<void> toggleDeafen() async {
if (!isDeafened) { if (!isDeafened) {
_mutedBeforeDeafen = isMuted; _mutedBeforeDeafen = isMuted;
@@ -377,6 +450,7 @@ class LiveKitCallManager with ChangeNotifier {
} }
} }
@override
Future<void> toggleCamera() async { Future<void> toggleCamera() async {
if (isCameraOff) { if (isCameraOff) {
try { try {
@@ -399,6 +473,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
Future<void> toggleScreenShare() async { Future<void> toggleScreenShare() async {
if (isScreenSharing) { if (isScreenSharing) {
await stopScreenShare(); await stopScreenShare();
@@ -422,18 +497,27 @@ class LiveKitCallManager with ChangeNotifier {
} }
VideoEncoding _screenShareEncoding() { VideoEncoding _screenShareEncoding() {
return _screenShareEncodings[_localQualityKey] ?? return screenShareEncodings[_localQualityKey] ??
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15); const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
} }
@override
Future<void> startScreenShare(String sourceId) async { Future<void> startScreenShare(String sourceId) async {
if (isScreenSharing) return; if (isScreenSharing) return;
try { try {
final encoding = _screenShareEncoding(); final encoding = _screenShareEncoding();
final highFps = isHighFpsPreset(_localQualityKey);
final track = await LocalVideoTrack.createScreenShareTrack( final track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions( ScreenShareCaptureOptions(
sourceId: sourceId, sourceId: sourceId,
maxFrameRate: encoding.maxFramerate.toDouble(), maxFrameRate: encoding.maxFramerate.toDouble(),
// Ohne explizite params bleibt die Capture-Auflösung beim
// LiveKit-Default (1080p/15) hängen – das Preset (Auflösung UND
// FPS) muss bis in die Capture-Constraints durchgereicht werden.
params: VideoParameters(
dimensions: _currentQuality.dimensions,
encoding: encoding,
),
), ),
); );
@@ -442,8 +526,15 @@ class LiveKitCallManager with ChangeNotifier {
await _room!.localParticipant!.publishVideoTrack( await _room!.localParticipant!.publishVideoTrack(
track, track,
publishOptions: VideoPublishOptions( publishOptions: VideoPublishOptions(
simulcast: true, // 60-FPS-Presets: EIN Layer mit voller Bitrate statt drei
degradationPreference: DegradationPreference.balanced, // Simulcast-Encodes (Software-Encoder!), und unter Last lieber
// Auflösung senken als Frames verlieren. contentHint 'motion'
// wäre ein weiterer Hebel, ist aber in flutter_webrtc 1.4.1 aus
// Dart nicht setzbar (Pub-Cache geprüft, 2026-07-06).
simulcast: !highFps,
degradationPreference: highFps
? DegradationPreference.maintainFramerate
: DegradationPreference.balanced,
screenShareEncoding: encoding, screenShareEncoding: encoding,
), ),
); );
@@ -480,6 +571,7 @@ class LiveKitCallManager with ChangeNotifier {
} }
} }
@override
Future<void> stopScreenShare() async { Future<void> stopScreenShare() async {
final local = _room?.localParticipant; final local = _room?.localParticipant;
if (local != null) { if (local != null) {
@@ -501,7 +593,10 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
Future<void> hangUp() async { Future<void> hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++;
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -529,6 +624,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
Future<void> changeQuality(String qualityKey) async { Future<void> changeQuality(String qualityKey) async {
debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey'); debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey');
_localQualityKey = qualityKey; _localQualityKey = qualityKey;
@@ -562,6 +658,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
Future<void> changeAudioInput(String deviceId) async { Future<void> changeAudioInput(String deviceId) async {
final participant = _room?.localParticipant; final participant = _room?.localParticipant;
if (participant == null) return; if (participant == null) return;
@@ -575,6 +672,7 @@ class LiveKitCallManager with ChangeNotifier {
} catch (e) { debugPrint('[Audio] error: $e'); } } catch (e) { debugPrint('[Audio] error: $e'); }
} }
@override
void setRemoteVolume(double volume) { void setRemoteVolume(double volume) {
if (_room == null) return; if (_room == null) return;
final mute = volume < 0.05; final mute = volume < 0.05;
@@ -0,0 +1,75 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/features/voice_channel/livekit_call_manager.dart';
/// Öffentliche Schnittstelle des Moduls `voice_channel` – LiveKit-SFU-basierte,
/// dauerhafte Voice-Channels (Discord-artig: Presence über Matrix-State-Events,
/// Screensharing, Mehrpersonen). Schnitt nach `docs/M2_MODULE_SCHNITT.md`;
/// bewusst GETRENNT vom 1:1-Modul `call_signaling`, siehe dort.
///
/// Andere Module konsumieren ausschließlich diese Schnittstelle über
/// [voiceChannelProvider] – nie `livekit_call_manager.dart` direkt.
///
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
/// Schönheit): ChangeNotifier statt `stateStream`, Methodennamen wie im
/// Bestand (`startCall`/`hangUp` statt `join`/`leave`), und [room] bleibt
/// als LiveKit-Typ sichtbar, weil die Teilnehmer-Kacheln der UI direkt
/// LiveKit-Tracks rendern (analog zu den WebRTC-Renderern im Signaling).
abstract class VoiceChannelService extends ChangeNotifier {
bool get isActive;
bool get isConnecting;
String? get error;
bool get isMuted;
bool get isCameraOff;
bool get isScreenSharing;
bool get isDeafened;
bool get isVoiceChannel;
/// Der verbundene LiveKit-Raum (null = nicht verbunden).
lk.Room? get room;
/// Matrix-Raum-ID des aktiven Voice-Channels.
String? get currentRoomId;
String get displayName;
/// Sende-Qualität ('sd' | 'hd' | 'hd60' | 'fhd' | 'fhd60' | '4k').
/// Die *60-Presets sind für flüssiges Bildschirm-Streaming (M4).
String get currentQualityKey;
/// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high').
String get subscribeQualityKey;
Future<void> startCall({
required String roomName,
required String roomDisplayName,
required String identity,
bool audioOnly,
bool voiceChannel,
Client? matrixClient,
String? matrixRoomId,
});
Future<void> hangUp();
Future<void> toggleMute();
Future<void> toggleDeafen();
Future<void> toggleCamera();
Future<void> toggleScreenShare();
Future<void> startScreenShare(String sourceId);
Future<void> stopScreenShare();
Future<void> changeQuality(String qualityKey);
Future<void> setSubscribeQuality(String key);
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
Future<void> setOutputVolume(double v);
Future<void> changeAudioInput(String deviceId);
void setRemoteVolume(double volume);
}
/// Einziger Zugriffsweg der UI auf das Voice-Channel-Modul.
final voiceChannelProvider =
ChangeNotifierProvider<VoiceChannelService>((ref) {
return LiveKitCallManager.instance;
});
+64 -26
View File
@@ -9,13 +9,16 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/auth/bootstrap_dialog.dart'; import 'package:pyramid/features/auth/bootstrap_dialog.dart';
import 'package:pyramid/features/call/voice_channel.dart'; import 'package:pyramid/features/call_ui/voice_channel.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/features/chat/chat_view.dart'; import 'package:pyramid/features/chat/chat_view.dart';
import 'package:pyramid/features/members/members_panel.dart'; import 'package:pyramid/features/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.dart'; import 'package:pyramid/features/spaces/spaces_rail.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/search_modal.dart'; import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart'; import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart'; import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -160,7 +163,7 @@ class _AppShellState extends ConsumerState<AppShell>
// notifyListeners (u.a. Render-Timer im Sekundentakt); ohne Flankenerkennung // notifyListeners (u.a. Render-Timer im Sekundentakt); ohne Flankenerkennung
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte // würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
// sie nie minimieren. // sie nie minimieren.
ref.listen(voipStateProvider, (prev, next) { ref.listen(callSignalingProvider, (prev, next) {
final wasConnected = _wasVoipConnected; final wasConnected = _wasVoipConnected;
final isConnected = next.currentCall != null && final isConnected = next.currentCall != null &&
next.currentCall!.state == CallState.kConnected; next.currentCall!.state == CallState.kConnected;
@@ -172,7 +175,7 @@ class _AppShellState extends ConsumerState<AppShell>
} }
}); });
ref.listen(callStateProvider, (prev, next) { ref.listen(voiceChannelProvider, (prev, next) {
// Only auto-switch to voice view for video/audio calls, NOT voice channels // Only auto-switch to voice view for video/audio calls, NOT voice channels
// (voice channels should stay in background with a mini bar) // (voice channels should stay in background with a mini bar)
final wasActive = _wasLiveKitActive; final wasActive = _wasLiveKitActive;
@@ -200,7 +203,7 @@ class _AppShellState extends ConsumerState<AppShell>
final activeModal = ref.watch(activeModalProvider); final activeModal = ref.watch(activeModalProvider);
final membersOpen = ref.watch(membersPanelOpenProvider); final membersOpen = ref.watch(membersPanelOpenProvider);
final innerPt = PyramidTheme.of(context); final innerPt = PyramidTheme.of(context);
final voip = ref.watch(voipStateProvider); final voip = ref.watch(callSignalingProvider);
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500; final isMobile = size.width < 900 || size.height < 500;
@@ -332,18 +335,33 @@ class _MobileShell extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final voip = ref.watch(voipStateProvider); final voip = ref.watch(callSignalingProvider);
Widget body; // Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
if (viewMode == ViewMode.voice) { // darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
body = const VoiceChannelView(); final Widget? front = viewMode == ViewMode.voice
} else if (activeRoomId != null) { ? const VoiceChannelView()
body = ChatView(roomId: activeRoomId!); : activeRoomId != null
} else { ? ChatView(roomId: activeRoomId!)
body = const RoomsPanel(); : null;
} final body = MobilePageStack(
base: const RoomsPanel(),
front: front,
background: pt.bg1,
);
return PopScope( // Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark)
.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
child: PopScope(
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen, canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) { if (!didPop) {
@@ -357,13 +375,17 @@ class _MobileShell extends ConsumerWidget {
} }
}, },
child: Scaffold( child: Scaffold(
backgroundColor: pt.bg0, // bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
// geht nahtlos in sie über.
backgroundColor: pt.bg1,
drawer: Drawer( drawer: Drawer(
width: 80, width: 80,
backgroundColor: pt.bg0, backgroundColor: pt.bg0,
child: const SpacesRail(), child: const SafeArea(child: SpacesRail()),
), ),
body: Stack( // SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
body: SafeArea(child: Stack(
children: [ children: [
body, body,
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[ if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
@@ -371,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
child: Container(color: Colors.black.withAlpha(110)), child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: motionDuration(context, 220),
builder: (_, v, _) =>
ColoredBox(color: Colors.black.withAlpha((110 * v).round())),
),
), ),
), ),
Positioned( Positioned(
@@ -380,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
bottom: 0, bottom: 0,
child: Builder(builder: (ctx) { child: Builder(builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width; final w = MediaQuery.sizeOf(ctx).width;
return MembersPanel( // Gleitet von rechts herein.
roomId: activeRoomId!, return TweenAnimationBuilder<double>(
width: (w * 0.82).clamp(240.0, 320.0), tween: Tween(begin: 1, end: 0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, duration: motionDuration(ctx, 340),
curve: Curves.easeOutCubic,
builder: (_, v, child) => FractionalTranslation(
translation: Offset(v, 0),
child: child,
),
child: MembersPanel(
roomId: activeRoomId!,
width: (w * 0.82).clamp(240.0, 320.0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
),
); );
}), }),
), ),
@@ -399,7 +436,8 @@ class _MobileShell extends ConsumerWidget {
child: _NotificationBlockedBanner(), child: _NotificationBlockedBanner(),
), ),
], ],
), )),
),
), ),
); );
} }
@@ -456,7 +494,7 @@ class _NotificationBlockedBanner extends ConsumerWidget {
class _IncomingCallOverlay extends ConsumerWidget { class _IncomingCallOverlay extends ConsumerWidget {
final CallSession call; final CallSession call;
final PyramidVoipManager voip; final CallSignalingService voip;
final PyramidTheme pt; final PyramidTheme pt;
const _IncomingCallOverlay({ const _IncomingCallOverlay({
@@ -575,7 +613,7 @@ class NoChatSelected extends StatelessWidget {
Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)), Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Select a chat to start messaging', 'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 15), style: TextStyle(color: pt.fgMuted, fontSize: 15),
), ),
], ],
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'package:pyramid/core/theme.dart';
/// Handy-Navigation zwischen Chatliste und geöffnetem Chat.
///
/// Die Chatliste ([base]) bleibt immer aufgebaut – Scrollposition und Zustand
/// bleiben beim Zurückkehren erhalten. Der Vordergrund ([front], z. B. der
/// Chat) gleitet federnd von rechts darüber, die Liste weicht dabei leicht
/// nach links aus und dunkelt ab; beim Zurück läuft es umgekehrt. Wechselt
/// der Vordergrund direkt (anderer Chat), wird ohne Schieben getauscht.
/// Mit „Animationen: 0 %“ wird sofort umgeschaltet.
class MobilePageStack extends StatefulWidget {
const MobilePageStack({
super.key,
required this.base,
required this.front,
required this.background,
});
final Widget base;
final Widget? front;
/// Hintergrund hinter dem Vordergrund, damit die Liste beim Schieben nicht
/// durch durchsichtige Stellen scheint.
final Color background;
@override
State<MobilePageStack> createState() => _MobilePageStackState();
}
class _MobilePageStackState extends State<MobilePageStack>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController.unbounded(
vsync: this,
value: widget.front != null ? 1.0 : 0.0,
);
/// Angezeigter Vordergrund – bleibt beim Hinausgleiten noch stehen.
late Widget? _front = widget.front;
@override
void didUpdateWidget(MobilePageStack oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.front != null) {
_front = widget.front;
if (oldWidget.front == null) _animateTo(1);
} else if (oldWidget.front != null) {
_animateTo(0);
}
}
void _animateTo(double target) {
final motion = PyramidTheme.of(context).motion;
if (motion < 0.05) {
_ctrl.value = target;
_settle();
return;
}
// Feder knapp unter kritischer Dämpfung: zügig, weich auslaufend.
// Kleinere „Animationen“-Werte = steifere Feder = schneller.
final spring = SpringDescription.withDampingRatio(
mass: 1,
stiffness: 520 / (motion * motion),
ratio: 0.86,
);
_ctrl
.animateWith(
SpringSimulation(spring, _ctrl.value, target, _ctrl.velocity),
)
.then((_) {
_ctrl.value = target;
_settle();
});
}
void _settle() {
if (mounted && widget.front == null && _front != null) {
setState(() => _front = null);
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
final front = _front;
final shown = _ctrl.value.clamp(0.0, 1.0);
// Vollständig verdeckt: Liste nicht mehr zeichnen/animieren
// (bleibt aber aufgebaut).
final covered = front != null && shown >= 0.999;
return Stack(
fit: StackFit.expand,
children: [
KeyedSubtree(
key: const ValueKey('base'),
child: IgnorePointer(
// Nur gesperrt, solange der Vordergrund offen ist bzw.
// hereinkommt – beim Hinausgleiten ist die Liste schon
// wieder bedienbar (schneller Tipp auf den nächsten Chat).
ignoring: widget.front != null,
child: TickerMode(
enabled: !covered,
child: Offstage(
offstage: covered,
child: Transform.translate(
offset: Offset(-0.25 * width * shown, 0),
child: widget.base,
),
),
),
),
),
if (front != null && !covered && shown > 0)
Positioned.fill(
key: const ValueKey('scrim'),
child: IgnorePointer(
child: ColoredBox(
color: const Color(0xFF000000)
.withValues(alpha: 0.30 * shown),
),
),
),
if (front != null)
KeyedSubtree(
key: const ValueKey('front'),
child: Transform.translate(
offset: Offset(width * (1 - shown), 0),
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.background,
boxShadow: covered
? null
: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 18,
),
],
),
// Beim Hinausgleiten nicht mehr bedienbar.
child: IgnorePointer(
ignoring: widget.front == null,
child: front,
),
),
),
),
],
);
},
);
},
);
}
}
+10 -2
View File
@@ -33,8 +33,16 @@ void main() async {
await vod.init(); await vod.init();
// Hide status bar and navigation bar for immersive feel // Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); // zeichnet darunter, hält per SafeArea (Handy-Layout in app_shell.dart)
// aber Abstand. Vorher immersiveSticky – da schob sich die Statusleiste
// beim Herunterwischen über Kopfzeile und Text (Bernds Feedback 2026-10-07).
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarContrastEnforced: false,
));
runApp(const ProviderScope(child: PyramidBootstrap())); runApp(const ProviderScope(child: PyramidBootstrap()));
} }
+25
View File
@@ -0,0 +1,25 @@
/// Kurze Datums-/Zeitangaben für Listen (deutsch).
library;
/// Zeit in der Chatliste: heute „14:05“, gestern „Gestern“, diese Woche der
/// Wochentag, sonst das Datum.
String chatListTime(DateTime t, {DateTime? now}) {
final n = now ?? DateTime.now();
final today = DateTime(n.year, n.month, n.day);
final day = DateTime(t.year, t.month, t.day);
// Über Stunden runden: an Zeitumstellungs-Tagen hat ein Tag 23/25 Stunden,
// inDays würde dann „gestern“ als „heute“ zählen.
final diff = (today.difference(day).inHours / 24).round();
if (diff <= 0) {
return '${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}';
}
if (diff == 1) return 'Gestern';
if (diff < 7) {
const days = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
return days[t.weekday - 1];
}
return '${t.day.toString().padLeft(2, '0')}.'
'${t.month.toString().padLeft(2, '0')}.'
'${(t.year % 100).toString().padLeft(2, '0')}';
}
+40 -29
View File
@@ -1,4 +1,9 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
// Simple hover-aware widget for desktop hover effects // Simple hover-aware widget for desktop hover effects
class HoverRegion extends StatefulWidget { class HoverRegion extends StatefulWidget {
@@ -29,7 +34,10 @@ class _HoverRegionState extends State<HoverRegion> {
} }
} }
// Icon button styled to match Pyramid's .icon-btn // Icon button styled to match Pyramid's .icon-btn.
// Handy-Layout (Bernds Feedback 2026-10-07: Symbole zu klein): Symbol 22 px
// statt 16, Fläche mind. 40 px, Tippfläche mind. 44 px. Farben folgen dem
// gewählten Farbmodus, Druck federt (SpringPress).
class PyrIconBtn extends StatefulWidget { class PyrIconBtn extends StatefulWidget {
final Widget icon; final Widget icon;
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
final Color? dangerColor; final Color? dangerColor;
final double size; final double size;
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
final double? iconSize;
const PyrIconBtn({ const PyrIconBtn({
super.key, super.key,
required this.icon, required this.icon,
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
this.activeColor, this.activeColor,
this.dangerColor, this.dangerColor,
this.size = 32, this.size = 32,
this.iconSize,
}); });
@override @override
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final pt = PyramidTheme.of(context);
final isDark = theme.brightness == Brightness.dark; final mobile = isMobileLayout(MediaQuery.sizeOf(context));
final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
final color = widget.active final color = widget.active
? (widget.activeColor ?? const Color(0xFFF5A614)) ? (widget.activeColor ?? pt.accent)
: _hovered ? fg : fgMuted; : _hovered ? pt.fg : pt.fgMuted;
final platform = Theme.of(context).platform; final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
// On mobile, use a slightly larger touch target, but respect the requested size final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
final btn = MouseRegion( final btn = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -80,23 +88,26 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onPressed, onTap: widget.onPressed,
child: SizedBox( child: SpringPress(
width: touchSize, enabled: widget.onPressed != null,
height: touchSize, child: SizedBox(
child: Center( width: touchSize,
child: AnimatedContainer( height: touchSize,
duration: const Duration(milliseconds: 150), child: Center(
curve: Curves.easeOutCubic, child: AnimatedContainer(
width: widget.size, duration: const Duration(milliseconds: 150),
height: widget.size, curve: Curves.easeOutCubic,
decoration: BoxDecoration( width: boxSize,
color: _hovered ? bgHover : Colors.transparent, height: boxSize,
borderRadius: BorderRadius.circular(7), decoration: BoxDecoration(
), color: _hovered ? pt.bgHover : Colors.transparent,
child: Center( borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
child: IconTheme( ),
data: IconThemeData(color: color, size: 16), child: Center(
child: widget.icon, child: IconTheme(
data: IconThemeData(color: color, size: iconSize),
child: widget.icon,
),
), ),
), ),
), ),
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Meldet die gezeichnete Größe des Kindes, sobald sie sich ändert (z. B.
/// die Höhe der schwebenden Eingabeleiste, die mit Antwort-Leiste oder
/// mehrzeiligem Text wächst). Die Meldung kommt nach dem Layout, damit der
/// Empfänger gefahrlos setState aufrufen kann.
class MeasureSize extends SingleChildRenderObjectWidget {
const MeasureSize({super.key, required this.onChange, super.child});
final ValueChanged<Size> onChange;
@override
RenderObject createRenderObject(BuildContext context) =>
_MeasureSizeRender(onChange);
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _MeasureSizeRender).onChange = onChange;
}
}
class _MeasureSizeRender extends RenderProxyBox {
_MeasureSizeRender(this.onChange);
ValueChanged<Size> onChange;
Size? _last;
@override
void performLayout() {
super.performLayout();
final size = this.size;
if (size == _last) return;
_last = size;
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
}
}
+18 -18
View File
@@ -104,9 +104,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
child: Container( child: Container(
width: popoverW, width: popoverW,
decoration: BoxDecoration( decoration: BoxDecoration(
color: PyramidColors.bg0, color: PyramidTheme.of(context).bg0,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: PyramidColors.border), border: Border.all(color: PyramidTheme.of(context).border),
boxShadow: const [ boxShadow: const [
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8)) BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
], ],
@@ -172,7 +172,7 @@ class _ProfilePopoverState extends State<ProfilePopover> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: PyramidColors.bg0, width: 3), border: Border.all(color: PyramidTheme.of(context).bg0, width: 3),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: avatarUrl != null child: avatarUrl != null
@@ -212,14 +212,14 @@ class _ProfilePopoverState extends State<ProfilePopover> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(displayName, Text(displayName,
style: const TextStyle( style: TextStyle(
color: PyramidColors.fg, color: PyramidTheme.of(context).fg,
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w700)), fontWeight: FontWeight.w700)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(handle, Text(handle,
style: const TextStyle( style: TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)), color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons // Action buttons
Row( Row(
@@ -248,9 +248,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
), ),
if (powerLevel > 0) ...[ if (powerLevel > 0) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
const Text('Rollen', Text('Rollen',
style: TextStyle( style: TextStyle(
color: PyramidColors.fgDim, color: PyramidTheme.of(context).fgDim,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.8)), letterSpacing: 0.8)),
@@ -270,16 +270,16 @@ class _ProfilePopoverState extends State<ProfilePopover> {
], ],
if (joinDate != null) ...[ if (joinDate != null) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
const Text('Mitglied seit', Text('Mitglied seit',
style: TextStyle( style: TextStyle(
color: PyramidColors.fgDim, color: PyramidTheme.of(context).fgDim,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.8)), letterSpacing: 0.8)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(joinDate, Text(joinDate,
style: const TextStyle( style: TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)), color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
], ],
], ],
), ),
@@ -337,7 +337,7 @@ class _PresenceDot extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: PyramidColors.bg0, width: 2), border: Border.all(color: PyramidTheme.of(context).bg0, width: 2),
), ),
); );
} }
@@ -380,10 +380,10 @@ class _ProfileBtnState extends State<_ProfileBtn> {
? (_hover ? (_hover
? PyramidTheme.of(context).accent.withAlpha(200) ? PyramidTheme.of(context).accent.withAlpha(200)
: PyramidTheme.of(context).accent) : PyramidTheme.of(context).accent)
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2), : (_hover ? PyramidTheme.of(context).bgHover : PyramidTheme.of(context).bg2),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: widget.primary ? Colors.transparent : PyramidColors.border, color: widget.primary ? Colors.transparent : PyramidTheme.of(context).border,
), ),
), ),
child: Row( child: Row(
@@ -393,14 +393,14 @@ class _ProfileBtnState extends State<_ProfileBtn> {
size: 14, size: 14,
color: widget.primary color: widget.primary
? PyramidColors.accentFg ? PyramidColors.accentFg
: PyramidColors.fg), : PyramidTheme.of(context).fg),
if (hasLabel) ...[ if (hasLabel) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Text(widget.label!, Text(widget.label!,
style: TextStyle( style: TextStyle(
color: widget.primary color: widget.primary
? PyramidColors.accentFg ? PyramidColors.accentFg
: PyramidColors.fg, : PyramidTheme.of(context).fg,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500)), fontWeight: FontWeight.w500)),
], ],
+7 -40
View File
@@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/search/local_message_search.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
// ─── State ──────────────────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────────────────
@@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState<SearchModal> {
List<Event> messageResults = []; List<Event> messageResults = [];
if (_filter != _Filter.people) { if (_filter != _Filter.people) {
try { try {
messageResults = await _localMessageSearch(client, q); messageResults = await localMessageSearch(
client,
q,
mediaOnly: _filter == _Filter.media,
);
} catch (e) { } catch (e) {
setState(() => _state = _state.copyWith( setState(() => _state = _state.copyWith(
loading: false, loading: false,
@@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState<SearchModal> {
} }
} }
// Scans the local (decrypted) event database across all rooms for messages
// whose body matches the query. For the media filter, only media messages
// whose filename/caption matches are returned.
Future<List<Event>> _localMessageSearch(Client client, String q) async {
final db = client.database;
final lower = q.toLowerCase();
final media = _filter == _Filter.media;
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
const perRoomScanCap = 1500;
const totalCap = 60;
const chunk = 500;
final results = <Event>[];
for (final room in client.rooms.where((r) => !r.isSpace)) {
var start = 0;
var scanned = 0;
try {
while (scanned < perRoomScanCap) {
final events = await db.getEventList(room, start: start, limit: chunk);
if (events.isEmpty) break;
for (final e in events) {
if (e.type != EventTypes.Message || e.redacted) continue;
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
if (media && !mediaTypes.contains(msgtype)) continue;
if (!e.body.toLowerCase().contains(lower)) continue;
results.add(e);
}
start += chunk;
scanned += events.length;
if (events.length < chunk || results.length >= totalCap) break;
}
} catch (_) {}
if (results.length >= totalCap) break;
}
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
return results.take(totalCap).toList();
}
void _openRoom(String roomId, {String? eventId}) { void _openRoom(String roomId, {String? eventId}) {
widget.onClose(); widget.onClose();
ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(activeRoomIdProvider.notifier).state = roomId;
@@ -657,6 +623,7 @@ class _MessageItemState extends State<_MessageItem> {
]), ]),
const SizedBox(height: 1), const SizedBox(height: 1),
RichText( RichText(
textScaler: MediaQuery.textScalerOf(context),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
text: TextSpan(children: [ text: TextSpan(children: [
@@ -0,0 +1,659 @@
part of '../../settings_modal.dart';
// ─────────────────────────────────────────────────────────────────────────────
enum _EncState { idle, loading, needsKey, unlocking, done, error }
enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error }
class _EncryptionSection extends ConsumerStatefulWidget {
final PyramidTheme pt;
final WidgetRef ref;
const _EncryptionSection({required this.pt, required this.ref});
@override
ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState();
}
class _EncryptionSectionState extends ConsumerState<_EncryptionSection> {
_EncState _state = _EncState.idle;
Bootstrap? _bootstrap;
final _keyCtrl = TextEditingController();
String? _error;
// Status loaded from encryption object
bool? _crossSigningEnabled;
bool? _crossSigningCached;
bool? _keyBackupEnabled;
bool _statusLoaded = false;
// Key file export / import
bool _exportLoading = false;
bool _importLoading = false;
String? _keyFileMsg;
bool _keyFileMsgIsError = false;
bool _diagUploading = false;
String? _diagMsg;
bool _diagMsgIsError = false;
@override
void initState() {
super.initState();
_loadStatus();
}
@override
void dispose() {
_keyCtrl.dispose();
super.dispose();
}
Future<void> _loadStatus() async {
try {
final client = await ref.read(matrixClientProvider.future);
final enc = client.encryption;
if (enc == null) return;
final cached = await enc.crossSigning.isCached().catchError((_) => false);
if (!mounted) return;
setState(() {
_crossSigningEnabled = enc.crossSigning.enabled;
_crossSigningCached = cached;
_keyBackupEnabled = enc.keyManager.enabled;
_statusLoaded = true;
});
} catch (_) {}
}
Future<void> _start() async {
setState(() { _state = _EncState.loading; _error = null; });
try {
final client = await ref.read(matrixClientProvider.future);
if (client.encryption == null) {
setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; });
return;
}
final enc = client.encryption!;
final cached = await enc.keyManager.isCached().catchError((_) => false);
if (cached) {
await enc.keyManager.loadAllKeys().catchError((_) {});
if (mounted) setState(() { _state = _EncState.done; _error = null; });
await _loadStatus();
return;
}
await client.roomsLoading;
await client.accountDataLoading;
await client.userDeviceKeysLoading;
final completer = Completer<bool>();
_bootstrap = enc.bootstrap(onUpdate: (bs) {
if (!mounted) return;
switch (bs.state) {
case BootstrapState.askWipeSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false));
case BootstrapState.askBadSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
case BootstrapState.askUseExistingSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true));
case BootstrapState.openExistingSsss:
if (!completer.isCompleted) completer.complete(true);
case BootstrapState.askNewSsss:
if (!completer.isCompleted) completer.complete(false);
case BootstrapState.error:
if (!completer.isCompleted) completer.complete(false);
if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; });
default:
break;
}
});
final needsKey = await completer.future
.timeout(const Duration(seconds: 30), onTimeout: () => false);
if (!mounted) return;
setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done);
await _loadStatus();
} catch (e) {
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
}
}
Future<void> _unlock() async {
final bs = _bootstrap;
if (bs == null || bs.newSsssKey == null) return;
final key = _keyCtrl.text.trim();
if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; }
setState(() { _state = _EncState.unlocking; _error = null; });
try {
await bs.newSsssKey!.unlock(keyOrPassphrase: key);
await bs.openExistingSsss();
final client = await ref.read(matrixClientProvider.future);
if (bs.encryption.crossSigning.enabled) {
await client.encryption!.crossSigning.selfSign(recoveryKey: key);
}
await client.encryption!.keyManager.loadAllKeys();
if (mounted) setState(() => _state = _EncState.done);
await _loadStatus();
} on InvalidPassphraseException catch (_) {
if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; });
} on FormatException catch (_) {
if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; });
} catch (e) {
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
}
}
Future<void> _resetSecurity() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) {
final pt = widget.pt;
return AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Sicherheit zurücksetzen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Zurücksetzen'),
),
],
);
},
);
if (confirm != true || !mounted) return;
setState(() { _state = _EncState.loading; _error = null; });
try {
final client = await ref.read(matrixClientProvider.future);
final enc = client.encryption;
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar');
await client.roomsLoading;
await client.accountDataLoading;
await client.userDeviceKeysLoading;
final completer = Completer<void>();
enc.bootstrap(onUpdate: (bs) {
if (!mounted) return;
switch (bs.state) {
case BootstrapState.askWipeSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true));
case BootstrapState.askBadSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
case BootstrapState.askUseExistingSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false));
case BootstrapState.askNewSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss());
case BootstrapState.askWipeCrossSigning:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true));
case BootstrapState.askSetupCrossSigning:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning(
setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true));
case BootstrapState.askWipeOnlineKeyBackup:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true));
case BootstrapState.askSetupOnlineKeyBackup:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true));
case BootstrapState.done:
if (!completer.isCompleted) completer.complete();
case BootstrapState.error:
if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler');
default:
break;
}
});
await completer.future.timeout(const Duration(seconds: 60));
if (mounted) setState(() => _state = _EncState.done);
await _loadStatus();
} catch (e) {
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
}
}
Future<String?> _showPassphraseDialog({required bool forExport}) {
return showDialog<String>(
context: context,
builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport),
);
}
Future<void> _exportKeys() async {
final passphrase = await _showPassphraseDialog(forExport: true);
if (passphrase == null || !mounted) return;
setState(() { _exportLoading = true; _keyFileMsg = null; });
try {
final client = await ref.read(matrixClientProvider.future);
final db = client.database;
final stored = await db.getAllInboundGroupSessions();
final sessions = <Map<String, dynamic>>[];
for (final s in stored) {
try {
final content = jsonDecode(s.content) as Map<String, dynamic>;
final sessionKey = content['session_key'] as String?;
if (sessionKey == null || sessionKey.isEmpty) continue;
final claimedKeys = s.senderClaimedKeys.isNotEmpty
? (jsonDecode(s.senderClaimedKeys) as Map<String, dynamic>)
.map((k, v) => MapEntry(k, v.toString()))
: <String, String>{};
sessions.add({
'algorithm': 'm.megolm.v1.aes-sha2',
'forwarding_curve25519_key_chain': <dynamic>[],
'room_id': s.roomId,
'sender_key': s.senderKey,
'sender_claimed_keys': claimedKeys,
'session_id': s.sessionId,
'session_key': sessionKey,
});
} catch (_) { continue; }
}
if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.');
final encrypted = await compute(_encryptMegolmKeys, {
'passphrase': passphrase,
'sessions': sessions,
});
final dir = await getApplicationDocumentsDirectory();
final now = DateTime.now();
final stamp = '${now.year}'
'${now.month.toString().padLeft(2, '0')}'
'${now.day.toString().padLeft(2, '0')}';
final file = File('${dir.path}/pyramid_keys_$stamp.txt');
await file.writeAsString(encrypted);
if (mounted) {
setState(() {
_exportLoading = false;
_keyFileMsgIsError = false;
_keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}';
});
}
} catch (e) {
if (mounted) {
setState(() {
_exportLoading = false;
_keyFileMsgIsError = true;
_keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _uploadDiagnostics() async {
setState(() { _diagUploading = true; _diagMsg = null; });
try {
final notifier = ref.read(e2eeDiagnosticsProvider.notifier);
final msg = await notifier.upload(
serverUrl: 'https://dashboard.steggi-matrix.work',
// Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an,
// KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token –
// der darf niemals im Client landen. Passendes Gegenstueck in
// /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi.
token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn',
);
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; });
} catch (e) {
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); });
}
}
Future<void> _importKeys() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['txt', 'key', 'keys'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty || result.files.first.path == null) return;
if (!mounted) return;
final passphrase = await _showPassphraseDialog(forExport: false);
if (passphrase == null || !mounted) return;
setState(() { _importLoading = true; _keyFileMsg = null; });
try {
final fileContent = await File(result.files.first.path!).readAsString();
final trimmed = fileContent.trim();
if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) {
throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.');
}
final lines = trimmed.split('\n');
// Strip \r so Windows line endings (\r\n) don't corrupt the base64 string.
final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), '');
final padded = b64 + '=' * ((4 - b64.length % 4) % 4);
final dataBytes = base64.decode(padded);
final sessions = await compute(_decryptMegolmKeys, {
'passphrase': passphrase,
'data': dataBytes.toList(),
});
final client = await ref.read(matrixClientProvider.future);
final km = client.encryption?.keyManager;
if (km == null) throw Exception('Verschlüsselung nicht initialisiert.');
var imported = 0;
for (final raw in sessions) {
try {
final session = raw as Map<String, dynamic>;
final roomId = session['room_id'] as String?;
final sessionId = session['session_id'] as String?;
final senderKey = session['sender_key'] as String?;
if (roomId == null || sessionId == null || senderKey == null) continue;
final claimedKeys = (session['sender_claimed_keys'] as Map?)
?.map((k, v) => MapEntry(k.toString(), v.toString())) ??
<String, String>{};
await km.setInboundGroupSession(
roomId, sessionId, senderKey,
Map<String, dynamic>.from(session),
forwarded: true,
senderClaimedKeys: claimedKeys,
);
imported++;
} catch (_) { continue; }
}
if (mounted) {
setState(() {
_importLoading = false;
_keyFileMsgIsError = false;
_keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.';
});
}
} catch (e) {
if (mounted) {
setState(() {
_importLoading = false;
_keyFileMsgIsError = true;
_keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _changeRecoveryKey() async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt),
);
if (changed == true && mounted) {
await _loadStatus();
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Verschlüsselung',
subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.',
pt: pt),
// ── Status overview ───────────────────────────────────────────────
if (_statusLoaded) ...[
_SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [
_SettingsRow(
title: 'Cross-Signing',
desc: _crossSigningEnabled == true
? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache')
: 'Nicht eingerichtet',
pt: pt,
control: Icon(
_crossSigningEnabled == true && _crossSigningCached == true
? Icons.verified_rounded
: (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined),
size: 16,
color: _crossSigningEnabled == true && _crossSigningCached == true
? PyramidColors.success
: (_crossSigningEnabled == true ? pt.away : pt.danger),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Online-Schlüsselbackup',
desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert',
pt: pt,
control: Icon(
_keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded,
size: 16,
color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim,
),
),
]),
const SizedBox(height: 20),
_FingerprintGroup(pt: pt),
const SizedBox(height: 20),
],
// ── Key recovery / unlock ─────────────────────────────────────────
_SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [
_SettingsRow(
title: 'Schlüssel wiederherstellen',
desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln',
pt: pt,
showArrow: true,
onTap: _state == _EncState.loading ? null : _start,
),
_Divider(pt: pt),
_SettingsRow(
title: 'Wiederherstellungsschlüssel ändern',
desc: 'Neuen Schlüssel generieren, notieren und aktivieren',
pt: pt,
showArrow: true,
onTap: _state == _EncState.loading ? null : _changeRecoveryKey,
),
_Divider(pt: pt),
_SettingsRow(
title: 'Sicherheit neu einrichten',
desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen',
pt: pt,
showArrow: true,
destructive: true,
onTap: _state == _EncState.loading ? null : _resetSecurity,
),
]),
const SizedBox(height: 20),
// ── Key file export / import ───────────────────────────────────────
_SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [
_SettingsRow(
title: 'Schlüssel exportieren',
desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)',
pt: pt,
showArrow: !_exportLoading,
control: _exportLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: null,
onTap: (_exportLoading || _importLoading) ? null : _exportKeys,
),
_Divider(pt: pt),
_SettingsRow(
title: 'Schlüssel importieren',
desc: 'Sitzungsschlüssel aus exportierter Datei laden',
pt: pt,
showArrow: !_importLoading,
control: _importLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: null,
onTap: (_exportLoading || _importLoading) ? null : _importKeys,
),
]),
if (_keyFileMsg != null) ...[
const SizedBox(height: 12),
_StatusCard(
pt: pt,
icon: _keyFileMsgIsError
? Icons.error_outline_rounded
: Icons.check_circle_outline_rounded,
color: _keyFileMsgIsError ? pt.danger : PyramidColors.success,
text: _keyFileMsg!,
),
],
const SizedBox(height: 20),
// ── E2EE Diagnostics ──────────────────────────────────────────────
Consumer(builder: (context, ref, _) {
final count = ref.watch(e2eeDiagnosticsProvider).length;
return _SettingsGroup(title: 'Diagnose', pt: pt, children: [
_SettingsRow(
title: 'Entschlüsselungsfehler hochladen',
desc: count == 0
? 'Keine Fehler gesammelt'
: '$count Fehler gesammelt – zum Server hochladen',
pt: pt,
showArrow: !_diagUploading && count > 0,
control: _diagUploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: count > 0
? Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: pt.danger.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)),
)
: null,
onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics,
),
]);
}),
if (_diagMsg != null) ...[
const SizedBox(height: 12),
_StatusCard(
pt: pt,
icon: _diagMsgIsError
? Icons.error_outline_rounded
: Icons.cloud_done_rounded,
color: _diagMsgIsError ? pt.danger : PyramidColors.success,
text: _diagMsg!,
),
],
const SizedBox(height: 20),
// ── Inline state UI ───────────────────────────────────────────────
if (_state == _EncState.loading)
const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())),
if (_state == _EncState.done) ...[
_StatusCard(
pt: pt,
icon: Icons.check_circle_outline_rounded,
color: PyramidColors.success,
text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'),
const SizedBox(height: 12),
],
if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[
_StatusCard(
pt: pt,
icon: Icons.lock_outline_rounded,
color: pt.accent,
text: 'Wiederherstellungsschlüssel erforderlich.'),
const SizedBox(height: 16),
TextField(
controller: _keyCtrl,
readOnly: _state == _EncState.unlocking,
autofocus: true,
autocorrect: false,
style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'),
decoration: InputDecoration(
hintText: 'EsXX XXXX XXXX XXXX …',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
labelText: 'Wiederherstellungsschlüssel',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
errorText: _error,
errorMaxLines: 2,
filled: true,
fillColor: pt.bg2,
prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
cursorColor: pt.accent,
onSubmitted: (_) => _unlock(),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: _PrimaryBtn(
label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln',
icon: Icons.lock_open_outlined,
pt: pt,
loading: _state == _EncState.unlocking,
onPressed: _unlock,
),
),
const SizedBox(height: 12),
],
if (_state == _EncState.error) ...[
_StatusCard(
pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger,
text: _error ?? 'Unbekannter Fehler.'),
const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: pt.fg,
side: BorderSide(color: pt.border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: _start,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Erneut versuchen'),
),
],
if (_state == _EncState.idle && !_statusLoaded)
_StatusCard(
pt: pt,
icon: Icons.shield_outlined,
color: pt.fgMuted,
text: 'Lade Sicherheitsstatus…'),
],
),
);
}
}
@@ -0,0 +1,204 @@
part of '../../settings_modal.dart';
class _FingerprintGroup extends ConsumerWidget {
final PyramidTheme pt;
const _FingerprintGroup({required this.pt});
static String _fmt(String key) {
final clean = key.replaceAll(RegExp(r'\s'), '');
final groups = <String>[];
for (var i = 0; i < clean.length; i += 4) {
groups.add(clean.substring(i, (i + 4).clamp(0, clean.length)));
}
return groups.join(' ');
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(matrixClientProvider);
final client = clientAsync.valueOrNull;
if (client == null) return const SizedBox.shrink();
final fingerprint = _fmt(client.fingerprintKey);
final deviceId = client.deviceID ?? '';
return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [
_SettingsRow(
title: 'Geräte-ID',
desc: deviceId,
pt: pt,
control: IconButton(
icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
tooltip: 'Kopieren',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)),
),
),
_Divider(pt: pt),
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Ed25519-Fingerabdruck',
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
const Spacer(),
IconButton(
icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
tooltip: 'Fingerabdruck kopieren',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)),
),
],
),
const SizedBox(height: 4),
SelectableText(
fingerprint,
style: TextStyle(
color: pt.fgMuted, fontSize: 11,
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
),
),
],
),
),
]);
}
}
// Passphrase dialog (for key export/import)
class _PassphraseDialog extends StatefulWidget {
final PyramidTheme pt;
final bool forExport;
const _PassphraseDialog({required this.pt, required this.forExport});
@override
State<_PassphraseDialog> createState() => _PassphraseDialogState();
}
class _PassphraseDialogState extends State<_PassphraseDialog> {
final _ctrl1 = TextEditingController();
final _ctrl2 = TextEditingController();
bool _obscure = true;
String? _error;
@override
void dispose() {
_ctrl1.dispose();
_ctrl2.dispose();
super.dispose();
}
void _submit() {
final pass = _ctrl1.text.trim();
if (pass.isEmpty) {
setState(() => _error = 'Bitte ein Passwort eingeben.');
return;
}
if (widget.forExport && pass != _ctrl2.text.trim()) {
setState(() => _error = 'Passwörter stimmen nicht überein.');
return;
}
Navigator.pop(context, pass);
}
InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration(
labelText: label,
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
errorText: _error,
filled: true,
fillColor: pt.bg3,
prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
isDense: true,
errorMaxLines: 2,
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
size: 18,
color: pt.fgDim,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
);
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border),
),
title: Text(
widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700),
),
content: SizedBox(
width: 340,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.forExport
? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.'
: 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
const SizedBox(height: 16),
TextField(
controller: _ctrl1,
obscureText: _obscure,
autofocus: true,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: _inputDeco('Passwort', pt).copyWith(
// Only show error on confirm field if export, else here
errorText: widget.forExport ? null : _error,
),
cursorColor: pt.accent,
onSubmitted: widget.forExport ? null : (_) => _submit(),
),
if (widget.forExport) ...[
const SizedBox(height: 12),
TextField(
controller: _ctrl2,
obscureText: _obscure,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: _inputDeco('Passwort bestätigen', pt),
cursorColor: pt.accent,
onSubmitted: (_) => _submit(),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: _submit,
child: Text(widget.forExport ? 'Exportieren' : 'Importieren'),
),
],
);
}
}
@@ -0,0 +1,129 @@
part of '../../settings_modal.dart';
// ── Megolm key export / import – top-level so compute() can use them ─────────
const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----';
const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----';
// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int}
Uint8List _runPbkdf2(Map<String, dynamic> args) {
final pass = args['pass'] as Uint8List;
final salt = args['salt'] as Uint8List;
final iterations = args['iter'] as int;
const blockLen = 64; // SHA-512 digest size
const dkLen = 64;
final hmac = mcrypto.Hmac(mcrypto.sha512, pass);
final blockCount = (dkLen / blockLen).ceil();
final dk = Uint8List(blockCount * blockLen);
for (var i = 1; i <= blockCount; i++) {
final saltI = Uint8List(salt.length + 4);
saltI.setRange(0, salt.length, salt);
ByteData.sublistView(saltI, salt.length).setUint32(0, i);
var u = Uint8List.fromList(hmac.convert(saltI).bytes);
final block = Uint8List.fromList(u);
for (var j = 1; j < iterations; j++) {
u = Uint8List.fromList(hmac.convert(u).bytes);
for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; }
}
dk.setRange((i - 1) * blockLen, i * blockLen, block);
}
return dk.sublist(0, dkLen);
}
Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) {
final cipher = pc.SICStreamCipher(pc.AESEngine())
..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv));
final out = Uint8List(data.length);
cipher.processBytes(data, 0, data.length, out, 0);
return out;
}
// args: {passphrase: String, sessions: List<dynamic>} → encrypted String
String _encryptMegolmKeys(Map<String, dynamic> args) {
final pass = args['passphrase'] as String;
final sessions = args['sessions'] as List<dynamic>;
final rng = Random.secure();
final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256)));
// IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0)
final iv = Uint8List(16);
for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); }
const iterations = 100000;
final dk = _runPbkdf2({
'pass': Uint8List.fromList(utf8.encode(pass)),
'salt': salt,
'iter': iterations,
});
final aesKey = dk.sublist(0, 32);
final hmacKey = dk.sublist(32, 64);
final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions)));
final encrypted = _aesCtr(aesKey, iv, plaintext);
final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations);
// header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n)
final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length);
var off = 0;
header[off++] = 0x01;
header.setRange(off, off += 16, salt);
header.setRange(off, off += 16, iv);
header.setRange(off, off += 4, iterBe);
header.setRange(off, off + encrypted.length, encrypted);
final mac = Uint8List.fromList(
mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes,
);
final full = Uint8List(header.length + 32);
full.setRange(0, header.length, header);
full.setRange(header.length, full.length, mac);
return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter';
}
// args: {passphrase: String, data: List<int>} → List<dynamic> sessions
List<dynamic> _decryptMegolmKeys(Map<String, dynamic> args) {
final pass = args['passphrase'] as String;
final raw = Uint8List.fromList(args['data'] as List<int>);
if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) {
throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).');
}
final salt = raw.sublist(1, 17);
final iv = raw.sublist(17, 33);
final iterations = raw.buffer.asByteData().getUint32(33);
final encrypted = raw.sublist(37, raw.length - 32);
final storedMac = raw.sublist(raw.length - 32);
if (iterations < 1 || iterations > 10000000) {
throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.');
}
final dk = _runPbkdf2({
'pass': Uint8List.fromList(utf8.encode(pass)),
'salt': Uint8List.fromList(salt),
'iter': iterations,
});
final aesKey = dk.sublist(0, 32);
final hmacKey = dk.sublist(32, 64);
// Verify MAC over all bytes except the appended MAC itself
final toMac = raw.sublist(0, raw.length - 32);
final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes;
var macOk = true;
for (var i = 0; i < 32; i++) {
if (expectedMac[i] != storedMac[i]) { macOk = false; break; }
}
if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.');
final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted));
return jsonDecode(utf8.decode(decrypted)) as List<dynamic>;
}
@@ -0,0 +1,484 @@
part of '../../settings_modal.dart';
class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _ChangeRecoveryKeyDialog({required this.pt});
@override
ConsumerState<_ChangeRecoveryKeyDialog> createState() =>
_ChangeRecoveryKeyDialogState();
}
class _ChangeRecoveryKeyDialogState
extends ConsumerState<_ChangeRecoveryKeyDialog> {
_RecoveryStep _step = _RecoveryStep.loading;
String? _generatedKey;
String? _error;
bool _copied = false;
// Completer signals when bootstrap reaches `done`
final _doneCompleter = Completer<void>();
@override
void initState() {
super.initState();
_startBootstrap();
}
Future<void> _startBootstrap() async {
try {
final client = await ref.read(matrixClientProvider.future);
final enc = client.encryption;
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.');
await client.roomsLoading;
await client.accountDataLoading;
await client.userDeviceKeysLoading;
enc.bootstrap(onUpdate: (bs) {
if (!mounted) return;
_onBootstrapUpdate(bs);
});
} catch (e) {
if (mounted) {
setState(() {
_step = _RecoveryStep.error;
_error = e.toString().split('\n').first;
});
}
}
}
// After newSsss() calls checkCrossSigning(), the bootstrap immediately
// transitions to askWipeCrossSigning (or askSetupCrossSigning). All post-
// newSsss states must therefore auto-advance unconditionally — guarding on
// _step would leave them stuck.
void _onBootstrapUpdate(Bootstrap bs) {
switch (bs.state) {
case BootstrapState.askWipeSsss:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true);
});
case BootstrapState.askBadSsss:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true);
});
case BootstrapState.askUseExistingSsss:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false);
});
case BootstrapState.askUnlockSsss:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss();
});
case BootstrapState.askNewSsss:
// Only generate once
if (_step == _RecoveryStep.loading) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (bs.state != BootstrapState.askNewSsss) return;
try {
await bs.newSsss();
// newSsss() internally calls checkCrossSigning() which already
// transitions to askWipeCrossSigning; capture key after it returns.
final key = bs.newSsssKey?.recoveryKey;
if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; });
} catch (e) {
if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; });
}
});
}
case BootstrapState.askWipeCrossSigning:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false);
});
case BootstrapState.askSetupCrossSigning:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askSetupCrossSigning) {
bs.askSetupCrossSigning(
setupMasterKey: true,
setupSelfSigningKey: true,
setupUserSigningKey: true,
);
}
});
case BootstrapState.askWipeOnlineKeyBackup:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true);
});
case BootstrapState.askSetupOnlineKeyBackup:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true);
});
case BootstrapState.done:
if (!_doneCompleter.isCompleted) _doneCompleter.complete();
// Only auto-advance to done if user already clicked through (applying).
// If still on showKey/confirmKey, the user must finish reading first.
if (mounted && _step == _RecoveryStep.applying) {
setState(() => _step = _RecoveryStep.done);
}
case BootstrapState.error:
if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler');
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; });
default:
break;
}
}
// Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen.
void _onKeySaved() {
setState(() => _step = _RecoveryStep.confirmKey);
}
// Called when user submits the correct key confirmation.
// Bootstrap may still be running (cross-signing + key backup); wait for done.
Future<void> _onKeyConfirmed() async {
setState(() => _step = _RecoveryStep.applying);
try {
await _doneCompleter.future.timeout(const Duration(seconds: 90));
if (mounted) setState(() => _step = _RecoveryStep.done);
} catch (e) {
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; });
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey;
return Dialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, minWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
child: Row(
children: [
Icon(Icons.key_rounded, size: 18, color: pt.accent),
const SizedBox(width: 8),
Expanded(
child: Text(
'Wiederherstellungsschlüssel',
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
),
),
if (canClose)
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
],
),
),
Divider(color: pt.border, height: 1),
Padding(
padding: const EdgeInsets.all(20),
child: _buildBody(pt),
),
],
),
),
);
}
Widget _buildBody(PyramidTheme pt) {
return switch (_step) {
_RecoveryStep.loading => const Center(
child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()),
),
_RecoveryStep.showKey => _ShowKeyBody(
pt: pt,
generatedKey: _generatedKey ?? '',
copied: _copied,
onCopy: () {
Clipboard.setData(ClipboardData(text: _generatedKey ?? ''));
setState(() => _copied = true);
},
onKeySaved: _onKeySaved,
),
_RecoveryStep.confirmKey => _ConfirmKeyBody(
pt: pt,
expectedKey: _generatedKey ?? '',
onConfirmed: _onKeyConfirmed,
),
_RecoveryStep.applying => Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator.adaptive(),
const SizedBox(height: 16),
Text(
'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
],
),
_RecoveryStep.done => Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
const SizedBox(height: 12),
Text(
'Wiederherstellungsschlüssel wurde erfolgreich geändert.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 6),
Text(
'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Fertig'),
),
),
],
),
_RecoveryStep.error => Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
const SizedBox(height: 12),
Text(
_error ?? 'Ein unbekannter Fehler ist aufgetreten.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.fg, side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Schließen'),
),
),
],
),
};
}
}
class _ShowKeyBody extends StatelessWidget {
final PyramidTheme pt;
final String generatedKey;
final bool copied;
final VoidCallback onCopy;
final VoidCallback onKeySaved;
const _ShowKeyBody({
required this.pt,
required this.generatedKey,
required this.copied,
required this.onCopy,
required this.onKeySaved,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dein neuer Wiederherstellungsschlüssel',
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: SelectableText(
generatedKey,
style: TextStyle(
color: pt.accent, fontSize: 13,
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
),
),
),
),
IconButton(
onPressed: onCopy,
icon: Icon(
copied ? Icons.check_rounded : Icons.copy_rounded,
size: 18,
color: copied ? PyramidColors.success : pt.fgDim,
),
tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren',
),
],
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.withAlpha(28),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withAlpha(80)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.',
style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4),
),
),
],
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: onKeySaved,
child: const Text('Schlüssel gespeichert – weiter'),
),
),
],
);
}
}
class _ConfirmKeyBody extends StatefulWidget {
final PyramidTheme pt;
final String expectedKey;
final VoidCallback onConfirmed;
const _ConfirmKeyBody({
required this.pt,
required this.expectedKey,
required this.onConfirmed,
});
@override
State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState();
}
class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> {
final _controller = TextEditingController();
bool _mismatch = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final entered = _controller.text.trim();
if (entered == widget.expectedKey) {
widget.onConfirmed();
} else {
setState(() => _mismatch = true);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Schlüssel bestätigen',
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
style: TextStyle(
color: pt.fg, fontSize: 13,
fontFamily: 'monospace', letterSpacing: 0.4,
),
decoration: InputDecoration(
hintText: 'EsTX XXXX XXXX …',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
filled: true,
fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5),
),
errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null,
),
onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); },
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: _submit,
child: const Text('Aktivieren'),
),
),
],
);
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ class _AboutSection extends ConsumerWidget {
); );
if (confirm == true) { if (confirm == true) {
unawaited(AuthLog.write('User-initiated logout (Settings)')); unawaited(AuthLog.write('User-initiated logout (Settings)'));
await client.logout().catchError((_) {}); await logoutWithoutStraySync(client).catchError((_) {});
} }
} }
} }

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