Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2413ef627 | |||
| a4f9fd70e6 | |||
| bf552ee194 | |||
| e0179ad907 | |||
| 8b36d16cdd | |||
| f8f5332d90 | |||
| ffd70caffd | |||
| 9f797279eb | |||
| dba4b64df8 | |||
| fdc16afa70 | |||
| 9950e1eb29 | |||
| 17b3798dc0 | |||
| f2b9afbfea | |||
| 9693174de9 | |||
| bca7abb206 | |||
| 3698048b26 | |||
| 3300a44c4a | |||
| 6b0ae21f2b | |||
| 41bcaf2a88 | |||
| 52b0ae975a | |||
| 6128953e28 | |||
| c8cc1480a8 | |||
| 643ba662dd | |||
| 95d9b07dbd | |||
| b131f56b72 | |||
| d22f9c2961 | |||
| b2335da2fb | |||
| b84d31ca87 | |||
| f87db183ea | |||
| 3c7eb9ddca | |||
| f64398d3c6 | |||
| cead62ccb2 | |||
| 4d16d49012 | |||
| 725f03abb0 | |||
| 15001ae80a | |||
| 1ef32c0022 | |||
| 66bf54bdff | |||
| 81ec06398f | |||
| 4477ed9101 | |||
| 825481e6fa | |||
| db4d001ef1 | |||
| af7c538e18 | |||
| 4c6efa2a7a | |||
| d3e75c36a1 | |||
| 326cc598c7 | |||
| 13b333cbec | |||
| 4d4044b888 | |||
| 5ada0e5038 | |||
| c8ff850c98 | |||
| d42beaf7ab | |||
| 343545b59e | |||
| 455742d1bf | |||
| e97a748b35 | |||
| ba97f942d6 | |||
| 88af6c016c | |||
| 78e4174658 | |||
| 925aafb2fe | |||
| fe6427bfba | |||
| a5a5c4baf5 | |||
| 7b90f20e36 | |||
| 03b84e0e0e | |||
| 7dfdbf1159 | |||
| e0ac5cb9fd | |||
| eebf8ae7dd | |||
| 566938f004 | |||
| 1630d0e389 | |||
| 99cde9a1e2 | |||
| bfe58760ee | |||
| 87ff15d760 | |||
| 16561bbdeb | |||
| 87b29b5912 | |||
| 73420fe245 |
@@ -45,3 +45,7 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
autopilot.log
|
autopilot.log
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
|
||||||
|
*.bak-*
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Bernds Entscheidungen – Richtungsfragen aus PROGRESS.md beantwortet
|
||||||
|
|
||||||
|
Diese Datei beantwortet die über mehrere Sessions in PROGRESS.md gesammelten
|
||||||
|
„Fragen an Bernd". Sie ist ab jetzt **maßgeblich** – die betroffenen Punkte in
|
||||||
|
ROADMAP.md/PROGRESS.md gelten als entschieden.
|
||||||
|
Zuletzt aktualisiert: **2026-07-05** (Bernd + Claude am PC).
|
||||||
|
|
||||||
|
## 1. SQLCipher (lokale DB + Access-Token verschlüsseln) → VOR M2 priorisieren
|
||||||
|
- **Entscheidung:** Ja. Die Verschlüsselung der lokalen `pyramid.sqlite` hat Vorrang
|
||||||
|
vor dem M2-Refactoring. Betrifft den M1-Punkt „Sichere Speicherung von Access-Token
|
||||||
|
& Krypto-DB".
|
||||||
|
- Umsetzung nach `docs/SQLCIPHER_MIGRATION.md`. **Wichtig:** Die Migration bestehender
|
||||||
|
Klartext-DBs (Utas Gerät!) ist der riskante Teil und braucht einen echten
|
||||||
|
PC-/Gerätetest. Auf dem Pi (kein GUI) nur so weit vorbereiten, wie es OHNE GUI sicher
|
||||||
|
testbar ist; alles Verhaltensrelevante klar als UNGETESTET markieren und erst nach
|
||||||
|
`docs/PC_TESTPLAN.md` als erledigt abhaken. Grundsatz „Kein Datenverlust" – im Zweifel
|
||||||
|
für den PC-Termin liegen lassen.
|
||||||
|
|
||||||
|
## 2. M2 – erstes Umbau-Modul → Call-Pilot zuerst
|
||||||
|
- **Entscheidung:** Wenn M2 startet (nach SQLCipher), zuerst die Call-Schicht entwirren
|
||||||
|
(Call-Pilot / gemeinsame Fassade über `voip_manager.dart` + `livekit_call_manager.dart`),
|
||||||
|
wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen. **Nicht** zuerst `settings_modal.dart`
|
||||||
|
aufsplitten.
|
||||||
|
- **Wichtig:** heiliger Call-Pfad → braucht zwingend echten Call-Test auf einem Gerät.
|
||||||
|
Auf dem Pi nicht blind umbauen; Vorbereitung/Analyse ok, Umsetzung + Test am PC.
|
||||||
|
|
||||||
|
## 3. Dashboard-Fernzugriff (stats.html) → Heimnetz-only lassen
|
||||||
|
- **Entscheidung:** KEIN Fernzugriff. Das Interims-Gate (nur Heimnetz/localhost) bleibt so.
|
||||||
|
Die offene A/B-Frage (Cloudflare Access vs. DASH_TOKEN) aus
|
||||||
|
`docs/DASHBOARD_AUTH_HARDENING.md` ist damit **geschlossen** – nicht weiter daran
|
||||||
|
arbeiten. Unterwegs per WireGuard erreichbar.
|
||||||
|
|
||||||
|
## 4. Git-History / Admin-Token → vorerst NICHTS tun
|
||||||
|
- **Entscheidung:** Kein Git-History-Rewrite und aktuell keine Token-Rotation durch den
|
||||||
|
Autopilot. Der Admin-Token ist bereits aus dem App-Code heraus (eigener `DIAG_TOKEN`).
|
||||||
|
Eine spätere Rotation bleibt Bernds manuelle Sache – nicht eigenmächtig anstoßen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Kurzfassung fürs Arbeiten:** SQLCipher vor M2, dann Call-Pilot als erstes M2-Modul.
|
||||||
|
Dashboard bleibt Heimnetz-only. History unangetastet. Riskante/GUI-relevante Teile
|
||||||
|
warten auf den PC-Termin (`docs/PC_TESTPLAN.md`); auf dem Pi nur sicher Vorbereitbares.
|
||||||
+305
@@ -1187,3 +1187,308 @@
|
|||||||
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/features/auth/login_notifier.dart
|
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/features/auth/login_notifier.dart
|
||||||
|
2026-07-03 17:45 [Edit] /home/steggi/pyramid/docs/PROGRESS.md
|
||||||
|
2026-07-03 17:46 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 17:46 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-03 17:49 [Write] /home/steggi/pyramid/docs/SQLCIPHER_MIGRATION.md
|
||||||
|
2026-07-03 17:49 [Edit] /home/steggi/pyramid/docs/SQLCIPHER_MIGRATION.md
|
||||||
|
2026-07-03 17:50 [Write] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||||
|
2026-07-03 17:50 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 17:50 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-03 17:50 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.md
|
||||||
|
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.md
|
||||||
|
2026-07-03 18:16 [Write] /home/steggi/pyramid/test/soft_logout_guard_test.dart
|
||||||
|
2026-07-03 18:17 [Write] /home/steggi/pyramid/test/power_levels_safety_test.dart
|
||||||
|
2026-07-03 22:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 22:22 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 22:23 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-03 22:23 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||||
|
2026-07-03 22:48 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 22:50 [Write] /home/steggi/pyramid/test/auth_log_test.dart
|
||||||
|
2026-07-03 22:50 [Edit] /home/steggi/pyramid/pubspec.yaml
|
||||||
|
2026-07-03 22:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 22:52 [Write] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||||
|
2026-07-03 22:53 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 22:53 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||||
|
2026-07-03 22:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||||
|
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||||
|
2026-07-03 23:19 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
|
||||||
|
2026-07-03 23:20 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
|
||||||
|
2026-07-03 23:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 23:21 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||||
|
2026-07-03 23:22 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 03:28 [Edit] /home/steggi/pyramid/scripts/release.ps1
|
||||||
|
2026-07-04 03:31 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 03:32 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 03:32 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 04:01 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 04:02 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
|
||||||
|
2026-07-04 04:03 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 04:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 04:06 [Write] /home/steggi/pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
|
||||||
|
2026-07-04 04:06 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 04:06 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 04:07 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 04:31 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 04:32 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 08:42 [Write] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-04 08:42 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 08:43 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 08:43 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 09:18 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 13:50 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 13:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 13:51 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 13:51 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-04 14:17 [Write] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 14:19 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 14:20 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 14:20 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 14:43 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 14:45 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 14:46 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 14:46 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 14:47 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 18:56 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 18:57 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-04 19:19 [Edit] /home/steggi/matrix/server.py
|
||||||
|
2026-07-04 19:20 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 19:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 19:21 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 19:22 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-04 19:44 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-04 19:45 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-05 14:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-05 14:53 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||||
|
2026-07-05 14:53 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-05 14:54 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||||
|
2026-07-05 14:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||||
|
2026-07-05 14:54 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/MEMORY.md
|
||||||
|
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: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
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo.
|
|||||||
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
||||||
April-Schnappschüsse – dort NIE arbeiten.)
|
April-Schnappschüsse – dort NIE arbeiten.)
|
||||||
|
|
||||||
|
> **Bernds Entscheidungen zu den offenen Richtungsfragen stehen in `ANTWORTEN_BERND.md`
|
||||||
|
> (zuletzt 2026-07-05) – vor dem Abarbeiten von ROADMAP-Punkten lesen, sie sind maßgeblich.**
|
||||||
|
|
||||||
## Infrastruktur
|
## Infrastruktur
|
||||||
|
|
||||||
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
||||||
|
|||||||
+1907
File diff suppressed because it is too large
Load Diff
+183
-15
@@ -15,16 +15,16 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
||||||
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
|
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
|
||||||
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
|
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
|
||||||
- [ ] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
- [x] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
||||||
Sämtliche Commits vom 2026-07-03 (25ed765..HEAD, gemacht von Sonnet 5) kritisch
|
ERLEDIGT – siehe Abschnitt „Fable-5-Review" oben in PROGRESS.md.
|
||||||
und in der Tiefe nachprüfen – nicht nur formal: (a) die 6 Gott-Datei-Aufteilungen
|
(a) alle 6 Gott-Datei-Aufteilungen unabhängig verifiziert (Zeilen-Multiset,
|
||||||
stichprobenartig fachlich lesen (stimmen `part of`-Bezüge, Sichtbarkeiten,
|
part-Bezüge): korrekt, verlorene Erklärkommentare waren mit f9979e4 schon
|
||||||
keine verlorenen Blöcke – über den automatisierten Blockvergleich hinaus),
|
wiederhergestellt; (b) Uta-Logout-/Push-Fix gegen matrix-SDK 6.2.0
|
||||||
(b) den Uta-Logout-/Push-Fix (63e4919, 9dc83f7) fachlich gegen das matrix-SDK
|
verifiziert: Analyse richtig, Lücke (SDK eskaliert Refresh-Fehler zu
|
||||||
verifizieren (ist die Ursachen-Analyse schlüssig? Nebenwirkungen?),
|
logout()+clear()) war mit 891f348 (onSoftLogout-Guard) geschlossen, Guard
|
||||||
(c) die analyze-/Lint-Fixes auf versehentliche Verhaltensänderungen prüfen,
|
heute gegenverifiziert; (c) Lint-Fixes verhaltensäquivalent (onReorderItem
|
||||||
(d) abgehakte ROADMAP-Punkte gegen PROGRESS.md und den Code gegenprüfen.
|
gegen Flutter-SDK geprüft, kein Off-by-One); (d) abgehakte Punkte stimmen
|
||||||
Befunde direkt fixen, alles in PROGRESS.md protokollieren.
|
mit dem Code überein. Alles weiterhin UNGETESTET (Pi) → PC-Praxistest bleibt.
|
||||||
|
|
||||||
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
||||||
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
||||||
@@ -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
|
||||||
@@ -49,7 +73,9 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
|
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
|
||||||
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
|
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
|
||||||
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
|
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
|
||||||
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd.
|
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd. **Umsetzungsplan liegt
|
||||||
|
fertig in `docs/SQLCIPHER_MIGRATION.md`** (2026-07-03, gegen Code und
|
||||||
|
SDK verifiziert) – PC-Termin kann direkt damit starten.
|
||||||
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||||
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
|
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
|
||||||
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||||
@@ -58,8 +84,91 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
||||||
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
||||||
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
|
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
|
||||||
|
- [x] **SICHERHEIT: Dashboard-Admin-Endpoints – ERLEDIGT: Loch geschlossen,
|
||||||
|
Heimnetz-only ist laut Bernd der Endzustand** (Fable-5-Review (o);
|
||||||
|
Entscheidung `ANTWORTEN_BERND.md` Nr. 3, 2026-07-05).
|
||||||
|
`server.py` nimmt `/api/ban`, `/api/unban`, `/api/toggle-registration`,
|
||||||
|
`/api/create-invite`, `/api/run-stats` jetzt NUR noch aus Heimnetz/localhost an
|
||||||
|
(Socket-IP + Cloudflare-Header-Check); Fremde aus dem Internet bekommen 403.
|
||||||
|
Headless verifiziert (öffentlich 403, Heimnetz ok, App-Endpoints unverändert),
|
||||||
|
siehe PROGRESS.md 2026-07-04. **Bernds Dashboard läuft im Heimnetz unverändert:
|
||||||
|
`http://192.168.178.71:8080/stats.html`.** Offen bleibt: Will Bernd die
|
||||||
|
Admin-Buttons auch von unterwegs? Dann Weg A (Cloudflare Access, empfohlen –
|
||||||
|
schließt auch die weiterhin öffentliche Nutzerlisten-Ansicht) oder Weg B
|
||||||
|
(`DASH_TOKEN`), Plan + Restrisiken in `docs/DASHBOARD_AUTH_HARDENING.md`.
|
||||||
|
Zusatz (Fable-5-Review (p), 2026-07-04): `server.py` läuft jetzt multithreaded
|
||||||
|
mit 30-s-Socket-Timeout – ein einzelner langsamer Client kann den Server
|
||||||
|
(und damit `/api/livekit-token`) nicht mehr blockieren; headless verifiziert.
|
||||||
|
Zusatz (Fable-5-Review (q), 2026-07-04): Der durch (p) eingeführte
|
||||||
|
Nebenläufigkeits-Race in den Admin-Routen (Read-Modify-Write auf `conduit.toml`,
|
||||||
|
`send_admin`-Readback) ist mit `ADMIN_LOCK` geschlossen, das NUR die
|
||||||
|
Admin-Mutationen serialisiert – die öffentlichen Routen bleiben nebenläufig;
|
||||||
|
headless verifiziert (2× parallel = 2× Laufzeit, öffentlicher Pfad 11 ms).
|
||||||
|
Zusatz (Fable-5-Review (r), 2026-07-04): drei Restbefunde gefixt –
|
||||||
|
`registration-status`-Leser jetzt unter ADMIN_LOCK (Truncate-Write-Race),
|
||||||
|
negative/übergroße `Content-Length` überall abgelehnt (kein Thread-Hänger,
|
||||||
|
ban/unban 4-KB-Deckel), Einladungs-Token per `secrets` statt `random`.
|
||||||
|
Zusatz (Fable-5-Review (s), 2026-07-04): ban/unban-Body wird jetzt VOR dem
|
||||||
|
ADMIN_LOCK gelesen (hängender Body-Read konnte den Lock 30 s festhalten),
|
||||||
|
und die öffentlichen Routen antworten bei internen Fehlern generisch statt
|
||||||
|
mit Exception-Text (Info-Leak); Details ins Journal. Headless verifiziert.
|
||||||
|
Zusatz (Fable-5-Review (t), 2026-07-04): auch der öffentliche GET-Leser
|
||||||
|
`registration-status` antwortet bei internen Fehlern jetzt generisch
|
||||||
|
statt mit `str(e)` (hätte den `conduit.toml`-Pfad geleakt); headless
|
||||||
|
verifiziert inkl. 500-Beweis.
|
||||||
|
Zusatz (Fable-5-Review (u), 2026-07-04): (t)-Fix gegengelesen – korrekt,
|
||||||
|
kein Befund; die server.py-Härtungskette ist damit KONVERGIERT (alle
|
||||||
|
verbliebenen `str(e)`-Antworten LAN-gegated, absichtlich).
|
||||||
|
**Abschluss (2026-07-05, `ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff
|
||||||
|
gewünscht – die A/B-Frage ist GESCHLOSSEN, das Heimnetz-Gate ist der
|
||||||
|
Endzustand (unterwegs nutzt Bernd WireGuard). Die Restrisiken des Gates
|
||||||
|
(öffentliche Nutzerlisten-Ansicht, theoretisches LAN-CSRF) sind bewusst
|
||||||
|
akzeptiert und in `docs/DASHBOARD_AUTH_HARDENING.md` dokumentiert.
|
||||||
|
Nicht weiter daran arbeiten.**
|
||||||
|
- [x] **LiveKit-Token-Route server-seitig gebaut + verifiziert** (Fable-5-Review (n),
|
||||||
|
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
|
||||||
|
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
|
||||||
|
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
|
||||||
|
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
|
||||||
|
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
|
||||||
|
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||||
|
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
|
||||||
|
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
|
||||||
|
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
|
||||||
|
- **Rotieren (Pflicht, sie sind bereits geleakt):** Matrix-Server-Admin-Token
|
||||||
|
`J5lax…` (noch gültig, voller Admin!), Gitea-Release-Token, LiveKit-`apiKey`/
|
||||||
|
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
|
||||||
|
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
|
||||||
|
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
|
||||||
|
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
|
||||||
|
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
|
||||||
|
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
|
||||||
|
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
|
||||||
|
String wie den Matrix-Admin-Token – deshalb steckte er im Client. Jetzt hat das
|
||||||
|
Diagnose-Endpoint einen eigenen, eng begrenzten Token (`DIAG_TOKEN`, hängt nur an
|
||||||
|
den Log an, keine Admin-Rechte); `settings_encryption.dart` benutzt diesen. Der
|
||||||
|
**Admin-Token ist damit nicht mehr im App-Code** (per `grep` bestätigt). Server +
|
||||||
|
Client umgestellt und verifiziert (neuer Token 200, alter Admin-Token 403). Der
|
||||||
|
Admin-Token selbst muss trotzdem noch rotiert werden (steht oben, Bernds Sache).
|
||||||
|
- Optional: Git-History-Rewrite (Bernds Entscheidung, Repo ist auch Backup).
|
||||||
|
- **Stand 2026-07-05 (`ANTWORTEN_BERND.md` Nr. 4): Rotation und
|
||||||
|
History-Rewrite ruhen BEWUSST – beides bleibt Bernds manuelle Sache,
|
||||||
|
NICHT vom Autopilot anstoßen oder nachfragen. Der Punkt bleibt nur als
|
||||||
|
Merkposten offen; Autopilot-bearbeitbar ist hier nichts mehr.**
|
||||||
|
- **ACHTUNG bei „Repo einfach privat schalten":** Der In-App-Updater
|
||||||
|
(`lib/core/update_checker.dart`) holt Releases **anonym** über die öffentliche
|
||||||
|
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
|
||||||
|
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
|
||||||
|
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
|
||||||
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
- [ ] 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
|
||||||
|
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
||||||
|
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
|
||||||
|
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
|
||||||
|
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
|
||||||
|
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
|
||||||
|
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
|
||||||
|
|
||||||
## M2 – Das große Refactoring (erst nach M0/M1!)
|
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||||
|
|
||||||
@@ -82,7 +191,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
|
||||||
@@ -99,9 +234,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
|
||||||
@@ -114,6 +258,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
- [ ] 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)
|
||||||
@@ -127,7 +283,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
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="pyramid"
|
android:label="Pyramid"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+26
-7
@@ -1,14 +1,24 @@
|
|||||||
#!/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"
|
||||||
|
|
||||||
|
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
|
||||||
|
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
|
||||||
|
# ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
|
||||||
|
# ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
|
||||||
|
# die Config faelschlich unter ~/.claude/.claude.json).
|
||||||
|
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
|
||||||
|
# ist oeffentlich, PII gehoert nicht hinein.)
|
||||||
|
apply_account() { unset CLAUDE_CONFIG_DIR; }
|
||||||
|
ACCOUNT_NAME="nordmann"
|
||||||
|
|
||||||
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
||||||
MATRIX_URL="http://127.0.0.1:6167"
|
MATRIX_URL="http://127.0.0.1:6167"
|
||||||
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
|
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
|
||||||
@@ -24,9 +34,7 @@ notify_matrix() {
|
|||||||
-d "$body" > /dev/null || true
|
-d "$body" > /dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
|
PROMPT="Lies CLAUDE.md. Arbeite in dieser Reihenfolge: (1) Review-Pass: Gehe die in ROADMAP.md/PROGRESS.md als erledigt markierten Punkte durch, prüfe den zugehörigen Code kritisch und überarbeite/verbessere ihn, wo nötig. Führe in PROGRESS.md einen Abschnitt 'Fable-5-Review' und hake dort jeden geprüften Punkt ab, damit bereits reviewte Punkte nicht erneut geprüft werden. (2) Wenn alle erledigten Punkte reviewt sind, arbeite ROADMAP.md Punkt für Punkt weiter ab. Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). Arbeite so viel ab wie möglich."
|
||||||
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
|
|
||||||
Arbeite so viele Punkte ab wie möglich."
|
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if [ -f PAUSE ]; then
|
if [ -f PAUSE ]; then
|
||||||
@@ -34,9 +42,11 @@ while true; do
|
|||||||
sleep 600
|
sleep 600
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
apply_account
|
||||||
|
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)
|
||||||
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
|
SESSION_OUT=$(mktemp)
|
||||||
|
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
|
||||||
AFTER=$(git rev-parse HEAD 2>/dev/null)
|
AFTER=$(git rev-parse HEAD 2>/dev/null)
|
||||||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
||||||
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
||||||
@@ -48,6 +58,15 @@ $COMMITS
|
|||||||
|
|
||||||
$NEXT"
|
$NEXT"
|
||||||
fi
|
fi
|
||||||
|
# Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
|
||||||
|
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
|
||||||
|
rm -f "$SESSION_OUT"
|
||||||
|
echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
|
||||||
|
sleep 1200
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$SESSION_OUT"
|
||||||
|
|
||||||
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||||
sleep 1200
|
sleep 1200
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
|
||||||
|
|
||||||
|
**Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate
|
||||||
|
(„Weg C", siehe unten) geschlossen, und Bernd hat entschieden
|
||||||
|
(`ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff – das Gate ist der ENDZUSTAND.**
|
||||||
|
Die fünf Admin-Endpoints akzeptieren nur noch Anfragen aus dem
|
||||||
|
Heimnetz/localhost; alles, was über den Cloudflare-Tunnel kommt, bekommt 403 mit
|
||||||
|
einer Erklärung (die das Dashboard per `alert()` anzeigt). Bernds Dashboard
|
||||||
|
funktioniert im Heimnetz unverändert unter
|
||||||
|
`http://192.168.178.71:8080/stats.html`; unterwegs kommt Bernd per WireGuard ins
|
||||||
|
Heimnetz. Die A/B-Frage (Cloudflare Access vs. `DASH_TOKEN`) ist damit
|
||||||
|
GESCHLOSSEN – die Optionen unten bleiben nur als Archiv stehen, falls sich der
|
||||||
|
Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst
|
||||||
|
akzeptiert. **Nicht weiter an diesem Thema arbeiten.**
|
||||||
|
|
||||||
|
## Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)
|
||||||
|
|
||||||
|
In `server.py` (Konstante `ADMIN_PATHS` + Helfer `lan_denied_reason`): eine
|
||||||
|
Admin-POST-Anfrage wird nur angenommen, wenn (1) **kein** `Cf-Connecting-Ip`/
|
||||||
|
`Cf-Ray`-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
|
||||||
|
(2) die **Socket-Absender-IP** (nicht spoofbar) `127.0.0.1`/`::1`/`192.168.*`
|
||||||
|
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
|
||||||
|
`security_alerts.log` und damit in der Dashboard-Anzeige. Headless verifiziert:
|
||||||
|
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
|
||||||
|
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
|
||||||
|
`GET /` öffentlich → 200, `/api/livekit-token` (401 bei ungültigem Token) und
|
||||||
|
`/api/e2ee-diagnostics` (403 bei falschem Token) unverändert öffentlich
|
||||||
|
erreichbar (die App braucht sie, eigene Auth vorhanden).
|
||||||
|
|
||||||
|
**Bekannte Restrisiken des Gates (Bernd bekannt und bewusst akzeptiert,
|
||||||
|
Entscheidung 2026-07-05):**
|
||||||
|
- Die **öffentliche Ansicht bleibt offen** und zeigt Nutzerliste + Präsenz
|
||||||
|
(Informations-Preisgabe). Weg A würde auch das schließen.
|
||||||
|
- **CSRF aus dem Heimnetz:** Eine bösartige Webseite in Bernds Browser könnte
|
||||||
|
theoretisch POSTs an `http://192.168.178.71:8080` schicken (moderne
|
||||||
|
Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig).
|
||||||
|
Erst ein Token/Access-Login (Weg A/B) schließt das sauber.
|
||||||
|
- Wer **im Heimnetz** ist, kann weiterhin ohne Anmeldung administrieren.
|
||||||
|
|
||||||
|
## Problem (belegt, nicht vermutet)
|
||||||
|
|
||||||
|
Der Dashboard-Server `/home/steggi/matrix/server.py` (läuft als
|
||||||
|
`matrix-stats.service` auf `0.0.0.0:8080`) ist über Cloudflare unter
|
||||||
|
`https://dashboard.steggi-matrix.work` **öffentlich aus dem Internet erreichbar**.
|
||||||
|
Getestet: `GET /` liefert 200 (stats.html), und die **zustandsändernden POST-Endpoints
|
||||||
|
antworten mit der App-eigenen Validierung – ohne jede Authentifizierung**:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
|
||||||
|
{"error": "Kein Nutzer angegeben"} # HTTP 400 – App-Antwort, KEIN Login davor
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit kann **jeder Fremde im Internet**, der den Hostnamen kennt, ohne Anmeldung:
|
||||||
|
|
||||||
|
- `/api/ban` / `/api/unban` – **jeden Matrix-Nutzer sperren/entsperren** (auch Uta!)
|
||||||
|
→ Aussperrung / Denial-of-Service gegen echte Nutzer.
|
||||||
|
- `/api/toggle-registration` und `/api/create-invite` – **offene Registrierung am
|
||||||
|
Homeserver aktivieren** (setzt `allow_registration = true` +
|
||||||
|
`yes_i_am_very_very_sure…`) → der Homeserver wird zur Spam-/Missbrauchsschleuder.
|
||||||
|
- `/api/run-stats` – Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
|
||||||
|
|
||||||
|
**Der Hostname ist nicht wirklich geheim:** Cloudflare stellt TLS-Zertifikate aus,
|
||||||
|
die in den öffentlichen Certificate-Transparency-Logs landen – jede Subdomain von
|
||||||
|
`steggi-matrix.work` ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
|
||||||
|
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
|
||||||
|
|
||||||
|
Bereits **korrekt abgesichert** (zum Vergleich, nicht betroffen):
|
||||||
|
- `/api/e2ee-diagnostics` – eigener `DIAG_TOKEN` (Bearer).
|
||||||
|
- `/api/livekit-token` – Matrix-Access-Token per `whoami` gegen Continuwuity.
|
||||||
|
|
||||||
|
Nur die vier Admin-/Stats-Endpoints oben sind offen.
|
||||||
|
|
||||||
|
## Wie das Dashboard heute aufruft
|
||||||
|
|
||||||
|
`stats.html` ruft alle Aktionen als **Same-Origin-`fetch` ohne Auth-Header** auf
|
||||||
|
(`toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction` →
|
||||||
|
`/api/ban` /`/api/unban`). Es gibt keine Session, kein Cookie, keinen Token –
|
||||||
|
deshalb bricht **jede** hinzugefügte Authentifizierung das Dashboard so lange,
|
||||||
|
bis `stats.html` den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
|
||||||
|
und kein stiller Fix.
|
||||||
|
|
||||||
|
## Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
|
||||||
|
|
||||||
|
Am saubersten wird das Dashboard **komplett** durch Cloudflare Access geschützt
|
||||||
|
(Zero-Trust-Login vor der Subdomain), statt in `server.py` Auth nachzubauen:
|
||||||
|
|
||||||
|
1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für
|
||||||
|
`dashboard.steggi-matrix.work` anlegen.
|
||||||
|
2. Policy: nur Bernds E-Mail (`bernd.steckmeister@gmail.com`) per One-Time-PIN
|
||||||
|
oder Google-Login zulassen.
|
||||||
|
3. Fertig – `server.py` und `stats.html` bleiben **unverändert**, das Dashboard
|
||||||
|
funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen
|
||||||
|
gar nicht erst an die Endpoints.
|
||||||
|
|
||||||
|
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko
|
||||||
|
durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im
|
||||||
|
Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). **Das ist der
|
||||||
|
Königsweg – Option B nur, falls Access nicht gewünscht ist.**
|
||||||
|
|
||||||
|
## Option B (Fallback, rein in server.py): eigener Dashboard-Token
|
||||||
|
|
||||||
|
Wenn Access nicht gewünscht ist, ein **neuer, zufälliger `DASH_TOKEN`** (getrennt
|
||||||
|
vom geleakten Admin-Token `J5lax…` und vom `DIAG_TOKEN`!), den die vier
|
||||||
|
Admin-/Stats-Endpoints als `Authorization: Bearer <DASH_TOKEN>` verlangen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server.py, zu den anderen Token-Konstanten:
|
||||||
|
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
|
||||||
|
|
||||||
|
def _dash_authed(self):
|
||||||
|
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
In `do_POST` **vor** der Behandlung von `/api/ban`, `/api/unban`,
|
||||||
|
`/api/toggle-registration`, `/api/create-invite`, `/api/run-stats` einfügen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
|
||||||
|
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /` und `/api/registration-status` bleiben öffentlich (nur Lesen), damit die
|
||||||
|
Stats-Seite ohne Token sichtbar bleibt.
|
||||||
|
|
||||||
|
**`stats.html`** darf den Token **NIEMALS fest eingebettet** ausliefern (die Seite
|
||||||
|
ist öffentlich – der Token läge sonst für jeden offen). Stattdessen einmal im
|
||||||
|
Browser abfragen und in `localStorage` halten:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function dashToken() {
|
||||||
|
var t = localStorage.getItem('dashToken');
|
||||||
|
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
|
||||||
|
return t || '';
|
||||||
|
}
|
||||||
|
// und bei JEDEM Aktions-fetch:
|
||||||
|
fetch('/api/ban', { method:'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
|
||||||
|
body: JSON.stringify({user:u}) })
|
||||||
|
```
|
||||||
|
|
||||||
|
(betrifft `toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`).
|
||||||
|
Bei 403 den gecachten Token verwerfen (`localStorage.removeItem('dashToken')`) und
|
||||||
|
neu fragen. Bernd gibt den Token so genau **einmal pro Browser** ein.
|
||||||
|
|
||||||
|
## Rollout / Testplan
|
||||||
|
|
||||||
|
**Option A:** Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
|
||||||
|
`https://dashboard.steggi-matrix.work/` zum Access-Login umleitet statt die Seite
|
||||||
|
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
|
||||||
|
|
||||||
|
**Option B (headless auf dem Pi verifizierbar):**
|
||||||
|
1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen,
|
||||||
|
`Restart=always` lädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle).
|
||||||
|
2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet.
|
||||||
|
3. Mit `Authorization: Bearer <DASH_TOKEN>` + leerem Nutzer → 400 (App-Validierung
|
||||||
|
erreicht) → beweist: Token akzeptiert, Auth greift.
|
||||||
|
4. Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen
|
||||||
|
(Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an
|
||||||
|
einem **Test-Nutzer**, nicht an Uta).
|
||||||
|
|
||||||
|
## Warum erst nur der Plan kam – und dann doch das Interims-Gate
|
||||||
|
|
||||||
|
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
|
||||||
|
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
|
||||||
|
einrichten) – das mitten in einer autonomen Session zu deployen verletzt die
|
||||||
|
CLAUDE.md-Leitplanke „kein Aussperren". Das *Wie* der Anmeldung bleibt deshalb
|
||||||
|
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
|
||||||
|
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
|
||||||
|
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
|
||||||
|
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
|
||||||
|
die öffentliche URL gehen nicht – die 403-Meldung im Dashboard erklärt das und
|
||||||
|
nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only,
|
||||||
|
unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau
|
||||||
|
(falls je nötig) = die Gate-Zeilen in `server.py` entfernen
|
||||||
|
(Backup: `server.py.bak-20260704-dashgate`).
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
|
||||||
|
|
||||||
|
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
|
||||||
|
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
|
||||||
|
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
|
||||||
|
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`lib/core/livekit_token.dart` mintet die LiveKit-JWTs **im Client**. Dafür muss das
|
||||||
|
LiveKit-`apiSecret` im App-Code liegen (`livekit_token.dart:6`) – d. h. jeder
|
||||||
|
App-Nutzer (auch Uta) trägt das SFU-Secret im Gerät. Wer es hat, kann sich für
|
||||||
|
**jeden** Raum und **jede** Identität ein Beitritts-Token ausstellen. Das ist ein
|
||||||
|
Architekturproblem, kein bloßer Repo-Leak: Selbst nach einem History-Rewrite bliebe
|
||||||
|
das Secret im ausgelieferten Binary.
|
||||||
|
|
||||||
|
Aktueller Fluss (`lib/core/livekit_call_manager.dart:154`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final token = LiveKitTokenGenerator.generate(
|
||||||
|
roomName: roomName,
|
||||||
|
identity: identity,
|
||||||
|
displayName: identity,
|
||||||
|
ttlSeconds: 21600, // 6 h
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugtes JWT (HS256, `livekit_token.dart:14-33`): Grants `video` mit
|
||||||
|
`roomJoin/room/canPublish/canSubscribe/canPublishData`, `metadata` = Anzeigename,
|
||||||
|
`iss` = apiKey (`LKMatrixPi`), `sub` = identity, `exp` = jetzt+ttl.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Das `apiSecret` liegt **nur noch auf dem Pi**. Der Client holt sich für einen
|
||||||
|
konkreten Raum ein fertiges JWT von einem kleinen Token-Endpoint und übergibt es
|
||||||
|
unverändert an `Room.connect`.
|
||||||
|
|
||||||
|
## Server-Seite (Pi)
|
||||||
|
|
||||||
|
Es gibt bereits einen schlanken Dashboard-Server ohne Fremd-Abhängigkeiten:
|
||||||
|
`/home/steggi/matrix/server.py` (stdlib `http.server`, läuft als
|
||||||
|
`matrix-stats.service` auf `0.0.0.0:8080`, nach außen `dashboard.steggi-matrix.work`).
|
||||||
|
Der Token-Endpoint kann dort als weitere Route `POST /api/livekit-token` andocken –
|
||||||
|
kein neuer Dienst nötig. Das LiveKit-`apiSecret` steht schon auf dem Pi in
|
||||||
|
`/home/steggi/matrix/livekit.yaml` (`keys: LKMatrixPi: <secret>`); der Server liest
|
||||||
|
es von dort, statt es erneut zu hinterlegen (eine Quelle der Wahrheit).
|
||||||
|
|
||||||
|
**Wichtig – Authentifizierung:** Der Endpoint darf NICHT anonym Tokens ausstellen
|
||||||
|
(sonst ist nichts gewonnen). Der Client schickt seinen **Matrix-Access-Token** mit;
|
||||||
|
der Server prüft ihn gegen Continuwuity und leitet daraus die Identität ab:
|
||||||
|
|
||||||
|
1. `POST /api/livekit-token`, Body `{ "room": "<roomName>", "matrix_token": "<access_token>" }`.
|
||||||
|
2. Server ruft `GET http://127.0.0.1:6167/_matrix/client/v3/account/whoami`
|
||||||
|
mit `Authorization: Bearer <matrix_token>` auf → liefert `user_id`
|
||||||
|
(401 → Endpoint gibt 401 zurück, kein Token).
|
||||||
|
3. `identity` = `user_id` (NICHT vom Client wählbar – verhindert Identitäts-Spoofing).
|
||||||
|
4. Optional, aber empfohlen: Raummitgliedschaft prüfen (`/_matrix/client/v3/rooms/
|
||||||
|
{roomId}/joined_members` oder der Matrix-Raum, zu dem der Voice-Channel gehört) –
|
||||||
|
nur ausstellen, wenn der Nutzer wirklich Mitglied ist. Für Phase 1 reicht die
|
||||||
|
whoami-Authentifizierung; Mitgliedschaftsprüfung als Härtung nachziehen.
|
||||||
|
5. Server mintet das LiveKit-JWT **mit denselben Grants wie heute** und gibt
|
||||||
|
`{ "token": "<jwt>", "url": "wss://livekit.steggi-matrix.work" }` zurück.
|
||||||
|
|
||||||
|
**JWT-Minting ohne Fremd-Paket (stdlib genügt):** LiveKit-Tokens sind HS256-JWTs.
|
||||||
|
Mit `hmac`/`hashlib`/`base64`/`json` aus der stdlib:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64, hmac, hashlib, json, time
|
||||||
|
|
||||||
|
def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
|
||||||
|
def mint_livekit(api_key, api_secret, identity, room, name, ttl=21600):
|
||||||
|
now = int(time.time())
|
||||||
|
header = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
payload = {
|
||||||
|
"iss": api_key, "sub": identity, "name": name,
|
||||||
|
"nbf": now, "exp": now + ttl, "metadata": name,
|
||||||
|
"video": {"roomJoin": True, "room": room,
|
||||||
|
"canPublish": True, "canSubscribe": True,
|
||||||
|
"canPublishData": True},
|
||||||
|
}
|
||||||
|
segs = [_b64url(json.dumps(header, separators=(",", ":")).encode()),
|
||||||
|
_b64url(json.dumps(payload, separators=(",", ":")).encode())]
|
||||||
|
signing_input = b".".join(segs)
|
||||||
|
sig = hmac.new(api_secret.encode(), signing_input, hashlib.sha256).digest()
|
||||||
|
return (signing_input + b"." + _b64url(sig)).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifikation lokal auf dem Pi (kein Gerät nötig): geminetes Token gegen den
|
||||||
|
laufenden LiveKit prüfen, z. B. per LiveKit-CLI `lk` oder indem man das JWT auf
|
||||||
|
jwt.io-Art dekodiert und Claims/Signatur vergleicht. Zusätzlich Gegentest mit dem
|
||||||
|
**alten** Client-Pfad: dasselbe Token muss `Room.connect` akzeptieren.
|
||||||
|
|
||||||
|
## Client-Seite
|
||||||
|
|
||||||
|
- `lib/core/livekit_token.dart`: `apiKey`/`apiSecret` **entfernen**. Die Klasse wird
|
||||||
|
entweder gelöscht oder zu einem dünnen HTTP-Client
|
||||||
|
`Future<String> fetchLiveKitToken({required String room, required String matrixToken})`,
|
||||||
|
der `POST https://dashboard.steggi-matrix.work/api/livekit-token` aufruft und das
|
||||||
|
`token`-Feld zurückgibt. `http`-Paket ist schon Abhängigkeit (siehe
|
||||||
|
`update_checker.dart`).
|
||||||
|
- `lib/core/livekit_call_manager.dart:154`: `LiveKitTokenGenerator.generate(...)`
|
||||||
|
→ `await fetchLiveKitToken(room: roomName, matrixToken: matrixClient.accessToken)`.
|
||||||
|
Der Manager hat den Matrix-Client bereits (`_matrixClient`, Zeile 149) – der
|
||||||
|
Access-Token ist also verfügbar. `identity`/`displayName` werden nicht mehr vom
|
||||||
|
Client bestimmt (Server leitet Identität aus whoami ab); den bisherigen
|
||||||
|
`identity`-Parameter entsprechend zurückbauen.
|
||||||
|
- Fehlerpfade: Netzfehler / 401 / 403 sauber als Call-Fehler anzeigen
|
||||||
|
(`error = ...; notifyListeners()`), NICHT still schlucken – sonst „Call verbindet
|
||||||
|
nicht" ohne Hinweis. TTL bleibt 21600 s (6 h), Erneuerung wie bisher beim
|
||||||
|
Neu-Beitreten.
|
||||||
|
|
||||||
|
## Rotation (Pflicht, danach)
|
||||||
|
|
||||||
|
Das alte `apiSecret` `rYUT2PRa…` ist geleakt (Client + Git-History). Nach der
|
||||||
|
Umstellung:
|
||||||
|
|
||||||
|
1. Neues `apiSecret` in `/home/steggi/matrix/livekit.yaml` (`keys:`) setzen.
|
||||||
|
2. LiveKit-Container neu starten
|
||||||
|
(`docker compose -f /home/steggi/matrix/docker-compose.yml restart livekit`).
|
||||||
|
3. `server.py` liest das Secret aus `livekit.yaml` – kein zweiter Ort zu pflegen.
|
||||||
|
4. Alte Clients (Utas installierte APK) minten mit dem alten Secret → deren
|
||||||
|
selbst-gemintete Tokens werden nach der Rotation abgelehnt. Deshalb Rotation
|
||||||
|
**zusammen mit einem neuen Release** ausrollen, das den neuen Fetch-Pfad nutzt.
|
||||||
|
|
||||||
|
## Reihenfolge / Testplan (PC + Gerät)
|
||||||
|
|
||||||
|
1. `server.py`-Route bauen, lokal auf dem Pi verifizieren (whoami-Auth greift,
|
||||||
|
geminetes Token dekodiert korrekt, `Room.connect` akzeptiert es).
|
||||||
|
2. Client umstellen, `flutter analyze` sauber, `flutter test` grün.
|
||||||
|
3. **Auf echtem Gerät** (Calls heilig, kein Headless-Test möglich): Voice-Channel
|
||||||
|
beitreten, Audio/Video/Screenshare, Verlassen/Wieder-Beitreten, zweiter
|
||||||
|
Teilnehmer. Erst wenn das steht: Secret rotieren + Release.
|
||||||
|
4. Danach ROADMAP-M0-Punkt „LiveKit-Token server-seitig minten" abhaken und den
|
||||||
|
Rotations-Teil des Secret-Punkts als erledigt markieren.
|
||||||
|
|
||||||
|
## Warum nicht jetzt (auf dem Pi) gemacht
|
||||||
|
|
||||||
|
Die Server-Route ließe sich hier bauen und headless testen – aber der zugehörige
|
||||||
|
Client-Umbau tauscht den **heiligen Call-Pfad** aus und ist auf diesem Pi ohne
|
||||||
|
GUI/Gerät nicht praktisch prüfbar. Einen halben, nicht end-to-end verifizierbaren
|
||||||
|
Umbau am Call-Pfad zu stapeln verbietet die CLAUDE.md-Leitplanke („keine
|
||||||
|
Verhaltensänderung ohne Not", Calls heilig). Deshalb hier nur der fertige Plan.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# M2 – Einheitliches State-Management: Ist-Analyse + Umbauplan
|
||||||
|
|
||||||
|
> **STATUS 2026-07-06: reine Analyse, noch KEIN Umbau.** Grundlage für den
|
||||||
|
> ROADMAP-M2-Punkt „Einheitliches State-Management (Riverpod konsequent,
|
||||||
|
> keine Misch-Patterns)". Die Schritte 1–2 unten betreffen den heiligen
|
||||||
|
> Call-Pfad und sind GESPERRT, bis Bernds Klick-Test des Call-Piloten
|
||||||
|
> (`docs/PC_TESTPLAN.md` Abschnitt 8) grün ist – vorher nur Schritt 3.
|
||||||
|
|
||||||
|
## Ist-Stand (repo-weit erhoben, 2026-07-06)
|
||||||
|
|
||||||
|
Riverpod ist bereits das dominante Muster: rund 50 Provider –
|
||||||
|
`StateProvider` für UI-Zustand, `StreamProvider` auf `client.onSync`,
|
||||||
|
`FutureProvider` (Client, Update-Info), `StateNotifierProvider` für die
|
||||||
|
Prefs-Notifier (`BoolPref`/`StringSetPref` in `core/settings_prefs.dart`).
|
||||||
|
`ValueNotifier`/eigene `StreamController` gibt es NICHT. Lokales
|
||||||
|
`setState` in Widgets für rein ephemeren Zustand (Hover, Dialog-Schritte,
|
||||||
|
Textfelder) ist normales Flutter und zählt hier bewusst NICHT als
|
||||||
|
Misch-Pattern.
|
||||||
|
|
||||||
|
Gefundene Misch-Patterns, nach Gewicht:
|
||||||
|
|
||||||
|
1. **Call-Schicht: Singletons + ChangeNotifier hinter den Fassaden.**
|
||||||
|
`LiveKitCallManager.instance` (static final Singleton,
|
||||||
|
`features/voice_channel/livekit_call_manager.dart:17`) und
|
||||||
|
`PyramidVoipManager._instance` (nullable static,
|
||||||
|
`features/call_signaling/voip_manager.dart:17`; konstruiert im
|
||||||
|
Kompositions-Punkt `core/matrix_client.dart:66–71`). Die Modul-Provider
|
||||||
|
(`voiceChannelProvider`/`callSignalingProvider`) geben nur die
|
||||||
|
Singletons zurück. Das ist die im Pilot BEWUSST gewählte Zwischenform
|
||||||
|
(Verhaltensgleichheit vor Schönheit). Eigentliches Problem: UI-Zustand
|
||||||
|
(Mute-Flags, Teilnehmer, Renderer, Qualitätswahl) lebt mutierbar IN den
|
||||||
|
Managern und wird per `notifyListeners()` grob-granular publiziert.
|
||||||
|
2. **Voice-UI-Zustand außerhalb des Moduls:** `activeVoiceRoomIdProvider`
|
||||||
|
und `voiceParticipantsProvider` liegen in `core/app_state.dart`
|
||||||
|
(Kopplungspunkt laut Ist-Analyse), werden aber von Rooms-Panel und
|
||||||
|
Mini-Call-Widget gesetzt/gelesen und gehören fachlich ins
|
||||||
|
`voice_channel`-Modul (Join-Zustand + Presence sind Modulsache).
|
||||||
|
3. **`MediaCache.instance`** (`core/media_cache.dart:19`): reines
|
||||||
|
Singleton, kein UI-Zustand, nur Cache. Nutzer sind `widgets/mxc_image.dart`
|
||||||
|
(7 Stellen, Widgets – leicht umstellbar) UND
|
||||||
|
`features/chat/message/message_media.dart` (7 Stellen, darunter der
|
||||||
|
statische Wrapper `_DocBytesCache` für Dokument-Vorschauen, dessen
|
||||||
|
Aufrufer keinen `ref`-Zugang haben). Eine Umstellung müsste `ref`/die
|
||||||
|
Cache-Instanz durch die statischen Helfer der Chat-Timeline fädeln –
|
||||||
|
und genau dieser Code (Download-/Entschlüsselungs-Fehlerpfade) ist
|
||||||
|
laut PC_TESTPLAN Abschnitt 3 der am wenigsten geprüfte Teil der App.
|
||||||
|
4. **Push/Notifications:** modul-globale Mutable-Zustände in
|
||||||
|
`core/notification_service.dart` (`_windowsNotifs`-Map, Plugin-Globals)
|
||||||
|
und direkte `SharedPreferences`-Zugriffe in `background_push.dart`/
|
||||||
|
`fcm_push_service.dart`. Das ist dem Background-Isolate-Zwang
|
||||||
|
geschuldet (siehe Schnitt-Doc Punkt 8 + Memory „Pyramid Push") –
|
||||||
|
**bewusst KEIN Umbau-Kandidat**, nur hier dokumentiert.
|
||||||
|
|
||||||
|
Bereits erledigt im Zuge dieser Analyse: der seit dem Call-Piloten tote
|
||||||
|
`incomingCallProvider` ist aus `app_state.dart` entfernt (41bcaf2).
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
- `core/app_state.dart` enthält nur noch ECHTEN App-weiten UI-Zustand
|
||||||
|
(Theme, Layout/Panels, Navigation, Toast, Space-Auswahl) – kein
|
||||||
|
Feature-Fachzustand.
|
||||||
|
- Feature-Zustand lebt im jeweiligen Modul hinter dessen Provider;
|
||||||
|
andere Module lesen ihn NUR über die öffentliche Schnittstelle.
|
||||||
|
- Kein `.instance`-Zugriff über Modulgrenzen; Konstruktion ausschließlich
|
||||||
|
im Kompositions-Punkt bzw. im Provider des Moduls.
|
||||||
|
- ChangeNotifier ist als Übergangsform ok; Endzustand der Call-Module ist
|
||||||
|
ein immutables Zustandsobjekt (z. B. `VoiceChannelState`) pro Modul,
|
||||||
|
das der Provider publiziert, plus Kommando-Methoden auf dem Service
|
||||||
|
(entspricht dem Schnitt-Doc-Entwurf, dort als Stream skizziert).
|
||||||
|
|
||||||
|
## Schrittfolge (Reihenfolge = Priorität)
|
||||||
|
|
||||||
|
1. **[GESPERRT bis Klick-Test] UI-State aus den Managern herauslösen:**
|
||||||
|
je Call-Modul ein immutables Zustandsobjekt + `Notifier`/
|
||||||
|
`StateNotifier`, Manager wird interne Implementierung ohne
|
||||||
|
UI-Zustand. Das ist der bereits in PROGRESS.md zurückgestellte
|
||||||
|
Logik-Umbau – NICHT vorher anfangen (heiliger Call-Pfad, ein
|
||||||
|
ungetesteter Umbau liegt schon auf dem Stapel).
|
||||||
|
2. **[GESPERRT bis Klick-Test] `activeVoiceRoomIdProvider` +
|
||||||
|
`voiceParticipantsProvider` ins `voice_channel`-Modul umziehen**
|
||||||
|
(zusammen mit `kVoicePresenceType`). Mechanisch klein, aber
|
||||||
|
Call-Pfad-nah – sinnvoll im selben Zug wie Schritt 1.
|
||||||
|
3. **[ZURÜCKGESTELLT] `MediaCache` hinter `Provider<MediaCache>`:**
|
||||||
|
Erst als „risikoarm, jederzeit" eingestuft – das stimmte NICHT
|
||||||
|
(Korrektur 2026-07-06, siehe Ist-Stand Punkt 3: auch die Chat-Timeline
|
||||||
|
hängt dran, inkl. statischer Helfer ohne `ref`). Gehört in den Zug des
|
||||||
|
`chat_timeline`-Moduls (Schnitt-Doc Punkt 9) bzw. frühestens nach dem
|
||||||
|
Klick-Test von PC_TESTPLAN Abschnitt 3 – nicht als Lückenfüller.
|
||||||
|
4. **[kein Umbau] Push/Notifications:** bleibt wie es ist
|
||||||
|
(Isolate-Zwang); nur bei ohnehin anstehenden Push-Arbeiten die
|
||||||
|
Prefs-Zugriffe auf die `Prefs`-Fassade ziehen (Schnitt-Doc Punkt 6).
|
||||||
|
|
||||||
|
Abhak-Kriterium für den ROADMAP-Punkt: Schritte 1–3 umgesetzt und die
|
||||||
|
Kernflows (Login, Raum, Nachricht, Voice-Join) nach jedem Schritt
|
||||||
|
praktisch geprüft; Punkt 4 braucht nur den Doku-Vermerk (steht hiermit).
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||||
|
|
||||||
|
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
|
||||||
|
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||||
|
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
||||||
|
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
||||||
|
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
||||||
|
Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
|
||||||
|
|
||||||
|
Schnellster Weg: `flutter run -d windows`.
|
||||||
|
|
||||||
|
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
|
||||||
|
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
|
||||||
|
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
|
||||||
|
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
|
||||||
|
|
||||||
|
## 0. Vorab (1 Minute)
|
||||||
|
|
||||||
|
- [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
|
||||||
|
SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
|
||||||
|
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
|
||||||
|
mehrfach gelaufen (vor/nach jedem Umbau des Tages).
|
||||||
|
|
||||||
|
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
||||||
|
|
||||||
|
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
||||||
|
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
|
||||||
|
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
|
||||||
|
|
||||||
|
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
||||||
|
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
|
||||||
|
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
|
||||||
|
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
|
||||||
|
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
|
||||||
|
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
|
||||||
|
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
|
||||||
|
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
|
||||||
|
Soft-Logout-Eintrag.
|
||||||
|
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
|
||||||
|
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
|
||||||
|
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||||
|
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
||||||
|
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
||||||
|
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
|
||||||
|
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
|
||||||
|
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
|
||||||
|
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
|
||||||
|
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
|
||||||
|
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
|
||||||
|
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
|
||||||
|
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
|
||||||
|
Migration). Danach zwei geplante Neustarts für die Refactoring-
|
||||||
|
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
|
||||||
|
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
|
||||||
|
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
|
||||||
|
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
|
||||||
|
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
|
||||||
|
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
|
||||||
|
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
|
||||||
|
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
|
||||||
|
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
|
||||||
|
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
|
||||||
|
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
|
||||||
|
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
|
||||||
|
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
|
||||||
|
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
|
||||||
|
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
|
||||||
|
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
|
||||||
|
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
|
||||||
|
echten Mehr-Stunden-Fenster abhaken.
|
||||||
|
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
|
||||||
|
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
|
||||||
|
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
|
||||||
|
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
|
||||||
|
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
|
||||||
|
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
|
||||||
|
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
|
||||||
|
läuft weiter, nächstes Fenster ab 23:12.
|
||||||
|
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
|
||||||
|
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
|
||||||
|
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
|
||||||
|
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
|
||||||
|
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
|
||||||
|
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
|
||||||
|
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
|
||||||
|
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
|
||||||
|
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
|
||||||
|
Fenster ab 03:06.
|
||||||
|
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
|
||||||
|
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
|
||||||
|
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
|
||||||
|
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
|
||||||
|
Stück.)
|
||||||
|
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
||||||
|
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
||||||
|
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
||||||
|
entschlüsselt ankommen (statt „Neue Nachricht"-Platzhalter). Falls nicht:
|
||||||
|
zweite Ursache, siehe `docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
|
||||||
|
- [ ] Danach Uta: einmal aus- und wieder einloggen (erst dann hat ihre
|
||||||
|
Session einen Refresh-Token!)
|
||||||
|
|
||||||
|
## 2. Settings-Split (5d77238, 12 Teildateien)
|
||||||
|
|
||||||
|
Alle Reiter öffnen und benutzen: Profil, Benachrichtigungen, Erscheinungsbild,
|
||||||
|
Voice, Tastaturkürzel, Privatsphäre, Sessions, Verschlüsselung, Account, Über.
|
||||||
|
|
||||||
|
- [ ] Verschlüsselung (HEILIG): Recovery-Key anzeigen, Megolm-Export UND
|
||||||
|
-Import (Passphrase), SAS/QR-Verifizierung starten
|
||||||
|
- [ ] Sessions: Gerät umbenennen, Einzel-Logout, Massen-Logout-Dialog
|
||||||
|
(Passwort-Reauth) nur bis zur Abfrage
|
||||||
|
- [ ] Account: Passwort-ändern-Dialog öffnen, Account-löschen-Dialog öffnen
|
||||||
|
(NICHT bestätigen)
|
||||||
|
- [ ] Logout-Warnungs-Text bei fehlendem Backup sichtbar (settings_about/shared)
|
||||||
|
|
||||||
|
## 3. Chat-Timeline-Split (5bf45a9) + tote-Code-Bereinigung (6400fb6)
|
||||||
|
|
||||||
|
Einen Chat mit allen Nachrichtentypen durchgehen:
|
||||||
|
|
||||||
|
- [ ] Text mit Link-Vorschau, Bild, Video, Audio, Datei-Anhang
|
||||||
|
- [ ] Download-/Entschlüsselungs-Fehlerpfade in `message_media.dart` gezielt
|
||||||
|
ansehen (am wenigsten geprüfter Teil)
|
||||||
|
- [ ] Reaktionen setzen/entfernen, Hover-Aktionsleiste, Antworten/Zitat
|
||||||
|
- [ ] Datums-Trenner, „Neue Nachrichten"-Divider
|
||||||
|
- [ ] Gescheiterte Nachricht: bleibt mit Retry/Löschen stehen, Retry geht
|
||||||
|
- [ ] Zeitanzeige bei aufeinanderfolgenden Nachrichten + Hover-Uhrzeit bei
|
||||||
|
Fortsetzungsnachrichten (`_formatMessageTime`-Zusammenführung)
|
||||||
|
|
||||||
|
## 4. Raumlisten-Split (a863fa3) + Reorder-Fix (401bd6a)
|
||||||
|
|
||||||
|
- [ ] Space wechseln, Raum beitreten/verlassen
|
||||||
|
- [ ] **Drag&Drop-Umsortieren: landet der Raum EXAKT an der Zielposition?**
|
||||||
|
(onReorder→onReorderItem, Index-Korrektur jetzt im Framework – Off-by-One
|
||||||
|
wäre hier sichtbar; nach oben UND nach unten ziehen testen)
|
||||||
|
- [ ] Voice-Channel-Teilnehmerliste in der Raumliste
|
||||||
|
- [ ] Einladung annehmen/ablehnen, Ungelesen-Badge, DM-Avatare
|
||||||
|
|
||||||
|
## 5. Space-Admin-Split (0c7d359)
|
||||||
|
|
||||||
|
An einem TEST-Space, nicht an Utas echtem:
|
||||||
|
|
||||||
|
- [ ] Übersicht: Sichtbarkeit umschalten, Banner ändern (Danger-Zone nur ansehen)
|
||||||
|
- [ ] Mitglieder: einladen, Rolle ändern (Selbst-Aussperr-Schutz
|
||||||
|
`updatePowerLevelsSafely` greift?), kicken
|
||||||
|
- [ ] Berechtigungen ändern, Kanäle: erstellen + bestehenden Raum hinzufügen
|
||||||
|
|
||||||
|
## 6. Chat-View-Split (31e4001)
|
||||||
|
|
||||||
|
- [ ] Chat-Header (normal + DM mit ausklappendem Header/Presence)
|
||||||
|
- [ ] Tippanzeige, Offline-/Reconnect-Banner (Netz kurz trennen)
|
||||||
|
- [ ] Datei per Drag&Drop in den Chat ziehen (Overlay + Senden)
|
||||||
|
|
||||||
|
## 7. Dokument-Viewer-Split (daf4cf3) + Matrix4-Fix (401bd6a)
|
||||||
|
|
||||||
|
- [ ] PDF inline im Chat, Vollbild-Viewer: **Zoom per Buttons/Strg+Mausrad**
|
||||||
|
(translateByDouble/scaleByDouble-Umbau – zoomt es zentriert wie vorher?),
|
||||||
|
Seiten-Navigation, Thumbnail-Leiste, Speichern-Button
|
||||||
|
- [ ] Text/Markdown/SVG-Vorschau, Office-Text-Extraktion
|
||||||
|
- [ ] ZIP/TAR-Archiv-Browser, PSD-Thumbnail
|
||||||
|
- [ ] Bild im Chat öffnen (Fallback-Pfade)
|
||||||
|
|
||||||
|
## 8. Kleinkram aus den Lint-Commits
|
||||||
|
|
||||||
|
- [ ] Mini-Call-Widget: Buttons zeigen jetzt Hover-Farbe (9dc1175, gewollt) –
|
||||||
|
sieht das ok aus?
|
||||||
|
- [ ] Anhang-Dialog + Raum-erstellen-Dialog: Switches (activeThumbColor) sehen
|
||||||
|
aus wie vorher
|
||||||
|
- [ ] Voice-Channel beitreten/verlassen (Kernflow laut CLAUDE.md, von den
|
||||||
|
Splits nicht berührt, aber Pflicht nach jedem Refactoring)
|
||||||
|
|
||||||
|
## Danach
|
||||||
|
|
||||||
|
- [ ] Alle ✅ hier eintragen, PROGRESS.md-Eintrag „PC-Praxistest" schreiben,
|
||||||
|
erst DANN neue riskante Punkte anfangen (Call-Fassade, SQLCipher –
|
||||||
|
siehe `docs/SQLCIPHER_MIGRATION.md`)
|
||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
# Pyramid – Fortschritt & offene Punkte
|
# Pyramid – Fortschritt & offene Punkte (ARCHIV, Stand 2026-06-04)
|
||||||
|
|
||||||
Stand: 2026-06-04
|
> **ACHTUNG:** Dies ist ein eingefrorener Alt-Stand von vor der neuen
|
||||||
|
> Arbeitsstruktur. Das aktuelle Arbeitsprotokoll liegt im Projekt-Root:
|
||||||
|
> `PROGRESS.md`. Diese Datei nur als historische Referenz lesen, nie pflegen.
|
||||||
|
|
||||||
## Erledigt
|
## Erledigt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
|
||||||
|
|
||||||
|
> **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
|
||||||
|
> Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
|
||||||
|
> 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die
|
||||||
|
> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem
|
||||||
|
> SQLCipher ab).
|
||||||
|
> **Android-Emulator-Test 2026-07-06 (API 36, x86_64, Debug-APK) GRÜN:**
|
||||||
|
> (a) frische Installation legt die DB direkt verschlüsselt an
|
||||||
|
> (Header = Zufallsbytes, per run-as verifiziert), kein Cipher-Fallback im
|
||||||
|
> auth_log; (b) Update-Szenario nach `pm clear` + untergeschobener
|
||||||
|
> Klartext-DB (synthetisch, 2 Tabellen/550 Zeilen): Migration lief beim
|
||||||
|
> ersten Start („2 Tabellen verifiziert" im auth_log), `.premigration`
|
||||||
|
> liegt da, DB danach verschlüsselt; (c) Neustart öffnet mit dem
|
||||||
|
> Keystore-Schlüssel OHNE Re-Migration, App rendert normal den
|
||||||
|
> Login-Screen. Damit sind openCipherOnAndroid, sqlcipher_export und
|
||||||
|
> flutter_secure_storage auf Android (Haupt-Isolate) real belegt.
|
||||||
|
> **Offen bleibt (deshalb Punkt 4 NICHT voll abgehakt):** echtes
|
||||||
|
> ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
|
||||||
|
> der Push-Test (FCM-Isolate liest verschlüsselte DB, secure_storage im
|
||||||
|
> Background-Isolate) – braucht ein Testgerät mit eingeloggtem Account.
|
||||||
|
> Erst danach Release und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries"
|
||||||
|
> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher
|
||||||
|
> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output
|
||||||
|
> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux
|
||||||
|
> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben
|
||||||
|
> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel).
|
||||||
|
|
||||||
|
## Für Bernd in einfach
|
||||||
|
|
||||||
|
Die Datenbank der App (Nachrichten, Zugangs-Token, Verschlüsselungs-Schlüssel)
|
||||||
|
liegt heute **unverschlüsselt** auf dem Gerät – geschützt nur durch die
|
||||||
|
Android-App-Sandbox. Ein Angreifer bräuchte Root oder physischen Zugriff mit
|
||||||
|
Entwickler-Tools, um sie zu lesen. Dieser Plan verschlüsselt die Datei selbst.
|
||||||
|
Der riskante Teil ist nicht die Verschlüsselung, sondern die **einmalige
|
||||||
|
Umwandlung bestehender Installationen** (Utas Handy!) – deshalb: erst am PC
|
||||||
|
mit Testdaten, dann eigenes Gerät, dann erst Release.
|
||||||
|
|
||||||
|
**Offene Entscheidung (Frage in PROGRESS.md):** Priorität vor/nach M2?
|
||||||
|
|
||||||
|
## Ist-Zustand (verifiziert im Code)
|
||||||
|
|
||||||
|
| Stelle | Datei | Factory heute |
|
||||||
|
|---|---|---|
|
||||||
|
| Haupt-App Android/iOS | `lib/core/matrix_client.dart:24` | `sqflite_native.databaseFactory` (System-SQLite, **kann kein SQLCipher**) |
|
||||||
|
| Haupt-App Desktop | `lib/core/matrix_client.dart:27` | `databaseFactoryFfi` (gebündeltes sqlite3) |
|
||||||
|
| Push-Isolate (nur Android) | `lib/core/background_push.dart` `_buildClient()` | `sqflite_native.databaseFactory` |
|
||||||
|
|
||||||
|
- `sqlcipher_flutter_libs ^0.6.8` ist bereits in `pubspec.yaml`, wird nirgends benutzt.
|
||||||
|
- `flutter_secure_storage ^10` ist bereits da (→ Schlüsselablage im Android Keystore).
|
||||||
|
- Beide Prozesse öffnen **dieselbe Datei** (`pyramid.sqlite`, WAL-Modus,
|
||||||
|
`busy_timeout 5000`).
|
||||||
|
|
||||||
|
## Zielarchitektur
|
||||||
|
|
||||||
|
1. **Ein gemeinsamer DB-Öffner** `lib/core/app_database.dart` (neues Storage-Modul
|
||||||
|
im Sinne von M2): kapselt Factory-Wahl, PRAGMAs, Schlüsselbeschaffung und
|
||||||
|
Migration. `matrix_client.dart` und `background_push.dart` rufen NUR noch
|
||||||
|
diese Fassade – damit verschwindet auch die heutige Code-Duplikation der
|
||||||
|
PRAGMA-Blöcke.
|
||||||
|
2. **Android:** nicht mehr `sqflite_native`, sondern `databaseFactoryFfi` mit
|
||||||
|
SQLCipher-Lib:
|
||||||
|
```dart
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||||
|
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||||
|
```
|
||||||
|
**Achtung Push-Isolate:** `databaseFactoryFfi` spawnt ein eigenes Isolate –
|
||||||
|
im Firebase-Background-Isolate ist das genau die Fallenklasse, an der schon
|
||||||
|
`NativeImplementationsIsolate` scheiterte (Kommentar in
|
||||||
|
`background_push.dart`). Dort deshalb `databaseFactoryFfiNoIsolate` benutzen.
|
||||||
|
3. **Schlüssel:** 32 Byte kryptografisch zufällig (`Random.secure()`), hex-kodiert
|
||||||
|
in `flutter_secure_storage` unter `db_cipher_key`. Beide Prozesse lesen
|
||||||
|
denselben Eintrag. Schlüssel wird bei ERSTER Nutzung erzeugt; existiert schon
|
||||||
|
eine verschlüsselte DB, aber kein Schlüssel im Storage → harter Fehler mit
|
||||||
|
Klartext-Meldung, NIEMALS still neue DB anlegen (Datenverlust-Regel!).
|
||||||
|
4. **Öffnen:** `PRAGMA key = "x'<hex>'"` als allererstes Statement
|
||||||
|
(`OpenDatabaseOptions(onConfigure: ...)`), danach wie bisher WAL + busy_timeout.
|
||||||
|
5. **Windows/Linux-Desktop:** `sqlcipher_flutter_libs` liefert dort KEINE
|
||||||
|
Binaries. Phase 1 = nur Android verschlüsseln (dort liegt das echte Risiko,
|
||||||
|
dort sind die echten Nutzer); Desktop bleibt vorerst Klartext, klar im Code
|
||||||
|
kommentiert. Desktop-SQLCipher später als eigener Punkt (eigene DLL bündeln).
|
||||||
|
|
||||||
|
## Migration bestehender Klartext-DBs (der heikle Teil)
|
||||||
|
|
||||||
|
Reihenfolge beim App-Start, VOR `MatrixSdkDatabase.init`, VOR jedem Push-Zugriff:
|
||||||
|
|
||||||
|
1. Erkennung: Datei beginnt mit `SQLite format 3\0` → Klartext → Migration nötig.
|
||||||
|
(Verschlüsselte SQLCipher-Dateien haben keinen lesbaren Header.)
|
||||||
|
2. WAL eindampfen: Klartext-DB kurz normal öffnen,
|
||||||
|
`PRAGMA wal_checkpoint(TRUNCATE)`, schließen – sonst verlieren wir Daten,
|
||||||
|
die noch in `pyramid.sqlite-wal` liegen.
|
||||||
|
3. **Backup:** `pyramid.sqlite` → `pyramid.sqlite.premigration` kopieren
|
||||||
|
(Bytes, nicht rename). Bleibt bis zum verifizierten Erfolg liegen.
|
||||||
|
4. Export in NEUE Datei (Original bleibt unangetastet):
|
||||||
|
```sql
|
||||||
|
ATTACH DATABASE 'pyramid.enc.sqlite' AS enc KEY "x'<hex>'";
|
||||||
|
SELECT sqlcipher_export('enc');
|
||||||
|
DETACH DATABASE enc;
|
||||||
|
```
|
||||||
|
(läuft über die SQLCipher-FFI-Verbindung auf der Klartext-DB)
|
||||||
|
5. Verifikation: `pyramid.enc.sqlite` mit Schlüssel öffnen,
|
||||||
|
`PRAGMA integrity_check` == ok UND Zeilenzahl einer Kerntabelle
|
||||||
|
(z. B. `box_inbound_group_session`, Name gegen matrix-6.2.0
|
||||||
|
`matrix_sdk_database.dart:139` verifiziert) identisch mit Original. Schlägt IRGENDWAS
|
||||||
|
fehl → `pyramid.enc.sqlite` löschen, App startet normal mit Klartext-DB
|
||||||
|
weiter, Fehler in `auth_log.txt`. **Kein Zustand darf die Klartext-DB
|
||||||
|
beschädigt zurücklassen.**
|
||||||
|
6. Tausch: Original + `-wal`/`-shm` löschen, `pyramid.enc.sqlite` →
|
||||||
|
`pyramid.sqlite` (rename, gleiche Partition = atomar).
|
||||||
|
7. `.premigration`-Backup erst nach N erfolgreichen Tagen / manuell löschen
|
||||||
|
(Entscheidung Bernd: sonst liegt weiter eine Klartext-Kopie herum –
|
||||||
|
Kompromiss: nach 7 Tagen beim Start automatisch löschen).
|
||||||
|
|
||||||
|
### Race mit dem Push-Isolate
|
||||||
|
|
||||||
|
Kommt ein FCM-Push, während die Haupt-App migriert, würde das Isolate die DB
|
||||||
|
mittendrin öffnen. Lösung: Marker-Datei `pyramid.sqlite.migrating` vor Schritt 2
|
||||||
|
anlegen, nach Schritt 6 löschen; `_buildClient()` im Push-Pfad bricht bei
|
||||||
|
vorhandenem Marker sofort ab (Notification zeigt dann einmalig den
|
||||||
|
„Neue Nachricht"-Platzhalter – akzeptabel). Zusätzlich erkennt das Isolate
|
||||||
|
selbst Klartext vs. verschlüsselt am Datei-Header und wählt PRAGMA key
|
||||||
|
entsprechend – es migriert aber NIE selbst.
|
||||||
|
|
||||||
|
## Testplan (PC-Termin, in dieser Reihenfolge)
|
||||||
|
|
||||||
|
1. Windows-Build mit Test-Account: Migration Klartext→SQLCipher durchlaufen
|
||||||
|
lassen (Desktop kriegt fürs TESTEN die FFI-SQLCipher-Variante per lokalem
|
||||||
|
Override, auch wenn Phase 1 sie im Release nicht aktiviert).
|
||||||
|
2. Kernflow danach: Login erhalten? Alte verschlüsselte Nachrichten lesbar?
|
||||||
|
Logout → Neu-Login → immer noch lesbar? (`docs/PC_TESTPLAN.md` Abschnitt 1)
|
||||||
|
3. Fehlerpfade erzwingen: Migration mit vollem Datenträger / Kill mitten in
|
||||||
|
Schritt 4 → App muss mit unversehrter Klartext-DB weiterlaufen.
|
||||||
|
4. Android-Testgerät (eigenes, NICHT Uta): frische Installation (Neuanlage
|
||||||
|
verschlüsselt), dann Update-Szenario (bestehende Klartext-DB migriert),
|
||||||
|
dann Push-Test (Isolate liest verschlüsselte DB; secure_storage-Zugriff
|
||||||
|
aus dem Background-Isolate explizit verifizieren – bekanntes Risiko).
|
||||||
|
5. Erst wenn 1–4 grün: Release, Uta informieren (ihr Gerät migriert beim
|
||||||
|
ersten Start automatisch; vorher ihr Key-Backup verifizieren!).
|
||||||
|
|
||||||
|
## Bewusst NICHT im Scope
|
||||||
|
|
||||||
|
- `auth_log.txt`, `SharedPreferences`, Media-Cache bleiben Klartext (keine
|
||||||
|
Geheimnisse bzw. nur Cache; einzeln bewerten, falls Bernd es wünscht).
|
||||||
|
- Passphrase-basierter Schlüssel (Nutzer-Eingabe): bewusst nein – Schlüssel
|
||||||
|
liegt im Keystore, gleiche Vertrauensstufe wie der Rest der App-Daten,
|
||||||
|
aber Datei-Exfiltration allein reicht dann nicht mehr.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Streaming: hohe Auflösung + 60 FPS (M4)
|
||||||
|
|
||||||
|
**Ziel (Bernd):** Bildschirm-Streams ruckeln aktuell. Es soll eine hohe Streaming-Einstellung
|
||||||
|
geben – hohe Auflösung UND hohe FPS, idealerweise **60 FPS**, flüssig.
|
||||||
|
|
||||||
|
## Ist-Zustand (im Code gefunden, 2026-07-06)
|
||||||
|
|
||||||
|
Datei `lib/features/voice_channel/livekit_call_manager.dart`:
|
||||||
|
- `qualityPresets` (~Z. 21): Auflösungs-Presets `sd`(360p) / `hd`(720p) / `fhd`(1080p) / `4k`(2160p).
|
||||||
|
- `_screenShareEncodings` (~Z. 28): **FPS hart gedeckelt** – `sd`=10, `hd`=15, `fhd`=20, `4k`=30.
|
||||||
|
**Es gibt KEIN 60-FPS-Preset.** Bitraten für hohe FPS zu niedrig (fhd nur 2,5 Mbit/s @20,
|
||||||
|
4k nur 4 Mbit/s @30).
|
||||||
|
- `startScreenShare` (~Z. 453): erstellt `ScreenShareCaptureOptions(maxFrameRate: …)` und
|
||||||
|
published mit `simulcast: true`, `degradationPreference: DegradationPreference.balanced`.
|
||||||
|
- **`contentHint` wird NICHT gesetzt** → der Encoder weiß nicht, dass es bewegtes Video ist,
|
||||||
|
und priorisiert Detail/Auflösung statt Framerate → Ruckeln.
|
||||||
|
- `_applyFpsToAllSimulcastLayers` (~Z. 485) hebt die FPS nachträglich auf allen Simulcast-Layern an.
|
||||||
|
|
||||||
|
UI `lib/features/call_ui/view/quality_menu.dart` (~Z. 21): „SENDEN"-Menü listet sd/hd/fhd/4k.
|
||||||
|
|
||||||
|
## Wahrscheinliche Ruckel-Ursachen
|
||||||
|
1. `maxFramerate` hart auf 20–30 begrenzt (nie 60).
|
||||||
|
2. Zu niedrige Bitrate für hohe FPS → Encoder verwirft/verwischt Frames.
|
||||||
|
3. `contentHint` nicht auf `motion` → Encoder opfert FPS für Detail.
|
||||||
|
4. `degradationPreference: balanced` → unter Last sinkt eher die FPS als die Auflösung.
|
||||||
|
|
||||||
|
## Aufgabe
|
||||||
|
1. **Neue High-FPS-Presets** für die Send-Qualität, z. B.:
|
||||||
|
- `hd60` → 720p, `VideoEncoding(maxBitrate ~4–5 Mbit/s, maxFramerate 60)`
|
||||||
|
- `fhd60` → 1080p, `VideoEncoding(maxBitrate ~6–8 Mbit/s, maxFramerate 60)`
|
||||||
|
Sowohl in `qualityPresets` (Auflösung) als auch `_screenShareEncodings` (Bitrate/FPS) eintragen
|
||||||
|
und im „SENDEN"-Menü in `quality_menu.dart` als „HD 60fps" / „Full HD 60fps" ergänzen.
|
||||||
|
2. **`contentHint = 'motion'`** auf dem Screenshare-Track setzen (flutter_webrtc/LiveKit-Feld) –
|
||||||
|
wichtigster Einzelhebel gegen Ruckeln bei Bewegung.
|
||||||
|
3. Für die 60-FPS-Modi **`degradationPreference: DegradationPreference.maintainFramerate`**
|
||||||
|
statt `balanced` (unter Last lieber Auflösung senken als FPS).
|
||||||
|
4. Sicherstellen, dass 60 wirklich durchgereicht wird – auch in `ScreenShareCaptureOptions.maxFrameRate`
|
||||||
|
und `_applyFpsToAllSimulcastLayers`.
|
||||||
|
5. Für den flüssigen 60-FPS-Modus **`simulcast: false` erwägen** (volle Bitrate auf einen Layer),
|
||||||
|
A/B gegen simulcast=true testen.
|
||||||
|
|
||||||
|
## Testen (mit den Test-Accounts)
|
||||||
|
- Streamen als pyramidtest1, zuschauen als pyramidtest2 (zwei Logins/Fenster).
|
||||||
|
- Prüfen, dass `sender.parameters.encodings` real `maxFramerate == 60` und die hohe `maxBitrate` tragen.
|
||||||
|
- Subjektiv: flüssiger? Bei Bewegung kein Ruckeln?
|
||||||
|
- EHRLICH protokollieren, was wirklich getestet wurde. Echtes flüssiges 60-FPS-Empfinden ist ggf.
|
||||||
|
nur mit echtem Gerät/Netz voll beurteilbar → dann klar als UNGETESTET markieren.
|
||||||
|
|
||||||
|
## Nicht vergessen
|
||||||
|
`flutter analyze` sauber, kleiner Commit + Push, PROGRESS.md + ROADMAP (M4) pflegen.
|
||||||
@@ -70,7 +70,7 @@ Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Be
|
|||||||
# Als curl (Admin-Token erforderlich):
|
# Als curl (Admin-Token erforderlich):
|
||||||
# Admin-Room-ID herausfinden:
|
# Admin-Room-ID herausfinden:
|
||||||
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
|
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,22 +84,22 @@ curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3
|
|||||||
|
|
||||||
# User zu Server-Admin machen
|
# User zu Server-Admin machen
|
||||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||||
|
|
||||||
# Server-Status abfragen
|
# Server-Status abfragen
|
||||||
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
|
||||||
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
||||||
|
|
||||||
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
||||||
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -112,7 +112,7 @@ Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setz
|
|||||||
```bash
|
```bash
|
||||||
# Schritt 1: Aktuelles Power-Level-Event lesen
|
# Schritt 1: Aktuelles Power-Level-Event lesen
|
||||||
ROOM_ID="!raumId:steggi-matrix.work"
|
ROOM_ID="!raumId:steggi-matrix.work"
|
||||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
TOKEN="<ADMIN_TOKEN>"
|
||||||
SERVER="https://steggi-matrix.work"
|
SERVER="https://steggi-matrix.work"
|
||||||
|
|
||||||
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||||
@@ -172,7 +172,7 @@ await client.setRoomStateWithKey(
|
|||||||
```bash
|
```bash
|
||||||
# Testen ob restricted join rules funktionieren:
|
# Testen ob restricted join rules funktionieren:
|
||||||
ROOM_ID="!testRaum:steggi-matrix.work"
|
ROOM_ID="!testRaum:steggi-matrix.work"
|
||||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
TOKEN="<ADMIN_TOKEN>"
|
||||||
SPACE_ID="!spaceId:steggi-matrix.work"
|
SPACE_ID="!spaceId:steggi-matrix.work"
|
||||||
|
|
||||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||||
@@ -230,7 +230,7 @@ try {
|
|||||||
```bash
|
```bash
|
||||||
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
||||||
ROOM_ID="!raumId:steggi-matrix.work"
|
ROOM_ID="!raumId:steggi-matrix.work"
|
||||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
TOKEN="<ADMIN_TOKEN>"
|
||||||
|
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||||
-H "Authorization: Bearer ${TOKEN}"
|
-H "Authorization: Bearer ${TOKEN}"
|
||||||
@@ -269,7 +269,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
|
|||||||
```bash
|
```bash
|
||||||
# Prüfen ob User Server-Admin ist:
|
# Prüfen ob User Server-Admin ist:
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
|
||||||
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
||||||
# Alternativ: Über Admin-Room-Befehl
|
# Alternativ: Über Admin-Room-Befehl
|
||||||
@@ -278,7 +278,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
|||||||
|
|
||||||
# Über HTTP (Conduwuit Admin-Endpoint):
|
# Über HTTP (Conduwuit Admin-Endpoint):
|
||||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||||
```
|
```
|
||||||
@@ -292,19 +292,19 @@ Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Ad
|
|||||||
```bash
|
```bash
|
||||||
# Raum beitreten (als Admin, auch wenn invite-only):
|
# Raum beitreten (als Admin, auch wenn invite-only):
|
||||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{}'
|
-d '{}'
|
||||||
|
|
||||||
# User in Raum einladen:
|
# User in Raum einladen:
|
||||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"user_id": "@user:steggi-matrix.work"}'
|
-d '{"user_id": "@user:steggi-matrix.work"}'
|
||||||
|
|
||||||
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
|
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"via": ["steggi-matrix.work"],
|
"via": ["steggi-matrix.work"],
|
||||||
@@ -313,7 +313,7 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Astegg
|
|||||||
|
|
||||||
# Space-Parent-Relation setzen (im Channel-Raum):
|
# Space-Parent-Relation setzen (im Channel-Raum):
|
||||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
|
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"via": ["steggi-matrix.work"],
|
"via": ["steggi-matrix.work"],
|
||||||
@@ -322,11 +322,11 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Aste
|
|||||||
|
|
||||||
# Alle Mitglieder eines Raums auflisten:
|
# Alle Mitglieder eines Raums auflisten:
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
|
||||||
# Raum verlassen (als User, nicht löschen):
|
# Raum verlassen (als User, nicht löschen):
|
||||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{}'
|
-d '{}'
|
||||||
```
|
```
|
||||||
@@ -419,15 +419,15 @@ Wenn du M_FORBIDDEN bekommst:
|
|||||||
```bash
|
```bash
|
||||||
# 1. Token gültig?
|
# 1. Token gültig?
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
|
||||||
# 2. PL im betroffenen Raum prüfen:
|
# 2. PL im betroffenen Raum prüfen:
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
|
||||||
# 3. Mitglied des Raums?
|
# 3. Mitglied des Raums?
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
|
||||||
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
||||||
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
import 'package:pyramid/core/app_dirs.dart';
|
||||||
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
|
|
||||||
|
/// Läuft im DB-Isolate der FFI-Factory (muss deshalb top-level sein):
|
||||||
|
/// Auf Android liefert das sqlcipher_flutter_libs-Plugin die Bibliothek als
|
||||||
|
/// `libsqlcipher.so` – dem sqlite3-Paket muss man das dort sagen. Auf
|
||||||
|
/// Windows/Linux bündelt dasselbe Plugin SQLCipher bereits UNTER DEM
|
||||||
|
/// STANDARDNAMEN (`sqlite3.dll` neben der exe, verifiziert im Build-Output),
|
||||||
|
/// der Default-Loader findet sie also ohne Override.
|
||||||
|
void _sqlcipherFfiInit() {
|
||||||
|
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemeinsamer Öffner für die `pyramid.sqlite` – der eine Storage-Baustein,
|
||||||
|
/// hinter dem Factory-Wahl, Verschlüsselung und Migration stecken (Plan:
|
||||||
|
/// `docs/SQLCIPHER_MIGRATION.md`, Modularitäts-Leitsatz aus CLAUDE.md).
|
||||||
|
///
|
||||||
|
/// Kapselt an EINER Stelle, was vorher in `matrix_client.dart` und
|
||||||
|
/// `background_push.dart` dupliziert war:
|
||||||
|
/// * Factory-Wahl pro Plattform (FFI + SQLCipher wo verfügbar),
|
||||||
|
/// * Schlüsselbeschaffung: 32 zufällige Bytes, hex-kodiert im
|
||||||
|
/// Plattform-Keystore (`flutter_secure_storage`, Eintrag `db_cipher_key`),
|
||||||
|
/// * einmalige Migration bestehender Klartext-DBs nach SQLCipher,
|
||||||
|
/// * die PRAGMAs (WAL, busy_timeout).
|
||||||
|
///
|
||||||
|
/// Oberste Regel (CLAUDE.md „Kein Datenverlust"): KEIN Codepfad darf eine
|
||||||
|
/// bestehende DB beschädigen oder still eine leere neue anlegen. Im Zweifel
|
||||||
|
/// läuft die App unverschlüsselt weiter und der Fehler steht im auth_log.
|
||||||
|
class AppDatabase {
|
||||||
|
AppDatabase._();
|
||||||
|
|
||||||
|
static const _cipherKeyStorageKey = 'db_cipher_key';
|
||||||
|
|
||||||
|
/// Klartext-Backup `*.premigration` nach so vielen Tagen automatisch
|
||||||
|
/// aufräumen (Kompromiss aus dem Migrationsplan: Rollback-Fenster vs.
|
||||||
|
/// dauerhaft herumliegende Klartext-Kopie).
|
||||||
|
static const _premigrationMaxAge = Duration(days: 7);
|
||||||
|
|
||||||
|
/// Plattformen, auf denen sqlcipher_flutter_libs die Cipher-Bibliothek
|
||||||
|
/// mitliefert. iOS/macOS bleiben bewusst beim nativen sqflite-Plugin
|
||||||
|
/// (Klartext): der SQLCipher-Pod kollidiert dort mit Paketen, die das
|
||||||
|
/// System-sqlite3 linken (z. B. firebase_messaging, siehe Paket-README),
|
||||||
|
/// und iOS ist kein Release-Ziel von Pyramid.
|
||||||
|
static bool get _isCipherPlatform =>
|
||||||
|
!kIsWeb && (Platform.isAndroid || Platform.isWindows || Platform.isLinux);
|
||||||
|
|
||||||
|
static Future<String> _dbPath() async {
|
||||||
|
final appDir = await appSupportDir();
|
||||||
|
return p.join(appDir.path, 'pyramid.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Öffnen: Haupt-App ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Öffnet die Datenbank für die Haupt-App. Nur dieser Pfad migriert eine
|
||||||
|
/// bestehende Klartext-DB (das Push-Isolate migriert NIE selbst).
|
||||||
|
static Future<Database> open() async {
|
||||||
|
final dbPath = await _dbPath();
|
||||||
|
|
||||||
|
if (!_isCipherPlatform) {
|
||||||
|
// iOS/macOS: unverändert das native sqflite-Plugin, Klartext.
|
||||||
|
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
// Lade-Workaround für Android 6 (Paket-README) – im Main-Isolate
|
||||||
|
// ausführen, BEVOR irgendein Isolate sqlite benutzt.
|
||||||
|
try {
|
||||||
|
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final factory = createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit);
|
||||||
|
|
||||||
|
await _recoverFromInterruptedSwap(dbPath);
|
||||||
|
await _clearStaleMarker(dbPath);
|
||||||
|
|
||||||
|
final dbFile = File(dbPath);
|
||||||
|
final exists = await dbFile.exists();
|
||||||
|
final isPlaintext = exists && await isPlaintextSqlite(dbFile);
|
||||||
|
|
||||||
|
if (!await _cipherAvailable(factory)) {
|
||||||
|
// Sicherheitsnetz: sollte auf Cipher-Plattformen nie passieren
|
||||||
|
// (Bibliothek wird als Plugin mitgebaut). Eine VERSCHLÜSSELTE DB ohne
|
||||||
|
// Cipher-Lib ist ein harter Fehler – niemals still neu anlegen.
|
||||||
|
if (exists && !isPlaintext) {
|
||||||
|
throw StateError(
|
||||||
|
'pyramid.sqlite ist verschlüsselt, aber die SQLCipher-Bibliothek '
|
||||||
|
'fehlt in diesem Build. Kein Zugriff möglich – Daten bleiben '
|
||||||
|
'unangetastet. Bitte App-Build prüfen (sqlcipher_flutter_libs).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] SQLCipher nicht verfügbar – Datenbank bleibt unverschlüsselt.',
|
||||||
|
);
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists && !isPlaintext) {
|
||||||
|
// Bereits verschlüsselt: der Schlüssel MUSS im Keystore liegen.
|
||||||
|
final key = await _readStoredKey();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
'pyramid.sqlite ist verschlüsselt, aber im Schlüsselspeicher fehlt '
|
||||||
|
'"$_cipherKeyStorageKey". Es wird KEINE neue Datenbank angelegt, '
|
||||||
|
'damit keine Daten verloren gehen. Schlüsselspeicher prüfen '
|
||||||
|
'(flutter_secure_storage) oder Backup einspielen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final db = await _openWithKey(factory, dbPath, key);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
await _cleanupOldBackup(dbPath);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = await _getOrCreateKey();
|
||||||
|
|
||||||
|
if (exists && isPlaintext) {
|
||||||
|
final migrated = await migratePlaintextToEncrypted(
|
||||||
|
factory: factory,
|
||||||
|
dbPath: dbPath,
|
||||||
|
hexKey: key,
|
||||||
|
);
|
||||||
|
if (!migrated) {
|
||||||
|
// Migration fehlgeschlagen → Klartext-DB ist unversehrt, App läuft
|
||||||
|
// wie bisher weiter (Fehlerdetails stehen im auth_log).
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frische Installation (legt verschlüsselt neu an) oder frisch migriert.
|
||||||
|
final db = await _openWithKey(factory, dbPath, key);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Öffnen: Android-Push-Isolate ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Öffnet die Datenbank für das Push-/Hintergrund-Isolate. Gibt `null`
|
||||||
|
/// zurück, wenn gerade kein sicherer Zugriff möglich ist (Migration läuft,
|
||||||
|
/// DB fehlt, Schlüssel fehlt) – die Notification zeigt dann einmalig den
|
||||||
|
/// nativen „Neue Nachricht"-Platzhalter, das ist der akzeptierte Preis.
|
||||||
|
/// Dieser Pfad migriert NIE und legt NIE eine neue Datei an.
|
||||||
|
static Future<Database?> openForPush() async {
|
||||||
|
final dbPath = await _dbPath();
|
||||||
|
|
||||||
|
if (await File('$dbPath.migrating').exists()) {
|
||||||
|
debugPrint('[DB] Migration läuft – Push-Zugriff übersprungen.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbFile = File(dbPath);
|
||||||
|
if (!await dbFile.exists()) return null;
|
||||||
|
|
||||||
|
if (!_isCipherPlatform) {
|
||||||
|
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// noIsolate: databaseFactoryFfi würde ein Unter-Isolate spawnen – genau
|
||||||
|
// die Fallenklasse, an der im Firebase-Background-Isolate schon
|
||||||
|
// NativeImplementationsIsolate scheiterte (siehe background_push.dart).
|
||||||
|
final factory =
|
||||||
|
createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit, noIsolate: true);
|
||||||
|
|
||||||
|
if (await isPlaintextSqlite(dbFile)) {
|
||||||
|
// Noch nicht migriert (macht beim nächsten Start die Haupt-App).
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = await _readStoredKey();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
// Bekanntes Risiko aus dem Plan: secure_storage aus dem
|
||||||
|
// Background-Isolate. Niemals raten oder neu anlegen – nur aussteigen.
|
||||||
|
debugPrint('[DB] Kein Cipher-Schlüssel im Push-Isolate lesbar.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final db = await _openWithKey(factory, dbPath, key);
|
||||||
|
await _applyPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration Klartext → SQLCipher ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wandelt eine bestehende Klartext-DB in eine SQLCipher-DB um – der heikle
|
||||||
|
/// Teil des Plans. Reihenfolge exakt wie in `docs/SQLCIPHER_MIGRATION.md`:
|
||||||
|
/// Marker → WAL-Checkpoint → Byte-Backup → Export in NEUE Datei →
|
||||||
|
/// Verifikation (integrity_check + Zeilenzahlen ALLER Tabellen) → atomarer
|
||||||
|
/// Tausch. Schlägt irgendetwas fehl, bleibt die Klartext-DB unangetastet
|
||||||
|
/// und die Funktion gibt `false` zurück.
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> migratePlaintextToEncrypted({
|
||||||
|
required DatabaseFactory factory,
|
||||||
|
required String dbPath,
|
||||||
|
required String hexKey,
|
||||||
|
}) async {
|
||||||
|
final marker = File('$dbPath.migrating');
|
||||||
|
final encPath = '$dbPath.enc';
|
||||||
|
final backupPath = '$dbPath.premigration';
|
||||||
|
try {
|
||||||
|
// Marker zuerst: hält das Push-Isolate fern (Race aus dem Plan).
|
||||||
|
await marker.writeAsString(DateTime.now().toIso8601String());
|
||||||
|
|
||||||
|
// 1. WAL eindampfen – sonst fehlen im Export Daten, die noch in
|
||||||
|
// pyramid.sqlite-wal liegen.
|
||||||
|
var db = await factory.openDatabase(dbPath);
|
||||||
|
try {
|
||||||
|
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Byte-Backup (Kopie, kein rename) – bleibt bis zum verifizierten
|
||||||
|
// Erfolg + Aufräumfrist liegen.
|
||||||
|
await File(dbPath).copy(backupPath);
|
||||||
|
|
||||||
|
// 3. Export in NEUE Datei; das Original wird nie beschrieben.
|
||||||
|
final encFile = File(encPath);
|
||||||
|
if (await encFile.exists()) await encFile.delete();
|
||||||
|
final Map<String, int> sourceCounts;
|
||||||
|
db = await factory.openDatabase(dbPath);
|
||||||
|
try {
|
||||||
|
sourceCounts = await _tableRowCounts(db);
|
||||||
|
await db.rawQuery(
|
||||||
|
'ATTACH DATABASE \'${_escapeSqlString(encPath)}\' AS enc '
|
||||||
|
'KEY "x\'$hexKey\'"',
|
||||||
|
);
|
||||||
|
await db.rawQuery("SELECT sqlcipher_export('enc')");
|
||||||
|
await db.rawQuery('DETACH DATABASE enc');
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verifikation der NEUEN Datei, bevor irgendetwas getauscht wird.
|
||||||
|
final encDb = await _openWithKey(factory, encPath, hexKey);
|
||||||
|
try {
|
||||||
|
final integrity = await encDb.rawQuery('PRAGMA integrity_check');
|
||||||
|
final integrityOk = integrity.length == 1 &&
|
||||||
|
integrity.first.values.first.toString() == 'ok';
|
||||||
|
if (!integrityOk) {
|
||||||
|
throw StateError('integrity_check fehlgeschlagen: $integrity');
|
||||||
|
}
|
||||||
|
final encCounts = await _tableRowCounts(encDb);
|
||||||
|
for (final entry in sourceCounts.entries) {
|
||||||
|
if (encCounts[entry.key] != entry.value) {
|
||||||
|
throw StateError(
|
||||||
|
'Zeilenzahl weicht ab in "${entry.key}": '
|
||||||
|
'${entry.value} (Original) != ${encCounts[entry.key]} (Export)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await encDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Tausch. rename auf derselben Partition ist atomar; für das
|
||||||
|
// Zeitfenster zwischen delete und rename existiert das Byte-Backup
|
||||||
|
// (Wiederanlauf repariert das, siehe _recoverFromInterruptedSwap).
|
||||||
|
for (final suffix in ['-wal', '-shm']) {
|
||||||
|
final sidecar = File('$dbPath$suffix');
|
||||||
|
if (await sidecar.exists()) await sidecar.delete();
|
||||||
|
}
|
||||||
|
await File(dbPath).delete();
|
||||||
|
await File(encPath).rename(dbPath);
|
||||||
|
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] Klartext-DB erfolgreich nach SQLCipher migriert '
|
||||||
|
'(${sourceCounts.length} Tabellen verifiziert).',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// Aufräumen: halbfertigen Export löschen, Original bleibt maßgeblich.
|
||||||
|
try {
|
||||||
|
final encFile = File(encPath);
|
||||||
|
if (await encFile.exists()) await encFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] SQLCipher-Migration fehlgeschlagen – Klartext-DB bleibt '
|
||||||
|
'unangetastet: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (await marker.exists()) await marker.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absturz exakt zwischen „Original gelöscht" und „Export umbenannt":
|
||||||
|
/// dann fehlt `pyramid.sqlite`, aber das Byte-Backup existiert. Backup
|
||||||
|
/// zurückkopieren (die Migration läuft danach einfach erneut). Ein evtl.
|
||||||
|
/// liegengebliebener Export ist nicht als vollständig beweisbar → löschen.
|
||||||
|
static Future<void> _recoverFromInterruptedSwap(String dbPath) async {
|
||||||
|
final backup = File('$dbPath.premigration');
|
||||||
|
if (await File(dbPath).exists() || !await backup.exists()) return;
|
||||||
|
try {
|
||||||
|
final enc = File('$dbPath.enc');
|
||||||
|
if (await enc.exists()) await enc.delete();
|
||||||
|
await backup.copy(dbPath);
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] Unterbrochene Migration erkannt – Klartext-Backup '
|
||||||
|
'wiederhergestellt, Migration läuft erneut.',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
await AuthLog.write('[DB] Wiederherstellung aus .premigration fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Marker ohne laufende Migration ist ein Absturz-Überbleibsel – beim
|
||||||
|
/// Start der Haupt-App (es gibt nur eine) gefahrlos entfernbar.
|
||||||
|
static Future<void> _clearStaleMarker(String dbPath) async {
|
||||||
|
try {
|
||||||
|
final marker = File('$dbPath.migrating');
|
||||||
|
if (await marker.exists()) {
|
||||||
|
await marker.delete();
|
||||||
|
await AuthLog.write('[DB] Verwaisten Migrations-Marker entfernt.');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _cleanupOldBackup(String dbPath) async {
|
||||||
|
try {
|
||||||
|
final backup = File('$dbPath.premigration');
|
||||||
|
if (!await backup.exists()) return;
|
||||||
|
final age = DateTime.now().difference((await backup.stat()).modified);
|
||||||
|
if (age > _premigrationMaxAge) {
|
||||||
|
await backup.delete();
|
||||||
|
await AuthLog.write(
|
||||||
|
'[DB] Klartext-Backup .premigration nach '
|
||||||
|
'${_premigrationMaxAge.inDays} Tagen gelöscht.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bausteine ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Klartext-SQLite-Dateien beginnen mit dem 16-Byte-Header
|
||||||
|
/// `SQLite format 3\0`; SQLCipher-Dateien haben keinen lesbaren Header.
|
||||||
|
/// Leere/winzige Dateien zählen als Klartext (die Migration exportiert sie
|
||||||
|
/// dann schlicht leer).
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> isPlaintextSqlite(File file) async {
|
||||||
|
final raf = await file.open();
|
||||||
|
try {
|
||||||
|
final header = await raf.read(16);
|
||||||
|
if (header.length < 16) return true;
|
||||||
|
// 16-Byte-Magic: "SQLite format 3" gefolgt von einem NUL-Byte.
|
||||||
|
const magic = 'SQLite format 3';
|
||||||
|
for (var i = 0; i < magic.length; i++) {
|
||||||
|
if (header[i] != magic.codeUnitAt(i)) return false;
|
||||||
|
}
|
||||||
|
return header[15] == 0x00;
|
||||||
|
} finally {
|
||||||
|
await raf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet eine Datei mit Schlüssel. `PRAGMA key` MUSS das allererste
|
||||||
|
/// Statement der Verbindung sein → onConfigure. Der sqlite_master-Zugriff
|
||||||
|
/// danach lässt einen falschen/fehlenden Schlüssel sofort hier scheitern
|
||||||
|
/// statt irgendwann später mitten im Betrieb.
|
||||||
|
static Future<Database> _openWithKey(
|
||||||
|
DatabaseFactory factory,
|
||||||
|
String path,
|
||||||
|
String hexKey,
|
||||||
|
) async {
|
||||||
|
final db = await factory.openDatabase(
|
||||||
|
path,
|
||||||
|
options: OpenDatabaseOptions(
|
||||||
|
onConfigure: (db) async {
|
||||||
|
await db.rawQuery('PRAGMA key = "x\'$hexKey\'"');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await db.rawQuery('SELECT count(*) FROM sqlite_master');
|
||||||
|
} catch (e) {
|
||||||
|
await db.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<Database> openEncrypted(
|
||||||
|
DatabaseFactory factory,
|
||||||
|
String path,
|
||||||
|
String hexKey,
|
||||||
|
) =>
|
||||||
|
_openWithKey(factory, path, hexKey);
|
||||||
|
|
||||||
|
/// `PRAGMA cipher_version` liefert bei normalem sqlite3 KEINE Zeile – der
|
||||||
|
/// vom Paket-README empfohlene Wächter dagegen, versehentlich unverschlüsselt
|
||||||
|
/// weiterzulaufen, weil die falsche Bibliothek geladen wurde.
|
||||||
|
static Future<bool> _cipherAvailable(DatabaseFactory factory) async {
|
||||||
|
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||||
|
try {
|
||||||
|
final rows = await db.rawQuery('PRAGMA cipher_version');
|
||||||
|
if (rows.isEmpty) return false;
|
||||||
|
final value = rows.first.values.first;
|
||||||
|
return value != null && value.toString().isNotEmpty;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie bisher in matrix_client.dart/background_push.dart: WAL gegen
|
||||||
|
/// "database is locked" zwischen Haupt-App und Push-Isolate.
|
||||||
|
static Future<void> _applyPragmas(Database db) async {
|
||||||
|
try {
|
||||||
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _readStoredKey() =>
|
||||||
|
const FlutterSecureStorage().read(key: _cipherKeyStorageKey);
|
||||||
|
|
||||||
|
/// Erzeugt beim allerersten Mal 32 kryptografisch zufällige Bytes und legt
|
||||||
|
/// sie hex-kodiert im Keystore ab. Der Read-Back danach ist Pflicht: würde
|
||||||
|
/// das Schreiben still scheitern und wir trotzdem damit verschlüsseln,
|
||||||
|
/// wäre die DB beim nächsten Start unlesbar (Totalverlust).
|
||||||
|
static Future<String> _getOrCreateKey() async {
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
final existing = await storage.read(key: _cipherKeyStorageKey);
|
||||||
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
|
||||||
|
final rnd = Random.secure();
|
||||||
|
final key = List.generate(
|
||||||
|
32,
|
||||||
|
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
).join();
|
||||||
|
await storage.write(key: _cipherKeyStorageKey, value: key);
|
||||||
|
final readBack = await storage.read(key: _cipherKeyStorageKey);
|
||||||
|
if (readBack != key) {
|
||||||
|
throw StateError(
|
||||||
|
'Schlüsselspeicher-Verifikation fehlgeschlagen: '
|
||||||
|
'"$_cipherKeyStorageKey" konnte nicht zuverlässig abgelegt werden. '
|
||||||
|
'Es wird NICHT verschlüsselt (sonst wäre die DB später unlesbar).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, int>> _tableRowCounts(Database db) async {
|
||||||
|
final tables = await db.rawQuery(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||||
|
);
|
||||||
|
final result = <String, int>{};
|
||||||
|
for (final row in tables) {
|
||||||
|
final name = row['name'] as String;
|
||||||
|
final count = await db.rawQuery('SELECT count(*) AS c FROM "$name"');
|
||||||
|
result[name] = (count.first['c'] as num).toInt();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQL-String-Literal: einfache Anführungszeichen verdoppeln (der Pfad
|
||||||
|
/// kommt aus dem App-Verzeichnis, aber sicher ist sicher).
|
||||||
|
static String _escapeSqlString(String value) => value.replaceAll("'", "''");
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Basisverzeichnis für alle lokalen App-Daten (pyramid.sqlite, auth_log.txt,
|
||||||
|
/// Media-Cache).
|
||||||
|
///
|
||||||
|
/// Normalfall: das Application-Support-Verzeichnis der Plattform – exakt wie
|
||||||
|
/// bisher, es ändert sich NICHTS.
|
||||||
|
///
|
||||||
|
/// Nur auf dem Desktop kann die Umgebungsvariable `PYRAMID_PROFILE_DIR` ein
|
||||||
|
/// abweichendes Verzeichnis erzwingen. Zweck: Testläufe mit den
|
||||||
|
/// Autopilot-Testkonten in einem eigenen Profil, parallel zur echten
|
||||||
|
/// Session desselben Windows-Nutzers (die echte Session sieht die Variable
|
||||||
|
/// nie und bleibt unberührt). Bewusst NICHT umgeleitet werden
|
||||||
|
/// shared_preferences und flutter_secure_storage – deren Plugins lösen ihre
|
||||||
|
/// Pfade selbst auf; ein Testprofil teilt sich also den DB-Schlüssel
|
||||||
|
/// (`db_cipher_key`) und die UI-Prefs mit dem Hauptprofil. Für Tests ist das
|
||||||
|
/// unkritisch (der Schlüssel wird nur GELESEN, nie überschrieben), echte
|
||||||
|
/// Zweitkonten bräuchten mehr.
|
||||||
|
Future<Directory> appSupportDir() async {
|
||||||
|
final override = Platform.isWindows || Platform.isLinux || Platform.isMacOS
|
||||||
|
? Platform.environment['PYRAMID_PROFILE_DIR']
|
||||||
|
: null;
|
||||||
|
if (override != null && override.trim().isNotEmpty) {
|
||||||
|
final dir = Directory(override.trim());
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
return getApplicationSupportDirectory();
|
||||||
|
}
|
||||||
+3
-12
@@ -3,8 +3,6 @@ import 'package:matrix/matrix.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 ──────────────────────────────────────────────────────────────
|
||||||
@@ -64,16 +62,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) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ 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/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.
|
||||||
@@ -182,18 +180,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class BoolPref extends StateNotifier<bool> {
|
class BoolPref extends StateNotifier<bool> {
|
||||||
final String _key;
|
final String _key;
|
||||||
|
// Guards against the load race: if set()/toggle() ran before _load's read
|
||||||
|
// lands, the stale persisted value must not overwrite the user's choice.
|
||||||
|
bool _userSet = false;
|
||||||
|
|
||||||
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
|
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
|
||||||
_load(defaultValue);
|
_load(defaultValue);
|
||||||
@@ -14,10 +17,11 @@ class BoolPref extends StateNotifier<bool> {
|
|||||||
|
|
||||||
Future<void> _load(bool def) async {
|
Future<void> _load(bool def) async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
if (mounted) state = p.getBool(_key) ?? def;
|
if (mounted && !_userSet) state = p.getBool(_key) ?? def;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> set(bool v) async {
|
Future<void> set(bool v) async {
|
||||||
|
_userSet = true;
|
||||||
state = v;
|
state = v;
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setBool(_key, v);
|
await p.setBool(_key, v);
|
||||||
@@ -30,9 +34,10 @@ class BoolPref extends StateNotifier<bool> {
|
|||||||
|
|
||||||
class StringSetPref extends StateNotifier<Set<String>> {
|
class StringSetPref extends StateNotifier<Set<String>> {
|
||||||
final String _key;
|
final String _key;
|
||||||
|
late final Future<void> _loaded;
|
||||||
|
|
||||||
StringSetPref(this._key) : super({}) {
|
StringSetPref(this._key) : super({}) {
|
||||||
_load();
|
_loaded = _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@@ -42,14 +47,18 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> add(String v) async {
|
Future<void> add(String v) async {
|
||||||
if (state.contains(v)) return;
|
// Read-modify-write: ohne abgeschlossenes Laden würde hier der halbleere
|
||||||
|
// Start-State zurückgeschrieben und persistierte Einträge gingen verloren.
|
||||||
|
await _loaded;
|
||||||
|
if (!mounted || state.contains(v)) return;
|
||||||
state = {...state, v};
|
state = {...state, v};
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setStringList(_key, state.toList());
|
await p.setStringList(_key, state.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> remove(String v) async {
|
Future<void> remove(String v) async {
|
||||||
if (!state.contains(v)) return;
|
await _loaded;
|
||||||
|
if (!mounted || !state.contains(v)) return;
|
||||||
state = {...state}..remove(v);
|
state = {...state}..remove(v);
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setStringList(_key, state.toList());
|
await p.setStringList(_key, state.toList());
|
||||||
@@ -81,6 +90,56 @@ final privacyReadReceiptsProvider =
|
|||||||
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({
|
||||||
|
|||||||
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) {
|
||||||
+8
-8
@@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -22,7 +22,8 @@ import 'package:pyramid/widgets/hover_region.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';
|
||||||
|
|||||||
@@ -167,13 +167,13 @@ 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: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
@@ -576,7 +576,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
final isVoice = _isVoiceRoom(room);
|
||||||
|
return _RoomItem(
|
||||||
room: room,
|
room: room,
|
||||||
isActive: activeRoomId == room.id,
|
isActive: isVoice ? false : activeRoomId == room.id,
|
||||||
|
isInVoice:
|
||||||
|
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
|
||||||
pt: pt,
|
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: () {
|
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;
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ 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/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/widgets/create_join_dialog.dart';
|
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||||
|
|||||||
+105
-14
@@ -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,
|
||||||
|
/// `docs/STREAMING_60FPS.md`) brauchen deutlich mehr Bitrate, sonst
|
||||||
|
/// verwirft der Encoder Frames und es ruckelt trotz hoher Framerate.
|
||||||
|
@visibleForTesting
|
||||||
|
static const Map<String, VideoEncoding> screenShareEncodings = {
|
||||||
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
|
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
|
||||||
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
||||||
|
'hd60': VideoEncoding(maxBitrate: 5_000_000, maxFramerate: 60),
|
||||||
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
||||||
|
'fhd60': VideoEncoding(maxBitrate: 8_000_000, maxFramerate: 60),
|
||||||
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
|
'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,6 +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';
|
final url = 'wss://livekit.steggi-matrix.work';
|
||||||
|
|
||||||
@@ -193,6 +233,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 +284,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 +298,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 +343,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 +369,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 +386,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 +404,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 +443,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleCamera() async {
|
Future<void> toggleCamera() async {
|
||||||
if (isCameraOff) {
|
if (isCameraOff) {
|
||||||
try {
|
try {
|
||||||
@@ -399,6 +466,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 +490,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 +519,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 +564,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 +586,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 +617,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 +651,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 +665,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;
|
||||||
|
});
|
||||||
@@ -9,9 +9,10 @@ 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';
|
||||||
@@ -160,7 +161,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 +173,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 +201,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,7 +333,7 @@ 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;
|
Widget body;
|
||||||
if (viewMode == ViewMode.voice) {
|
if (viewMode == ViewMode.voice) {
|
||||||
@@ -456,7 +457,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({
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
part of '../settings_modal.dart';
|
part of '../settings_modal.dart';
|
||||||
|
|
||||||
class _VoiceSection extends StatefulWidget {
|
class _VoiceSection extends ConsumerStatefulWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _VoiceSection({required this.pt});
|
const _VoiceSection({required this.pt});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_VoiceSection> createState() => _VoiceSectionState();
|
ConsumerState<_VoiceSection> createState() => _VoiceSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VoiceSectionState extends State<_VoiceSection> {
|
class _VoiceSectionState extends ConsumerState<_VoiceSection> {
|
||||||
List<MediaDeviceInfo> _audioInputs = [];
|
List<MediaDeviceInfo> _audioInputs = [];
|
||||||
List<MediaDeviceInfo> _audioOutputs = [];
|
List<MediaDeviceInfo> _audioOutputs = [];
|
||||||
List<MediaDeviceInfo> _videoInputs = [];
|
List<MediaDeviceInfo> _videoInputs = [];
|
||||||
@@ -19,11 +19,6 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _loadError;
|
String? _loadError;
|
||||||
|
|
||||||
static const _kMic = 'voice_mic_device';
|
|
||||||
static const _kSpeaker = 'voice_speaker_device';
|
|
||||||
static const _kCamera = 'voice_camera_device';
|
|
||||||
static const _kOutputVolume = 'voice_output_volume';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -31,7 +26,8 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final devicePrefs = await VoicePrefs.loadDevices();
|
||||||
|
final outputVolume = await VoicePrefs.loadOutputVolume();
|
||||||
List<MediaDeviceInfo> devices = [];
|
List<MediaDeviceInfo> devices = [];
|
||||||
String? loadError;
|
String? loadError;
|
||||||
try {
|
try {
|
||||||
@@ -39,12 +35,30 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
devices = [];
|
devices = [];
|
||||||
}
|
}
|
||||||
// Windows (und teils Android) liefert eine LEERE Liste ohne Fehler,
|
// Windows: die Geräteliste kommt aus dem Audio-Device-Module der
|
||||||
// solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde.
|
// libwebrtc.dll, und das wird erst initialisiert, wenn irgendeine
|
||||||
// Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal
|
// PeerConnection existiert (webrtc-sdk/libwebrtc Issue #141, Fix #142
|
||||||
// kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE
|
// noch in keinem Binary-Release – Diagnose siehe tool/device_probe.dart).
|
||||||
// der Stream noch offen ist (nach stop() melden manche Systeme wieder 0).
|
// Ohne laufenden Call ist die Liste deshalb IMMER leer. Workaround:
|
||||||
final hasAudioInput =
|
// kurz eine Dummy-PeerConnection anlegen (kein Mikrofonzugriff nötig),
|
||||||
|
// enumerieren, wieder schließen.
|
||||||
|
var hasAudioInput =
|
||||||
|
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
|
||||||
|
if (devices.isEmpty || !hasAudioInput) {
|
||||||
|
RTCPeerConnection? admWakeup;
|
||||||
|
try {
|
||||||
|
admWakeup = await createPeerConnection({});
|
||||||
|
devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback unten übernimmt.
|
||||||
|
} finally {
|
||||||
|
await admWakeup?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zweite Stufe (andere Systeme, z. B. teils Android): einmal kurz
|
||||||
|
// getUserMedia anfordern und erneut enumerieren, SOLANGE der Stream
|
||||||
|
// offen ist (nach stop() melden manche Systeme wieder 0).
|
||||||
|
hasAudioInput =
|
||||||
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
|
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
|
||||||
if (devices.isEmpty || !hasAudioInput) {
|
if (devices.isEmpty || !hasAudioInput) {
|
||||||
try {
|
try {
|
||||||
@@ -76,10 +90,10 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
_audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList();
|
_audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList();
|
||||||
_audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList();
|
_audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList();
|
||||||
_videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList();
|
_videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList();
|
||||||
_selectedMic = prefs.getString(_kMic);
|
_selectedMic = devicePrefs.mic;
|
||||||
_selectedSpeaker = prefs.getString(_kSpeaker);
|
_selectedSpeaker = devicePrefs.speaker;
|
||||||
_selectedCamera = prefs.getString(_kCamera);
|
_selectedCamera = devicePrefs.camera;
|
||||||
_outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0;
|
_outputVolume = outputVolume;
|
||||||
_loadError = loadError;
|
_loadError = loadError;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
@@ -87,24 +101,14 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
|
|
||||||
Future<void> _setOutputVolume(double v) async {
|
Future<void> _setOutputVolume(double v) async {
|
||||||
setState(() => _outputVolume = v);
|
setState(() => _outputVolume = v);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await VoicePrefs.saveOutputVolume(v);
|
||||||
await prefs.setDouble(_kOutputVolume, v);
|
// Live auf laufende Calls anwenden (über die Modul-Fassaden).
|
||||||
// Live auf laufende Calls anwenden.
|
ref.read(voiceChannelProvider).setOutputVolume(v).catchError((_) {});
|
||||||
LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {});
|
|
||||||
try {
|
try {
|
||||||
PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {});
|
ref.read(callSignalingProvider).applyOutputVolume(v).catchError((_) {});
|
||||||
} catch (_) {} // Instanz existiert evtl. noch nicht
|
} catch (_) {} // Instanz existiert evtl. noch nicht
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save(String key, String? value) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
await prefs.remove(key);
|
|
||||||
} else {
|
|
||||||
await prefs.setString(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _deviceDropdown({
|
Widget _deviceDropdown({
|
||||||
required List<MediaDeviceInfo> devices,
|
required List<MediaDeviceInfo> devices,
|
||||||
required String? selected,
|
required String? selected,
|
||||||
@@ -227,7 +231,7 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
icon: Icons.mic_rounded,
|
icon: Icons.mic_rounded,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => _selectedMic = v);
|
setState(() => _selectedMic = v);
|
||||||
_save(_kMic, v);
|
VoicePrefs.saveMic(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -243,7 +247,7 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
icon: Icons.volume_up_rounded,
|
icon: Icons.volume_up_rounded,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => _selectedSpeaker = v);
|
setState(() => _selectedSpeaker = v);
|
||||||
_save(_kSpeaker, v);
|
VoicePrefs.saveSpeaker(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -259,7 +263,7 @@ class _VoiceSectionState extends State<_VoiceSection> {
|
|||||||
icon: Icons.videocam_rounded,
|
icon: Icons.videocam_rounded,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => _selectedCamera = v);
|
setState(() => _selectedCamera = v);
|
||||||
_save(_kCamera, v);
|
VoicePrefs.saveCamera(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import 'package:pyramid/core/app_state.dart';
|
|||||||
import 'package:pyramid/core/auth_log.dart';
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
||||||
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
|
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
|
||||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/voip_manager.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/update_checker.dart';
|
import 'package:pyramid/core/update_checker.dart';
|
||||||
@@ -36,7 +36,6 @@ import 'package:pyramid/widgets/banner_crop_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/pyramid_loader.dart';
|
import 'package:pyramid/widgets/pyramid_loader.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
part 'settings/settings_shared.dart';
|
part 'settings/settings_shared.dart';
|
||||||
part 'settings/settings_profile.dart';
|
part 'settings/settings_profile.dart';
|
||||||
@@ -47,7 +46,10 @@ part 'settings/settings_keybinds.dart';
|
|||||||
part 'settings/settings_privacy.dart';
|
part 'settings/settings_privacy.dart';
|
||||||
part 'settings/settings_sessions.dart';
|
part 'settings/settings_sessions.dart';
|
||||||
part 'settings/settings_verification.dart';
|
part 'settings/settings_verification.dart';
|
||||||
part 'settings/settings_encryption.dart';
|
part 'settings/encryption/megolm_crypto.dart';
|
||||||
|
part 'settings/encryption/encryption_section.dart';
|
||||||
|
part 'settings/encryption/recovery_key_dialogs.dart';
|
||||||
|
part 'settings/encryption/fingerprint_passphrase.dart';
|
||||||
part 'settings/settings_account.dart';
|
part 'settings/settings_account.dart';
|
||||||
part 'settings/settings_about.dart';
|
part 'settings/settings_about.dart';
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -801,10 +801,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -918,7 +918,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.2.1"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
@@ -1235,7 +1235,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.8"
|
version: "0.6.8"
|
||||||
sqlite3:
|
sqlite3:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
@@ -1294,18 +1294,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.17"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+8
-2
@@ -1,8 +1,7 @@
|
|||||||
name: pyramid
|
name: pyramid
|
||||||
description: A flexible Matrix client with resizable panel layout.
|
description: A flexible Matrix client with resizable panel layout.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.35+36
|
version: 0.1.36+37
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.11.1 <4.0.0"
|
sdk: ">=3.11.1 <4.0.0"
|
||||||
|
|
||||||
@@ -19,6 +18,9 @@ dependencies:
|
|||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
sqflite_common_ffi: ^2.3.7+1
|
sqflite_common_ffi: ^2.3.7+1
|
||||||
sqlcipher_flutter_libs: ^0.6.8
|
sqlcipher_flutter_libs: ^0.6.8
|
||||||
|
# Direkt (statt nur transitiv) für open.overrideFor in app_database.dart —
|
||||||
|
# so lädt Android libsqlcipher.so statt des System-sqlite3.
|
||||||
|
sqlite3: ^2.4.0
|
||||||
|
|
||||||
# LiveKit video/audio/screen
|
# LiveKit video/audio/screen
|
||||||
livekit_client: ^2.4.0
|
livekit_client: ^2.4.0
|
||||||
@@ -82,6 +84,10 @@ dev_dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
|
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
|
||||||
|
# mocken (PathProviderPlatform.instance überschreiben). War schon transitiv
|
||||||
|
# im Lockfile, kein neues externes Paket.
|
||||||
|
path_provider_platform_interface: ^2.1.2
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
webcrypto:
|
webcrypto:
|
||||||
|
|||||||
+11
-1
@@ -11,7 +11,17 @@ Set-Location (Split-Path $PSScriptRoot -Parent)
|
|||||||
# ── Config ────────────────────────────────────────────────────────────────────
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
$GiteaBase = "http://192.168.178.71:3000"
|
$GiteaBase = "http://192.168.178.71:3000"
|
||||||
$GiteaRepo = "steggi/pyramid"
|
$GiteaRepo = "steggi/pyramid"
|
||||||
$GiteaToken = "bb522f9646c6856c9ab58bef0151ac36a9ce1d57"
|
# Token NIE hier eintragen (CLAUDE.md: keine Secrets ins Repo). Er kommt aus der
|
||||||
|
# Umgebungsvariable PYRAMID_GITEA_TOKEN – einmalig pro PC setzen mit:
|
||||||
|
# [Environment]::SetEnvironmentVariable("PYRAMID_GITEA_TOKEN", "<token>", "User")
|
||||||
|
# (neues Terminal öffnen, damit die Variable sichtbar ist)
|
||||||
|
$GiteaToken = $env:PYRAMID_GITEA_TOKEN
|
||||||
|
if ([string]::IsNullOrWhiteSpace($GiteaToken)) {
|
||||||
|
Write-Error ("PYRAMID_GITEA_TOKEN ist nicht gesetzt. Einmalig setzen mit:`n" +
|
||||||
|
' [Environment]::SetEnvironmentVariable("PYRAMID_GITEA_TOKEN", "<token>", "User")' +
|
||||||
|
"`nDen Token in Gitea unter Einstellungen > Anwendungen erzeugen (Scope: repository).")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$Headers = @{ Authorization = "token $GiteaToken"; "Content-Type" = "application/json" }
|
$Headers = @{ Authorization = "token $GiteaToken"; "Content-Type" = "application/json" }
|
||||||
|
|
||||||
# ── Read and Update version from pubspec.yaml ─────────────────────────────────
|
# ── Read and Update version from pubspec.yaml ─────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
import 'package:pyramid/core/app_database.dart';
|
||||||
|
|
||||||
|
/// Diese Tests fahren die ECHTE Klartext→SQLCipher-Migration – mit derselben
|
||||||
|
/// SQLCipher-Bibliothek, die auch die App benutzt: der vom
|
||||||
|
/// sqlcipher_flutter_libs-Plugin gebauten `sqlite3.dll` aus dem Windows-Build.
|
||||||
|
/// Existiert kein Build-Output (z. B. auf dem Pi), werden sie übersprungen –
|
||||||
|
/// dann zählt der Lauf als UNGETESTET im Sinne von CLAUDE.md.
|
||||||
|
String? _findCipherDll() {
|
||||||
|
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||||
|
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||||
|
if (file.existsSync()) return file.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _hexKey =
|
||||||
|
'a3b1c2d3e4f50617a3b1c2d3e4f50617a3b1c2d3e4f50617a3b1c2d3e4f50617';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final dllPath = _findCipherDll();
|
||||||
|
final skip = dllPath == null
|
||||||
|
? 'SQLCipher-DLL fehlt (erst "flutter build windows" bzw. '
|
||||||
|
'"flutter run -d windows" ausführen)'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// noIsolate: der Override muss im selben Isolate wirken wie die DB-Zugriffe.
|
||||||
|
final factory = dllPath == null
|
||||||
|
? null
|
||||||
|
: createDatabaseFactoryFfi(
|
||||||
|
ffiInit: () {
|
||||||
|
open.overrideFor(
|
||||||
|
OperatingSystem.windows,
|
||||||
|
() => DynamicLibrary.open(dllPath),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
noIsolate: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
late Directory tempDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tempDir = await Directory.systemTemp.createTemp('pyramid_db_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
try {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Legt eine Klartext-DB mit zwei Tabellen und bekannten Zeilen an.
|
||||||
|
Future<String> createPlaintextDb() async {
|
||||||
|
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
|
||||||
|
final db = await factory!.openDatabase(dbPath);
|
||||||
|
await db.execute('CREATE TABLE box_client (k TEXT PRIMARY KEY, v TEXT)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE TABLE box_inbound_group_session (k TEXT PRIMARY KEY, v TEXT)',
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 50; i++) {
|
||||||
|
await db.insert('box_client', {'k': 'key$i', 'v': 'wert$i'});
|
||||||
|
}
|
||||||
|
for (var i = 0; i < 7; i++) {
|
||||||
|
await db.insert(
|
||||||
|
'box_inbound_group_session',
|
||||||
|
{'k': 'session$i', 'v': 'geheim$i'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.close();
|
||||||
|
return dbPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Geladene Bibliothek ist wirklich SQLCipher (cipher_version)',
|
||||||
|
() async {
|
||||||
|
final db = await factory!.openDatabase(inMemoryDatabasePath);
|
||||||
|
final rows = await db.rawQuery('PRAGMA cipher_version');
|
||||||
|
await db.close();
|
||||||
|
expect(rows, isNotEmpty,
|
||||||
|
reason: 'PRAGMA cipher_version leer → normales sqlite3 geladen');
|
||||||
|
expect(rows.first.values.first.toString(), isNotEmpty);
|
||||||
|
}, skip: skip);
|
||||||
|
|
||||||
|
test('Header-Erkennung: Klartext ja, verschlüsselt nein, Mini-Datei ja',
|
||||||
|
() async {
|
||||||
|
final dbPath = await createPlaintextDb();
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
|
||||||
|
|
||||||
|
final tiny = File('${tempDir.path}${Platform.pathSeparator}tiny.sqlite');
|
||||||
|
await tiny.writeAsString('kurz');
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(tiny), isTrue);
|
||||||
|
|
||||||
|
final encPath = '${tempDir.path}${Platform.pathSeparator}enc.sqlite';
|
||||||
|
final enc = await AppDatabase.openEncrypted(factory!, encPath, _hexKey);
|
||||||
|
await enc.execute('CREATE TABLE t (x INTEGER)');
|
||||||
|
await enc.close();
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(encPath)), isFalse);
|
||||||
|
}, skip: skip);
|
||||||
|
|
||||||
|
test('Migration: verschlüsselt, alle Zeilen erhalten, Backup liegt da',
|
||||||
|
() async {
|
||||||
|
final dbPath = await createPlaintextDb();
|
||||||
|
|
||||||
|
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||||||
|
factory: factory!,
|
||||||
|
dbPath: dbPath,
|
||||||
|
hexKey: _hexKey,
|
||||||
|
);
|
||||||
|
expect(ok, isTrue);
|
||||||
|
|
||||||
|
// Datei ist jetzt verschlüsselt, Aufräum-Artefakte sind weg.
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse);
|
||||||
|
expect(File('$dbPath.enc').existsSync(), isFalse);
|
||||||
|
expect(File('$dbPath.migrating').existsSync(), isFalse);
|
||||||
|
|
||||||
|
// Byte-Backup existiert und ist die alte Klartext-DB.
|
||||||
|
final backup = File('$dbPath.premigration');
|
||||||
|
expect(backup.existsSync(), isTrue);
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(backup), isTrue);
|
||||||
|
|
||||||
|
// Inhalt vollständig? Mit Schlüssel öffnen und nachzählen.
|
||||||
|
final db = await AppDatabase.openEncrypted(factory, dbPath, _hexKey);
|
||||||
|
final clients = await db.rawQuery('SELECT count(*) AS c FROM box_client');
|
||||||
|
final sessions = await db
|
||||||
|
.rawQuery('SELECT count(*) AS c FROM box_inbound_group_session');
|
||||||
|
final probe = await db.rawQuery(
|
||||||
|
"SELECT v FROM box_inbound_group_session WHERE k = 'session3'");
|
||||||
|
await db.close();
|
||||||
|
expect(clients.first['c'], 50);
|
||||||
|
expect(sessions.first['c'], 7);
|
||||||
|
expect(probe.first['v'], 'geheim3');
|
||||||
|
}, skip: skip);
|
||||||
|
|
||||||
|
test('Falscher Schlüssel scheitert sofort beim Öffnen', () async {
|
||||||
|
final dbPath = await createPlaintextDb();
|
||||||
|
await AppDatabase.migratePlaintextToEncrypted(
|
||||||
|
factory: factory!,
|
||||||
|
dbPath: dbPath,
|
||||||
|
hexKey: _hexKey,
|
||||||
|
);
|
||||||
|
final wrongKey = 'ff${_hexKey.substring(2)}';
|
||||||
|
await expectLater(
|
||||||
|
AppDatabase.openEncrypted(factory, dbPath, wrongKey),
|
||||||
|
throwsA(anything),
|
||||||
|
);
|
||||||
|
}, skip: skip);
|
||||||
|
|
||||||
|
test('Fehlschlag lässt die Klartext-DB unangetastet weiterlaufen', () async {
|
||||||
|
final dbPath = await createPlaintextDb();
|
||||||
|
// Fehler erzwingen: an der Stelle des Exports liegt ein VERZEICHNIS –
|
||||||
|
// ATTACH kann die Datei nicht anlegen.
|
||||||
|
await Directory('$dbPath.enc').create();
|
||||||
|
|
||||||
|
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||||||
|
factory: factory!,
|
||||||
|
dbPath: dbPath,
|
||||||
|
hexKey: _hexKey,
|
||||||
|
);
|
||||||
|
expect(ok, isFalse);
|
||||||
|
|
||||||
|
// Original unversehrt, Marker weg, App könnte normal weiterlaufen.
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
|
||||||
|
expect(File('$dbPath.migrating').existsSync(), isFalse);
|
||||||
|
final db = await factory.openDatabase(dbPath);
|
||||||
|
final clients = await db.rawQuery('SELECT count(*) AS c FROM box_client');
|
||||||
|
await db.close();
|
||||||
|
expect(clients.first['c'], 50);
|
||||||
|
}, skip: skip);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||||
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
|
|
||||||
|
/// Plattform kaputt/nicht verfügbar (z. B. sehr früher App-Start, exotischer
|
||||||
|
/// Fehler): genau der Fall, in dem AuthLog.write NIEMALS werfen darf – der
|
||||||
|
/// Soft-Logout-Guard ruft es in seinem catch-Pfad auf, und ein Throw dort
|
||||||
|
/// würde im SDK zu logout()+clear() eskalieren (der Uta-Bug).
|
||||||
|
class _BrokenPathProvider extends PathProviderPlatform {
|
||||||
|
@override
|
||||||
|
Future<String?> getApplicationSupportPath() async =>
|
||||||
|
throw StateError('Plattform nicht verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TempPathProvider extends PathProviderPlatform {
|
||||||
|
_TempPathProvider(this.path);
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getApplicationSupportPath() async => path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// WICHTIG: Reihenfolge der Tests ist Absicht. AuthLog cached sein File-Handle
|
||||||
|
// statisch; der Kaputt-Plattform-Test muss laufen, BEVOR ein Test mit
|
||||||
|
// funktionierender Plattform das Handle in den Cache legt.
|
||||||
|
late Directory tempDir;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
tempDir = Directory.systemTemp.createTempSync('pyramid_auth_log_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() {
|
||||||
|
tempDir.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'write/read werfen NIE, auch wenn die Plattform fehlt '
|
||||||
|
'(CLAUDE.md: Fehlerpfade dürfen nie in einem stillen Logout enden)',
|
||||||
|
() async {
|
||||||
|
PathProviderPlatform.instance = _BrokenPathProvider();
|
||||||
|
// Darf normal zurückkehren, obwohl getApplicationSupportPath wirft.
|
||||||
|
await AuthLog.write('darf nicht werfen');
|
||||||
|
expect(await AuthLog.read(), startsWith('Log konnte nicht gelesen werden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read ohne bisherige Einträge liefert Platzhalter statt Fehler',
|
||||||
|
() async {
|
||||||
|
PathProviderPlatform.instance = _TempPathProvider(tempDir.path);
|
||||||
|
expect(await AuthLog.read(), '(noch keine Einträge)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write hängt Zeile mit ISO-Zeitstempel an, read liefert sie zurück',
|
||||||
|
() async {
|
||||||
|
PathProviderPlatform.instance = _TempPathProvider(tempDir.path);
|
||||||
|
await AuthLog.write('Hallo Log');
|
||||||
|
final content = await AuthLog.read();
|
||||||
|
expect(content, contains('Hallo Log'));
|
||||||
|
expect(
|
||||||
|
RegExp(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}').hasMatch(content),
|
||||||
|
isTrue,
|
||||||
|
reason: 'jede Zeile beginnt mit einem ISO-8601-Zeitstempel',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Log wird auf 500 Zeilen gekappt (älteste fliegen raus), '
|
||||||
|
'damit es nie unbegrenzt wächst', () async {
|
||||||
|
PathProviderPlatform.instance = _TempPathProvider(tempDir.path);
|
||||||
|
for (var i = 0; i < 520; i++) {
|
||||||
|
await AuthLog.write('Zeile $i');
|
||||||
|
}
|
||||||
|
final lines = (await AuthLog.read()).trimRight().split('\n');
|
||||||
|
expect(lines.length, 500);
|
||||||
|
expect(lines.last, contains('Zeile 519'),
|
||||||
|
reason: 'neueste Einträge müssen überleben');
|
||||||
|
expect((await AuthLog.read()).contains('Hallo Log'), isFalse,
|
||||||
|
reason: 'ältester Eintrag (aus dem Test davor) muss weggekappt sein');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||||
|
|
||||||
|
/// Setup-Helfer für die Call-Tests mit den Autopilot-Test-Accounts (gegated
|
||||||
|
/// wie `live_encryption_e2e_test.dart`, läuft NIE im normalen `flutter test`):
|
||||||
|
/// stellt sicher, dass ein Voice-Channel-Raum („Pyramid Autopilot Voice",
|
||||||
|
/// `io.pyramid.voice_channel` + Presence-PowerLevel 0 wie im
|
||||||
|
/// create_join_dialog) existiert und BEIDE Test-Nutzer Mitglied sind.
|
||||||
|
/// Danach können zwei GUI-Instanzen (PYRAMID_PROFILE_DIR) den Kanal joinen.
|
||||||
|
///
|
||||||
|
/// Kein Logout am Ende nötig/gewollt für die GUI-Sessions – die Skript-Geräte
|
||||||
|
/// melden sich selbst ab, die GUI-Tokens bleiben unberührt.
|
||||||
|
|
||||||
|
const _kVoiceRoomName = 'Pyramid Autopilot Voice';
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final profile = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$profile\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _findSqliteDll() {
|
||||||
|
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||||
|
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||||
|
if (file.existsSync()) return file.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Client> _login(String name, String user, String pass, Uri hs) async {
|
||||||
|
// Wegwerf-Client: In-Memory-DB reicht (die DLL aus dem App-Build laden,
|
||||||
|
// der flutter_tester bringt selbst keine sqlite3.dll mit).
|
||||||
|
final dll = _findSqliteDll()!;
|
||||||
|
final factory = createDatabaseFactoryFfi(
|
||||||
|
ffiInit: () {
|
||||||
|
sqlite_open.open.overrideFor(
|
||||||
|
sqlite_open.OperatingSystem.windows,
|
||||||
|
() => DynamicLibrary.open(dll),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
noIsolate: true,
|
||||||
|
);
|
||||||
|
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||||
|
final sdkDb = await MatrixSdkDatabase.init(name, database: db);
|
||||||
|
final client = Client(name, database: sdkDb);
|
||||||
|
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||||
|
await client.checkHomeserver(hs);
|
||||||
|
await client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: user),
|
||||||
|
password: pass,
|
||||||
|
initialDeviceDisplayName: name,
|
||||||
|
);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final acc = _loadAccounts();
|
||||||
|
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
|
||||||
|
? 'Live-Setup nur mit PYRAMID_LIVE_TEST=1'
|
||||||
|
: acc == null
|
||||||
|
? 'Test-Account-Datei fehlt'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test('Voice-Channel-Testraum existiert und beide Test-Nutzer sind drin',
|
||||||
|
() async {
|
||||||
|
final hs = Uri.parse(acc!['homeserver']!);
|
||||||
|
final c1 = await _login('AutopilotCallSetup1', acc['user1']!, acc['pass1']!, hs);
|
||||||
|
final c2 = await _login('AutopilotCallSetup2', acc['user2']!, acc['pass2']!, hs);
|
||||||
|
try {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] beide Logins ok, erster Sync…');
|
||||||
|
await c1.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] c1 sync ok, suche Raum…');
|
||||||
|
|
||||||
|
Room? voiceRoom;
|
||||||
|
for (final r in c1.rooms) {
|
||||||
|
if (r.membership == Membership.join &&
|
||||||
|
r.name == _kVoiceRoomName &&
|
||||||
|
r.getState(EventTypes.RoomCreate)?.content['type'] ==
|
||||||
|
'io.pyramid.voice_channel') {
|
||||||
|
voiceRoom = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceRoom == null) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] Raum fehlt, lege an…');
|
||||||
|
final roomId = await c1.createRoom(
|
||||||
|
name: _kVoiceRoomName,
|
||||||
|
visibility: Visibility.private,
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
creationContent: {'type': 'io.pyramid.voice_channel'},
|
||||||
|
);
|
||||||
|
// Presence muss für normale Mitglieder schreibbar sein – exakt wie
|
||||||
|
// in create_join_dialog.dart (Continuwuity ignoriert
|
||||||
|
// power_level_content_override beim createRoom).
|
||||||
|
final plRaw =
|
||||||
|
await c1.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
|
||||||
|
final pl = Map<String, dynamic>.from(plRaw as Map);
|
||||||
|
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||||
|
events['io.pyramid.voice.presence'] = 0;
|
||||||
|
pl['events'] = events;
|
||||||
|
await c1.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] Raum $roomId angelegt, warte auf Sync…');
|
||||||
|
await c1.waitForRoomInSync(roomId, join: true)
|
||||||
|
.timeout(const Duration(seconds: 60));
|
||||||
|
voiceRoom = c1.getRoomById(roomId);
|
||||||
|
}
|
||||||
|
expect(voiceRoom, isNotNull);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] Raum da: ${voiceRoom!.id}, hole user2 rein…');
|
||||||
|
|
||||||
|
// user2 einladen (falls noch nicht Mitglied) und beitreten lassen.
|
||||||
|
final u2 = c2.userID!;
|
||||||
|
await c2.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] c2 sync ok…');
|
||||||
|
final r2 = c2.getRoomById(voiceRoom.id);
|
||||||
|
if (r2 == null || r2.membership != Membership.join) {
|
||||||
|
if (r2 == null || r2.membership != Membership.invite) {
|
||||||
|
try {
|
||||||
|
await voiceRoom.invite(u2);
|
||||||
|
} on MatrixException catch (e) {
|
||||||
|
// "already in the room" o. ä. ist ok.
|
||||||
|
if (e.error != MatrixError.M_FORBIDDEN) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await c2.joinRoom(voiceRoom.id);
|
||||||
|
await c2.waitForRoomInSync(voiceRoom.id, join: true)
|
||||||
|
.timeout(const Duration(seconds: 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
final members = await voiceRoom.requestParticipants();
|
||||||
|
final joined = members
|
||||||
|
.where((m) => m.membership == Membership.join)
|
||||||
|
.map((m) => m.id.toLowerCase())
|
||||||
|
.toSet();
|
||||||
|
expect(joined.contains(acc['user1']!.toLowerCase()), isTrue);
|
||||||
|
expect(joined.contains(u2.toLowerCase()), isTrue,
|
||||||
|
reason: 'user2 ist dem Voice-Raum nicht beigetreten');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Voice-Testraum bereit: ${voiceRoom.id}');
|
||||||
|
} finally {
|
||||||
|
// Nur die Skript-Geräte abmelden – GUI-Sessions bleiben unberührt.
|
||||||
|
try {
|
||||||
|
await c1.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await c2.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
await c1.dispose(closeDatabase: false);
|
||||||
|
await c2.dispose(closeDatabase: false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||||
|
import 'package:pyramid/core/app_database.dart';
|
||||||
|
|
||||||
|
/// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN
|
||||||
|
/// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta).
|
||||||
|
///
|
||||||
|
/// Bewusst doppelt gegated, damit er nie in einem normalen `flutter test`
|
||||||
|
/// mitläuft (Netzwerk + echter Homeserver):
|
||||||
|
/// 1. Umgebungsvariable `PYRAMID_LIVE_TEST=1` muss gesetzt sein.
|
||||||
|
/// 2. Die Zugangsdaten-Datei (`PYRAMID_TEST_ACCOUNTS` bzw.
|
||||||
|
/// `%USERPROFILE%\.pyramid-autopilot\test-accounts.txt`) muss existieren –
|
||||||
|
/// sie liegt AUSSERHALB des Repos, hier stehen keine Secrets.
|
||||||
|
///
|
||||||
|
/// Was der Test wirklich beweist (auf Windows, mit der SQLCipher-DLL aus dem
|
||||||
|
/// App-Build):
|
||||||
|
/// * Eine frisch angelegte DB ist auf der Platte verschlüsselt: Header ist
|
||||||
|
/// nicht "SQLite format 3", und weder der echte Access-Token noch der
|
||||||
|
/// Klartext einer echten gesendeten Nachricht sind in den Datei-Bytes
|
||||||
|
/// auffindbar (inkl. WAL-Sidecar).
|
||||||
|
/// * Die Byte-Suche selbst funktioniert (Positiv-Kontrolle: in einer
|
||||||
|
/// Klartext-DB werden Token UND Nachricht gefunden).
|
||||||
|
/// * "Neustart": Die Session (Access-Token) und die Nachricht kommen
|
||||||
|
/// nachweislich aus der verschlüsselten DB (Raw-Query mit Schlüssel,
|
||||||
|
/// Client-Init ohne erneute Passwort-Eingabe).
|
||||||
|
/// * Logout → erneuter Login → die alte Nachricht ist wieder lesbar.
|
||||||
|
/// * Utas Update-Fall: eine Klartext-DB MIT ECHTER eingeloggter Session wird
|
||||||
|
/// migriert; Session und Nachricht überleben, danach ist nichts mehr im
|
||||||
|
/// Klartext auf der Platte.
|
||||||
|
///
|
||||||
|
/// NICHT abgedeckt (ehrlich): flutter_secure_storage (der Test injiziert den
|
||||||
|
/// Schlüssel direkt – der Keystore-Pfad ist durch die echten App-Läufe auf
|
||||||
|
/// Windows und dem Android-Emulator belegt) und E2EE-verschlüsselte Räume
|
||||||
|
/// (kein vodozemac im Host-Test; der Testraum ist unverschlüsselt, was für
|
||||||
|
/// die Klartext-Suche sogar der härtere Fall ist).
|
||||||
|
|
||||||
|
String? _findCipherDll() {
|
||||||
|
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||||
|
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||||
|
if (file.existsSync()) return file.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestAccounts {
|
||||||
|
_TestAccounts({
|
||||||
|
required this.homeserver,
|
||||||
|
required this.user1,
|
||||||
|
required this.pass1,
|
||||||
|
required this.user2,
|
||||||
|
required this.pass2,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Uri homeserver;
|
||||||
|
final String user1;
|
||||||
|
final String pass1;
|
||||||
|
final String user2;
|
||||||
|
final String pass2;
|
||||||
|
|
||||||
|
static _TestAccounts? load() {
|
||||||
|
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
|
||||||
|
final profile = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final path = (override != null && override.isNotEmpty)
|
||||||
|
? override
|
||||||
|
: '$profile\\.pyramid-autopilot\\test-accounts.txt';
|
||||||
|
final file = File(path);
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
||||||
|
final idx = trimmed.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
map[trimmed.substring(0, idx)] = trimmed.substring(idx + 1);
|
||||||
|
}
|
||||||
|
final hs = map['homeserver'];
|
||||||
|
if (hs == null ||
|
||||||
|
map['user1'] == null ||
|
||||||
|
map['pass1'] == null ||
|
||||||
|
map['user2'] == null ||
|
||||||
|
map['pass2'] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _TestAccounts(
|
||||||
|
homeserver: Uri.parse(hs),
|
||||||
|
user1: map['user1']!,
|
||||||
|
pass1: map['pass1']!,
|
||||||
|
user2: map['user2']!,
|
||||||
|
pass2: map['pass2']!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zufälliger 32-Byte-Schlüssel wie in AppDatabase._getOrCreateKey – nur
|
||||||
|
/// direkt injiziert statt über den Keystore (siehe Kopfkommentar).
|
||||||
|
String _randomHexKey() {
|
||||||
|
final rnd = Random.secure();
|
||||||
|
return List.generate(
|
||||||
|
32,
|
||||||
|
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _randomMarker(String prefix) {
|
||||||
|
final rnd = Random.secure();
|
||||||
|
final suffix =
|
||||||
|
List.generate(8, (_) => rnd.nextInt(16).toRadixString(16)).join();
|
||||||
|
return 'PYRAMID_${prefix}_PROBE_$suffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Naive ASCII-Substring-Suche über rohe Datei-Bytes (DB + WAL). Bewusst kein
|
||||||
|
/// String-Decode der Gesamtdatei: Binärdaten sind kein gültiges UTF-8.
|
||||||
|
bool _bytesContain(Uint8List haystack, String needle) {
|
||||||
|
final n = ascii.encode(needle);
|
||||||
|
if (n.isEmpty || haystack.length < n.length) return false;
|
||||||
|
outer:
|
||||||
|
for (var i = 0; i <= haystack.length - n.length; i++) {
|
||||||
|
for (var j = 0; j < n.length; j++) {
|
||||||
|
if (haystack[i + j] != n[j]) continue outer;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durchsucht Hauptdatei + WAL/SHM-Sidecars nach einem Klartext-String.
|
||||||
|
Future<bool> _filesContain(String dbPath, String needle) async {
|
||||||
|
for (final path in [dbPath, '$dbPath-wal', '$dbPath-shm']) {
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) continue;
|
||||||
|
if (_bytesContain(await file.readAsBytes(), needle)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet die verschlüsselte DB mit Schlüssel und sucht den String in allen
|
||||||
|
/// Tabelleninhalten – beweist, dass die Daten IN der verschlüsselten Datei
|
||||||
|
/// stecken (und nicht etwa vom Server nachgeladen werden müssten).
|
||||||
|
Future<bool> _encryptedDbContains(
|
||||||
|
DatabaseFactory factory,
|
||||||
|
String dbPath,
|
||||||
|
String hexKey,
|
||||||
|
String needle,
|
||||||
|
) async {
|
||||||
|
final db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
|
||||||
|
try {
|
||||||
|
final tables = await db.rawQuery(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||||
|
);
|
||||||
|
for (final row in tables) {
|
||||||
|
final name = row['name'] as String;
|
||||||
|
final rows = await db.rawQuery('SELECT * FROM "$name"');
|
||||||
|
for (final r in rows) {
|
||||||
|
for (final v in r.values) {
|
||||||
|
if (v == null) continue;
|
||||||
|
if (v is Uint8List) {
|
||||||
|
if (_bytesContain(v, needle)) return true;
|
||||||
|
} else if (v.toString().contains(needle)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Client> _newClient(
|
||||||
|
Database db, {
|
||||||
|
required String name,
|
||||||
|
bool waitForRooms = false,
|
||||||
|
}) async {
|
||||||
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
|
final client = Client(name, database: sdkDb);
|
||||||
|
// waitForRooms: Die Raumliste wird beim Init ASYNCHRON aus der DB geladen –
|
||||||
|
// wer direkt danach client.rooms prüfen will, muss darauf warten.
|
||||||
|
await client.init(
|
||||||
|
waitForFirstSync: false,
|
||||||
|
waitUntilLoadCompletedLoaded: waitForRooms,
|
||||||
|
);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _login(Client client, _TestAccounts acc,
|
||||||
|
{required String user, required String pass, required String device}) async {
|
||||||
|
await client.checkHomeserver(acc.homeserver);
|
||||||
|
await client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: user),
|
||||||
|
password: pass,
|
||||||
|
initialDeviceDisplayName: device,
|
||||||
|
refreshToken: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt (oder erzeugt) den unverschlüsselten Autopilot-Testraum.
|
||||||
|
Future<Room> _ensureTestRoom(Client client) async {
|
||||||
|
const roomName = 'Pyramid Autopilot Testraum';
|
||||||
|
await client.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
for (final room in client.rooms) {
|
||||||
|
if (room.name == roomName && room.membership == Membership.join) {
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final roomId = await client.createRoom(
|
||||||
|
name: roomName,
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
);
|
||||||
|
await client.waitForRoomInSync(roomId, join: true).timeout(
|
||||||
|
const Duration(seconds: 60),
|
||||||
|
);
|
||||||
|
final room = client.getRoomById(roomId);
|
||||||
|
expect(room, isNotNull, reason: 'Testraum nach createRoom nicht im Sync');
|
||||||
|
return room!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sucht die Marker-Nachricht in der Timeline (lädt bei Bedarf Historie nach).
|
||||||
|
Future<bool> _timelineContains(Room room, String marker) async {
|
||||||
|
final timeline = await room.getTimeline();
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
if (timeline.events.any(
|
||||||
|
(e) => e.type == EventTypes.Message && e.body.contains(marker),
|
||||||
|
)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!timeline.canRequestHistory) break;
|
||||||
|
await timeline.requestHistory(historyCount: 100);
|
||||||
|
}
|
||||||
|
return timeline.events.any(
|
||||||
|
(e) => e.type == EventTypes.Message && e.body.contains(marker),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
timeline.cancelSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final dllPath = _findCipherDll();
|
||||||
|
final accounts = _TestAccounts.load();
|
||||||
|
final liveEnabled = Platform.environment['PYRAMID_LIVE_TEST'] == '1';
|
||||||
|
|
||||||
|
final skip = !liveEnabled
|
||||||
|
? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)'
|
||||||
|
: dllPath == null
|
||||||
|
? 'SQLCipher-DLL fehlt (erst "flutter build windows")'
|
||||||
|
: accounts == null
|
||||||
|
? 'Test-Account-Datei fehlt (~/.pyramid-autopilot/test-accounts.txt)'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final factory = dllPath == null
|
||||||
|
? null
|
||||||
|
: createDatabaseFactoryFfi(
|
||||||
|
ffiInit: () {
|
||||||
|
sqlite_open.open.overrideFor(
|
||||||
|
sqlite_open.OperatingSystem.windows,
|
||||||
|
() => DynamicLibrary.open(dllPath),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
noIsolate: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
late Directory tempDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tempDir = await Directory.systemTemp.createTemp('pyramid_live_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
try {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Frische Installation: Login → Platte verschlüsselt → Neustart → '
|
||||||
|
'Logout → Re-Login → alte Nachricht lesbar',
|
||||||
|
() async {
|
||||||
|
final acc = accounts!;
|
||||||
|
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
|
||||||
|
final hexKey = _randomHexKey();
|
||||||
|
final marker = _randomMarker('ENC');
|
||||||
|
|
||||||
|
// ── Phase 1: frisch verschlüsselt anlegen, echter Login, Nachricht ──
|
||||||
|
var db = await AppDatabase.openEncrypted(factory!, dbPath, hexKey);
|
||||||
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
var client = await _newClient(db, name: 'PyramidLiveTest');
|
||||||
|
await _login(client, acc,
|
||||||
|
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test');
|
||||||
|
expect(client.isLogged(), isTrue);
|
||||||
|
final accessToken = client.accessToken!;
|
||||||
|
final room = await _ensureTestRoom(client);
|
||||||
|
final eventId = await room.sendTextEvent(marker);
|
||||||
|
expect(eventId, isNotNull, reason: 'Senden der Probe-Nachricht schlug fehl');
|
||||||
|
await client.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
|
||||||
|
// ── Phase 2: Byte-Prüfung auf der Platte ──
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse,
|
||||||
|
reason: 'DB-Header ist "SQLite format 3" – NICHT verschlüsselt!');
|
||||||
|
expect(await _filesContain(dbPath, accessToken), isFalse,
|
||||||
|
reason: 'Access-Token im Klartext auf der Platte gefunden!');
|
||||||
|
expect(await _filesContain(dbPath, marker), isFalse,
|
||||||
|
reason: 'Nachrichtentext im Klartext auf der Platte gefunden!');
|
||||||
|
|
||||||
|
// Beweis, dass die Daten wirklich IN der Datei stecken (nur eben
|
||||||
|
// verschlüsselt): Raw-Query mit Schlüssel findet beides.
|
||||||
|
expect(
|
||||||
|
await _encryptedDbContains(factory, dbPath, hexKey, accessToken),
|
||||||
|
isTrue,
|
||||||
|
reason: 'Access-Token fehlt in der DB – falsche Datei geprüft?',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await _encryptedDbContains(factory, dbPath, hexKey, marker),
|
||||||
|
isTrue,
|
||||||
|
reason: 'Nachricht fehlt in der DB – falsche Datei geprüft?',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Phase 3: „Neustart" – Session kommt aus der verschlüsselten DB ──
|
||||||
|
db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
|
||||||
|
client = await _newClient(db, name: 'PyramidLiveTest');
|
||||||
|
expect(client.isLogged(), isTrue,
|
||||||
|
reason: 'Session hat den Neustart nicht überlebt');
|
||||||
|
expect(client.accessToken, accessToken,
|
||||||
|
reason: 'Access-Token nach Neustart nicht aus der DB restauriert');
|
||||||
|
expect(client.userID?.toLowerCase(), acc.user1.toLowerCase());
|
||||||
|
|
||||||
|
// ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ──
|
||||||
|
await client.logout();
|
||||||
|
expect(client.isLogged(), isFalse);
|
||||||
|
await _login(client, acc,
|
||||||
|
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2');
|
||||||
|
final roomAfter = await _ensureTestRoom(client);
|
||||||
|
expect(await _timelineContains(roomAfter, marker), isTrue,
|
||||||
|
reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar');
|
||||||
|
// Aufräumen: Session serverseitig beenden, DB wird dabei geleert.
|
||||||
|
await client.logout();
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 8)),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Utas Update-Fall: Klartext-DB mit ECHTER Session → Migration → '
|
||||||
|
'Session + Nachricht überleben, Platte danach dicht',
|
||||||
|
() async {
|
||||||
|
final acc = accounts!;
|
||||||
|
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
|
||||||
|
final hexKey = _randomHexKey();
|
||||||
|
final marker = _randomMarker('MIG');
|
||||||
|
|
||||||
|
// ── Phase 1: Klartext-DB wie vor der SQLCipher-Einführung ──
|
||||||
|
var db = await factory!.openDatabase(dbPath);
|
||||||
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
var client = await _newClient(db, name: 'PyramidLiveTest');
|
||||||
|
await _login(client, acc,
|
||||||
|
user: acc.user1, pass: acc.pass1, device: 'Autopilot Mig-Test');
|
||||||
|
final accessToken = client.accessToken!;
|
||||||
|
final room = await _ensureTestRoom(client);
|
||||||
|
final eventId = await room.sendTextEvent(marker);
|
||||||
|
expect(eventId, isNotNull);
|
||||||
|
await client.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
|
||||||
|
// Positiv-Kontrolle: In der Klartext-DB findet die Byte-Suche BEIDES –
|
||||||
|
// damit ist bewiesen, dass die Suche Leaks erkennen würde.
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
|
||||||
|
expect(await _filesContain(dbPath, accessToken), isTrue,
|
||||||
|
reason: 'Kontrolle kaputt: Token nicht mal im Klartext gefunden');
|
||||||
|
expect(await _filesContain(dbPath, marker), isTrue,
|
||||||
|
reason: 'Kontrolle kaputt: Nachricht nicht mal im Klartext gefunden');
|
||||||
|
|
||||||
|
// ── Phase 2: Migration (exakt der App-Codepfad) ──
|
||||||
|
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||||||
|
factory: factory,
|
||||||
|
dbPath: dbPath,
|
||||||
|
hexKey: hexKey,
|
||||||
|
);
|
||||||
|
expect(ok, isTrue, reason: 'Migration fehlgeschlagen');
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse);
|
||||||
|
expect(File('$dbPath.premigration').existsSync(), isTrue);
|
||||||
|
expect(await _filesContain(dbPath, accessToken), isFalse,
|
||||||
|
reason: 'Token nach Migration weiterhin im Klartext!');
|
||||||
|
expect(await _filesContain(dbPath, marker), isFalse,
|
||||||
|
reason: 'Nachricht nach Migration weiterhin im Klartext!');
|
||||||
|
|
||||||
|
// ── Phase 3: Session + Daten intakt? ──
|
||||||
|
expect(
|
||||||
|
await _encryptedDbContains(factory, dbPath, hexKey, marker),
|
||||||
|
isTrue,
|
||||||
|
reason: 'Nachricht hat die Migration nicht überlebt',
|
||||||
|
);
|
||||||
|
db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
|
||||||
|
client = await _newClient(db, name: 'PyramidLiveTest', waitForRooms: true);
|
||||||
|
expect(client.isLogged(), isTrue,
|
||||||
|
reason: 'Session hat die Migration nicht überlebt');
|
||||||
|
expect(client.accessToken, accessToken);
|
||||||
|
final roomAfter = client.getRoomById(room.id);
|
||||||
|
expect(roomAfter, isNotNull,
|
||||||
|
reason: 'Testraum nach Migration nicht mehr in der DB');
|
||||||
|
// Aufräumen (Session serverseitig beenden).
|
||||||
|
await client.logout();
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 8)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
||||||
|
|
||||||
|
/// Fake-Client für `updatePowerLevelsSafely`: liefert einen vorgegebenen
|
||||||
|
/// Server-Stand des power_levels-Events und zeichnet auf, was (wenn
|
||||||
|
/// überhaupt) zurückgeschrieben wird. Alles andere → NoSuchMethodError.
|
||||||
|
class _FakeClient implements Client {
|
||||||
|
_FakeClient({required this.serverState, this.getError});
|
||||||
|
|
||||||
|
Map<String, Object?> serverState;
|
||||||
|
MatrixException? getError;
|
||||||
|
Map<String, Object?>? written;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get userID => '@me:pyramid.example';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, Object?>> getRoomStateWithKey(
|
||||||
|
String roomId,
|
||||||
|
String eventType,
|
||||||
|
String stateKey, {
|
||||||
|
Format? format,
|
||||||
|
}) async {
|
||||||
|
final err = getError;
|
||||||
|
if (err != null) throw err;
|
||||||
|
return serverState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> setRoomStateWithKey(
|
||||||
|
String roomId,
|
||||||
|
String eventType,
|
||||||
|
String stateKey,
|
||||||
|
Map<String, Object?> body,
|
||||||
|
) async {
|
||||||
|
written = body;
|
||||||
|
return r'$neuesEvent';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeRoom implements Room {
|
||||||
|
_FakeRoom(this.client);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Client client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => '!raum:pyramid.example';
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('Server-Stand bleibt erhalten: fremde Einträge werden nie überschrieben '
|
||||||
|
'(genau der Bug, der schon einmal Admins ausgesperrt hat)', () async {
|
||||||
|
final client = _FakeClient(serverState: {
|
||||||
|
'users': {'@me:pyramid.example': 100, '@uta:pyramid.example': 50},
|
||||||
|
'users_default': 0,
|
||||||
|
'state_default': 50,
|
||||||
|
});
|
||||||
|
final room = _FakeRoom(client);
|
||||||
|
|
||||||
|
await updatePowerLevelsSafely(room, (content) {
|
||||||
|
(content['users'] as Map<String, dynamic>)['@neu:pyramid.example'] = 50;
|
||||||
|
});
|
||||||
|
|
||||||
|
final users = client.written!['users'] as Map;
|
||||||
|
expect(users['@uta:pyramid.example'], 50,
|
||||||
|
reason: 'bestehender Server-Eintrag muss überleben');
|
||||||
|
expect(users['@me:pyramid.example'], 100);
|
||||||
|
expect(users['@neu:pyramid.example'], 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selbst-Degradierung unter das nötige Level wird abgebrochen, '
|
||||||
|
'nichts wird geschrieben', () async {
|
||||||
|
final client = _FakeClient(serverState: {
|
||||||
|
'users': {'@me:pyramid.example': 100},
|
||||||
|
'state_default': 50,
|
||||||
|
});
|
||||||
|
final room = _FakeRoom(client);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
updatePowerLevelsSafely(room, (content) {
|
||||||
|
(content['users'] as Map<String, dynamic>)['@me:pyramid.example'] = 0;
|
||||||
|
}),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(client.written, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eigener Eintrag entfernt → Fallback auf users_default greift und '
|
||||||
|
'schützt ebenfalls', () async {
|
||||||
|
final client = _FakeClient(serverState: {
|
||||||
|
'users': {'@me:pyramid.example': 100},
|
||||||
|
'users_default': 0,
|
||||||
|
});
|
||||||
|
final room = _FakeRoom(client);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
updatePowerLevelsSafely(room, (content) {
|
||||||
|
(content['users'] as Map<String, dynamic>)
|
||||||
|
.remove('@me:pyramid.example');
|
||||||
|
}),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(client.written, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explizites events[m.room.power_levels]-Erfordernis wird respektiert',
|
||||||
|
() async {
|
||||||
|
final client = _FakeClient(serverState: {
|
||||||
|
'users': {'@me:pyramid.example': 60},
|
||||||
|
'events': {'m.room.power_levels': 75},
|
||||||
|
'state_default': 50,
|
||||||
|
});
|
||||||
|
final room = _FakeRoom(client);
|
||||||
|
|
||||||
|
// 60 < 75 → muss abbrechen, obwohl state_default (50) erfüllt wäre.
|
||||||
|
await expectLater(
|
||||||
|
updatePowerLevelsSafely(room, (_) {}),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(client.written, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlendes power_levels-Event (M_NOT_FOUND): Start mit leerem Event, '
|
||||||
|
'Schreiben nur wenn man sich selbst hoch genug setzt', () async {
|
||||||
|
final client = _FakeClient(
|
||||||
|
serverState: {},
|
||||||
|
getError: MatrixException.fromJson(
|
||||||
|
{'errcode': 'M_NOT_FOUND', 'error': 'Event not found.'}),
|
||||||
|
);
|
||||||
|
final room = _FakeRoom(client);
|
||||||
|
|
||||||
|
// Ohne eigenen Eintrag: users_default 0 < state_default-Fallback 50 → Abbruch.
|
||||||
|
await expectLater(
|
||||||
|
updatePowerLevelsSafely(room, (_) {}),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(client.written, isNull);
|
||||||
|
|
||||||
|
// Setzt man sich selbst auf 100, darf geschrieben werden.
|
||||||
|
await updatePowerLevelsSafely(room, (content) {
|
||||||
|
(content['users'] as Map<String, dynamic>)['@me:pyramid.example'] = 100;
|
||||||
|
});
|
||||||
|
final users = client.written!['users'] as Map;
|
||||||
|
expect(users['@me:pyramid.example'], 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('andere Serverfehler (z. B. M_FORBIDDEN) werden durchgereicht, '
|
||||||
|
'nichts wird geschrieben', () async {
|
||||||
|
final client = _FakeClient(
|
||||||
|
serverState: {},
|
||||||
|
getError: MatrixException.fromJson(
|
||||||
|
{'errcode': 'M_FORBIDDEN', 'error': 'Nope.'}),
|
||||||
|
);
|
||||||
|
final room = _FakeRoom(client);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
updatePowerLevelsSafely(room, (_) {}),
|
||||||
|
throwsA(isA<MatrixException>()),
|
||||||
|
);
|
||||||
|
expect(client.written, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
|
||||||
|
/// Schnittstelle für alle Einstellungs-Zugriffe ausgebaut werden soll –
|
||||||
|
/// dieses Vertragsnetz muss bei dem Umbau stehen bleiben.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
// Frischer In-Memory-Store pro Test (setzt auch die gecachte
|
||||||
|
// SharedPreferences-Instanz zurück).
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BoolPref', () {
|
||||||
|
test('lädt persistierten Wert und überstimmt damit den Default', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({'notif_sound': false});
|
||||||
|
final pref = BoolPref('notif_sound', true);
|
||||||
|
expect(pref.state, true, reason: 'Default gilt, bis geladen ist');
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(pref.state, false, reason: 'persistierter Wert muss gewinnen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'set() sofort nach Erstellung gewinnt gegen den später ladenden '
|
||||||
|
'persistierten Wert (Lade-Race)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({'notif_sound': false});
|
||||||
|
final pref = BoolPref('notif_sound', false);
|
||||||
|
final done = pref.set(true); // bevor _load fertig ist
|
||||||
|
await pumpEventQueue();
|
||||||
|
await done;
|
||||||
|
expect(pref.state, true,
|
||||||
|
reason: 'die Nutzeraktion darf nicht vom Lade-Ergebnis '
|
||||||
|
'zurücküberschrieben werden');
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
expect(p.getBool('notif_sound'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set/toggle ändern Zustand und persistieren', () async {
|
||||||
|
final pref = BoolPref('notif_desktop', true);
|
||||||
|
await pumpEventQueue();
|
||||||
|
pref.toggle();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(pref.state, false);
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
expect(p.getBool('notif_desktop'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('StringSetPref', () {
|
||||||
|
test('lädt persistierte Liste als Set, add/remove persistieren', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'muted_rooms': ['!a:hs', '!b:hs'],
|
||||||
|
});
|
||||||
|
final pref = StringSetPref('muted_rooms');
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(pref.state, {'!a:hs', '!b:hs'});
|
||||||
|
|
||||||
|
await pref.add('!c:hs');
|
||||||
|
await pref.remove('!a:hs');
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
expect(p.getStringList('muted_rooms')!.toSet(), {'!b:hs', '!c:hs'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'add() sofort nach Erstellung verliert keine bereits persistierten '
|
||||||
|
'Einträge (Lade-Race)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'muted_rooms': ['!a:hs'],
|
||||||
|
});
|
||||||
|
final pref = StringSetPref('muted_rooms');
|
||||||
|
final done = pref.add('!b:hs'); // bevor _load fertig ist
|
||||||
|
await pumpEventQueue();
|
||||||
|
await done;
|
||||||
|
expect(pref.state, {'!a:hs', '!b:hs'});
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
expect(p.getStringList('muted_rooms')!.toSet(), {'!a:hs', '!b:hs'},
|
||||||
|
reason: 'Read-Modify-Write auf ungeladenem State darf keine '
|
||||||
|
'persistierten Einträge verlieren');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doppeltes add und remove von Fehlendem sind No-Ops', () async {
|
||||||
|
final pref = StringSetPref('muted_rooms');
|
||||||
|
await pumpEventQueue();
|
||||||
|
await pref.add('!a:hs');
|
||||||
|
await pref.add('!a:hs');
|
||||||
|
await pref.remove('!gibtsnicht:hs');
|
||||||
|
expect(pref.state, {'!a:hs'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Theme-Prefs', () {
|
||||||
|
test('leerer Store liefert die dokumentierten Defaults', () async {
|
||||||
|
final t = await loadThemePrefs();
|
||||||
|
expect(t.isDark, true);
|
||||||
|
expect(t.accentIdx, 0);
|
||||||
|
expect(t.radius, 12.0);
|
||||||
|
expect(t.motion, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Roundtrip: gespeicherte Werte kommen zurück, Teil-Save lässt '
|
||||||
|
'den Rest unangetastet', () async {
|
||||||
|
await saveThemePrefs(isDark: false, accentIdx: 3, radius: 8, motion: 0.5);
|
||||||
|
var t = await loadThemePrefs();
|
||||||
|
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 3, 8.0, 0.5));
|
||||||
|
|
||||||
|
// Nur ein Feld ändern – die anderen dürfen nicht zurückfallen.
|
||||||
|
await saveThemePrefs(accentIdx: 1);
|
||||||
|
t = await loadThemePrefs();
|
||||||
|
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 1, 8.0, 0.5));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoicePrefs', () {
|
||||||
|
// Die Fassade (M2, 2026-07-06) hat drei dokumentierte Garantien:
|
||||||
|
// historische voice_*-Keys unverändert, null/leer = Pref löschen,
|
||||||
|
// Fallback-Lautstärke 1.0. Dieser Vertrag muss bei jedem weiteren
|
||||||
|
// Umbau der Call-Schicht stehen bleiben.
|
||||||
|
test('leerer Store: Geräte null (= System-Standard), Lautstärke 1.0',
|
||||||
|
() async {
|
||||||
|
final d = await VoicePrefs.loadDevices();
|
||||||
|
expect((d.mic, d.speaker, d.camera), (null, null, null));
|
||||||
|
expect(await VoicePrefs.loadOutputVolume(), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('liest die historischen voice_*-Keys – Bestands-Einstellungen '
|
||||||
|
'(Utas Gerät!) bleiben nach dem Fassaden-Umbau erhalten', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'voice_mic_device': 'mic-1',
|
||||||
|
'voice_speaker_device': 'spk-1',
|
||||||
|
'voice_camera_device': 'cam-1',
|
||||||
|
'voice_output_volume': 0.4,
|
||||||
|
});
|
||||||
|
final d = await VoicePrefs.loadDevices();
|
||||||
|
expect((d.mic, d.speaker, d.camera), ('mic-1', 'spk-1', 'cam-1'));
|
||||||
|
expect(await VoicePrefs.loadOutputVolume(), 0.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Roundtrip unter denselben Keys; null/leer LÖSCHT das Pref', () async {
|
||||||
|
await VoicePrefs.saveMic('mic-2');
|
||||||
|
await VoicePrefs.saveSpeaker('spk-2');
|
||||||
|
await VoicePrefs.saveCamera('cam-2');
|
||||||
|
await VoicePrefs.saveOutputVolume(0.75);
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
// Key-Vertrag: exakt die historischen Keys, keine neuen Namen.
|
||||||
|
expect(p.getString('voice_mic_device'), 'mic-2');
|
||||||
|
expect(p.getString('voice_speaker_device'), 'spk-2');
|
||||||
|
expect(p.getString('voice_camera_device'), 'cam-2');
|
||||||
|
expect(p.getDouble('voice_output_volume'), 0.75);
|
||||||
|
|
||||||
|
await VoicePrefs.saveMic(null);
|
||||||
|
await VoicePrefs.saveSpeaker('');
|
||||||
|
final d = await VoicePrefs.loadDevices();
|
||||||
|
expect((d.mic, d.speaker, d.camera), (null, null, 'cam-2'));
|
||||||
|
expect(p.containsKey('voice_mic_device'), false,
|
||||||
|
reason: 'null muss das Pref löschen (System-Standard), '
|
||||||
|
'nicht einen leeren String speichern');
|
||||||
|
expect(p.containsKey('voice_speaker_device'), false,
|
||||||
|
reason: 'leerer String zählt wie null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Server-Banner', () {
|
||||||
|
test('Roundtrip + Entfernen per null/leerem mxcUri', () async {
|
||||||
|
await saveServerBanner('https://hs1', 'mxc://hs1/abc');
|
||||||
|
await saveServerBanner('https://hs2', 'mxc://hs2/def');
|
||||||
|
expect(await loadAllServerBanners(),
|
||||||
|
{'https://hs1': 'mxc://hs1/abc', 'https://hs2': 'mxc://hs2/def'});
|
||||||
|
|
||||||
|
await saveServerBanner('https://hs1', null);
|
||||||
|
await saveServerBanner('https://hs2', '');
|
||||||
|
expect(await loadAllServerBanners(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('korruptes JSON im Store crasht nicht, sondern liefert leere Map',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues(
|
||||||
|
{'server_banners': '{kein json'});
|
||||||
|
expect(await loadAllServerBanners(), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
|
|
||||||
|
/// Fake-Client: nur `refreshAccessToken` ist implementiert. Jeder andere
|
||||||
|
/// SDK-Aufruf (insbesondere `logout()`/`clear()`!) schlägt laut mit
|
||||||
|
/// NoSuchMethodError fehl – so würde der Test es sofort merken, wenn der
|
||||||
|
/// Guard je etwas anderes am Client aufriefe als den Refresh.
|
||||||
|
class _FakeClient implements Client {
|
||||||
|
_FakeClient(this._onRefresh);
|
||||||
|
|
||||||
|
final Future<void> Function(int attempt) _onRefresh;
|
||||||
|
int attempts = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refreshAccessToken({Duration? customRefreshTokenLifetime}) {
|
||||||
|
attempts++;
|
||||||
|
return _onRefresh(attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('erfolgreicher Refresh beim ersten Versuch: genau 1 Aufruf', () async {
|
||||||
|
final client = _FakeClient((_) async {});
|
||||||
|
await guardedSoftLogoutRefresh(client);
|
||||||
|
expect(client.attempts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'transienter Fehler (z. B. Netz weg / Token-Rotations-Race mit dem '
|
||||||
|
'Push-Isolate): Retry heilt, kein Fehler nach außen',
|
||||||
|
() async {
|
||||||
|
final client = _FakeClient((attempt) async {
|
||||||
|
if (attempt == 1) throw Exception('Netzwerkfehler');
|
||||||
|
});
|
||||||
|
await guardedSoftLogoutRefresh(client);
|
||||||
|
expect(client.attempts, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'dauerhafter Fehler: genau 3 Versuche, und der Guard wirft NIE '
|
||||||
|
'(CLAUDE.md: Fehlerpfade dürfen nie in einem stillen Logout enden – '
|
||||||
|
'ein Throw hier würde im SDK zu logout()+clear() eskalieren)',
|
||||||
|
() async {
|
||||||
|
final client = _FakeClient((_) async {
|
||||||
|
throw Exception('Server dauerhaft nicht erreichbar');
|
||||||
|
});
|
||||||
|
// Darf trotz 3 Fehlversuchen normal (ohne Exception) zurückkehren.
|
||||||
|
await guardedSoftLogoutRefresh(client);
|
||||||
|
expect(client.attempts, 3);
|
||||||
|
},
|
||||||
|
// Der Guard wartet real 2s+4s zwischen den Versuchen.
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('auch ein Error (nicht nur Exception) im Refresh entkommt nie', () async {
|
||||||
|
final client = _FakeClient((_) async {
|
||||||
|
throw StateError('unerwarteter Zustand');
|
||||||
|
});
|
||||||
|
await guardedSoftLogoutRefresh(client);
|
||||||
|
expect(client.attempts, 3);
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 30)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/livekit_call_manager.dart';
|
||||||
|
|
||||||
|
/// Vertragstests für die Streaming-Qualitäts-Presets (M4,
|
||||||
|
/// `docs/STREAMING_60FPS.md`). Nagelt fest, was `startScreenShare` und das
|
||||||
|
/// SENDEN-Menü voraussetzen: jede Auflösung hat eine Encoding-Zeile, die
|
||||||
|
/// 60-FPS-Presets tragen wirklich 60 FPS und genug Bitrate dafür.
|
||||||
|
/// (Das echte Publish-Verhalten – sender.parameters.encodings im Call –
|
||||||
|
/// braucht zwei verbundene Clients und bleibt ein Gerätetest.)
|
||||||
|
void main() {
|
||||||
|
test('Jedes Auflösungs-Preset hat genau eine Encoding-Zeile (und umgekehrt)',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
LiveKitCallManager.qualityPresets.keys.toSet(),
|
||||||
|
LiveKitCallManager.screenShareEncodings.keys.toSet(),
|
||||||
|
reason: 'qualityPresets und screenShareEncodings müssen dieselben '
|
||||||
|
'Keys haben – sonst fällt ein Menü-Eintrag auf den Fallback zurück',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('60-FPS-Presets: wirklich 60 FPS, hohe Bitrate, richtige Auflösung',
|
||||||
|
() {
|
||||||
|
for (final key in ['hd60', 'fhd60']) {
|
||||||
|
final enc = LiveKitCallManager.screenShareEncodings[key]!;
|
||||||
|
expect(enc.maxFramerate, 60, reason: '$key muss 60 FPS tragen');
|
||||||
|
expect(enc.maxBitrate, greaterThanOrEqualTo(4_000_000),
|
||||||
|
reason: '$key: zu wenig Bitrate für 60 FPS → Encoder ruckelt');
|
||||||
|
expect(LiveKitCallManager.isHighFpsPreset(key), isTrue);
|
||||||
|
}
|
||||||
|
expect(LiveKitCallManager.qualityPresets['hd60']!.dimensions,
|
||||||
|
VideoParametersPresets.h720_169.dimensions);
|
||||||
|
expect(LiveKitCallManager.qualityPresets['fhd60']!.dimensions,
|
||||||
|
VideoParametersPresets.h1080_169.dimensions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('60-FPS-Preset hat mehr Bitrate als sein Standard-Pendant', () {
|
||||||
|
final enc = LiveKitCallManager.screenShareEncodings;
|
||||||
|
expect(enc['hd60']!.maxBitrate, greaterThan(enc['hd']!.maxBitrate));
|
||||||
|
expect(enc['fhd60']!.maxBitrate, greaterThan(enc['fhd']!.maxBitrate));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bestands-Presets unverändert (kein Verhaltenssprung für Uta)', () {
|
||||||
|
final enc = LiveKitCallManager.screenShareEncodings;
|
||||||
|
expect(enc['sd'], const VideoEncoding(maxBitrate: 500_000, maxFramerate: 10));
|
||||||
|
expect(enc['hd'], const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15));
|
||||||
|
expect(enc['fhd'], const VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20));
|
||||||
|
expect(enc['4k'], const VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30));
|
||||||
|
for (final key in ['sd', 'hd', 'fhd', '4k']) {
|
||||||
|
expect(LiveKitCallManager.isHighFpsPreset(key), isFalse,
|
||||||
|
reason: '$key darf NICHT als 60-FPS-Preset behandelt werden '
|
||||||
|
'(sonst verliert es Simulcast)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Diagnose-Sonde für die Windows-Audiogeräte-Erkennung (ROADMAP M3).
|
||||||
|
//
|
||||||
|
// BEFUND (2026-07-07, PC-Diagnose): enumerateDevices()/getSources liefert auf
|
||||||
|
// Windows IMMER eine leere Liste, solange keine RTCPeerConnection existiert.
|
||||||
|
// Ursache: flutter_webrtc holt die Geräteliste über das Audio-Device-Module
|
||||||
|
// der libwebrtc.dll (`GetAudioDevice()`), und dieses ADM bekommt sein Init()
|
||||||
|
// erst, wenn die Voice-Engine anläuft – sprich: wenn irgendeine
|
||||||
|
// PeerConnection existiert. Ohne Init() liefern RecordingDevices()/
|
||||||
|
// PlayoutDevices() -1 (per Plugin-Instrumentierung verifiziert), die
|
||||||
|
// Geräteliste bleibt leer, obwohl die MMDevice-API des Systems 23 Endpunkte
|
||||||
|
// meldet. Upstream-Fix: webrtc-sdk/libwebrtc #142 (2026-06-24, „fix: audio
|
||||||
|
// devices init issue"), aber noch in KEINEM libwebrtc-Binary-Release
|
||||||
|
// (Stand 2026-07-07: neuestes ist m144.7559.09 vom 2026-06-11) und damit in
|
||||||
|
// keiner flutter_webrtc-Version (geprüft: 1.4.1 und 1.5.2 identisch kaputt).
|
||||||
|
// Workaround in der App: vor dem Enumerieren eine Dummy-PeerConnection
|
||||||
|
// anlegen (siehe settings_voice.dart) – kein getUserMedia/Mikrofonzugriff
|
||||||
|
// nötig.
|
||||||
|
//
|
||||||
|
// Diese Sonde belegt das stufenweise. Lauf:
|
||||||
|
// flutter build windows --release -t tool/device_probe.dart
|
||||||
|
// build\windows\x64\runner\Release\pyramid.exe
|
||||||
|
// Ergebnis landet in %TEMP%\pyramid_device_probe.txt
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final out = StringBuffer();
|
||||||
|
void log(String s) {
|
||||||
|
out.writeln(s);
|
||||||
|
debugPrint('[probe] $s');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('device_probe ${DateTime.now().toIso8601String()}');
|
||||||
|
|
||||||
|
Future<int> countDevices(String phase) async {
|
||||||
|
try {
|
||||||
|
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
||||||
|
log('$phase: ${devices.length} Geräte');
|
||||||
|
for (final d in devices.take(30)) {
|
||||||
|
log(' kind=${d.kind} label="${d.label}"');
|
||||||
|
}
|
||||||
|
return devices.length;
|
||||||
|
} catch (e) {
|
||||||
|
log('$phase: FEHLER $e');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await countDevices('0-kalt');
|
||||||
|
|
||||||
|
// Stufe A: nur PeerConnection anlegen – das allein initialisiert das ADM.
|
||||||
|
final pc = await rtc.createPeerConnection({});
|
||||||
|
await countDevices('A-pc-angelegt');
|
||||||
|
|
||||||
|
// Nachkontrolle: nach close() fällt die Liste wieder auf 0 zurück.
|
||||||
|
await pc.close();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
await countDevices('B-nach-close');
|
||||||
|
|
||||||
|
final f = File(
|
||||||
|
'${Platform.environment['TEMP'] ?? '.'}\\pyramid_device_probe.txt');
|
||||||
|
await f.writeAsString(out.toString());
|
||||||
|
debugPrint('[probe] geschrieben: ${f.path}');
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
@@ -90,12 +90,12 @@ BEGIN
|
|||||||
BLOCK "040904e4"
|
BLOCK "040904e4"
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "chat.pyramid" "\0"
|
VALUE "CompanyName", "chat.pyramid" "\0"
|
||||||
VALUE "FileDescription", "pyramid" "\0"
|
VALUE "FileDescription", "Pyramid" "\0"
|
||||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
VALUE "InternalName", "pyramid" "\0"
|
VALUE "InternalName", "pyramid" "\0"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2026 chat.pyramid. All rights reserved." "\0"
|
VALUE "LegalCopyright", "Copyright (C) 2026 chat.pyramid. All rights reserved." "\0"
|
||||||
VALUE "OriginalFilename", "pyramid.exe" "\0"
|
VALUE "OriginalFilename", "pyramid.exe" "\0"
|
||||||
VALUE "ProductName", "pyramid" "\0"
|
VALUE "ProductName", "Pyramid" "\0"
|
||||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||||||
FlutterWindow window(project);
|
FlutterWindow window(project);
|
||||||
Win32Window::Point origin(10, 10);
|
Win32Window::Point origin(10, 10);
|
||||||
Win32Window::Size size(1280, 720);
|
Win32Window::Size size(1280, 720);
|
||||||
if (!window.Create(L"pyramid", origin, size)) {
|
if (!window.Create(L"Pyramid", origin, size)) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user