Compare commits
68 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 |
@@ -46,3 +46,6 @@ app.*.map.json
|
|||||||
autopilot.log
|
autopilot.log
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
|
||||||
|
*.bak-*
|
||||||
|
|||||||
@@ -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.
|
||||||
+293
@@ -1199,3 +1199,296 @@
|
|||||||
2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.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/pyramid-arbeitsstand.md
|
||||||
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.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`)
|
||||||
|
|||||||
+1782
File diff suppressed because it is too large
Load Diff
+167
-3
@@ -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
|
||||||
@@ -60,10 +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
|
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
|
||||||
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
||||||
|
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
|
||||||
|
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
|
||||||
|
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
|
||||||
|
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
|
||||||
|
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
|
||||||
|
|
||||||
## M2 – Das große Refactoring (erst nach M0/M1!)
|
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||||
|
|
||||||
@@ -86,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
|
||||||
@@ -103,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
|
||||||
@@ -118,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)
|
||||||
@@ -131,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
|
||||||
|
|||||||
+24
-7
@@ -1,17 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
# Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
|
||||||
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||||
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||||
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
|
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
|
||||||
# FORTSETZEN: rm ~/pyramid/PAUSE
|
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||||
# GANZ STOPPEN: tmux kill-session -t pyramid
|
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||||
|
|
||||||
# Pyramid laeuft auf eigenem Claude-Konto (Profil ~/.claude-pyramid),
|
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
|
||||||
# das Standard-Profil ~/.claude bleibt fuer die Webseiten-Arbeit.
|
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
|
||||||
export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid"
|
# 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"
|
||||||
@@ -36,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)
|
||||||
@@ -50,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).
|
||||||
+77
-6
@@ -1,6 +1,7 @@
|
|||||||
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||||
|
|
||||||
Stand: 2026-07-03. Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
|
||||||
|
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||||
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
||||||
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
||||||
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
||||||
@@ -8,18 +9,88 @@ Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
|
|||||||
|
|
||||||
Schnellster Weg: `flutter run -d windows`.
|
Schnellster Weg: `flutter run -d windows`.
|
||||||
|
|
||||||
|
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
|
||||||
|
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
|
||||||
|
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
|
||||||
|
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
|
||||||
|
|
||||||
|
## 0. Vorab (1 Minute)
|
||||||
|
|
||||||
|
- [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!)
|
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
||||||
|
|
||||||
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
||||||
|
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
|
||||||
|
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
|
||||||
|
|
||||||
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
||||||
- [ ] `auth_log.txt` (App-Support-Verzeichnis) prüfen: LoginState-Wechsel
|
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
|
||||||
werden mitgeschrieben, keine unerwarteten Warnungen
|
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
|
||||||
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea)
|
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
|
||||||
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?**
|
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
|
||||||
- [ ] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
|
||||||
|
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
|
||||||
|
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
|
||||||
|
Soft-Logout-Eintrag.
|
||||||
|
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
|
||||||
|
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
|
||||||
|
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||||
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
||||||
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
||||||
|
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
|
||||||
|
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
|
||||||
|
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
|
||||||
|
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
|
||||||
|
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
|
||||||
|
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
|
||||||
|
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
|
||||||
|
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
|
||||||
|
Migration). Danach zwei geplante Neustarts für die Refactoring-
|
||||||
|
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
|
||||||
|
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
|
||||||
|
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
|
||||||
|
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
|
||||||
|
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
|
||||||
|
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
|
||||||
|
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
|
||||||
|
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
|
||||||
|
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
|
||||||
|
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
|
||||||
|
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
|
||||||
|
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
|
||||||
|
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
|
||||||
|
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
|
||||||
|
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
|
||||||
|
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
|
||||||
|
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
|
||||||
|
echten Mehr-Stunden-Fenster abhaken.
|
||||||
|
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
|
||||||
|
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
|
||||||
|
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
|
||||||
|
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
|
||||||
|
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
|
||||||
|
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
|
||||||
|
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
|
||||||
|
läuft weiter, nächstes Fenster ab 23:12.
|
||||||
|
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
|
||||||
|
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
|
||||||
|
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
|
||||||
|
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
|
||||||
|
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
|
||||||
|
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
|
||||||
|
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
|
||||||
|
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
|
||||||
|
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
|
||||||
|
Fenster ab 03:06.
|
||||||
|
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
|
||||||
|
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
|
||||||
|
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
|
||||||
|
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
|
||||||
|
Stück.)
|
||||||
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
||||||
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
||||||
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
|
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
|
||||||
|
|
||||||
Stand: 2026-07-03, geschrieben auf dem Pi (Plan gegen den echten Code verifiziert,
|
> **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
|
||||||
Umsetzung braucht PC + Android-Testgerät). Gehört zum offenen M1-Punkt
|
> Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
|
||||||
„Sichere Speicherung von Access-Token & Krypto-DB".
|
> 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die
|
||||||
|
> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem
|
||||||
|
> SQLCipher ab).
|
||||||
|
> **Android-Emulator-Test 2026-07-06 (API 36, x86_64, Debug-APK) GRÜN:**
|
||||||
|
> (a) frische Installation legt die DB direkt verschlüsselt an
|
||||||
|
> (Header = Zufallsbytes, per run-as verifiziert), kein Cipher-Fallback im
|
||||||
|
> auth_log; (b) Update-Szenario nach `pm clear` + untergeschobener
|
||||||
|
> Klartext-DB (synthetisch, 2 Tabellen/550 Zeilen): Migration lief beim
|
||||||
|
> ersten Start („2 Tabellen verifiziert" im auth_log), `.premigration`
|
||||||
|
> liegt da, DB danach verschlüsselt; (c) Neustart öffnet mit dem
|
||||||
|
> Keystore-Schlüssel OHNE Re-Migration, App rendert normal den
|
||||||
|
> Login-Screen. Damit sind openCipherOnAndroid, sqlcipher_export und
|
||||||
|
> flutter_secure_storage auf Android (Haupt-Isolate) real belegt.
|
||||||
|
> **Offen bleibt (deshalb Punkt 4 NICHT voll abgehakt):** echtes
|
||||||
|
> ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
|
||||||
|
> der Push-Test (FCM-Isolate liest verschlüsselte DB, secure_storage im
|
||||||
|
> Background-Isolate) – braucht ein Testgerät mit eingeloggtem Account.
|
||||||
|
> Erst danach Release und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries"
|
||||||
|
> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher
|
||||||
|
> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output
|
||||||
|
> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux
|
||||||
|
> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben
|
||||||
|
> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel).
|
||||||
|
|
||||||
## Für Bernd in einfach
|
## Für Bernd in einfach
|
||||||
|
|
||||||
|
|||||||
@@ -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