Compare commits
45
Commits
725f03abb0
...
v0.1.37
@@ -46,3 +46,6 @@ app.*.map.json
|
|||||||
autopilot.log
|
autopilot.log
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
|
||||||
|
*.bak-*
|
||||||
|
|||||||
+183
@@ -1309,3 +1309,186 @@
|
|||||||
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
|
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
|
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
|
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-05 19:03 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||||
|
2026-07-06 11:56 [Edit] /home/steggi/pyramid/autopilot.sh
|
||||||
|
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||||
|
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||||
|
2026-07-06 12:16 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||||
|
2026-07-06 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||||
|
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||||
|
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
|
||||||
|
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
|
||||||
|
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
|
||||||
|
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml
|
||||||
|
2026-07-06 12:22 [Write] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||||
|
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||||
|
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||||
|
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||||
|
2026-07-06 12:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
|
||||||
|
2026-07-06 12:30 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 12:31 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
|
||||||
|
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||||
|
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||||
|
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
|
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
|
||||||
|
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
|
||||||
|
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||||
|
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
|
||||||
|
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
|
||||||
|
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
|
||||||
|
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 12:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 16:58 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 16:59 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
|
||||||
|
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||||
|
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart
|
||||||
|
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||||
|
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||||
|
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
|
||||||
|
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 17:12 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
|
||||||
|
2026-07-06 17:13 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
|
||||||
|
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 17:41 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\0954ef5a-3f8c-4521-ac42-f4ee83e4873b\scratchpad\split_encryption.ps1
|
||||||
|
2026-07-06 17:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||||
|
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 17:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 17:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||||
|
2026-07-06 17:49 [Write] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||||
|
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||||
|
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||||
|
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 17:53 [Edit] C:\Users\nordm\pyramid - Kopie\test\settings_prefs_test.dart
|
||||||
|
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 18:19 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 18:22 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\b2380513-3ca6-4879-b9df-23f8ba907354\scratchpad\make_plain_db.dart
|
||||||
|
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
|
||||||
|
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 18:28 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 18:48 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_dirs.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
|
||||||
|
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
|
||||||
|
2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1
|
||||||
|
2026-07-06 22:04 [Write] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||||
|
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||||
|
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||||
|
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_ui\view\quality_menu.dart
|
||||||
|
2026-07-06 22:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||||
|
2026-07-06 22:16 [Write] C:\Users\nordm\pyramid - Kopie\test\streaming_presets_test.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_panel_core.dart
|
||||||
|
2026-07-06 22:26 [Write] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:26 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:31 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\6aa736cb-f5e2-471d-9167-7695c1c38655\scratchpad\win_tools.ps1
|
||||||
|
2026-07-06 22:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_list.dart
|
||||||
|
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\test-accounts-gui.md
|
||||||
|
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md
|
||||||
|
2026-07-06 23:13 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-06 23:23 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
|
||||||
|
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||||
|
2026-07-07 03:16 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\windows\runner\main.cpp
|
||||||
|
2026-07-07 03:19 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\mmprobe.cpp
|
||||||
|
2026-07-07 03:23 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\lib\main.dart
|
||||||
|
2026-07-07 03:24 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
|
||||||
|
2026-07-07 03:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||||
|
2026-07-07 03:29 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\win_tools.ps1
|
||||||
|
|||||||
+903
@@ -13,6 +13,882 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-07 (Teil 3) – Beta-Feedback umgesetzt: Push-Verlauf, Handy-UI, neues Aussehen → Test-Version 0.1.37
|
||||||
|
|
||||||
|
**Erledigt (je ein Commit, alles gepusht):**
|
||||||
|
- **Push als Chat-Verlauf** (ef1d881): MessagingStyle mit den letzten 8
|
||||||
|
Nachrichten pro Raum, voller Text (Deckel 1000 statt 100), Antwort-Zitat
|
||||||
|
entfernt, Medien-Labels, Bildvorschau (E2EE entschlüsselt, FileProvider),
|
||||||
|
Avatare, „Gelesen“ setzt echte Lesebestätigung. Emulator: Verlauf,
|
||||||
|
Platzhalter-Ersetzen, Bild, Avatar, Gelesen geprüft (DebugNotifyReceiver,
|
||||||
|
nur Debug-Build).
|
||||||
|
- **Handy** (a1d64ec): edge-to-edge statt immersiveSticky + SafeArea
|
||||||
|
(Statusleiste überdeckt nichts mehr); Bild-Vollansicht randlos, Tippen
|
||||||
|
daneben/Wischen schließt, Doppeltipp zoomt; Bilder im Chat bis halbe
|
||||||
|
Bildschirmhöhe, Videos im echten Seitenverhältnis.
|
||||||
|
- **UI-Umbau** (c5f1090): Farbmodus Dunkel (neues weiches Dunkelgrau,
|
||||||
|
Standard) / Schwarz (alt) / Hell; Schriftgrößen-Regler (Handy-Standard
|
||||||
|
115 % × Systemgröße); Nachrichten Automatisch/Sprechblasen/Liste;
|
||||||
|
Einstellungen auf dem Handy als Vollbild; formatierte Nachrichten
|
||||||
|
(Antworten!) ignorierten die Schriftgröße (RichText → Text.rich).
|
||||||
|
- **Chatliste** (c38e9c1): Handy-Kachel mit Avatar, Uhrzeit, Vorschau der
|
||||||
|
letzten Nachricht, Ungelesen-Zähler; sichtbare englische Texte eingedeutscht.
|
||||||
|
- **Werkzeuge:** `integration_test/mobile_ui_screens_test.dart` (echte App im
|
||||||
|
Handy-Format mit Testprofil, prüft Vollansicht-Gesten, Screenshots nach
|
||||||
|
`build/ui_screens/`), `test/live_ui_fixture_test.dart` (Testinhalte),
|
||||||
|
`scripts/test.ps1 -Ui`. 60 Unit-Tests grün.
|
||||||
|
- **Version 0.1.37+38**, release-signierte APKs lokal gebaut
|
||||||
|
(`build/app/outputs/flutter-apk/`), Signatur-Zertifikat identisch mit
|
||||||
|
v0.1.36 (SHA-256 c3de66be…), Release-APK startet auf dem Emulator ohne
|
||||||
|
Fehler. NICHT auf Gitea veröffentlicht (Bernd testet zuerst).
|
||||||
|
|
||||||
|
- **SDK-Logout-Wettlauf** geschlossen: `lib/core/session_logout.dart`
|
||||||
|
(`logoutWithoutStraySync`) an allen drei Abmelde-Stellen; Live-Test
|
||||||
|
`test/live_logout_race_test.dart` (Leerlauf + direkt nach Login, je 45 s
|
||||||
|
beobachtet). Ehrliche Einordnung: verspätete 401 tritt real auf, kam aber
|
||||||
|
stets vor Abschluss der Neuanmeldung – Risiko war klein. E2E-Live-Test nutzt
|
||||||
|
jetzt denselben App-Baustein.
|
||||||
|
|
||||||
|
- **Android-Backup aus** (allowBackup=false, fullBackupContent=false,
|
||||||
|
dataExtractionRules schließt Cloud-Backup UND Geräteumzug aus): verhindert
|
||||||
|
den Aussperr-Fall „verschlüsselte DB ohne Schlüssel auf neuem Handy“ und
|
||||||
|
den Push-Token im Cloud-Backup. Gradle 8.13 → 8.14.3.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Bernd testet 0.1.37 auf dem Handy (Push mit
|
||||||
|
mehreren Nachrichten + Bild, „Gelesen“, Statusleiste, Vollansicht-Gesten,
|
||||||
|
Sprechblasen, Schriftgröße). Danach ROADMAP: SDK-Logout-Wettlauf im
|
||||||
|
App-Pfad, Abhängigkeiten (sqlcipher_flutter_libs EOL, Gradle 8.13→8.14,
|
||||||
|
Kotlin-Plugin-Migration laut Flutter-Warnung).
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Simulierte Fensterklicks (PostMessage) erreichen
|
||||||
|
Flutter unter Windows nicht → UI-Prüfung über integration_test. (2) PowerShell
|
||||||
|
ohne DPI-Awareness bekommt bei 125 % Skalierung verkleinerte Fenstermaße →
|
||||||
|
abgeschnittene Screenshots (SetProcessDPIAware). (3) SharedPreferences sind
|
||||||
|
unter Windows NICHT pro PYRAMID_PROFILE_DIR getrennt – Testläufe teilen Bernds
|
||||||
|
Einstellungen (nur lesen, im UI-Test nichts speichern). (4) RichText
|
||||||
|
skaliert nicht mit MediaQuery.textScaler – immer Text.rich verwenden.
|
||||||
|
(5) apksigner braucht JAVA_HOME = Android-Studio-JBR (aus `flutter doctor -v`).
|
||||||
|
(6) Kein `shrinkImageMaxDimension: 0` an sendFileEvent (SDK verkleinert auf 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-07 (Teil 2) – Bernds Beta-Feedback analysiert, Plan steht (NOCH KEIN CODE)
|
||||||
|
|
||||||
|
**Bernds Feedback (Beta v0.1.36, Android):** Push kommt an, aber (a) beim
|
||||||
|
Aufklappen viel weniger Text als bei Element/WhatsApp, (b) immer nur EINE
|
||||||
|
Nachricht statt mehrerer, (c) keine Bildvorschau. Handy-UI gefällt nicht:
|
||||||
|
dunkle Farbe, Schrift zu klein, oben kein Platz (Statusleiste überlagert
|
||||||
|
Text), Bild-/Video-Vollansicht unnötig klein (durchsichtiger Rahmen), lässt
|
||||||
|
sich nicht durch Tippen ins Leere schließen. Wunsch: genereller UI-Overhaul;
|
||||||
|
danach Liste weiter, Fokus Stabilität + Flexibilität. Härtetest (PC_TESTPLAN 1)
|
||||||
|
und Call-Tests (3) kann er noch nicht machen.
|
||||||
|
|
||||||
|
**Bernds Entscheidungen (AskUserQuestion, 2026-10-07):** Farben = „Weiches
|
||||||
|
Dunkel“ (Dunkelgrau statt #0A0A0C, Hell bleibt Option); Nachrichten-
|
||||||
|
Darstellung = UMSCHALTBAR (Sprechblasen / Liste, Einstellung); Schrift =
|
||||||
|
größerer Standard (≈16 statt 14 px) + Regler unter Erscheinungsbild, System-
|
||||||
|
Schriftgröße weiter beachten.
|
||||||
|
|
||||||
|
**Ursachen (im Code verifiziert):**
|
||||||
|
- Push (b): `NotificationHelper.show` nutzt EINE notifId pro Raum
|
||||||
|
(`stableId(roomId)`) und ersetzt die alte Benachrichtigung komplett.
|
||||||
|
- Push (a): kein Style (nur `setContentText`) UND Dart kürzt hart auf 100
|
||||||
|
Zeichen (`_buildBody` in background_push.dart, `_watchEvents` +
|
||||||
|
`_decryptAndUpdateNotification` in notification_service.dart).
|
||||||
|
- Push (c): kein Bild-Pfad vorhanden.
|
||||||
|
- Statusleiste: `main.dart` setzt `SystemUiMode.immersiveSticky`, und
|
||||||
|
`_MobileShell` (app_shell.dart) hat kein SafeArea → Leiste liegt über
|
||||||
|
Kopfzeile (auch auf April-Screenshots sichtbar).
|
||||||
|
- Vollansicht: `media_viewer.dart` Inhalt = Bildschirm minus 24 px,
|
||||||
|
Barriere 70 % transparent; leere Fläche neben dem Bild gehört zum
|
||||||
|
GestureDetector (`_toggle`) → Tippen blendet nur Knöpfe aus.
|
||||||
|
- Zusatzbefunde: Knopf „Gelesen“ schließt nur, sendet KEINE Lesebestätigung;
|
||||||
|
`_decryptAndUpdateNotification` nimmt `room.lastEvent` statt des gepushten
|
||||||
|
Events; Access-Token liegt für PushService.kt im Klartext in
|
||||||
|
FlutterSharedPreferences (`notif_access_token`) – Sicherheitsnotiz.
|
||||||
|
|
||||||
|
**Plan / nächster konkreter Handgriff (je ein Commit):**
|
||||||
|
1. Push: Dart-Modul für Benachrichtigungs-Inhalt (Text ohne 100er-Kürzung,
|
||||||
|
Antwort-Zitat entfernt via `calcUnlocalizedBody(hideReply: true)`,
|
||||||
|
Medien-Labels, `notif_preview` auch im Hintergrund beachten) + Tests;
|
||||||
|
native `NotificationHelper` auf `MessagingStyle` (Verlauf pro Raum in
|
||||||
|
SharedPreferences, nur fortführen solange Benachrichtigung aktiv ist;
|
||||||
|
Ersetzen per eventId statt Anhängen beim Platzhalter→Klartext; neue
|
||||||
|
Nachricht = Alarm, Update = still); Bild: Dart lädt Thumbnail
|
||||||
|
(`downloadAndDecryptAttachment(getThumbnail: true)`) nach
|
||||||
|
`cacheDir/downloads/notif/`, native per FileProvider (`.fileprovider`,
|
||||||
|
Pfad `downloads/` existiert) + `grantUriPermission("com.android.systemui")`;
|
||||||
|
„Gelesen“ → echte Lesebestätigung (Haupt-Engine bzw. BgEngine `markRead`).
|
||||||
|
Test: Debug-only Receiver zum Auslösen auf dem Emulator (kein Login nötig),
|
||||||
|
dann Bernd auf echtem Gerät.
|
||||||
|
2. Handy: `edgeToEdge` statt immersiveSticky + SafeArea im MobileShell;
|
||||||
|
Vollansicht randlos/deckend schwarz, Tippen außerhalb des Bildes schließt,
|
||||||
|
Wischen nach unten schließt, Doppeltipp-Zoom.
|
||||||
|
3. UI-Overhaul nach Bernds Entscheidungen (Palette in theme.dart, globaler
|
||||||
|
TextScaler × Nutzer-Regler, Sprechblasen-Variante in chat/message/).
|
||||||
|
4. Danach ROADMAP-Liste (SDK-Logout-Wettlauf, Abhängigkeiten, …).
|
||||||
|
|
||||||
|
**Stolperfallen:** Android-Toolchain + Emulator `Medium_Phone_API_36.1`
|
||||||
|
laufen auf diesem PC (flutter doctor sauber). In die App auf dem Emulator
|
||||||
|
KEINE Zugangsdaten eintippen (echter Server) – UI im eingeloggten Zustand
|
||||||
|
per Windows-Build mit `PYRAMID_PROFILE_DIR=…\.pyramid-autopilot\profile1`
|
||||||
|
und schmalem Fenster (<900 px = Handy-Layout) prüfen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-07 – Wiederaufnahme nach Pause: Bestandsaufnahme, Test-Runner, Benchmarks, Live-Test stabilisiert
|
||||||
|
|
||||||
|
**Erledigt:**
|
||||||
|
- **Bestandsaufnahme:** Repo synchron mit Gitea (0 vor/0 hinter), Arbeitsbaum
|
||||||
|
war sauber, Toolchain unverändert (Flutter 3.44.4 / Dart 3.12.2).
|
||||||
|
`flutter analyze` sauber (nur bekannter onUpdate-Hinweis), 36 Unit-Tests
|
||||||
|
grün. Autopilot (PC + Pi) weiterhin pausiert.
|
||||||
|
- **Lokale Suche als Modul:** `_localMessageSearch` aus `search_modal.dart`
|
||||||
|
nach `lib/features/search/local_message_search.dart` verschoben – gleiche
|
||||||
|
Deckel (1500/Raum, 60 Treffer, 500er-Blöcke), gleiche Schleife, nur der
|
||||||
|
Medien-Filter kommt jetzt als Parameter. 5 Vertragstests
|
||||||
|
(`test/local_message_search_test.dart`) auf der echten SDK-DB-Schicht.
|
||||||
|
- **Benchmarks** (`benchmark/`, `docs/TESTS_UND_BENCHMARKS.md`): Klartext vs.
|
||||||
|
SQLCipher (Sync-Schreiben, App-Start, Chat öffnen, Suche) + Migrationsdauer;
|
||||||
|
JSON-Berichte, Baseline in `benchmark/baseline/`, Regressionswarnung >25 %.
|
||||||
|
Ergebnis: SQLCipher-Aufschlag im Rauschen (±5–10 %), Migration ≈ 42 ms/MB,
|
||||||
|
Suche (Worst Case) 64 ms für 10 000 Nachrichten.
|
||||||
|
- **`scripts/test.ps1`:** Analyse + Unit-Tests (+ `-Live`, `-Bench`, `-All`,
|
||||||
|
`-UpdateBaseline`) mit Zusammenfassung; baut die SQLCipher-DLL bei Bedarf.
|
||||||
|
- **Live-Test sporadisch rot → Ursache gefunden, Test stabilisiert:** siehe
|
||||||
|
Befund unten. `live_encryption_e2e_test.dart` meldet sich jetzt über
|
||||||
|
`_logoutWithoutStraySync` ab (Sync anhalten, laufenden Long-Poll
|
||||||
|
auslaufen lassen, dann Logout). Danach 3 Läufe in Folge grün (Ergebnis
|
||||||
|
siehe unten).
|
||||||
|
|
||||||
|
**Befund (neu in ROADMAP, M1-Bereich):** matrix 6.2 `_innerSync` wirft den
|
||||||
|
Fehler einer Sync-Antwort, bevor geprüft wird, ob die Anfrage noch aktuell
|
||||||
|
ist. Die 401 eines alten Long-Polls nach einem Logout löst ein erneutes
|
||||||
|
`clear()` aus – nach einem schnellen Re-Login auf demselben Client würde das
|
||||||
|
die neue Session löschen. Im Test trat es als „databaseFactory not
|
||||||
|
initialized" nach `dispose()` auf. Upstream `main` unverändert (geprüft).
|
||||||
|
App-Logout-Pfad noch NICHT angefasst (heilig, braucht Gerätetest).
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Bernds Rückmeldungen aus der Beta-Testphase
|
||||||
|
(v0.1.36) einsammeln, dann Prioritäten setzen: Klick-Tests aus
|
||||||
|
`docs/PC_TESTPLAN.md`, App-Logout-Pfad gegen den SDK-Wettlauf prüfen,
|
||||||
|
Abhängigkeits-Updates (ROADMAP M2, Achtung `sqlcipher_flutter_libs` EOL).
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) sqflite liefert für denselben Pfad dieselbe
|
||||||
|
Verbindung zurück – ein zweites Öffnen+Schließen kappt die erste
|
||||||
|
(Benchmark-„database_closed"). (2) Benchmark-Reihenfolge: der Dart-JIT wird
|
||||||
|
während des Laufs schneller, der zuerst gemessene Modus verlor ±20 % →
|
||||||
|
großer Aufwärm-Durchlauf + A-B-B-A. (3) `dart format` NUR auf neue Dateien
|
||||||
|
anwenden – Bestandsdateien sind nicht formatiert, sonst riesiger Diff.
|
||||||
|
(4) Vor einer Aussage nachmessen: Schreibkosten hängen NICHT nennenswert von
|
||||||
|
der Raumgröße ab (250 vs. 4000 Nachrichten/Raum ≈ +15 %).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Nacht-Session (C+B): 60-FPS-Streaming + Call mit ZWEI echten Test-Accounts real durchgeklickt
|
||||||
|
|
||||||
|
**Erledigt:** Fokus (C) Streaming und (B) Calls – beides mit zwei echten
|
||||||
|
Test-Logins (pyramidtest1 GUI-Fenster links, pyramidtest2 rechts, zwei
|
||||||
|
getrennte Profile über `PYRAMID_PROFILE_DIR`) am laufenden Windows-Build
|
||||||
|
verifiziert, nicht nur analysiert.
|
||||||
|
|
||||||
|
**(C) 60-FPS-Streaming (`docs/STREAMING_60FPS.md`, Commit ffd70ca):**
|
||||||
|
- Neue Presets `hd60` (720p·60, 5 Mbit/s) und `fhd60` (1080p·60, 8 Mbit/s)
|
||||||
|
in `qualityPresets` + `screenShareEncodings`; im SENDEN-Menü als
|
||||||
|
„HD 60fps"/„Full HD 60fps" ergänzt. Preset-Auflösung UND -FPS werden
|
||||||
|
jetzt bis in `ScreenShareCaptureOptions.params` durchgereicht (vorher
|
||||||
|
blieb der LiveKit-Default 1080p/15 aktiv, egal welches Preset). 60-FPS-
|
||||||
|
Modi publishen ohne Simulcast mit
|
||||||
|
`DegradationPreference.maintainFramerate` (unter Last Auflösung statt
|
||||||
|
Frames opfern). `contentHint='motion'` ist in flutter_webrtc 1.4.1 aus
|
||||||
|
Dart NICHT setzbar (Pub-Cache geprüft) – im Code ehrlich dokumentiert.
|
||||||
|
4 Vertragstests für die Preset-Tabellen (grün).
|
||||||
|
- **Real getestet:** pyramidtest1 teilt Bildschirm → pyramidtest2 sieht den
|
||||||
|
Stream (und umgekehrt, beide Richtungen). Über das SENDEN-Menü live auf
|
||||||
|
„Full HD 60fps" umgeschaltet: Screenshare-Track wird mit dem neuen
|
||||||
|
Encoding neu published, kein Absturz, Empfänger bekommt weiter Bild.
|
||||||
|
- **UNGETESTET (ehrlich):** subjektives „flüssiges 60-FPS-Gefühl" –
|
||||||
|
Sender und Empfänger liefen auf DEMSELBEN PC (Selbst-Streaming =
|
||||||
|
Hall-of-Mirrors), das taugt nicht zur Ruckel-Beurteilung. Ob
|
||||||
|
`sender.parameters.encodings` real `maxFramerate==60` trägt, ist nur am
|
||||||
|
Preset-Vertrag belegt, nicht am Live-RTP-Report. Braucht zwei echte
|
||||||
|
Geräte + Netz.
|
||||||
|
|
||||||
|
**(B) Call-Robustheit (Commit siehe git log):**
|
||||||
|
- **Bug gefunden + gefixt:** Standalone-Voice-Channels (nicht in einem
|
||||||
|
Space, sondern in der normalen Raumliste) waren per Klick GAR NICHT
|
||||||
|
beitretbar – `rooms_list.dart` öffnete für sie nur die Chat-Ansicht.
|
||||||
|
Jetzt tritt ein Klick dem Kanal bei/verlässt ihn (Toggle), exakt wie in
|
||||||
|
der Space-Liste (`_toggleVoiceChannel`). Real verifiziert: beide
|
||||||
|
Test-User beigetreten, Teilnehmerliste zeigt beide, Zähler „2".
|
||||||
|
- **Zustands-Härtung `livekit_call_manager.dart`:** (1) `_callGeneration`
|
||||||
|
entwertet einen noch laufenden `connect`, wenn zwischendurch aufgelegt/
|
||||||
|
gewechselt wird – vorher konnte eine Geisterverbindung mit offenem Mikro
|
||||||
|
im alten Kanal zurückbleiben, während die UI „nicht verbunden" zeigte.
|
||||||
|
(2) Fehlerpfade setzen `error` sichtbar (unerwarteter
|
||||||
|
`RoomDisconnectedEvent` mit Grund, fehlgeschlagener Beitritt) statt
|
||||||
|
still aus dem Kanal zu verschwinden – CLAUDE.md „keine stillen
|
||||||
|
Fehlerpfade". (3) `rooms_panel_core.dart` hört auf den Voice-Service und
|
||||||
|
räumt `activeVoiceRoomIdProvider` auf, sobald der Service inaktiv wird
|
||||||
|
(Kanal-Highlight/eigener Teilnehmer-Eintrag blieben sonst hängen),
|
||||||
|
und zeigt den Fehler als SnackBar.
|
||||||
|
- **Real getestet:** Beitreten (beide), Teilnehmerliste, Screenshare
|
||||||
|
bidirektional, Qualitätswechsel live, sauberes Verlassen (pyramidtest1
|
||||||
|
legt auf → bei test1 Voice-Widget weg + Kanal nicht mehr aktiv, bei
|
||||||
|
test2 Teilnehmerzähler auf 1, test1-Stream verschwunden).
|
||||||
|
- **Setup-Helfer `test/live_call_setup_test.dart`** (gegated wie die
|
||||||
|
Encryption-Tests): legt den Voice-Testraum an und bringt beide Test-User
|
||||||
|
rein, damit die zwei GUI-Instanzen sofort joinen können.
|
||||||
|
- **UNGETESTET (ehrlich):** echtes 2-Peer-AUDIO (beide auf einem PC =
|
||||||
|
Rückkopplung, nicht sinnvoll prüfbar). VerbindungsABBRUCH mitten im Call
|
||||||
|
(Netz weg → `RoomDisconnectedEvent`-Pfad mit meiner Fehlermeldung) nur
|
||||||
|
im Code abgesichert, nicht live simuliert – braucht kontrollierten
|
||||||
|
Netzausfall. M3-Punkt „Voice-Channel-Logik robust" daher nur annotiert,
|
||||||
|
nicht abgehakt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check des Beobachtungslaufs (Bernds
|
||||||
|
echtes Profil neu gestartet 22:49:22 `loggedIn`, keine Re-Migration) –
|
||||||
|
Einträge seit 2026-07-06 22:49 prüfen. Für Bernd: 60-FPS-Streaming auf zwei
|
||||||
|
ECHTEN Geräten übers Netz gegentesten (Ruckeln?), echtes 2-Peer-Audio.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) `PYRAMID_PROFILE_DIR` leitet DB/Media um, aber NICHT
|
||||||
|
flutter_secure_storage – zwei Profile teilen sich denselben
|
||||||
|
`db_cipher_key`. Für reine Login-Tests unkritisch (Schlüssel wird nur
|
||||||
|
gelesen). (2) Der SENDEN-Qualitäts-Zahnrad taucht erst beim Hovern über den
|
||||||
|
Hero-Videobereich auf (Bottom-Bar slidet ein). (3) Eine per
|
||||||
|
`flutter build` neu gebaute exe lässt sich nicht überschreiben, solange die
|
||||||
|
Beobachtungs-exe läuft – vorher beenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Nacht-Session (A): SQLCipher mit ECHTEM Login live verifiziert (Test-Account, 2 neue E2E-Tests grün)
|
||||||
|
|
||||||
|
**Erledigt:** Bernds Fokus (A) für die Nacht – die Verschlüsselung nicht nur
|
||||||
|
behaupten, sondern mit einem echten Konto belegen. Bernd hat dafür
|
||||||
|
Test-Accounts bereitgestellt (`~/.pyramid-autopilot/test-accounts.txt`,
|
||||||
|
pyramidtest1/pyramidtest2 auf steggi-matrix.work – NICHT Bernds/Utas Konten).
|
||||||
|
Neuer Live-Test `test/live_encryption_e2e_test.dart` (nur auf Windows, nur
|
||||||
|
mit `PYRAMID_LIVE_TEST=1`, Zugangsdaten bleiben AUSSERHALB des Repos):
|
||||||
|
|
||||||
|
- **Test 1 – frische Installation (grün, echter Lauf gegen den Homeserver):**
|
||||||
|
DB verschlüsselt angelegt (Schlüssel-Injektion wie in den Bestands-Tests),
|
||||||
|
echter Passwort-Login als pyramidtest1, Testraum angelegt, Nachricht mit
|
||||||
|
Zufalls-Marker gesendet. Danach Byte-Prüfung der Platte: Header ist NICHT
|
||||||
|
„SQLite format 3", und weder der echte Access-Token noch der
|
||||||
|
Nachrichtentext sind in `pyramid.sqlite`/`-wal` im Klartext auffindbar.
|
||||||
|
Gegenprobe: Raw-Query MIT Schlüssel findet beides in der Datei → die Daten
|
||||||
|
stecken wirklich drin, nur verschlüsselt. „Neustart" (DB neu geöffnet,
|
||||||
|
neuer Client): Session inkl. identischem Access-Token kommt aus der
|
||||||
|
verschlüsselten DB, ohne Passwort. Dann Logout → erneuter Login → alte
|
||||||
|
Nachricht wieder lesbar (Timeline inkl. History-Nachladen).
|
||||||
|
- **Test 2 – Utas Update-Fall mit ECHTER Session (grün):** Klartext-DB wie
|
||||||
|
vor der SQLCipher-Einführung, echter Login, Nachricht. POSITIV-KONTROLLE:
|
||||||
|
in der Klartext-DB findet die Byte-Suche Token UND Nachricht (beweist,
|
||||||
|
dass die Suche Leaks erkennen würde). Dann `migratePlaintextToEncrypted`
|
||||||
|
(exakt der App-Codepfad): Migration grün, `.premigration`-Backup da,
|
||||||
|
danach ist auf der Platte nichts mehr im Klartext, Session (gleicher
|
||||||
|
Access-Token) und Nachricht überleben die Migration. Das war bisher der
|
||||||
|
große UNGETESTET-Punkt „Migration mit echter eingeloggter Session".
|
||||||
|
- Beide Tests räumen serverseitig auf (Logout am Ende). Gesamtsuite: 32
|
||||||
|
Tests grün, die 2 Live-Tests skippen ohne Gate sauber. analyze sauber
|
||||||
|
(nur bekannter onUpdate-Hinweis).
|
||||||
|
|
||||||
|
**Ehrlich abgegrenzt (bleibt UNGETESTET):** flutter_secure_storage wird im
|
||||||
|
Host-Test umgangen (Schlüssel direkt injiziert; der Keystore-Pfad ist durch
|
||||||
|
die echten App-Läufe Windows + Android-Emulator belegt). E2EE-verschlüsselte
|
||||||
|
Räume kann der Host-Test nicht (kein vodozemac) – der Testraum ist
|
||||||
|
unverschlüsselt, was für die Klartext-Suche der härtere Fall ist. Echtes
|
||||||
|
ARM-Gerät + FCM-Push-Isolate bleiben offen (ROADMAP-Punkt weiter nicht
|
||||||
|
abgehakt).
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Fokus (C) Streaming 60 FPS
|
||||||
|
(`docs/STREAMING_60FPS.md`), danach Fokus (B) Call-Logik mit beiden
|
||||||
|
Test-Accounts.
|
||||||
|
|
||||||
|
**Stolperfallen:** `Client.init(waitUntilLoadCompletedLoaded: false)` lädt
|
||||||
|
die Raumliste ASYNCHRON – wer direkt nach dem Init `client.rooms`/
|
||||||
|
`getRoomById` prüft, sieht eine leere Liste und meldet fälschlich
|
||||||
|
Datenverlust. Für DB-Restaurations-Checks `waitUntilLoadCompletedLoaded:
|
||||||
|
true` verwenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – SQLCipher auf ANDROID getestet (Emulator API 36): Neuanlage, Migration und Neustart grün
|
||||||
|
|
||||||
|
**Erledigt:** Der größte offene Teil von Testplan Punkt 4
|
||||||
|
(`docs/SQLCIPHER_MIGRATION.md`) ist jetzt real auf Android belegt – kein
|
||||||
|
Gerät angeschlossen, aber der vorhandene Emulator (Medium Phone API 36.1,
|
||||||
|
x86_64, MIT Play Services) reicht für die DB-Schicht:
|
||||||
|
|
||||||
|
- **Szenario A – frische Installation:** Debug-APK installiert, App
|
||||||
|
gestartet → `pyramid.sqlite` wird von Anfang an VERSCHLÜSSELT angelegt
|
||||||
|
(Header = Zufallsbytes statt „SQLite format 3", per `run-as`/`od`
|
||||||
|
verifiziert); auth_log ohne Cipher-Fallback oder Fehler.
|
||||||
|
- **Szenario B – Update-Szenario (Utas Fall):** `pm clear` (auch der
|
||||||
|
Keystore-Eintrag weg) + synthetische Klartext-DB (2 Tabellen, 550
|
||||||
|
Zeilen) VOR dem ersten Start in `files/` gelegt → beim Start lief die
|
||||||
|
echte Migration: auth_log „Klartext-DB erfolgreich nach SQLCipher
|
||||||
|
migriert (2 Tabellen verifiziert)", `.premigration`-Backup liegt da,
|
||||||
|
DB danach verschlüsselt, App lief normal weiter (Login-Screen,
|
||||||
|
Screenshot geprüft). **Bewusst NICHT mit Bernds echter DB getestet** –
|
||||||
|
die enthält seine Session/Olm-Account, ein Duplikat auf einem zweiten
|
||||||
|
„Gerät" würde den E2EE-State korrumpieren.
|
||||||
|
- **Szenario C – Neustart:** force-stop + Relaunch → verschlüsselte DB
|
||||||
|
öffnet mit dem Schlüssel aus dem Android-Keystore, KEINE zweite
|
||||||
|
Migration, kein Fehler. Damit sind `openCipherOnAndroid`,
|
||||||
|
`sqlcipher_export` und flutter_secure_storage auf Android
|
||||||
|
(Haupt-Isolate) praktisch belegt.
|
||||||
|
- Doku nachgezogen: Status-Kopf `docs/SQLCIPHER_MIGRATION.md`,
|
||||||
|
ROADMAP-M1-Vermerk. Emulator danach heruntergefahren. Nebenbei:
|
||||||
|
`android/gradle.properties` bekam beim APK-Build automatisch zwei
|
||||||
|
Flutter-Migrator-Flags (`builtInKotlin=false`, `newDsl=false` – pinnt
|
||||||
|
bestehendes Verhalten), mit committet.
|
||||||
|
|
||||||
|
**UNGETESTET bleibt (ROADMAP-Punkt weiter NICHT abgehakt):** echtes
|
||||||
|
ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
|
||||||
|
der Push-Test (FCM-Background-Isolate liest verschlüsselte DB,
|
||||||
|
secure_storage im Background-Isolate) – braucht ein Testgerät mit
|
||||||
|
eingeloggtem Account (Login-Daten hat nur Bernd). Erst danach Release
|
||||||
|
und Utas Gerät.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check des weiterlaufenden
|
||||||
|
Windows-Beobachtungslaufs (Einträge seit 2026-07-06 18:29; Check 18:29
|
||||||
|
ergab einen zweiten kurzen Netz-Blip 18:25:56, ~40 s, wieder selbst
|
||||||
|
erholt ohne Logout – im PC_TESTPLAN eingetragen). Für Bernd unverändert:
|
||||||
|
PC_TESTPLAN 1 (Rest) + 2–8 durchklicken; fürs SQLCipher-Finale ein
|
||||||
|
echtes Android-Gerät anstecken (Debug-Build reicht, Ablauf wie hier im
|
||||||
|
Emulator). Falls Bernd dem Autopilot einen TEST-Account (eigener User
|
||||||
|
auf steggi-matrix.work, nicht Bernds/Utas) bereitstellt, könnte der
|
||||||
|
Emulator-Test zusätzlich echten Login + Session-Migration + FCM-Push
|
||||||
|
abdecken – als Frage notiert, nicht eigenmächtig anlegen.
|
||||||
|
|
||||||
|
**Fragen an Bernd:** Soll ich für künftige Emulator-/Gerätetests einen
|
||||||
|
eigenen Matrix-TEST-Account bekommen (Nutzername+Passwort, z. B.
|
||||||
|
@pyramidtest)? Damit wären Login-Flow, Migration mit echter Session und
|
||||||
|
Push auf Android ohne Risiko für echte Konten automatisiert testbar.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Ein Dart-Skript außerhalb des Repos kann
|
||||||
|
`package:`-Imports nicht auflösen – Hilfsskripte kurz ins Repo legen
|
||||||
|
(`tool/`, untracked) und nach dem Lauf löschen. (2) Für Datei-Zugriff im
|
||||||
|
App-Sandbox-Verzeichnis braucht es eine DEBUG-APK (`run-as` geht nur bei
|
||||||
|
debuggable); das Emulator-Image hat kein `sqlite3`-Binary – Test-DBs
|
||||||
|
also am PC bauen und pushen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – auth_log-Check 17:43-Fenster: echter ~7-min-Netzausfall OHNE Logout überstanden (stärkster Positiv-Befund bisher)
|
||||||
|
|
||||||
|
**Erledigt:** Vierte PC-Session, zuerst der Pflicht-Check der Vorsession
|
||||||
|
(auth_log seit 17:43), Ergebnis im PC_TESTPLAN Abschnitt 1 eingetragen:
|
||||||
|
|
||||||
|
- **Fenster 17:43–18:19:** `loggedIn` um 17:43:14, dann um 18:09:57 ein
|
||||||
|
ECHTER Verbindungsabriss zum Homeserver – „Syncloop failed: Client has
|
||||||
|
not connection to the server" alle 3 s bis 18:16:39 (~7 Minuten;
|
||||||
|
Server war zum Check-Zeitpunkt von diesem PC wieder erreichbar,
|
||||||
|
HTTP 200). **Kein Logout, keine Eskalation** – der Sync erholte sich
|
||||||
|
von selbst (WAL-Write 18:18:13 NACH der letzten Fehlerzeile, Prozess
|
||||||
|
durchgehend am Leben). Das ist genau der Fehlerpfad, den die
|
||||||
|
Uta-Logout-Fixes (63e4919/9dc83f7/891f348) absichern sollen, erstmals
|
||||||
|
mit einem realen Netzausfall belegt.
|
||||||
|
- Beobachtungslauf (PID seit 17:43) läuft unangetastet weiter; kumulativ
|
||||||
|
heute 12:26–18:19 über 7 Starts ohne einen einzigen Random-Logout.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** SQLCipher-Android-Test per Emulator
|
||||||
|
(Medium Phone API 36.1 ist verfügbar, kein echtes Gerät angeschlossen) –
|
||||||
|
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`, so weit im Emulator
|
||||||
|
abbildbar; Rest (echtes Gerät, FCM-Push-Isolate) klar abgrenzen.
|
||||||
|
|
||||||
|
**Stolperfallen:** Erfolgreiche Syncs werden NICHT geloggt – das Ende
|
||||||
|
einer Fehlerserie im auth_log erkennt man am Ausbleiben weiterer
|
||||||
|
Fehlerzeilen plus einem WAL-Write danach, nicht an einem „recovered"-Eintrag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Analyse-Korrektur (MediaCache NICHT risikoarm) + VoicePrefs-Vertragstests, Session-Ende sauber
|
||||||
|
|
||||||
|
**Erledigt:** Abschluss der dritten PC-Session:
|
||||||
|
|
||||||
|
- **Ehrlichkeits-Korrektur im State-Management-Doc (3300a44):** Die
|
||||||
|
Einstufung „MediaCache hinter Provider = risikoarmer Lückenfüller" war
|
||||||
|
FALSCH – sie beruhte auf einem durch `head_limit` abgeschnittenen grep.
|
||||||
|
Tatsächlich nutzt auch `message_media.dart` den Cache (7 Stellen, inkl.
|
||||||
|
statischem `_DocBytesCache`-Wrapper ohne ref-Zugang), und genau dieser
|
||||||
|
Chat-Timeline-Code ist laut PC_TESTPLAN 3 der am wenigsten geprüfte
|
||||||
|
Teil. Schritt als ZURÜCKGESTELLT markiert (gehört zum
|
||||||
|
chat_timeline-Modul, Schnitt-Doc 9); ROADMAP-Vermerk angepasst.
|
||||||
|
- **VoicePrefs-Vertragstests (3698048):** 3 neue Tests in
|
||||||
|
`test/settings_prefs_test.dart` nageln die dokumentierten Garantien der
|
||||||
|
gestrigen/heutigen Fassade fest (historische voice_*-Keys unverändert →
|
||||||
|
Utas Bestands-Einstellungen bleiben erhalten; null/leer LÖSCHT das Pref
|
||||||
|
statt leeren String zu speichern; Fallback-Lautstärke 1.0). Gesamt jetzt
|
||||||
|
**32 Tests grün**, analyze sauber.
|
||||||
|
- **auth_log-Endkontrolle 17:54:** Beobachtungslauf seit 17:43 unauffällig
|
||||||
|
(keine neuen Einträge, DB-Writes laufen, Prozess lebt) – App bleibt über
|
||||||
|
das Session-Ende hinaus als Langzeit-Beobachtung laufen.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
|
||||||
|
17:43) in der nächsten Session – das Fenster wächst jetzt ungestört, kein
|
||||||
|
weiterer Neustart geplant. Ohne Bernds Klick-Tests (PC_TESTPLAN 1 Rest,
|
||||||
|
2–8) ist am PC nichts Risikoarmes mehr offen; danach: State-Management
|
||||||
|
Schritte 1–2 (docs/M2_STATE_MANAGEMENT.md) bzw. SQLCipher-Gerätetest
|
||||||
|
(Android, `docs/SQLCIPHER_MIGRATION.md` Punkt 4).
|
||||||
|
|
||||||
|
**Stolperfallen:** grep-Werkzeuge mit Treffer-Limit (`head_limit`)
|
||||||
|
schneiden still ab – vor Architektur-Aussagen („X wird nur von Y benutzt")
|
||||||
|
die Trefferliste IMMER ohne Limit bzw. mit Vollständigkeits-Check erheben.
|
||||||
|
Der Fehler stand schon committet im Doc und hätte die nächste Session in
|
||||||
|
einen „risikoarmen" Umbau am ungeprüftesten Code der App gelockt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – State-Management-Ist-Analyse (docs/M2_STATE_MANAGEMENT.md) + toter incomingCallProvider entfernt
|
||||||
|
|
||||||
|
**Erledigt:** Nächster offener M2-Punkt „Einheitliches State-Management" –
|
||||||
|
nach dem erprobten Vorgehen erst die Analyse, Umsetzung folgt gestaffelt:
|
||||||
|
|
||||||
|
- **`docs/M2_STATE_MANAGEMENT.md` geschrieben:** Repo-weite Bestandsaufnahme
|
||||||
|
(Riverpod schon dominant, ~50 Provider, keine ValueNotifier/eigenen
|
||||||
|
StreamController). 4 Misch-Patterns identifiziert und gewichtet:
|
||||||
|
(1) Call-Singletons + ChangeNotifier hinter den Pilot-Fassaden mit
|
||||||
|
UI-Zustand IN den Managern, (2) `activeVoiceRoomIdProvider`/
|
||||||
|
`voiceParticipantsProvider` liegen in app_state.dart statt im
|
||||||
|
voice_channel-Modul, (3) `MediaCache.instance`, (4) Push-Globals
|
||||||
|
(bewusst so, Isolate-Zwang – KEIN Umbau-Kandidat). Zielbild +
|
||||||
|
Schrittfolge mit klarem Gating: Schritte 1–2 (Call-Pfad) GESPERRT bis
|
||||||
|
Bernds Klick-Test des Piloten; frei ist nur MediaCache-hinter-Provider.
|
||||||
|
- **Toter `incomingCallProvider` entfernt (41bcaf2):** einzige Fundstelle
|
||||||
|
repo-weit war die Definition in app_state.dart – Überbleibsel des
|
||||||
|
Call-Piloten (Provider ersatzlos in die Module verlegt). analyze sauber,
|
||||||
|
29 Tests grün.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
|
||||||
|
17:43). Umsetzbar ohne Bernd wäre noch Schritt 3 aus dem neuen Doc
|
||||||
|
(MediaCache hinter Provider); Schritte 1–2 erst nach dem Klick-Test.
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine – Analyse + risikofreie Leichen-Entfernung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – auth_log-Check 17:14-Fenster sauber + settings_encryption.dart in 4 part-Dateien geteilt (6128953)
|
||||||
|
|
||||||
|
**Erledigt:** Dritte PC-Session des Tages. Zuerst der von der Vorsession
|
||||||
|
hinterlassene Pflicht-Check, dann der nächste risikoarme M2-Schritt:
|
||||||
|
|
||||||
|
- **auth_log-Check (PC_TESTPLAN Abschnitt 1 aktualisiert):** Fenster
|
||||||
|
2026-07-06 17:14–17:42 sauber – einziger Eintrag ist das `loggedIn` des
|
||||||
|
17:14-Starts, kein Logout, kein Fehler, DB-Writes liefen durchgehend
|
||||||
|
(WAL zuletzt 17:37 beim Check). Lauf um 17:42 vom Autopilot beendet
|
||||||
|
(Rebuild nötig). Kumulativ heute 12:26–17:42 über 6 Starts ohne einen
|
||||||
|
Random-Logout; für „mehrere Stunden am Stück" bleibt der Punkt offen.
|
||||||
|
- **`settings_encryption.dart` (1470 Zeilen, größte verbliebene
|
||||||
|
Settings-Teildatei) nach dem 7-fach erprobten Muster geteilt (6128953):**
|
||||||
|
4 part-Dateien unter `lib/widgets/settings/encryption/` –
|
||||||
|
`megolm_crypto` (Export/Import-Krypto-Helfer, PBKDF2/AES-CTR),
|
||||||
|
`encryption_section` (Enums + _EncryptionSection),
|
||||||
|
`recovery_key_dialogs` (_ChangeRecoveryKeyDialog/_ShowKeyBody/
|
||||||
|
_ConfirmKeyBody), `fingerprint_passphrase` (_FingerprintGroup/
|
||||||
|
_PassphraseDialog). Das ist Vorbereitung für Schnitt-Doc Schritt 3
|
||||||
|
(verification-Modul), OHNE den Verschlüsselungs-heiligen Code
|
||||||
|
anzufassen: Skript-Split mit Rekonstruktions-Verifikation, alle 1468
|
||||||
|
Code-Zeilen ZEICHENIDENTISCH (ordinaler Vergleich), keine Zeile geändert.
|
||||||
|
- **Geprüft:** analyze sauber (nur bekannter onUpdate-Hinweis), 29 Tests
|
||||||
|
grün, `flutter build windows --debug` + entkoppelter App-Start: auth_log
|
||||||
|
17:43:14 `loggedIn`, keine erneute Migration, kein Fehler. Beobachtung
|
||||||
|
läuft seit 17:43 mit dem neuen Code.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
|
||||||
|
17:43). Klick-Tests (PC_TESTPLAN 1 Rest + 2–8) weiter bei Bernd – der
|
||||||
|
Verschlüsselungs-Reiter (Abschnitt 2) deckt jetzt zugleich diesen Split ab.
|
||||||
|
Verification-Modul (Schnitt-Doc 3) NICHT ohne Bernd anfangen
|
||||||
|
(2-Geräte-Test nötig); UI-State-Herauslösen wartet auf den Klick-Test.
|
||||||
|
|
||||||
|
**Stolperfallen:** Ein Debug-Rebuild kann die laufende exe nicht
|
||||||
|
überschreiben – den entkoppelten Beobachtungs-Lauf vorher gezielt beenden
|
||||||
|
(Stop-Process), das Fenster im PC_TESTPLAN sauber abschließen und nach dem
|
||||||
|
Build neu starten; sonst schlägt der Build mit gesperrter Datei fehl.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – call_ui/voice_channel.dart in 5 part-Dateien geteilt (letzte Gott-Datei der Call-Schicht, 95d9b07)
|
||||||
|
|
||||||
|
**Erledigt:** Die beim M0-Aufräumen bewusst zurückgestellte Datei
|
||||||
|
`voice_channel.dart` (1123 Zeilen – wartete auf die Call-Fassaden-
|
||||||
|
Entscheidung, die seit dem Piloten gefallen ist) ist nach dem 6-fach
|
||||||
|
erprobten Muster geteilt: Owner (19 Zeilen, Imports + part-Direktiven) +
|
||||||
|
5 thematische part-Dateien unter `lib/features/call_ui/view/`
|
||||||
|
(`voice_channel_core` = VoiceChannelView/_ParticipantData, `call_view` =
|
||||||
|
_UnifiedCallView/Header, `participant_layout` = Grid/Hero/Strip/Tile,
|
||||||
|
`avatars_controls` = Avatare/_CallControls/_NoCall/Fullscreen,
|
||||||
|
`quality_menu` = ScreenShare-/Qualitäts-Menüs/_VoiceCtrlBtn).
|
||||||
|
|
||||||
|
- **Beweis:** Skript-Split mit Rekonstruktions-Verifikation – Header +
|
||||||
|
Teile sind ZEICHENIDENTISCH zum Original (1123 Zeilen, case-sensitiver
|
||||||
|
Zeilenvergleich). Keine einzige Code-Zeile geändert.
|
||||||
|
- **Geprüft:** analyze sauber (nur bekannter onUpdate-Hinweis), 29 Tests
|
||||||
|
grün, Build + App-Start ok (auth_log 17:14:31 `loggedIn`); App läuft
|
||||||
|
seit 17:14 wieder entkoppelt als Langzeit-Beobachtung.
|
||||||
|
- `app_shell.dart` (1073 Zeilen) bleibt bewusst ungeteilt –
|
||||||
|
Bootstrap/Login-Pfad ist heilig, kein Anlass.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
|
||||||
|
17:14). Danach wartet die Call-Schicht auf Bernds Klick-Test
|
||||||
|
(PC_TESTPLAN Abschnitt 8); nächster risikoarmer M2-Kandidat wäre
|
||||||
|
Schnitt-Doc Schritt 3 (verification-Modul) – aber Verifikations-Flows
|
||||||
|
sind Verschlüsselungs-heilig und brauchen einen 2-Geräte-Test, daher
|
||||||
|
nicht ohne Bernd anfangen.
|
||||||
|
|
||||||
|
**Stolperfallen:** In PowerShell bindet das Komma stärker als Minus:
|
||||||
|
`@(999, $l.Count - 1)` ist `(999, $l.Count) - 1` (Array-Minus = Fehler) –
|
||||||
|
immer `($l.Count - 1)` klammern. Und `Measure-Object -Line` zählt
|
||||||
|
Leerzeilen NICHT mit – für echte Zeilenzahlen `$array.Count` nehmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – M2-Call-Schicht, zwei weitere Teilschritte: call_ui-Modul + VoicePrefs-Fassade (verhaltensgleich)
|
||||||
|
|
||||||
|
**Erledigt:** Nach dem auth_log-Check die zwei risikoärmsten der offenen
|
||||||
|
inneren Aufräumarbeiten der Call-Schicht (ROADMAP „Call-Schicht
|
||||||
|
entwirren", `docs/M2_MODULE_SCHNITT.md`):
|
||||||
|
|
||||||
|
- **`call_ui` als Modul (b2335da):** `lib/features/call/` →
|
||||||
|
`lib/features/call_ui/` (git mv, 100 % rename), Importe in den einzigen
|
||||||
|
zwei Verwendern (`rooms_panel.dart`, `app_shell.dart`) angepasst. Die
|
||||||
|
beiden UI-Dateien konsumierten seit dem Pilot schon ausschließlich die
|
||||||
|
Service-Interfaces – damit ist Pilot-Schritt 1 aus dem Schnitt-Doc
|
||||||
|
(call_signaling + voice_channel + call_ui) strukturell komplett.
|
||||||
|
- **VoicePrefs-Fassade (d22f9c2):** Die 4 voice_*-Prefs (Mikro/Kamera/
|
||||||
|
Lautsprecher-Gerät, Ausgabelautstärke) laufen jetzt über eine neue
|
||||||
|
`VoicePrefs`-Kapsel in `core/settings_prefs.dart` – vorher griffen
|
||||||
|
`voip_manager.dart` (2 Stellen), `livekit_call_manager.dart` und
|
||||||
|
`settings_voice.dart` direkt auf SharedPreferences zu (die im
|
||||||
|
Schnitt-Doc Punkt 6 genannten Stellen). Keys und Semantik (null/leer =
|
||||||
|
Pref löschen, Fallback-Lautstärke 1.0) exakt beibehalten; der
|
||||||
|
shared_preferences-Import ist aus settings_modal.dart, beiden Managern
|
||||||
|
und damit aus der ganzen Call-Schicht verschwunden.
|
||||||
|
- **Geprüft:** `flutter analyze` sauber (nur bekannter onUpdate-Hinweis),
|
||||||
|
29 Tests grün, `flutter build windows --debug` + App-Start: Login/Sync
|
||||||
|
laufen (auth_log 17:08:57 `loggedIn`, keine Migration, kein Fehler).
|
||||||
|
App läuft seit 17:08 wieder ENTKOPPELT als Langzeit-Beobachtung – jetzt
|
||||||
|
mit dem NEUEN Code.
|
||||||
|
- **Bewusst zurückgestellt:** „UI-State aus den Managern herauslösen" –
|
||||||
|
das wäre ein echter Logik-Umbau auf dem heiligen Call-Pfad, während
|
||||||
|
Bernds Klick-Test des Piloten (PC_TESTPLAN Abschnitt 8) noch aussteht.
|
||||||
|
Kein zweiter ungetesteter Umbau obendrauf; steht als Nächstes an, SOBALD
|
||||||
|
der Klick-Test grün ist.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check (Einträge seit 2026-07-06
|
||||||
|
17:08) in der nächsten Session bzw. vor Session-Ende. Danach ist auf dem
|
||||||
|
PC ohne Bernds Klick-Tests nichts Risikoarmes mehr offen: M2-Schritt 2
|
||||||
|
(settings_modal weiter aufsplitten) wäre der nächste Kandidat.
|
||||||
|
|
||||||
|
**Stolperfallen:** Nach dem Umstellen einer part-Datei (settings_voice)
|
||||||
|
den dann ungenutzten Import (shared_preferences) beim OWNER
|
||||||
|
(settings_modal.dart) entfernen – analyze meldet ihn sonst als unused,
|
||||||
|
und die Fundstelle ist nicht die geänderte Datei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Langzeit-Check auth_log: 12:43-Lauf endete früh (kein Logout im Fenster), Beobachtung 16:57 neu gestartet
|
||||||
|
|
||||||
|
**Erledigt:** Der von der Vorsession hinterlassene Pflicht-Check
|
||||||
|
(auth_log seit 12:43 prüfen) ist ausgeführt, Ergebnis im PC_TESTPLAN
|
||||||
|
(Abschnitt 1) eingetragen:
|
||||||
|
|
||||||
|
- **Befund:** Der entkoppelte 12:43-Lauf lief nur ~20 Minuten – letzter
|
||||||
|
DB-Write 13:03:21, danach Prozess weg. KEINE Absturzspur (Application-
|
||||||
|
Event-Log leer, keine WER-Reports), kein Logout-/Fehler-Eintrag im
|
||||||
|
auth_log → vermutlich wurde das Fenster manuell geschlossen (Bernds
|
||||||
|
PC). Im 20-Minuten-Fenster kein Random-Logout, aber das ist zu kurz
|
||||||
|
für „mehrere Stunden" – Punkt bleibt offen.
|
||||||
|
- **Positiv-Evidenz obendrauf:** Neustart der exe um 16:57 → auth_log
|
||||||
|
`LoginState -> loggedIn` (16:57:46), KEINE erneute Migration, kein
|
||||||
|
Fehler – die Session überlebt jetzt nachweislich den 4. Start mit
|
||||||
|
SQLCipher-verschlüsselter DB.
|
||||||
|
- **Beobachtung neu gestartet:** App läuft seit 16:57 wieder ENTKOPPELT
|
||||||
|
(Start-Process, überlebt Session-Ende). Die nächste Session (oder das
|
||||||
|
Ende dieser) muss `auth_log.txt` auf Einträge seit 2026-07-06 16:57
|
||||||
|
prüfen und im PC_TESTPLAN eintragen.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Weiter mit dem obersten offenen, hier
|
||||||
|
bearbeitbaren ROADMAP-Punkt: innere M2-Aufräumarbeiten der Call-Schicht
|
||||||
|
(UI-State aus den Managern, `call_ui`-Modul, storage-Fassade für die
|
||||||
|
voice_*-Prefs). Vor Session-Ende auth_log erneut prüfen.
|
||||||
|
|
||||||
|
**Stolperfallen:** Ein entkoppelter Beobachtungs-Lauf auf Bernds PC kann
|
||||||
|
jederzeit von Bernd geschlossen werden – das ist KEIN Fehlerbefund
|
||||||
|
(keine Panik wegen „Prozess weg"): erst Event-Log/WER auf Absturz und
|
||||||
|
auth_log auf Logout prüfen, dann als „manuell beendet" einordnen. Die
|
||||||
|
Beobachtungsdauer misst man am letzten DB-Write (`pyramid.sqlite-wal`),
|
||||||
|
nicht an der Start-Zeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Härtetest (PC_TESTPLAN): automatisierbarer Teil erledigt, Klick-Teil vorbereitet, Langzeit-Lauf gestartet
|
||||||
|
|
||||||
|
**Erledigt:** Dritter Auftragspunkt der PC-Session (nach SQLCipher und
|
||||||
|
Call-Pilot): `docs/PC_TESTPLAN.md` so weit abgearbeitet, wie es ohne
|
||||||
|
UI-Klicks geht, Ergebnisse dort eingetragen:
|
||||||
|
|
||||||
|
- **Abschnitt 0 ✅:** `flutter test` grün – 29 Tests (24 Bestand + 5 neue
|
||||||
|
SQLCipher-Tests), mehrfach gelaufen (vor/nach jedem Umbau des Tages).
|
||||||
|
- **Abschnitt 1 (Teil-Evidenz, dokumentiert):** Bernds Bestands-Session
|
||||||
|
blieb über drei App-Starts UND die SQLCipher-Migration hinweg eingeloggt;
|
||||||
|
die App entschlüsselte live to_device-Events von @uta und forwardete
|
||||||
|
Room-Keys (E2EE-Empfangspfad belegt); `auth_log.txt` schreibt sauber
|
||||||
|
LoginState-Wechsel mit, keine Soft-Logout-/Fehler-Einträge.
|
||||||
|
- **Langzeit-Beobachtung GESTARTET:** Die App läuft seit 12:43 vom
|
||||||
|
Autopilot ENTKOPPELT (Start-Process, überlebt das Session-Ende) –
|
||||||
|
die NÄCHSTE Session muss `auth_log.txt` (App-Support-Verzeichnis
|
||||||
|
`%APPDATA%\chat.pyramid\pyramid\`) auf Random-Logout-Einträge seit
|
||||||
|
2026-07-06 12:43 prüfen und das Ergebnis im PC_TESTPLAN eintragen.
|
||||||
|
- **Klar abgegrenzt, was Bernd braucht:** Alles Klick-Interaktive
|
||||||
|
(Nachricht senden, Logout-Warnung, Re-Login → alte Nachrichten lesbar,
|
||||||
|
Abschnitte 2–8 der Splits, Voice-Channel-Join) kann der Autopilot nicht
|
||||||
|
ausführen – im PC_TESTPLAN als Grenze vermerkt. ROADMAP-Härtetest-Punkt
|
||||||
|
entsprechend NUR annotiert, nicht abgehakt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Nächste Session: auth_log-Langzeit-Check (s. o.).
|
||||||
|
Für Bernd: PC_TESTPLAN Abschnitte 1 (Rest) und 2–8 durchklicken – dabei
|
||||||
|
deckt „Voice-Channel beitreten/verlassen" (Abschnitt 8) zugleich den
|
||||||
|
Klick-Test des heutigen M2-Call-Piloten ab. Danach ggf. SQLCipher auf dem
|
||||||
|
Android-Testgerät (Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`).
|
||||||
|
|
||||||
|
**Stolperfallen:** Ein per `flutter run` gestarteter Lauf stirbt mit der
|
||||||
|
Session – für Beobachtungen über das Session-Ende hinaus die gebaute exe
|
||||||
|
direkt und entkoppelt starten (`Start-Process` auf
|
||||||
|
`build\windows\x64\runner\Debug\pyramid.exe`); das auth_log persistiert
|
||||||
|
die Ereignisse für die Folgesession.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – M2-Call-Pilot: Module call_signaling + voice_channel mit Fassaden umgesetzt (Entscheidung 2)
|
||||||
|
|
||||||
|
**Erledigt:** Das erste M2-Umbau-Modul nach `docs/M2_MODULE_SCHNITT.md`
|
||||||
|
(Bernds Entscheidung 2: Call-Pilot zuerst, nicht settings_modal):
|
||||||
|
|
||||||
|
- **Zwei neue Module mit schmaler öffentlicher Schnittstelle:**
|
||||||
|
`lib/features/call_signaling/` (`CallSignalingService` +
|
||||||
|
`callSignalingProvider`; Implementierung `voip_manager.dart` hierher
|
||||||
|
verschoben) und `lib/features/voice_channel/` (`VoiceChannelService` +
|
||||||
|
`voiceChannelProvider`; Implementierung `livekit_call_manager.dart`
|
||||||
|
hierher verschoben). Die zwei Call-Mechanismen bleiben wie im Doc
|
||||||
|
begründet GETRENNT (Matrix-1:1-VoIP vs. LiveKit-SFU-Kanäle).
|
||||||
|
- **Alle Konsumenten entkoppelt:** app_shell, Call-UI (`voice_channel.dart`,
|
||||||
|
`mini_call_widget.dart`), Rooms-Panel (Join/Leave, Call-Dock),
|
||||||
|
Chat-Header (Call-Start), Settings (`_VoiceSection` ist jetzt
|
||||||
|
ConsumerStatefulWidget und nutzt die Provider statt der Singletons) –
|
||||||
|
niemand importiert mehr die Implementierungsdateien, repo-weit verifiziert.
|
||||||
|
Einzige dokumentierte Ausnahme: `matrix_client.dart` als Kompositions-
|
||||||
|
Punkt, der `PyramidVoipManager(client)` konstruiert. Die alten Provider
|
||||||
|
`callStateProvider`/`voipStateProvider` in app_state.dart sind ersatzlos
|
||||||
|
durch die Modul-Provider ersetzt (reine Umbenennung + Typ = Interface).
|
||||||
|
- **Bewusste Abweichungen vom Schnitt-Entwurf** (Verhaltensgleichheit vor
|
||||||
|
Schönheit, heiliger Call-Pfad; im Doc-Status-Kopf dokumentiert):
|
||||||
|
ChangeNotifier statt Streams, Methodennamen wie im Bestand
|
||||||
|
(`startCall`/`hangUp` statt `join`/`leave`), `CallSession` und
|
||||||
|
LiveKit-`Room` bleiben in den Schnittstellen sichtbar (UI rendert direkt
|
||||||
|
Renderer/Tracks – für die WebRTC-Renderer sieht das Doc das selbst vor).
|
||||||
|
Kein einziger Handler-/Logik-Block wurde verändert, nur Klassen-Köpfe
|
||||||
|
(`extends` + `@override`), Imports, Provider-Namen und Typ-Annotationen.
|
||||||
|
- **Geprüft:** `flutter analyze` sauber (nur bekannter onUpdate-Hinweis),
|
||||||
|
alle 29 Tests grün, `flutter run -d windows`: App startet, Login/Sync/E2EE
|
||||||
|
laufen, sauberer Quit. (Commit-Hash siehe git log dieses Eintrags.)
|
||||||
|
|
||||||
|
**UNGETESTET (interaktiv/Gerät – deshalb ROADMAP-Punkt „Call-Schicht
|
||||||
|
entwirren" nur annotiert, nicht abgehakt):** echter Voice-Channel-Join per
|
||||||
|
Klick auf Windows und echter 1:1-Call zwischen zwei Geräten. Der Umbau ist
|
||||||
|
bewusst rein mechanisch (Fassade davor, Logik unangetastet), aber die
|
||||||
|
CLAUDE.md-Regel „nach Refactoring Kernflows prüfen" ist für die Call-Flows
|
||||||
|
erst nach einem Klick-/Gerätetest voll erfüllt – steht im PC_TESTPLAN
|
||||||
|
(Abschnitt 8) bzw. braucht Bernd/Uta.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Härtetest nach `docs/PC_TESTPLAN.md` (so weit
|
||||||
|
ohne Klick-Interaktion möglich); die inneren M2-Aufräumarbeiten der
|
||||||
|
Call-Schicht (UI-State aus den Managern, `call_ui`-Modul, storage-Fassade
|
||||||
|
für die voice_*-Prefs) folgen als eigene Schritte.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Bei part-Dateien (settings, rooms_panel, chat_view)
|
||||||
|
hängen die Imports an der EINEN Owner-Datei – wer eine part-Datei umstellt,
|
||||||
|
muss die Imports beim Owner ändern und findet Verwender nur per Repo-grep
|
||||||
|
auf den Provider-Namen. (2) Ein `ChangeNotifierProvider<Interface>` braucht
|
||||||
|
ein Interface, das ChangeNotifier ERWEITERT (abstract class X extends
|
||||||
|
ChangeNotifier), sonst passt der Typparameter nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – SQLCipher-Migration UMGESETZT + auf Windows mit echter DB praxisgetestet (M1, Bernds Prio 1)
|
||||||
|
|
||||||
|
**Erledigt:** Der M1-Punkt „Sichere Speicherung von Access-Token & Krypto-DB"
|
||||||
|
ist nach `docs/SQLCIPHER_MIGRATION.md` umgesetzt (PC-Session laut
|
||||||
|
Entscheidung 1 in `ANTWORTEN_BERND.md`):
|
||||||
|
|
||||||
|
- **Neue Storage-Fassade `lib/core/app_database.dart`** (erster Baustein im
|
||||||
|
Sinne des M2-Modularitäts-Leitsatzes): kapselt Factory-Wahl, SQLCipher-
|
||||||
|
Schlüssel (32 Zufallsbytes, hex im Keystore via flutter_secure_storage,
|
||||||
|
Eintrag `db_cipher_key`, mit Read-Back-Verifikation gegen stilles
|
||||||
|
Write-Versagen), Klartext→SQLCipher-Migration und die vorher duplizierten
|
||||||
|
PRAGMAs. `matrix_client.dart` und `background_push.dart` rufen nur noch
|
||||||
|
`AppDatabase.open()` / `AppDatabase.openForPush()`.
|
||||||
|
- **Migration exakt nach Plan:** Marker gegen Push-Isolate-Race →
|
||||||
|
WAL-Checkpoint → Byte-Backup `.premigration` (nach 7 Tagen auto-gelöscht) →
|
||||||
|
`sqlcipher_export` in NEUE Datei → Verifikation (integrity_check +
|
||||||
|
Zeilenzahlen ALLER Tabellen) → atomarer rename-Tausch. Jeder Fehlpfad lässt
|
||||||
|
die Klartext-DB unangetastet (App läuft dann unverschlüsselt weiter,
|
||||||
|
Fehler im auth_log). Absturz zwischen Löschen und Umbenennen wird beim
|
||||||
|
nächsten Start aus dem Byte-Backup repariert.
|
||||||
|
- **Wichtige Plan-Korrektur:** Die Plan-Annahme „sqlcipher_flutter_libs hat
|
||||||
|
keine Desktop-Binaries" ist seit 0.6.x überholt – das Plugin baut SQLCipher
|
||||||
|
auf Windows als `sqlite3.dll` MIT (im Build-Output verifiziert, nur dieses
|
||||||
|
eine sqlite-Plugin ist im Build). Deshalb verschlüsseln Windows/Linux
|
||||||
|
ebenfalls (kein Test-Override nötig); iOS/macOS bleiben bewusst Klartext
|
||||||
|
(Pod-Kollision mit firebase_messaging, kein Release-Ziel). Laufzeit-Wächter
|
||||||
|
`PRAGMA cipher_version` verhindert, dass je stillschweigend die falsche
|
||||||
|
Bibliothek benutzt wird; verschlüsselte DB ohne Cipher-Lib/Schlüssel ist
|
||||||
|
ein harter Fehler mit Klartext-Meldung – NIE stille Neuanlage.
|
||||||
|
- **Tests `test/app_database_test.dart`** (5 neue, gesamt 29, alle grün):
|
||||||
|
fahren die ECHTE Migration mit der SQLCipher-DLL aus dem Build-Output –
|
||||||
|
cipher_version-Wächter, Header-Erkennung, Migration mit Zeilenzahl-
|
||||||
|
Verifikation, falscher Schlüssel scheitert sofort, erzwungener Fehlschlag
|
||||||
|
lässt das Klartext-Original intakt.
|
||||||
|
- **Praxistest Windows (`flutter run -d windows`), echte Daten:** Bernds
|
||||||
|
gewachsene 14-MB-DB (inkl. 4 MB WAL) wurde beim Start migriert
|
||||||
|
(auth_log: „23 Tabellen verifiziert"), Session blieb eingeloggt, E2EE
|
||||||
|
nachweislich intakt (App entschlüsselte live to_device-Events von
|
||||||
|
@uta, forwardete Room-Keys). Zweiter Start öffnet die verschlüsselte DB
|
||||||
|
ohne erneute Migration, sauberer Quit per `q`. DB-Header jetzt zufällig
|
||||||
|
(verschlüsselt), `.premigration` = Klartext-Original. Zusätzliche manuelle
|
||||||
|
Sicherung liegt als `pyramid.sqlite*.claude-vormigration-20260706` im
|
||||||
|
App-Verzeichnis (kann nach ein paar Tagen weg).
|
||||||
|
- `sqlite3` als direkte Dependency ergänzt (war schon transitiv im Lockfile –
|
||||||
|
kein neues externes Paket), für `open.overrideFor`/`openCipherOnAndroid`.
|
||||||
|
- `flutter analyze` sauber (nur der bekannte, bewusst zurückgestellte
|
||||||
|
onUpdate-Hinweis).
|
||||||
|
|
||||||
|
**UNGETESTET (Android – deshalb ROADMAP-Punkt NICHT abgehakt):** Migration
|
||||||
|
auf echtem Android-Gerät, Push-Isolate liest verschlüsselte DB
|
||||||
|
(`databaseFactoryFfiNoIsolate` + `openCipherOnAndroid`), flutter_secure_storage
|
||||||
|
aus dem Background-Isolate (bekanntes Risiko, Code steigt dann kontrolliert
|
||||||
|
mit Platzhalter-Notification aus). Testplan Punkt 4 in
|
||||||
|
`docs/SQLCIPHER_MIGRATION.md`; erst danach Release + Uta (vorher ihr
|
||||||
|
Key-Backup verifizieren).
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** M2-Call-Pilot (Entscheidung 2: Call-Fassade nach
|
||||||
|
`docs/M2_MODULE_SCHNITT.md`), danach Härtetest nach `docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Plan-Annahmen über Paket-Fähigkeiten VOR der Umsetzung
|
||||||
|
gegen den Pub-Cache/Build-Output prüfen – die „keine Desktop-Binaries"-Annahme
|
||||||
|
hätte unnötig einen Test-Override-Sonderpfad erzeugt. (2) `sqfliteFfiInit()`
|
||||||
|
war auf Windows faktisch wirkungslos fürs DB-Isolate (Override wirkt nur im
|
||||||
|
aufrufenden Isolate; das Worker-Isolate lädt die exe-nachbarschaftliche
|
||||||
|
`sqlite3.dll` – und die IST hier SQLCipher). Für eigene Overrides deshalb
|
||||||
|
`createDatabaseFactoryFfi(ffiInit: ...)` benutzen, das läuft IM DB-Isolate.
|
||||||
|
(3) Beim Schlüssel-Anlegen im Keystore IMMER read-back-verifizieren, bevor
|
||||||
|
damit verschlüsselt wird – ein still verlorener Schlüssel wäre Totalverlust.
|
||||||
|
(4) `flutter_test` findet keine `sqlite3.dll` neben dem Tester-Binary – die
|
||||||
|
DLL aus `build/windows/x64/runner/*/` explizit laden; Tests überspringen sich
|
||||||
|
selbst, wenn kein Build-Output da ist (Pi-tauglich).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Fable-5-Review (aa): autopilot.sh fest auf nordmann-Konto (uncommittete Änderung geprüft, E-Mail-Leak vor Publikation gefixt, committet)
|
||||||
|
|
||||||
|
**Erledigt:** Session-Start nach Arbeitszyklus zeigte echten neuen Anlass (also
|
||||||
|
kein reiner Protokoll-Commit, Ende-Kriterium aus (z) greift nicht): eine
|
||||||
|
uncommittete Änderung an `autopilot.sh` von heute plus Backup
|
||||||
|
`autopilot.sh.bak-20260706-vor-nordmann-pin`. Inhalt: Die Zwei-Konten-Rotation
|
||||||
|
(pyramid↔haupt) ist komplett entfernt, der Autopilot läuft laut Kommentar auf
|
||||||
|
Bernds Vorgabe (2026-07-06) nur noch mit dem nordmann-Konto
|
||||||
|
(Standard-Profil `~/.claude`). Der CHANGES-Hook hat dafür KEINEN Eintrag
|
||||||
|
(Log endet 2026-07-05 19:03) – die Änderung kam also von außerhalb einer
|
||||||
|
gehookten Claude-Session (Bernd/manuell). Als Punkt (aa) im
|
||||||
|
Fable-5-Review-Abschnitt abgehakt:
|
||||||
|
|
||||||
|
- **Umbau korrekt:** Rotation restlos raus (repo-weit kein
|
||||||
|
`get_account`/`switch_account`/`JUST_SWITCHED`/`ACCOUNT_FILE`-Rest),
|
||||||
|
`apply_account` macht nur noch `unset CLAUDE_CONFIG_DIR` – exakt die Lehre
|
||||||
|
aus Review (w) (Hauptprofil liegt unter `~/.claude.json`, die Variable darf
|
||||||
|
nicht gesetzt sein). `bash -n` sauber. Limit-Pfad: 20 min warten statt
|
||||||
|
Konto-Wechsel, PAUSE-/Notify-Logik unverändert.
|
||||||
|
- **Am lebenden System belegt:** tmux-Session „pyramid" wurde heute 11:54 neu
|
||||||
|
gestartet, `autopilot.log` zeigt das neue Log-Format, und DIESE Session
|
||||||
|
läuft nachweislich als nordmann-Konto mit leerem `CLAUDE_CONFIG_DIR`.
|
||||||
|
- **Befund (gefixt VOR dem Commit):** Die neue Fassung enthielt Bernds
|
||||||
|
private E-Mail-Adresse im Klartext (Kommentar + `ACCOUNT_NAME`-Log-Zeile).
|
||||||
|
Das Gitea-Repo ist ÖFFENTLICH, und bisher enthielt keine einzige getrackte
|
||||||
|
Datei diese Adresse – ein Commit hätte sie erstmals veröffentlicht
|
||||||
|
(PII/Spam-Harvesting, gleiche Familie wie der Secrets-Leitsatz in
|
||||||
|
CLAUDE.md). Funktional ist die Adresse unnötig (stand nur in Kommentar und
|
||||||
|
gitignoriertem `autopilot.log`) → durch neutrales „nordmann" ersetzt,
|
||||||
|
Begründungs-Kommentar dagelassen, damit sie niemand „zur Klarheit" wieder
|
||||||
|
einträgt.
|
||||||
|
- Aufräumen drumherum: `*.bak-*` in `.gitignore` aufgenommen (das heutige
|
||||||
|
Backup bleibt als Rollback auf der Platte, hält aber den Arbeitsbaum-
|
||||||
|
Detektor künftiger Sessions sauber). Hinweis: `~/.claude-accounts/active`
|
||||||
|
(Inhalt „haupt") liegt verwaist außerhalb des Repos – wird von nichts mehr
|
||||||
|
gelesen, bewusst nicht angefasst. Memory (Haupt-Profil) auf den neuen
|
||||||
|
Ein-Konto-Stand aktualisiert.
|
||||||
|
|
||||||
|
**ROADMAP-Check danach (Schritt 2 des Auftrags):** Unverändert kein offener
|
||||||
|
Punkt auf dem Pi sicher bearbeitbar – SQLCipher und Härtetest brauchen
|
||||||
|
PC/Gerät (Entscheidung 1, `docs/PC_TESTPLAN.md`), der M2-Call-Pilot braucht
|
||||||
|
Gerätetest (Entscheidung 2, heiliger Call-Pfad), Secrets/History ruhen
|
||||||
|
bewusst (Entscheidung 4). Keine Arbeit erfunden – Session endet sauber nach
|
||||||
|
diesem Commit.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Unverändert – nächster PC-/Gerätelauf startet mit
|
||||||
|
SQLCipher (`docs/SQLCIPHER_MIGRATION.md`), danach Call-Pilot; Härtetest nach
|
||||||
|
`docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Der CHANGES-Hook-Detektor sieht nur Claude-Edits –
|
||||||
|
Bernds manuelle Änderungen tauchen NUR in `git status` auf; deshalb gehört
|
||||||
|
`git status` (nicht nur das Hook-Log) an jeden Session-Start. (2) Vor dem
|
||||||
|
Committen fremder/manueller Änderungen in ein ÖFFENTLICHES Repo den Diff auf
|
||||||
|
PII/Secrets abklopfen – hier wäre sonst eine private E-Mail-Adresse
|
||||||
|
mitpubliziert worden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-05 – Fable-5-Review (z): (y)-Protokoll-Commit geprüft (kein Befund) + Ende-Kriterium geschärft
|
||||||
|
|
||||||
|
**Erledigt:** Session-Start nach Arbeitszyklus: Arbeitsbaum sauber, Gitea nicht
|
||||||
|
voraus, CHANGES-Hook-Log endet 18:41 = exakt die (y)-PROGRESS-Edits → keine
|
||||||
|
unprotokollierte Pi-Arbeit. Einziger seit (y) ungeprüfter Rest war der
|
||||||
|
(y)-Protokoll-Commit 725f03a selbst; als Punkt (z) im Fable-5-Review-Abschnitt
|
||||||
|
abgehakt (kurzer Gegenlese-Pass, keine Volltiefen-Runde):
|
||||||
|
|
||||||
|
- Diff von 725f03a ist reine Doku und deckt sich exakt mit dem (y)-Eintrag:
|
||||||
|
PROGRESS.md +45 Zeilen ((y)-Eintrag + Checklisten-Häkchen), CHANGES.md
|
||||||
|
+2 Hook-Zeilen. Kein Code, nichts Halbfertiges.
|
||||||
|
- (y)-Kernaussagen stichprobenhaft gegengeprüft, beide korrekt: 15001ae ist
|
||||||
|
wirklich PROGRESS +46/CHANGES +2 (reine Doku), und
|
||||||
|
`git log 925aafb..HEAD -- lib/ test/ pubspec.*` ist leer – seit dem letzten
|
||||||
|
grünen Testlauf (24/24) ist kein Code angefasst, kein flutter-Lauf nötig.
|
||||||
|
|
||||||
|
**Ende-Kriterium geschärft (in der Review-Checkliste dokumentiert):** Die
|
||||||
|
Kette (a)–(z) reviewte zuletzt nur noch die Protokoll-Commits der jeweiligen
|
||||||
|
Vorsession – das terminiert nie von selbst. Ab jetzt gilt: Ein reiner,
|
||||||
|
befundloser Protokoll-Commit einer Review-Session ist KEIN neuer
|
||||||
|
Review-Anlass. Neue Review-Punkte gibt es nur noch für Arbeit mit Substanz
|
||||||
|
(Code- oder inhaltliche Doku-Änderungen, z. B. neue PC-Commits).
|
||||||
|
|
||||||
|
**ROADMAP-Check danach (Schritt 2 des Auftrags):** Unverändert kein offener
|
||||||
|
Punkt auf dem Pi sicher bearbeitbar – SQLCipher und Härtetest brauchen
|
||||||
|
PC/Gerät (Entscheidung 1, `docs/PC_TESTPLAN.md`), der M2-Call-Pilot braucht
|
||||||
|
Gerätetest (Entscheidung 2, heiliger Call-Pfad), Secrets/History ruhen
|
||||||
|
bewusst (Entscheidung 4, nicht anstoßen). Keine Arbeit erfunden – Session
|
||||||
|
endet sauber nach diesem Protokoll-Commit.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Unverändert – nächster PC-/Gerätelauf startet mit
|
||||||
|
SQLCipher (`docs/SQLCIPHER_MIGRATION.md`), danach Call-Pilot; Härtetest nach
|
||||||
|
`docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
**Stolperfallen:** Eine Review-Kette, die Protokoll-Commits reviewt, erzeugt
|
||||||
|
mit jedem Review einen neuen Protokoll-Commit – ohne explizites
|
||||||
|
„Protokoll-only zählt nicht"-Kriterium terminiert sie nie. Das Kriterium
|
||||||
|
gehört an die Checkliste selbst, nicht nur in einen Session-Eintrag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-05 – Fable-5-Review (y): liegengebliebenen (x)-Protokoll-Commit der Vorsession geprüft und nachgeholt
|
## 2026-07-05 – Fable-5-Review (y): liegengebliebenen (x)-Protokoll-Commit der Vorsession geprüft und nachgeholt
|
||||||
|
|
||||||
**Erledigt:** Session-Start zeigte einen unsauberen Arbeitsbaum: Die
|
**Erledigt:** Session-Start zeigte einen unsauberen Arbeitsbaum: Die
|
||||||
@@ -1180,6 +2056,33 @@ schließt den Review ab.
|
|||||||
stichprobenhaft verifiziert (66bf54b-Umfang, keine `lib/`-Änderungen seit
|
stichprobenhaft verifiziert (66bf54b-Umfang, keine `lib/`-Änderungen seit
|
||||||
925aafb, A/B-Frage überall geschlossen) und unverändert committet
|
925aafb, A/B-Frage überall geschlossen) und unverändert committet
|
||||||
(15001ae). Details im PROGRESS-Eintrag „(y)" oben.
|
(15001ae). Details im PROGRESS-Eintrag „(y)" oben.
|
||||||
|
- [x] **(z) (y)-Protokoll-Commit 725f03a gegengelesen – korrekt, KEIN Befund;
|
||||||
|
Ende-Kriterium der Kette geschärft (2026-07-05).** Diff ist exakt der
|
||||||
|
(y)-Eintrag + Checklisten-Häkchen + 2 Hook-Zeilen (kein Code); Kernaussagen
|
||||||
|
stichprobenhaft verifiziert (15001ae-Umfang PROGRESS +46/CHANGES +2,
|
||||||
|
`git log 925aafb..HEAD -- lib/ test/ pubspec.*` leer). **Ab jetzt gilt:**
|
||||||
|
Ein reiner, befundloser Protokoll-Commit einer Review-Session ist KEIN
|
||||||
|
neuer Review-Anlass mehr – künftige Sessions prüfen nur noch Commits mit
|
||||||
|
Code-/Doku-Substanz (alles außer dem eigenen Review-Protokoll), sonst
|
||||||
|
reviewt die Kette ewig ihr eigenes Protokoll. Details im
|
||||||
|
PROGRESS-Eintrag „(z)" oben.
|
||||||
|
- [x] **(aa) Uncommittete autopilot.sh-Umstellung auf EIN Konto geprüft und
|
||||||
|
committet – 1 Befund gefixt (2026-07-06).** Die Umstellung (Konto-Rotation
|
||||||
|
raus, Autopilot fest auf dem nordmann-Konto = Standard-Profil `~/.claude`;
|
||||||
|
Bernds Vorgabe laut Kommentar, Backup
|
||||||
|
`autopilot.sh.bak-20260706-vor-nordmann-pin`) lag uncommitted im
|
||||||
|
Arbeitsbaum. Geprüft: Rotation restlos entfernt (kein
|
||||||
|
`get_account`/`switch_account`/`JUST_SWITCHED`/`ACCOUNT_FILE`-Rest, repo-weit
|
||||||
|
gegrept), `apply_account` = `unset CLAUDE_CONFIG_DIR` deckt sich mit dem
|
||||||
|
(w)-Befund, `bash -n` sauber, Limit-Pfad wartet jetzt 20 min statt zu
|
||||||
|
wechseln. Am lebenden System belegt: tmux-Session „pyramid" am 2026-07-06
|
||||||
|
11:54 neu gestartet, autopilot.log zeigt das neue Format, DIESE Session
|
||||||
|
läuft als nordmann-Konto mit leerem `CLAUDE_CONFIG_DIR`. **Befund (vor dem
|
||||||
|
Commit gefixt):** Die neue Fassung enthielt Bernds private E-Mail-Adresse
|
||||||
|
(Kommentar + `ACCOUNT_NAME`) – das Gitea-Repo ist ÖFFENTLICH, und bisher
|
||||||
|
enthielt keine getrackte Datei diese Adresse; ein Commit hätte sie erstmals
|
||||||
|
veröffentlicht. Durch neutrales „nordmann" ersetzt (funktionsgleich, die
|
||||||
|
Adresse stand nur in Kommentar und Log-Zeile).
|
||||||
|
|
||||||
**Fazit:** Die Sonnet-5-Arbeit war handwerklich sauber; die zwei echten Lücken
|
**Fazit:** Die Sonnet-5-Arbeit war handwerklich sauber; die zwei echten Lücken
|
||||||
(verlorene Kommentare, fehlender Soft-Logout-Guard) hatte die abgebrochene
|
(verlorene Kommentare, fehlender Soft-Logout-Guard) hatte die abgebrochene
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ für die unterstützte Dart-Version).
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d windows # schnellster Praxistest während der Entwicklung
|
flutter run -d windows # schnellster Praxistest während der Entwicklung
|
||||||
flutter analyze # muss sauber bleiben
|
flutter analyze # muss sauber bleiben
|
||||||
|
pwsh scripts/test.ps1 # Analyse + Tests (-Live, -Bench, -All: siehe docs/TESTS_UND_BENCHMARKS.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
||||||
@@ -52,5 +53,6 @@ lib/
|
|||||||
|
|
||||||
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
||||||
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
||||||
|
- `docs/TESTS_UND_BENCHMARKS.md` – Tests, Live-Tests, Benchmarks (ein Befehl)
|
||||||
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
||||||
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
||||||
|
|||||||
+152
-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
|
||||||
@@ -136,10 +160,44 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
|
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
|
||||||
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
|
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
|
||||||
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
|
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
|
||||||
|
- [x] **Android-Backup/Geräteumzug abgeschaltet** (2026-10-07): ohne
|
||||||
|
`allowBackup=false` + `dataExtractionRules` wäre die SQLCipher-DB beim
|
||||||
|
Handywechsel ohne ihren Keystore-Schlüssel mitgezogen → App startet
|
||||||
|
nicht (Aussperr-Fall), und der Push-Zugangsschlüssel aus den
|
||||||
|
SharedPreferences läge im Cloud-Backup. Neues Gerät = neu anmelden +
|
||||||
|
Schlüssel per Wiederherstellungsschlüssel. Im APK geprüft (aapt2,
|
||||||
|
`dumpsys package`: kein ALLOW_BACKUP mehr).
|
||||||
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
||||||
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
|
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
|
||||||
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
|
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
|
||||||
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
||||||
|
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
|
||||||
|
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
|
||||||
|
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
|
||||||
|
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
|
||||||
|
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
|
||||||
|
- [x] **SDK-Wettlauf: verspätete 401 einer alten Sync-Anfrage löscht ggf. die
|
||||||
|
NEUE Session** – ERLEDIGT 2026-10-07: alle App-Abmeldungen laufen über
|
||||||
|
`logoutWithoutStraySync` (lib/core/session_logout.dart: Sync anhalten,
|
||||||
|
Long-Poll per Account-Daten wecken, auslaufen lassen, dann abmelden).
|
||||||
|
Live gemessen (test/live_logout_race_test.dart): die verspätete 401 tritt
|
||||||
|
real auf, kam aber in allen Läufen an, BEVOR die Neuanmeldung fertig
|
||||||
|
war – reales Risiko in der App klein (Neuanmeldung binnen ~1 s nötig),
|
||||||
|
jetzt ausgeschlossen; Regressionstest in `scripts/test.ps1 -Live`.
|
||||||
|
Ursprüngliche Analyse: (gefunden 2026-10-07 über einen sporadisch roten
|
||||||
|
Live-Test, siehe PROGRESS.md). matrix 6.2 `Client._innerSync` wirft den
|
||||||
|
Fehler einer Sync-Antwort, BEVOR geprüft wird, ob die Anfrage noch
|
||||||
|
aktuell ist (`_currentSyncId`). Läuft beim Logout noch ein Long-Poll
|
||||||
|
(bis 30 s) mit dem alten Token, kommt dessen M_UNKNOWN_TOKEN danach an
|
||||||
|
und das SDK ruft `clear()` – nach einem schnellen Re-Login auf
|
||||||
|
demselben Client wäre das ein „Random-Logout" direkt nach dem Login.
|
||||||
|
Upstream (famedly/matrix-dart-sdk `main`, 2026-10-07) unverändert;
|
||||||
|
neuere SDKs versuchen bei vorhandenem Refresh-Token aber zuerst einen
|
||||||
|
Refresh statt `clear()` (entschärft). Im Live-Test per
|
||||||
|
`_logoutWithoutStraySync` umgangen. **Offen:** App-Logout-Pfad prüfen
|
||||||
|
(läuft der Re-Login auf demselben Client-Objekt? dann vor
|
||||||
|
`client.logout()` Sync anhalten + laufenden Long-Poll abwarten) –
|
||||||
|
heiliger Pfad, nur mit Gerätetest; ggf. Upstream-Issue melden.
|
||||||
|
|
||||||
## M2 – Das große Refactoring (erst nach M0/M1!)
|
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||||
|
|
||||||
@@ -162,7 +220,33 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
||||||
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||||
|
**Teilschritt erledigt (2026-07-06, M2-Pilot laut Entscheidung 2):**
|
||||||
|
Module `call_signaling` und `voice_channel` mit Fassaden-Interfaces +
|
||||||
|
Providern existieren (`lib/features/call_signaling/`,
|
||||||
|
`lib/features/voice_channel/`), alle Konsumenten gehen nur noch über
|
||||||
|
`callSignalingProvider`/`voiceChannelProvider` – kein UI-Import der
|
||||||
|
Implementierungen mehr (Details/Abweichungen: Status-Kopf in
|
||||||
|
`docs/M2_MODULE_SCHNITT.md`). App startet, 29 Tests grün, Sync/E2EE
|
||||||
|
laufen. **UNGETESTET:** echter 1:1-Call und Voice-Channel-Join per
|
||||||
|
Klick (braucht Gerät bzw. interaktiven Test) – deshalb nicht abgehakt.
|
||||||
|
**Weitere Teilschritte erledigt (2026-07-06, zweite PC-Session):**
|
||||||
|
`call_ui` ist jetzt ein eigenes Modul (`lib/features/call_ui/`,
|
||||||
|
b2335da) und die voice_*-Prefs laufen über die `VoicePrefs`-Fassade
|
||||||
|
in `core/settings_prefs.dart` statt direktem SharedPreferences-Zugriff
|
||||||
|
(d22f9c2) – beides verhaltensgleich, analyze/Tests grün, App-Start
|
||||||
|
geprüft. **Bewusst zurückgestellt bis Bernds Klick-Test des Piloten
|
||||||
|
(PC_TESTPLAN Abschnitt 8) grün ist:** UI-State aus den Managern
|
||||||
|
herauslösen – kein zweiter ungetesteter Logik-Umbau auf dem heiligen
|
||||||
|
Call-Pfad.
|
||||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||||
|
**Ist-Analyse + Umbauplan liegen in `docs/M2_STATE_MANAGEMENT.md`**
|
||||||
|
(2026-07-06): Riverpod ist schon dominant (~50 Provider); 4
|
||||||
|
Misch-Patterns identifiziert, Schrittfolge definiert. Schritte 1–2
|
||||||
|
(Call-Manager-UI-State, Voice-Provider-Umzug) GESPERRT bis Bernds
|
||||||
|
Klick-Test (PC_TESTPLAN Abschnitt 8); MediaCache-Umstellung
|
||||||
|
zurückgestellt (hängt an der Chat-Timeline, Korrektur im Doc).
|
||||||
|
Push bleibt bewusst wie er ist (Isolate-Zwang). Toter
|
||||||
|
`incomingCallProvider` bereits entfernt (41bcaf2).
|
||||||
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||||
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||||
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
||||||
@@ -179,9 +263,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
||||||
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
||||||
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
||||||
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst –
|
und `layout/app_shell.dart` (1073 Zeilen) waren zunächst bewusst NICHT
|
||||||
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
|
mit angefasst – beide hängen eng mit der damals unentschiedenen
|
||||||
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
|
Call-Fassade bzw. dem Bootstrap/Login-Pfad zusammen.
|
||||||
|
**Nachtrag 2026-07-06:** Nach dem Call-Piloten ist
|
||||||
|
`call_ui/voice_channel.dart` jetzt ebenfalls geteilt (5 part-Dateien
|
||||||
|
unter `call_ui/view/`, zeichenidentisch verifiziert, 95d9b07);
|
||||||
|
`app_shell.dart` bleibt wegen des Bootstrap/Login-Pfads (heilig)
|
||||||
|
weiter unangetastet. Ebenso `settings/settings_encryption.dart`
|
||||||
|
(1470 Zeilen, größte verbliebene Teildatei) in 4 part-Dateien unter
|
||||||
|
`settings/encryption/` geteilt (zeichenidentisch verifiziert,
|
||||||
|
6128953) – Vorbereitung für das spätere verification-Modul
|
||||||
|
(Schnitt-Doc Schritt 3), Code selbst unangetastet.
|
||||||
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
||||||
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
||||||
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
||||||
@@ -189,11 +282,49 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
||||||
PROGRESS.md-Einträgen)
|
PROGRESS.md-Einträgen)
|
||||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
- [x] Test-/Benchmark-Gerüst: `scripts/test.ps1` (Analyse, Unit-, Live-Tests,
|
||||||
|
Benchmarks mit einem Befehl), `benchmark/` mit Baseline, lokale Suche
|
||||||
|
als testbares Modul (`lib/features/search/`). Siehe
|
||||||
|
`docs/TESTS_UND_BENCHMARKS.md`, PROGRESS.md 2026-10-07.
|
||||||
|
- [ ] **Abhängigkeiten aktualisieren (3 Monate Stand, 119 Pakete veraltet)** –
|
||||||
|
einzeln, jeweils mit `scripts/test.ps1 -All` abgesichert:
|
||||||
|
- **`sqlcipher_flutter_libs` ist EOL:** 0.7.0 ist ein leerer Platzhalter
|
||||||
|
(pub.dev). Unsere Bindung `^0.6.8` schließt ihn aus – NIEMALS per
|
||||||
|
`pub upgrade --major-versions` mitziehen, sonst fehlt SQLCipher und
|
||||||
|
die App kann die verschlüsselte DB nicht mehr öffnen
|
||||||
|
(`AppDatabase` wirft dann bewusst, kein Datenverlust, aber Aussperren).
|
||||||
|
Nachfolger: `sqlite3` 3.x mit `hooks: user_defines: sqlite3: source:
|
||||||
|
sqlcipher` – Kompatibilität mit `sqflite_common_ffi` und Android
|
||||||
|
vorher klären; Migrations-/DB-Tests + Gerätetest Pflicht.
|
||||||
|
- `matrix` 6.2 → 13.x (großer Sprung, Breaking Changes; heiliger
|
||||||
|
Login-/Krypto-Pfad), `flutter_vodozemac` 0.5 → 0.8 gehört dazu.
|
||||||
|
- `livekit_client` 2.7 → 2.13 (innerhalb der Bindung möglich),
|
||||||
|
`flutter_webrtc`-Override 1.4.1 → 1.6.2: der Windows-Audiogeräte-Fix
|
||||||
|
(libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten
|
||||||
|
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
|
||||||
|
bleibt nötig.
|
||||||
|
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). Offen: Umstieg auf
|
||||||
|
„Built-in Kotlin“ (Flutter kündigt Build-Fehler für das Kotlin-Plugin an;
|
||||||
|
betrifft auch Plugins wie flutter_webrtc/livekit_client).
|
||||||
|
- `flutter_markdown` ist eingestellt (pub: „replaced by
|
||||||
|
flutter_markdown_plus“) → umstellen (Markdown-Vorschau im Dokument-Viewer).
|
||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|
||||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||||
|
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||||
|
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
|
||||||
|
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
|
||||||
|
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
|
||||||
|
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
|
||||||
|
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
|
||||||
|
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
|
||||||
|
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
|
||||||
|
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
|
||||||
|
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
|
||||||
|
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
|
||||||
|
Code abgesichert). Nicht abgehakt.
|
||||||
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||||
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||||
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||||
@@ -207,7 +338,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||||
Software- statt Hardware-Encoding?), dann gezielt fixen
|
Software- statt Hardware-Encoding?), dann gezielt fixen
|
||||||
|
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
|
||||||
|
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
|
||||||
|
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
|
||||||
|
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
|
||||||
|
durchgereicht), degradationPreference (60-FPS-Modi
|
||||||
|
maintainFramerate, kein Simulcast). contentHint='motion' in
|
||||||
|
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
|
||||||
|
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
|
||||||
|
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
|
||||||
|
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
|
||||||
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||||
|
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
|
||||||
|
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
|
||||||
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||||
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||||
|
|
||||||
@@ -228,6 +371,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
## M7 – Design & Feinschliff
|
## M7 – Design & Feinschliff
|
||||||
|
|
||||||
|
- [x] **Beta-Feedback 2026-10-07 umgesetzt (UNGETESTET auf echtem Handy):**
|
||||||
|
Push als Chat-Verlauf mit Bildvorschau + echtem „Gelesen“, Statusleiste
|
||||||
|
überdeckt nichts mehr, Bild-Vollansicht mit Gesten, weiches Dunkel,
|
||||||
|
Schriftgrößen-Regler, Sprechblasen umschaltbar, Messenger-Chatliste,
|
||||||
|
deutsche Beschriftungen. Test-Version 0.1.37 (siehe PROGRESS.md).
|
||||||
|
|
||||||
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
||||||
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
||||||
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
||||||
|
|||||||
@@ -4,4 +4,10 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<application>
|
||||||
|
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".DebugNotifyReceiver"
|
||||||
|
android:exported="true" />
|
||||||
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
|
||||||
|
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
|
||||||
|
* Ist nicht im Release-APK enthalten.
|
||||||
|
*
|
||||||
|
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
|
||||||
|
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
|
||||||
|
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
|
||||||
|
*/
|
||||||
|
class DebugNotifyReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
|
||||||
|
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
|
||||||
|
val roomName = intent.getStringExtra("roomName")
|
||||||
|
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
|
||||||
|
val args = mapOf(
|
||||||
|
"roomId" to roomId,
|
||||||
|
"notifId" to NotificationHelper.stableId(roomId),
|
||||||
|
"eventId" to eventId,
|
||||||
|
"senderId" to "@${sender.lowercase()}:pyramid.local",
|
||||||
|
"senderName" to sender,
|
||||||
|
"roomName" to roomName,
|
||||||
|
"isGroup" to (roomName != null),
|
||||||
|
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
|
||||||
|
"timestamp" to System.currentTimeMillis(),
|
||||||
|
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
|
||||||
|
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
|
||||||
|
)
|
||||||
|
NotificationHelper.showFromArgs(context, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
|
||||||
|
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
|
||||||
|
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
|
||||||
|
val file = File(dir, name)
|
||||||
|
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
val c = Canvas(bmp)
|
||||||
|
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
|
||||||
|
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
|
||||||
|
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
|
||||||
|
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||||
|
return file.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,10 +17,15 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
|
||||||
|
<!-- Kein Backup/Umzug der App-Daten: siehe res/xml/data_extraction_rules.xml
|
||||||
|
(verschlüsselte DB ohne Keystore-Schlüssel = App startet nicht). -->
|
||||||
<application
|
<application
|
||||||
android:label="pyramid"
|
android:label="Pyramid"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
<service
|
<service
|
||||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|||||||
@@ -35,14 +35,17 @@ object BgEngine {
|
|||||||
private val fallbacks = mutableMapOf<Int, Runnable>()
|
private val fallbacks = mutableMapOf<Int, Runnable>()
|
||||||
|
|
||||||
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
||||||
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) {
|
fun scheduleFallback(
|
||||||
|
context: Context, meta: PushService.PushMeta, roomId: String, notifId: Int,
|
||||||
|
eventId: String?, delayMs: Long,
|
||||||
|
) {
|
||||||
val ctx = context.applicationContext
|
val ctx = context.applicationContext
|
||||||
appContext = ctx
|
appContext = ctx
|
||||||
fallbackHandler.post {
|
fallbackHandler.post {
|
||||||
cancelFallback(notifId)
|
cancelFallback(notifId)
|
||||||
val r = Runnable {
|
val r = Runnable {
|
||||||
fallbacks.remove(notifId)
|
fallbacks.remove(notifId)
|
||||||
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId)
|
meta.showPlaceholder(ctx, roomId, notifId, eventId)
|
||||||
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
||||||
}
|
}
|
||||||
fallbacks[notifId] = r
|
fallbacks[notifId] = r
|
||||||
@@ -114,16 +117,19 @@ object BgEngine {
|
|||||||
}
|
}
|
||||||
// Dart finished decrypting — show the real content using the SAME
|
// Dart finished decrypting — show the real content using the SAME
|
||||||
// native notification (keeps the reliable MainActivity reply action).
|
// native notification (keeps the reliable MainActivity reply action).
|
||||||
|
// May arrive twice per event: first the text, then silently
|
||||||
|
// the same event with avatar/image (NotificationHelper merges).
|
||||||
"showDecrypted" -> {
|
"showDecrypted" -> {
|
||||||
val title = call.argument<String>("title") ?: ""
|
@Suppress("UNCHECKED_CAST")
|
||||||
val body = call.argument<String>("body") ?: "Neue Nachricht"
|
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
|
||||||
val roomId = call.argument<String>("room_id") ?: ""
|
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
|
||||||
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
|
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
|
||||||
|
?: NotificationHelper.stableId(roomId)
|
||||||
val ctx = appContext
|
val ctx = appContext
|
||||||
if (ctx != null && roomId.isNotEmpty()) {
|
if (ctx != null && roomId.isNotEmpty()) {
|
||||||
// Beat the fallback placeholder to it — show decrypted directly.
|
// Beat the fallback placeholder to it — show decrypted directly.
|
||||||
cancelFallback(notifId)
|
cancelFallback(notifId)
|
||||||
NotificationHelper.show(ctx, title, body, roomId, notifId)
|
NotificationHelper.showFromArgs(ctx, args)
|
||||||
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
|
|||||||
@@ -206,12 +206,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"showNativeNotification" -> {
|
"showNativeNotification" -> {
|
||||||
val title = call.argument<String>("title") ?: ""
|
@Suppress("UNCHECKED_CAST")
|
||||||
val body = call.argument<String>("body") ?: ""
|
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
|
||||||
val roomId = call.argument<String>("roomId") ?: ""
|
val roomId = args["roomId"] as? String ?: ""
|
||||||
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
|
|
||||||
if (roomId.isNotEmpty()) {
|
if (roomId.isNotEmpty()) {
|
||||||
NotificationHelper.show(this, title, body, roomId, notifId)
|
NotificationHelper.showFromArgs(this, args)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} else {
|
} else {
|
||||||
result.error("INVALID_ARG", "roomId missing", null)
|
result.error("INVALID_ARG", "roomId missing", null)
|
||||||
|
|||||||
@@ -5,13 +5,79 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native Nachrichten-Benachrichtigungen (alle Pfade: Platzhalter aus
|
||||||
|
* PushService, Klartext aus der Haupt-Engine oder dem BgEngine-Isolate).
|
||||||
|
*
|
||||||
|
* Pro Raum gibt es EINE Benachrichtigung (notifId = stableId(roomId)), die als
|
||||||
|
* Chat-Verlauf (MessagingStyle) die letzten [MAX_MESSAGES] Nachrichten zeigt –
|
||||||
|
* wie WhatsApp/Element. Der Verlauf liegt in SharedPreferences, weil bei
|
||||||
|
* beendeter App jeder Push in einem neuen Prozess ankommt. Er wird nur
|
||||||
|
* fortgeführt, solange die Benachrichtigung noch sichtbar ist: hat der Nutzer
|
||||||
|
* sie weggewischt oder den Raum in der App geöffnet (Dart cancelt), beginnt
|
||||||
|
* der nächste Push mit einem leeren Verlauf.
|
||||||
|
*
|
||||||
|
* Gleiche eventId = Aktualisierung (Platzhalter → Klartext, Text → Text+Bild):
|
||||||
|
* die Nachricht wird ersetzt statt doppelt angehängt, und es klingelt nicht
|
||||||
|
* erneut. Nur eine wirklich neue Nachricht alarmiert.
|
||||||
|
*/
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
const val CHANNEL_ID = "pyramid_messages"
|
const val CHANNEL_ID = "pyramid_messages"
|
||||||
|
const val PLACEHOLDER_BODY = "Neue Nachricht"
|
||||||
|
private const val TAG = "PYRAMID-NOTIF"
|
||||||
|
private const val STORE = "pyramid_notif_history"
|
||||||
|
private const val MAX_MESSAGES = 8
|
||||||
|
private const val SYSTEM_UI = "com.android.systemui"
|
||||||
|
|
||||||
|
/** Eine Nachricht im Verlauf einer Raum-Benachrichtigung. */
|
||||||
|
data class Msg(
|
||||||
|
val eventId: String?,
|
||||||
|
val senderKey: String,
|
||||||
|
val senderName: String,
|
||||||
|
val body: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val imagePath: String? = null,
|
||||||
|
val avatarPath: String? = null,
|
||||||
|
) {
|
||||||
|
val isPlaceholder get() = body == PLACEHOLDER_BODY
|
||||||
|
|
||||||
|
fun toJson(): JSONObject = JSONObject().apply {
|
||||||
|
put("eventId", eventId ?: JSONObject.NULL)
|
||||||
|
put("senderKey", senderKey)
|
||||||
|
put("senderName", senderName)
|
||||||
|
put("body", body)
|
||||||
|
put("timestamp", timestamp)
|
||||||
|
put("imagePath", imagePath ?: JSONObject.NULL)
|
||||||
|
put("avatarPath", avatarPath ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(o: JSONObject) = Msg(
|
||||||
|
eventId = o.optStringOrNull("eventId"),
|
||||||
|
senderKey = o.optString("senderKey"),
|
||||||
|
senderName = o.optString("senderName"),
|
||||||
|
body = o.optString("body"),
|
||||||
|
timestamp = o.optLong("timestamp"),
|
||||||
|
imagePath = o.optStringOrNull("imagePath"),
|
||||||
|
avatarPath = o.optStringOrNull("avatarPath"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
||||||
fun stableId(roomId: String): Int {
|
fun stableId(roomId: String): Int {
|
||||||
@@ -32,15 +98,226 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) {
|
/**
|
||||||
ensureChannel(context)
|
* Platzhalter/Alt-Aufrufer mit fertigem Titel „Absender · Raum“ (bzw. nur
|
||||||
|
* „Absender“ bei Direktnachrichten).
|
||||||
|
*/
|
||||||
|
fun show(
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
roomId: String,
|
||||||
|
notifId: Int,
|
||||||
|
eventId: String? = null,
|
||||||
|
) {
|
||||||
|
val parts = title.split(" · ", limit = 2)
|
||||||
|
val sender = parts[0].ifBlank { "Pyramid" }
|
||||||
|
val roomName = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||||
|
showMessage(
|
||||||
|
context, roomId, notifId, roomName, isGroup = roomName != null,
|
||||||
|
msg = Msg(eventId, sender, sender, body, System.currentTimeMillis()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
/** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
|
||||||
PendingIntent.FLAG_MUTABLE else 0
|
fun showFromArgs(context: Context, args: Map<String, Any?>) {
|
||||||
|
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
|
||||||
|
if (roomId.isEmpty()) return
|
||||||
|
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
|
||||||
|
?: stableId(roomId)
|
||||||
|
val senderName = args["senderName"] as? String
|
||||||
|
if (senderName == null) {
|
||||||
|
// Alter Aufrufer ohne strukturierte Felder.
|
||||||
|
show(
|
||||||
|
context, args["title"] as? String ?: "",
|
||||||
|
args["body"] as? String ?: PLACEHOLDER_BODY,
|
||||||
|
roomId, notifId, args["eventId"] as? String,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
showMessage(
|
||||||
|
context, roomId, notifId, roomName,
|
||||||
|
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
|
||||||
|
msg = Msg(
|
||||||
|
eventId = args["eventId"] as? String,
|
||||||
|
senderKey = args["senderId"] as? String ?: senderName,
|
||||||
|
senderName = senderName,
|
||||||
|
body = args["body"] as? String ?: PLACEHOLDER_BODY,
|
||||||
|
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||||
|
imagePath = args["imagePath"] as? String,
|
||||||
|
avatarPath = args["avatarPath"] as? String,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
|
||||||
|
fun showMessage(
|
||||||
|
context: Context,
|
||||||
|
roomId: String,
|
||||||
|
notifId: Int,
|
||||||
|
roomName: String?,
|
||||||
|
isGroup: Boolean,
|
||||||
|
msg: Msg,
|
||||||
|
) {
|
||||||
|
ensureChannel(context)
|
||||||
|
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
|
||||||
|
val latest = history.last()
|
||||||
|
|
||||||
|
val style = NotificationCompat.MessagingStyle(
|
||||||
|
Person.Builder().setName("Du").setKey("me").build()
|
||||||
|
)
|
||||||
|
if (isGroup && roomName != null) {
|
||||||
|
style.conversationTitle = roomName
|
||||||
|
style.isGroupConversation = true
|
||||||
|
}
|
||||||
|
for (m in history) {
|
||||||
|
val person = Person.Builder()
|
||||||
|
.setName(m.senderName)
|
||||||
|
.setKey(m.senderKey)
|
||||||
|
.apply {
|
||||||
|
contentUri(context, m.avatarPath)?.let { setIcon(IconCompat.createWithContentUri(it)) }
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
val message = NotificationCompat.MessagingStyle.Message(m.body, m.timestamp, person)
|
||||||
|
contentUri(context, m.imagePath)?.let { message.setData("image/jpeg", it) }
|
||||||
|
style.addMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
||||||
|
.setStyle(style)
|
||||||
|
// Fallback-Texte für Geräte/Ansichten ohne MessagingStyle.
|
||||||
|
.setContentTitle(if (isGroup && roomName != null) roomName else latest.senderName)
|
||||||
|
.setContentText(latest.body)
|
||||||
|
.setWhen(latest.timestamp)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setNumber(history.size)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
// Nur eine wirklich neue Nachricht klingelt; Aktualisierungen
|
||||||
|
// (Platzhalter → Klartext, Bild nachgeladen) ersetzen still.
|
||||||
|
.setOnlyAlertOnce(!isNew)
|
||||||
|
.setContentIntent(tapIntent(context, roomId, notifId))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.addAction(replyAction(context, roomId, notifId))
|
||||||
|
.addAction(markReadAction(context, roomId, notifId, latest.eventId))
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
if (!isGroup) {
|
||||||
|
avatarBitmap(latest.avatarPath)?.let { builder.setLargeIcon(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
nm(context).notify(notifId, builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Sicherheitsnetz: lieber ohne Bilder/Avatare als gar nicht.
|
||||||
|
Log.w(TAG, "notify failed (${e.message}) — retrying as plain text")
|
||||||
|
builder.setLargeIcon(null as Bitmap?)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(latest.body))
|
||||||
|
nm(context).notify(notifId, builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergisst den Verlauf eines Raums (nach Antworten/„Gelesen“). */
|
||||||
|
@Synchronized
|
||||||
|
fun clearHistory(context: Context, roomId: String) {
|
||||||
|
prefs(context).edit().remove(roomId).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun mergeIntoHistory(
|
||||||
|
context: Context, roomId: String, notifId: Int, msg: Msg,
|
||||||
|
): Pair<List<Msg>, Boolean> {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
|
||||||
|
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
|
||||||
|
val isNew = index < 0
|
||||||
|
if (isNew) {
|
||||||
|
list.add(msg)
|
||||||
|
} else {
|
||||||
|
val old = list[index]
|
||||||
|
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
|
||||||
|
val keepOld = msg.isPlaceholder && !old.isPlaceholder
|
||||||
|
list[index] = Msg(
|
||||||
|
eventId = old.eventId,
|
||||||
|
senderKey = if (keepOld) old.senderKey else msg.senderKey,
|
||||||
|
senderName = if (keepOld) old.senderName else msg.senderName,
|
||||||
|
body = if (keepOld) old.body else msg.body,
|
||||||
|
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
|
||||||
|
imagePath = msg.imagePath ?: old.imagePath,
|
||||||
|
avatarPath = msg.avatarPath ?: old.avatarPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
list.sortBy { it.timestamp }
|
||||||
|
while (list.size > MAX_MESSAGES) list.removeAt(0)
|
||||||
|
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
|
||||||
|
return list to isNew
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(json: String?): MutableList<Msg> {
|
||||||
|
if (json == null) return mutableListOf()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
|
||||||
|
private fun isActive(context: Context, notifId: Int): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
|
return try {
|
||||||
|
nm(context).activeNotifications.any { it.id == notifId }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
|
||||||
|
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
|
||||||
|
*/
|
||||||
|
private fun contentUri(context: Context, path: String?): Uri? {
|
||||||
|
if (path.isNullOrEmpty()) return null
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return try {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
uri
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "contentUri failed for $path: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
|
||||||
|
private fun avatarBitmap(path: String?): Bitmap? {
|
||||||
|
if (path.isNullOrEmpty() || !File(path).exists()) return null
|
||||||
|
return try {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
|
var sample = 1
|
||||||
|
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
|
||||||
|
sample *= 2
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aktionen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun mutableFlag() =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||||
|
|
||||||
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
||||||
// ReplyReceiver decrypts/sends via the background engine when killed.
|
// ReplyReceiver decrypts/sends via the background engine when killed.
|
||||||
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
||||||
|
private fun replyAction(context: Context, roomId: String, notifId: Int): NotificationCompat.Action {
|
||||||
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||||
.setLabel("Antworten…").build()
|
.setLabel("Antworten…").build()
|
||||||
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||||
@@ -49,59 +326,57 @@ object NotificationHelper {
|
|||||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
}
|
}
|
||||||
val replyPi = PendingIntent.getBroadcast(
|
val replyPi = PendingIntent.getBroadcast(
|
||||||
context, notifId,
|
context, notifId, replyIntent,
|
||||||
replyIntent,
|
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
|
||||||
)
|
)
|
||||||
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
||||||
.addRemoteInput(replyInput)
|
.addRemoteInput(replyInput)
|
||||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
// Dismiss action — also points to ReplyReceiver so notification is cancelled.
|
// „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
|
||||||
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
|
// bis zur neuesten Nachricht (ReplyReceiver → Haupt-Engine bzw. BgEngine).
|
||||||
|
private fun markReadAction(
|
||||||
|
context: Context, roomId: String, notifId: Int, eventId: String?,
|
||||||
|
): NotificationCompat.Action {
|
||||||
|
val intent = Intent(context, ReplyReceiver::class.java).apply {
|
||||||
action = ReplyReceiver.ACTION_DISMISS
|
action = ReplyReceiver.ACTION_DISMISS
|
||||||
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
|
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
|
||||||
}
|
}
|
||||||
val dismissPi = PendingIntent.getBroadcast(
|
val pi = PendingIntent.getBroadcast(
|
||||||
context, notifId + 1_000_000,
|
context, notifId + 1_000_000, intent,
|
||||||
dismissIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
return NotificationCompat.Action.Builder(0, "Gelesen", pi)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
// Tap action — opens MainActivity to the right room.
|
// Tap action — opens MainActivity to the right room.
|
||||||
|
private fun tapIntent(context: Context, roomId: String, notifId: Int): PendingIntent {
|
||||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
action = Intent.ACTION_MAIN
|
action = Intent.ACTION_MAIN
|
||||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
putExtra("room_id", roomId)
|
putExtra("room_id", roomId)
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
}
|
}
|
||||||
val tapPi = PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
context, notifId + 2_000_000,
|
context, notifId + 2_000_000, tapIntent,
|
||||||
tapIntent,
|
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(body)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
// Only buzz/heads-up on the first show — the decrypted update reuses
|
|
||||||
// the same notifId and replaces the text silently (no second alert).
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setContentIntent(tapPi)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.addAction(replyAction)
|
|
||||||
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
|
|
||||||
.setGroup(CHANNEL_ID)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
nm(context).notify(notifId, notif)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private fun nm(context: Context) =
|
private fun nm(context: Context) =
|
||||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||||
|
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
|
||||||
|
|||||||
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
|
|||||||
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
||||||
|
|
||||||
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
||||||
var title = "Pyramid"
|
var meta = PushMeta()
|
||||||
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
||||||
val latch = CountDownLatch(1)
|
val latch = CountDownLatch(1)
|
||||||
Thread {
|
Thread {
|
||||||
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
|
try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
|
||||||
finally { latch.countDown() }
|
finally { latch.countDown() }
|
||||||
}.start()
|
}.start()
|
||||||
latch.await(12L, TimeUnit.SECONDS)
|
latch.await(12L, TimeUnit.SECONDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "handling push: title=$title roomId=$roomId")
|
Log.d(TAG, "handling push: sender=${meta.senderName} room=${meta.roomName} roomId=$roomId")
|
||||||
|
|
||||||
val engine = FlutterEngineCache.getInstance().get("main")
|
val engine = FlutterEngineCache.getInstance().get("main")
|
||||||
if (engine != null) {
|
if (engine != null) {
|
||||||
// App backgrounded but alive — show placeholder immediately, then the
|
// App backgrounded but alive — show placeholder immediately, then the
|
||||||
// warm main client decrypts and updates it (fast, barely visible).
|
// warm main client decrypts and updates it (fast, barely visible).
|
||||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
meta.showPlaceholder(this, roomId, notifId, eventId)
|
||||||
if (eventId != null) {
|
if (eventId != null) {
|
||||||
val capturedRoomId = roomId
|
val capturedRoomId = roomId
|
||||||
val capturedEventId = eventId
|
val capturedEventId = eventId
|
||||||
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
|
|||||||
// real content directly. The "Neue Nachricht" placeholder is only a
|
// real content directly. The "Neue Nachricht" placeholder is only a
|
||||||
// fallback if decryption takes too long or fails.
|
// fallback if decryption takes too long or fails.
|
||||||
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
||||||
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L)
|
BgEngine.scheduleFallback(this, meta, roomId, notifId, eventId, 6_000L)
|
||||||
BgEngine.run(this, "decryptAndShow", mapOf(
|
BgEngine.run(this, "decryptAndShow", mapOf(
|
||||||
"room_id" to roomId,
|
"room_id" to roomId,
|
||||||
"event_id" to eventId,
|
"event_id" to eventId,
|
||||||
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
|
|||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
// No event id to decrypt — just show the placeholder.
|
// No event id to decrypt — just show the placeholder.
|
||||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
meta.showPlaceholder(this, roomId, notifId, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +102,31 @@ class PushService : FirebaseMessagingService() {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Absender + Raumname aus unverschlüsselten Room-States (für den Platzhalter). */
|
||||||
|
data class PushMeta(
|
||||||
|
val senderId: String = "",
|
||||||
|
val senderName: String = "Pyramid",
|
||||||
|
val roomName: String? = null,
|
||||||
|
) {
|
||||||
|
fun showPlaceholder(context: android.content.Context, roomId: String, notifId: Int, eventId: String?) {
|
||||||
|
NotificationHelper.showMessage(
|
||||||
|
context, roomId, notifId, roomName, isGroup = roomName != null,
|
||||||
|
msg = NotificationHelper.Msg(
|
||||||
|
eventId = eventId,
|
||||||
|
senderKey = senderId.ifEmpty { senderName },
|
||||||
|
senderName = senderName,
|
||||||
|
body = NotificationHelper.PLACEHOLDER_BODY,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun fetchTitle(
|
private fun fetchMeta(
|
||||||
hs: String, token: String, roomId: String, eventId: String
|
hs: String, token: String, roomId: String, eventId: String
|
||||||
): String? {
|
): PushMeta? {
|
||||||
return try {
|
return try {
|
||||||
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
||||||
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
||||||
@@ -128,8 +148,11 @@ class PushService : FirebaseMessagingService() {
|
|||||||
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
||||||
val roomName = nameState?.optString("name")
|
val roomName = nameState?.optString("name")
|
||||||
|
|
||||||
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
|
return PushMeta(
|
||||||
else senderName.ifEmpty { null }
|
senderId = senderId,
|
||||||
|
senderName = senderName.ifEmpty { "Pyramid" },
|
||||||
|
roomName = roomName?.takeIf { it.isNotEmpty() },
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
||||||
const val EXTRA_ROOM_ID = "room_id"
|
const val EXTRA_ROOM_ID = "room_id"
|
||||||
const val EXTRA_NOTIF_ID = "notif_id"
|
const val EXTRA_NOTIF_ID = "notif_id"
|
||||||
|
const val EXTRA_EVENT_ID = "event_id"
|
||||||
const val REMOTE_INPUT_KEY = "reply_text"
|
const val REMOTE_INPUT_KEY = "reply_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||||||
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
||||||
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
||||||
|
|
||||||
// Dismiss the notification immediately.
|
// Dismiss the notification immediately; its message history goes too.
|
||||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||||
.cancel(notifId)
|
.cancel(notifId)
|
||||||
|
NotificationHelper.clearHistory(context, roomId)
|
||||||
|
|
||||||
if (intent.action == ACTION_DISMISS) {
|
if (intent.action == ACTION_DISMISS) {
|
||||||
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
|
markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||||||
try { pending.finish() } catch (_: Exception) {}
|
try { pending.finish() } catch (_: Exception) {}
|
||||||
}, 15_000L)
|
}, 15_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Gelesen“: Lesebestätigung bis [eventId] setzen – über die laufende
|
||||||
|
* Haupt-Engine oder (App beendet) still über die BgEngine. Ohne eventId
|
||||||
|
* (alter Platzhalter) bleibt es beim Schließen.
|
||||||
|
*/
|
||||||
|
private fun markRead(context: Context, roomId: String, eventId: String?) {
|
||||||
|
Log.d("PYRAMID-REPLY", "mark-read action for room $roomId event=$eventId")
|
||||||
|
if (eventId.isNullOrEmpty()) return
|
||||||
|
val args = mapOf("room_id" to roomId, "event_id" to eventId)
|
||||||
|
val engine = FlutterEngineCache.getInstance().get("main")
|
||||||
|
if (engine != null) {
|
||||||
|
try {
|
||||||
|
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
|
||||||
|
.invokeMethod("markReadFromNotification", args)
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("PYRAMID-REPLY", "markRead via main engine failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pending = goAsync()
|
||||||
|
BgEngine.run(context, "markRead", args)
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||||
|
try { pending.finish() } catch (_: Exception) {}
|
||||||
|
}, 10_000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Kein Cloud-Backup und keine Gerät-zu-Gerät-Übertragung der App-Daten
|
||||||
|
(Android 12+). Grund: Die lokale Datenbank ist mit SQLCipher verschlüsselt,
|
||||||
|
ihr Schlüssel liegt im Android-Keystore und zieht NICHT mit um – eine
|
||||||
|
übertragene Datenbank ließe sich auf dem neuen Gerät nicht öffnen
|
||||||
|
(AppDatabase bricht dann bewusst ab, statt Daten zu überschreiben).
|
||||||
|
Außerdem enthalten die SharedPreferences den Zugangsschlüssel für den
|
||||||
|
Push-Dienst. Matrix-Weg auf neuem Gerät: neu anmelden + Schlüssel mit dem
|
||||||
|
Wiederherstellungsschlüssel aus dem Server-Backup holen.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
|||||||
+14
-38
@@ -1,40 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
# Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
|
||||||
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||||
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||||
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
|
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
|
||||||
# FORTSETZEN: rm ~/pyramid/PAUSE
|
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||||
# GANZ STOPPEN: tmux kill-session -t pyramid
|
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||||
|
|
||||||
# --- Zwei Claude-Konten mit automatischem Wechsel bei 5h-Limit ---
|
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
|
||||||
# Konto "pyramid" = Profil ~/.claude-pyramid (Standard fuer diese Arbeit),
|
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
|
||||||
# Konto "haupt" = Profil ~/.claude (springt ein, wenn pyramid am Limit ist).
|
# ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
|
||||||
ACCOUNT_FILE="$HOME/.claude-accounts/active"
|
# ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
|
||||||
get_account() { cat "$ACCOUNT_FILE" 2>/dev/null || echo pyramid; }
|
# die Config faelschlich unter ~/.claude/.claude.json).
|
||||||
|
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
|
||||||
apply_account() {
|
# ist oeffentlich, PII gehoert nicht hinein.)
|
||||||
if [ "$(get_account)" = "haupt" ]; then
|
apply_account() { unset CLAUDE_CONFIG_DIR; }
|
||||||
# Hauptkonto = Standard-Profil -> Variable NICHT setzen
|
ACCOUNT_NAME="nordmann"
|
||||||
# (sonst sucht Claude die Config unter ~/.claude/.claude.json statt ~/.claude.json)
|
|
||||||
unset CLAUDE_CONFIG_DIR
|
|
||||||
else
|
|
||||||
export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
switch_account() {
|
|
||||||
local cur next
|
|
||||||
cur=$(get_account)
|
|
||||||
if [ "$cur" = "pyramid" ]; then next=haupt; else next=pyramid; fi
|
|
||||||
mkdir -p "$HOME/.claude-accounts"
|
|
||||||
echo "$next" > "$ACCOUNT_FILE"
|
|
||||||
notify_matrix "🔺 Autopilot: 5h-Limit auf Konto '$cur' erreicht – wechsle auf Konto '$next' und arbeite direkt weiter."
|
|
||||||
}
|
|
||||||
|
|
||||||
JUST_SWITCHED=0
|
|
||||||
|
|
||||||
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
||||||
MATRIX_URL="http://127.0.0.1:6167"
|
MATRIX_URL="http://127.0.0.1:6167"
|
||||||
@@ -60,7 +43,7 @@ while true; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
apply_account
|
apply_account
|
||||||
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $(get_account)) =====" | tee -a autopilot.log
|
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $ACCOUNT_NAME) =====" | tee -a autopilot.log
|
||||||
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
||||||
SESSION_OUT=$(mktemp)
|
SESSION_OUT=$(mktemp)
|
||||||
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
|
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
|
||||||
@@ -75,21 +58,14 @@ $COMMITS
|
|||||||
|
|
||||||
$NEXT"
|
$NEXT"
|
||||||
fi
|
fi
|
||||||
# 5h-Limit erkannt? -> sofort aufs andere Konto wechseln statt zu warten
|
# Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
|
||||||
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
|
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
|
||||||
rm -f "$SESSION_OUT"
|
rm -f "$SESSION_OUT"
|
||||||
if [ "$JUST_SWITCHED" != "1" ]; then
|
echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
|
||||||
switch_account
|
|
||||||
JUST_SWITCHED=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
JUST_SWITCHED=0
|
|
||||||
echo "===== $(date '+%F %T') Beide Konten am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
|
|
||||||
sleep 1200
|
sleep 1200
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
rm -f "$SESSION_OUT"
|
rm -f "$SESSION_OUT"
|
||||||
JUST_SWITCHED=0
|
|
||||||
|
|
||||||
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||||
sleep 1200
|
sleep 1200
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"suite": "db",
|
||||||
|
"timestamp": "2026-10-07T11:35:13.322832",
|
||||||
|
"git": "b2413ef",
|
||||||
|
"host": {
|
||||||
|
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
|
||||||
|
"cpus": 12
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"rooms": 20,
|
||||||
|
"eventsPerRoom": 500
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"klartext.write_total_ms": {
|
||||||
|
"label": "[klartext] Sync-Schreiben, 10000 Nachrichten",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 2513.443,
|
||||||
|
"min": 2469.054,
|
||||||
|
"runs": 2,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.write_per_event_us": {
|
||||||
|
"label": "[klartext] Sync-Schreiben je Nachricht",
|
||||||
|
"unit": "µs",
|
||||||
|
"value": 251.344,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.startup_ms": {
|
||||||
|
"label": "[klartext] App-Start: DB + Raumliste",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 4.121,
|
||||||
|
"min": 3.254,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.open_chat_ms": {
|
||||||
|
"label": "[klartext] Chat öffnen (50 Nachr.)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 0.328,
|
||||||
|
"min": 0.286,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.search_miss_ms": {
|
||||||
|
"label": "[klartext] Suche ohne Treffer (Worst Case)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 63.897,
|
||||||
|
"min": 63.716,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.search_hit_ms": {
|
||||||
|
"label": "[klartext] Suche mit Treffern",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 31.777,
|
||||||
|
"min": 31.202,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.size_mb": {
|
||||||
|
"label": "[klartext] DB-Größe inkl. WAL",
|
||||||
|
"unit": "MB",
|
||||||
|
"value": 4.008,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"sqlcipher.write_total_ms": {
|
||||||
|
"label": "[sqlcipher] Sync-Schreiben, 10000 Nachrichten",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 2615.633,
|
||||||
|
"min": 2456.356,
|
||||||
|
"runs": 2,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.write_per_event_us": {
|
||||||
|
"label": "[sqlcipher] Sync-Schreiben je Nachricht",
|
||||||
|
"unit": "µs",
|
||||||
|
"value": 261.563,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.startup_ms": {
|
||||||
|
"label": "[sqlcipher] App-Start: DB + Raumliste",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 4.313,
|
||||||
|
"min": 3.718,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.open_chat_ms": {
|
||||||
|
"label": "[sqlcipher] Chat öffnen (50 Nachr.)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 0.323,
|
||||||
|
"min": 0.309,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.search_miss_ms": {
|
||||||
|
"label": "[sqlcipher] Suche ohne Treffer (Worst Case)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 63.748,
|
||||||
|
"min": 62.8,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.search_hit_ms": {
|
||||||
|
"label": "[sqlcipher] Suche mit Treffern",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 31.676,
|
||||||
|
"min": 31.296,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.size_mb": {
|
||||||
|
"label": "[sqlcipher] DB-Größe inkl. WAL",
|
||||||
|
"unit": "MB",
|
||||||
|
"value": 4.098,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.write_total_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: Sync-Schreiben",
|
||||||
|
"unit": "%",
|
||||||
|
"value": 4.066,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.startup_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: App-Start",
|
||||||
|
"unit": "%",
|
||||||
|
"value": 4.634,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.open_chat_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: Chat öffnen",
|
||||||
|
"unit": "%",
|
||||||
|
"value": -1.388,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.search_miss_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: Suche ohne Treffer",
|
||||||
|
"unit": "%",
|
||||||
|
"value": -0.233,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"suite": "migration",
|
||||||
|
"timestamp": "2026-10-07T11:35:21.106818",
|
||||||
|
"git": "b2413ef",
|
||||||
|
"host": {
|
||||||
|
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
|
||||||
|
"cpus": 12
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"rooms": 20,
|
||||||
|
"eventsPerRoom": 1000
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"db_size_mb": {
|
||||||
|
"label": "DB-Größe vor Migration",
|
||||||
|
"unit": "MB",
|
||||||
|
"value": 7.793,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"migrate_ms": {
|
||||||
|
"label": "Migration gesamt",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 325.058,
|
||||||
|
"min": 322.058,
|
||||||
|
"runs": 3,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"migrate_ms_per_mb": {
|
||||||
|
"label": "Migration je MB",
|
||||||
|
"unit": "ms/MB",
|
||||||
|
"value": 41.712,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
|
||||||
|
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
|
||||||
|
// der Analyzer sie nicht als Tests.
|
||||||
|
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:pyramid/core/app_database.dart';
|
||||||
|
import 'package:pyramid/features/search/local_message_search.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import '../test/support/synthetic_matrix_data.dart';
|
||||||
|
import 'support/bench.dart';
|
||||||
|
|
||||||
|
/// Datenbank-Benchmark: Was kostet die SQLCipher-Verschlüsselung im Alltag?
|
||||||
|
///
|
||||||
|
/// Misst – jeweils für eine Klartext-DB und eine SQLCipher-DB mit DERSELBEN
|
||||||
|
/// Bibliothek aus dem App-Build – die vier Wege, auf denen Nutzer die DB
|
||||||
|
/// spüren:
|
||||||
|
/// 1. Sync-Schreiben (Nachrichten landen in Transaktionen wie beim Sync),
|
||||||
|
/// 2. App-Start (DB öffnen + SDK-Schema + Raumliste laden),
|
||||||
|
/// 3. Chat öffnen (50 neueste Nachrichten eines Raums),
|
||||||
|
/// 4. lokale Suche (Strg+K) – ohne Treffer = Worst Case, scannt alles.
|
||||||
|
///
|
||||||
|
/// Daten sind synthetisch (`test/support/synthetic_matrix_data.dart`), laufen
|
||||||
|
/// aber über die echte SDK-Speicherschicht. Lauf:
|
||||||
|
/// flutter test benchmark/db_benchmark_test.dart
|
||||||
|
/// oder alles zusammen über `scripts/test.ps1 -Bench`.
|
||||||
|
|
||||||
|
const _modes = ['klartext', 'sqlcipher'];
|
||||||
|
|
||||||
|
const _benchKey =
|
||||||
|
'0b1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final skip = findCipherDll() == null
|
||||||
|
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test(
|
||||||
|
'DB: Klartext vs. SQLCipher',
|
||||||
|
() async {
|
||||||
|
final rooms = scaled(20);
|
||||||
|
final eventsPerRoom = scaled(500);
|
||||||
|
final totalEvents = rooms * eventsPerRoom;
|
||||||
|
final report = BenchReport(
|
||||||
|
'db',
|
||||||
|
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
|
||||||
|
);
|
||||||
|
final factory = cipherFactory();
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('pyramid_bench_db');
|
||||||
|
|
||||||
|
Future<Database> openBenchDb(String mode, String path) async {
|
||||||
|
final db = mode == 'sqlcipher'
|
||||||
|
? await AppDatabase.openEncrypted(factory, path, _benchKey)
|
||||||
|
: await factory.openDatabase(path);
|
||||||
|
await applyAppPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ungemessener Aufwärm-Durchlauf (JIT, DB-Isolate, SQLCipher-Init).
|
||||||
|
// Bewusst mit voller Raumgröße: Der JIT optimiert die SDK-Pfade erst
|
||||||
|
// nach vielen Aufrufen mit wachsender Fragment-Liste – mit einem
|
||||||
|
// Mini-Durchlauf war die erste Messrunde noch ~45 % langsamer.
|
||||||
|
for (final mode in _modes) {
|
||||||
|
final path = p.join(tempDir.path, 'warmup_$mode.sqlite');
|
||||||
|
final client = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, path),
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: (rooms ~/ 4).clamp(1, rooms),
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.loadRooms(client);
|
||||||
|
await localMessageSearch(client, 'aufwärmen');
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemessen wird abwechselnd A-B-B-A: Die Schreibkosten sind von
|
||||||
|
// Dart-Code (SDK-JSON) dominiert, und der JIT wird während des Laufs
|
||||||
|
// weiter schneller. Ohne das Muster gewinnt einfach der später
|
||||||
|
// gemessene Modus (2026-10-07 nachgemessen: ±20 % je nach Reihenfolge).
|
||||||
|
const order = ['klartext', 'sqlcipher', 'sqlcipher', 'klartext'];
|
||||||
|
final timings = {
|
||||||
|
for (final mode in _modes) mode: <String, List<double>>{},
|
||||||
|
};
|
||||||
|
void record(String mode, String metric, Iterable<double> values) =>
|
||||||
|
timings[mode]!.putIfAbsent(metric, () => []).addAll(values);
|
||||||
|
|
||||||
|
// 1. Sync-Schreiben – je Runde eine frische DB.
|
||||||
|
final paths = <String, String>{};
|
||||||
|
for (var round = 0; round < order.length; round++) {
|
||||||
|
final mode = order[round];
|
||||||
|
final path = p.join(tempDir.path, '${mode}_$round.sqlite');
|
||||||
|
final client = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, path),
|
||||||
|
);
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: rooms,
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
needleEvery: 97,
|
||||||
|
);
|
||||||
|
watch.stop();
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
record(mode, 'write', [watch.elapsedMicroseconds / 1000]);
|
||||||
|
paths[mode] = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. App-Start: DB öffnen + SDK-Schema + Raumliste, danach wieder
|
||||||
|
// schließen – wie ein Neustart. Eigene Runde VOR den Lese-Messungen:
|
||||||
|
// sqflite liefert für denselben Pfad dieselbe Verbindung zurück,
|
||||||
|
// ein Schließen hier würde sonst deren offene Verbindung kappen.
|
||||||
|
for (final mode in order) {
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'startup',
|
||||||
|
await sample(() async {
|
||||||
|
final c = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, paths[mode]!),
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.loadRooms(c);
|
||||||
|
await c.dispose(closeDatabase: true);
|
||||||
|
}, runs: 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.–4. Lesen auf der jeweils zuletzt geschriebenen DB.
|
||||||
|
final clients = <String, Client>{};
|
||||||
|
final roomLists = <String, List<Room>>{};
|
||||||
|
for (final mode in _modes) {
|
||||||
|
final client = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, paths[mode]!),
|
||||||
|
);
|
||||||
|
clients[mode] = client;
|
||||||
|
roomLists[mode] = await SyntheticMatrixData.loadRooms(client);
|
||||||
|
expect(
|
||||||
|
roomLists[mode],
|
||||||
|
hasLength(rooms),
|
||||||
|
reason: 'Raumliste vollständig ($mode)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final mode in order) {
|
||||||
|
final client = clients[mode]!;
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'open_chat',
|
||||||
|
(await sample(() async {
|
||||||
|
for (final room in roomLists[mode]!) {
|
||||||
|
final page = await client.database.getEventList(
|
||||||
|
room,
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
if (page.length != 50) throw StateError('Seite unvollständig');
|
||||||
|
}
|
||||||
|
}, runs: 3)).map((t) => t / rooms),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'search_miss',
|
||||||
|
await sample(
|
||||||
|
() => localMessageSearch(client, 'gibtsnichtwort'),
|
||||||
|
runs: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'search_hit',
|
||||||
|
await sample(
|
||||||
|
() => localMessageSearch(client, 'nadelwort'),
|
||||||
|
runs: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final client in clients.values) {
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final mode in _modes) {
|
||||||
|
final t = timings[mode]!;
|
||||||
|
final writeMs = median(t['write']!);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.write_total_ms',
|
||||||
|
'[$mode] Sync-Schreiben, $totalEvents Nachrichten',
|
||||||
|
t['write']!,
|
||||||
|
);
|
||||||
|
report.addValue(
|
||||||
|
'$mode.write_per_event_us',
|
||||||
|
'[$mode] Sync-Schreiben je Nachricht',
|
||||||
|
writeMs * 1000 / totalEvents,
|
||||||
|
'µs',
|
||||||
|
compare: true,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.startup_ms',
|
||||||
|
'[$mode] App-Start: DB + Raumliste',
|
||||||
|
t['startup']!,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.open_chat_ms',
|
||||||
|
'[$mode] Chat öffnen (50 Nachr.)',
|
||||||
|
t['open_chat']!,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.search_miss_ms',
|
||||||
|
'[$mode] Suche ohne Treffer (Worst Case)',
|
||||||
|
t['search_miss']!,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.search_hit_ms',
|
||||||
|
'[$mode] Suche mit Treffern',
|
||||||
|
t['search_hit']!,
|
||||||
|
);
|
||||||
|
report.addValue(
|
||||||
|
'$mode.size_mb',
|
||||||
|
'[$mode] DB-Größe inkl. WAL',
|
||||||
|
dbSizeMb(paths[mode]!),
|
||||||
|
'MB',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final (id, label) in [
|
||||||
|
('write_total_ms', 'Sync-Schreiben'),
|
||||||
|
('startup_ms', 'App-Start'),
|
||||||
|
('open_chat_ms', 'Chat öffnen'),
|
||||||
|
('search_miss_ms', 'Suche ohne Treffer'),
|
||||||
|
]) {
|
||||||
|
final plain = report.valueOf('klartext.$id');
|
||||||
|
final enc = report.valueOf('sqlcipher.$id');
|
||||||
|
report.addValue(
|
||||||
|
'cipher_overhead.$id',
|
||||||
|
'Aufschlag Verschlüsselung: $label',
|
||||||
|
(enc / plain - 1) * 100,
|
||||||
|
'%',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final regressions = await report.finish();
|
||||||
|
if (strictBench) expect(regressions, isEmpty);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 20)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
|
||||||
|
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
|
||||||
|
// der Analyzer sie nicht als Tests.
|
||||||
|
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:pyramid/core/app_database.dart';
|
||||||
|
|
||||||
|
import '../test/support/synthetic_matrix_data.dart';
|
||||||
|
import 'support/bench.dart';
|
||||||
|
|
||||||
|
/// Migrations-Benchmark: Wie lange dauert die einmalige Umstellung
|
||||||
|
/// Klartext → SQLCipher beim ersten Start nach dem Update (Utas Fall)?
|
||||||
|
///
|
||||||
|
/// Baut eine Klartext-DB über die echte SDK-Speicherschicht, kopiert sie vor
|
||||||
|
/// jedem Durchlauf frisch und misst `AppDatabase.migratePlaintextToEncrypted`
|
||||||
|
/// (exakt der App-Codepfad: Checkpoint, Backup, Export, Verifikation, Tausch).
|
||||||
|
/// Danach wird geprüft, dass die Raumliste aus der verschlüsselten DB wieder
|
||||||
|
/// vollständig lädt. Ergebnis zusätzlich als ms pro MB – damit lässt sich die
|
||||||
|
/// Dauer für eine echte DB-Größe hochrechnen.
|
||||||
|
|
||||||
|
const _benchKey =
|
||||||
|
'5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final skip = findCipherDll() == null
|
||||||
|
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Migration Klartext → SQLCipher',
|
||||||
|
() async {
|
||||||
|
final rooms = scaled(20);
|
||||||
|
final eventsPerRoom = scaled(1000);
|
||||||
|
final report = BenchReport(
|
||||||
|
'migration',
|
||||||
|
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
|
||||||
|
);
|
||||||
|
final factory = cipherFactory();
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'pyramid_bench_migration',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vorlage: Klartext-DB wie vor der SQLCipher-Einführung.
|
||||||
|
final templatePath = p.join(tempDir.path, 'template.sqlite');
|
||||||
|
var db = await factory.openDatabase(templatePath);
|
||||||
|
await applyAppPragmas(db);
|
||||||
|
var client = await SyntheticMatrixData.openClient(db);
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: rooms,
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
);
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
// WAL in die Hauptdatei schreiben, damit die Kopie vollständig ist.
|
||||||
|
db = await factory.openDatabase(templatePath);
|
||||||
|
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
await db.close();
|
||||||
|
final sizeMb = dbSizeMb(templatePath);
|
||||||
|
|
||||||
|
final workPath = p.join(tempDir.path, 'pyramid.sqlite');
|
||||||
|
Future<void> freshCopy() async {
|
||||||
|
for (final suffix in ['', '-wal', '-shm', '.enc', '.premigration']) {
|
||||||
|
final f = File('$workPath$suffix');
|
||||||
|
if (f.existsSync()) f.deleteSync();
|
||||||
|
}
|
||||||
|
await File(templatePath).copy(workPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
final times = await sample(
|
||||||
|
() async {
|
||||||
|
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||||||
|
factory: factory,
|
||||||
|
dbPath: workPath,
|
||||||
|
hexKey: _benchKey,
|
||||||
|
);
|
||||||
|
if (!ok) throw StateError('Migration fehlgeschlagen');
|
||||||
|
},
|
||||||
|
setup: freshCopy,
|
||||||
|
warmup: 0,
|
||||||
|
runs: 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ergebnis der letzten Migration prüfen: verschlüsselt + vollständig.
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(workPath)), isFalse);
|
||||||
|
db = await AppDatabase.openEncrypted(factory, workPath, _benchKey);
|
||||||
|
client = await SyntheticMatrixData.openClient(db);
|
||||||
|
final loaded = await SyntheticMatrixData.loadRooms(client);
|
||||||
|
final firstPage = await client.database.getEventList(
|
||||||
|
loaded.first,
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
expect(loaded, hasLength(rooms));
|
||||||
|
expect(firstPage, hasLength(50));
|
||||||
|
|
||||||
|
report.addValue('db_size_mb', 'DB-Größe vor Migration', sizeMb, 'MB');
|
||||||
|
report.addTimings('migrate_ms', 'Migration gesamt', times);
|
||||||
|
report.addValue(
|
||||||
|
'migrate_ms_per_mb',
|
||||||
|
'Migration je MB',
|
||||||
|
median(times) / sizeMb,
|
||||||
|
'ms/MB',
|
||||||
|
compare: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final regressions = await report.finish();
|
||||||
|
if (strictBench) expect(regressions, isEmpty);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 20)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
|
||||||
|
/// Kleines Benchmark-Gerüst für `benchmark/` (bewusst ohne Zusatzpaket).
|
||||||
|
///
|
||||||
|
/// Jede Suite sammelt Messwerte in einem [BenchReport]. Am Ende:
|
||||||
|
/// * Tabelle in der Konsole (inkl. Vergleich mit der Baseline),
|
||||||
|
/// * JSON nach `build/benchmarks/<suite>.json` (für spätere Vergleiche),
|
||||||
|
/// * Vergleich mit `benchmark/baseline/<suite>.json`, falls vorhanden.
|
||||||
|
///
|
||||||
|
/// Umgebungsvariablen:
|
||||||
|
/// * `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als neue Baseline
|
||||||
|
/// speichern (nach einer bewussten Änderung / auf neuer Hardware).
|
||||||
|
/// * `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern
|
||||||
|
/// (sonst nur Warnung, weil Messrauschen normal ist).
|
||||||
|
/// * `PYRAMID_BENCH_SCALE` – Datenmenge skalieren (Standard 1.0;
|
||||||
|
/// z. B. 0.2 für einen schnellen Probelauf, 3 für „Power-User").
|
||||||
|
|
||||||
|
/// Ab so viel Prozent langsamer als die Baseline gilt ein Wert als Regression.
|
||||||
|
const regressionThresholdPercent = 25.0;
|
||||||
|
|
||||||
|
/// Unterhalb dieser absoluten Differenz (ms) wird nie gewarnt – bei sehr
|
||||||
|
/// kleinen Werten ist das reines Rauschen.
|
||||||
|
const regressionFloorMs = 2.0;
|
||||||
|
|
||||||
|
double get benchScale =>
|
||||||
|
double.tryParse(Platform.environment['PYRAMID_BENCH_SCALE'] ?? '') ?? 1.0;
|
||||||
|
|
||||||
|
int scaled(int value) => (value * benchScale).round().clamp(1, 1 << 30);
|
||||||
|
|
||||||
|
/// Führt [body] erst [warmup]-mal ungemessen, dann [runs]-mal gemessen aus
|
||||||
|
/// und liefert die Laufzeiten in Millisekunden. [setup] läuft vor JEDEM
|
||||||
|
/// Durchlauf und wird nicht mitgemessen.
|
||||||
|
Future<List<double>> sample(
|
||||||
|
Future<void> Function() body, {
|
||||||
|
int warmup = 1,
|
||||||
|
int runs = 5,
|
||||||
|
Future<void> Function()? setup,
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < warmup; i++) {
|
||||||
|
if (setup != null) await setup();
|
||||||
|
await body();
|
||||||
|
}
|
||||||
|
final out = <double>[];
|
||||||
|
for (var i = 0; i < runs; i++) {
|
||||||
|
if (setup != null) await setup();
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
await body();
|
||||||
|
sw.stop();
|
||||||
|
out.add(sw.elapsedMicroseconds / 1000.0);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
double median(List<double> values) {
|
||||||
|
final sorted = [...values]..sort();
|
||||||
|
final mid = sorted.length ~/ 2;
|
||||||
|
return sorted.length.isOdd
|
||||||
|
? sorted[mid]
|
||||||
|
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BenchMetric {
|
||||||
|
BenchMetric({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.unit,
|
||||||
|
required this.value,
|
||||||
|
this.min,
|
||||||
|
this.runs = 1,
|
||||||
|
this.compare = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
/// Median (bzw. Einzelwert) – der Wert, der verglichen wird.
|
||||||
|
final double value;
|
||||||
|
final double? min;
|
||||||
|
final int runs;
|
||||||
|
|
||||||
|
/// `false` für reine Info-Werte (z. B. Dateigröße), die nicht als
|
||||||
|
/// Regression gewertet werden.
|
||||||
|
final bool compare;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'label': label,
|
||||||
|
'unit': unit,
|
||||||
|
'value': double.parse(value.toStringAsFixed(3)),
|
||||||
|
if (min != null) 'min': double.parse(min!.toStringAsFixed(3)),
|
||||||
|
'runs': runs,
|
||||||
|
'compare': compare,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BenchReport {
|
||||||
|
BenchReport(this.suite, {this.params = const {}});
|
||||||
|
|
||||||
|
final String suite;
|
||||||
|
final Map<String, Object?> params;
|
||||||
|
final _metrics = <BenchMetric>[];
|
||||||
|
|
||||||
|
/// Laufzeit-Messreihe (ms) als Metrik ablegen.
|
||||||
|
void addTimings(String id, String label, List<double> timingsMs) {
|
||||||
|
_metrics.add(
|
||||||
|
BenchMetric(
|
||||||
|
id: id,
|
||||||
|
label: label,
|
||||||
|
unit: 'ms',
|
||||||
|
value: median(timingsMs),
|
||||||
|
min: timingsMs.reduce((a, b) => a < b ? a : b),
|
||||||
|
runs: timingsMs.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addValue(
|
||||||
|
String id,
|
||||||
|
String label,
|
||||||
|
double value,
|
||||||
|
String unit, {
|
||||||
|
bool compare = false,
|
||||||
|
}) {
|
||||||
|
_metrics.add(
|
||||||
|
BenchMetric(
|
||||||
|
id: id,
|
||||||
|
label: label,
|
||||||
|
unit: unit,
|
||||||
|
value: value,
|
||||||
|
compare: compare,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double valueOf(String id) => _metrics.firstWhere((m) => m.id == id).value;
|
||||||
|
|
||||||
|
/// Schreibt Bericht + ggf. Baseline und liefert die Liste der Regressionen
|
||||||
|
/// (leer = alles im Rahmen).
|
||||||
|
Future<List<String>> finish() async {
|
||||||
|
final baselineFile = File('benchmark/baseline/$suite.json');
|
||||||
|
Map<String, Object?>? baseline;
|
||||||
|
if (baselineFile.existsSync()) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(baselineFile.readAsStringSync()) as Map<String, Object?>;
|
||||||
|
baseline = (json['metrics'] as Map).cast<String, Object?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
final regressions = <String>[];
|
||||||
|
final buf = StringBuffer()
|
||||||
|
..writeln()
|
||||||
|
..writeln(
|
||||||
|
'══ Benchmark "$suite" '
|
||||||
|
'${params.isEmpty ? '' : params.entries.map((e) => '${e.key}=${e.value}').join(', ')}',
|
||||||
|
)
|
||||||
|
..writeln(
|
||||||
|
'${'Messung'.padRight(52)}${'Wert'.padLeft(12)} ${'Baseline'.padLeft(10)} Δ',
|
||||||
|
);
|
||||||
|
for (final m in _metrics) {
|
||||||
|
final base = (baseline?[m.id] as Map?)?['value'] as num?;
|
||||||
|
var delta = '';
|
||||||
|
// Für Prozent-Werte (z. B. Verschlüsselungs-Aufschlag) wäre ein
|
||||||
|
// relatives Δ irreführend (4 % → 11 % = „+150 %“) – dort nur die Werte.
|
||||||
|
if (base != null && base > 0 && m.unit != '%') {
|
||||||
|
final pct = (m.value - base) / base * 100;
|
||||||
|
delta = '${pct >= 0 ? '+' : ''}${pct.toStringAsFixed(0)} %';
|
||||||
|
if (m.compare &&
|
||||||
|
pct > regressionThresholdPercent &&
|
||||||
|
(m.unit != 'ms' || m.value - base > regressionFloorMs)) {
|
||||||
|
delta += ' ⚠ LANGSAMER';
|
||||||
|
regressions.add(
|
||||||
|
'${m.label}: ${_fmt(m.value)} ${m.unit} '
|
||||||
|
'(Baseline ${_fmt(base.toDouble())} ${m.unit}, $delta)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.writeln(
|
||||||
|
'${m.label.padRight(52)}'
|
||||||
|
'${'${_fmt(m.value)} ${m.unit}'.padLeft(12)} '
|
||||||
|
'${base == null ? '–'.padLeft(10) : _fmt(base.toDouble()).padLeft(10)} $delta',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(buf);
|
||||||
|
|
||||||
|
final report = {
|
||||||
|
'suite': suite,
|
||||||
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
|
'git': await _gitRevision(),
|
||||||
|
'host': {
|
||||||
|
'os': Platform.operatingSystemVersion,
|
||||||
|
'cpus': Platform.numberOfProcessors,
|
||||||
|
},
|
||||||
|
'params': params,
|
||||||
|
'metrics': {for (final m in _metrics) m.id: m.toJson()},
|
||||||
|
};
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
final outFile = File('build/benchmarks/$suite.json');
|
||||||
|
outFile.parent.createSync(recursive: true);
|
||||||
|
outFile.writeAsStringSync(encoder.convert(report));
|
||||||
|
|
||||||
|
if (Platform.environment['PYRAMID_BENCH_UPDATE_BASELINE'] == '1') {
|
||||||
|
baselineFile.parent.createSync(recursive: true);
|
||||||
|
baselineFile.writeAsStringSync('${encoder.convert(report)}\n');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Baseline aktualisiert: ${baselineFile.path}');
|
||||||
|
}
|
||||||
|
return regressions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fmt(double v) =>
|
||||||
|
v >= 100 ? v.toStringAsFixed(0) : v.toStringAsFixed(v >= 10 ? 1 : 2);
|
||||||
|
|
||||||
|
static Future<String?> _gitRevision() async {
|
||||||
|
try {
|
||||||
|
final r = await Process.run('git', ['rev-parse', '--short', 'HEAD']);
|
||||||
|
return r.exitCode == 0 ? (r.stdout as String).trim() : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get strictBench => Platform.environment['PYRAMID_BENCH_STRICT'] == '1';
|
||||||
|
|
||||||
|
// ── SQLCipher aus dem App-Build ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Die vom sqlcipher_flutter_libs-Plugin gebaute `sqlite3.dll` – dieselbe
|
||||||
|
/// Bibliothek, die die App benutzt. `null`, wenn noch nie gebaut wurde.
|
||||||
|
String? findCipherDll() {
|
||||||
|
for (final config in ['Release', 'Profile', 'Debug']) {
|
||||||
|
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||||
|
if (file.existsSync()) return file.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Läuft (wie in der App) im Hintergrund-Isolate von sqflite_common_ffi –
|
||||||
|
/// deshalb top-level und ohne eingefangene Variablen.
|
||||||
|
void cipherFfiInit() {
|
||||||
|
final dll = findCipherDll();
|
||||||
|
if (dll == null) throw StateError('SQLCipher-DLL fehlt');
|
||||||
|
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open(dll));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory wie in der App: eigenes DB-Isolate, SQLCipher-Bibliothek.
|
||||||
|
/// Ohne `PRAGMA key` verhält sich SQLCipher wie normales SQLite – so laufen
|
||||||
|
/// Klartext- und verschlüsselte Messung mit DERSELBEN Bibliothek und sind
|
||||||
|
/// fair vergleichbar.
|
||||||
|
DatabaseFactory cipherFactory() =>
|
||||||
|
createDatabaseFactoryFfi(ffiInit: cipherFfiInit);
|
||||||
|
|
||||||
|
/// Dieselben PRAGMAs wie `AppDatabase._applyPragmas` (dort privat).
|
||||||
|
Future<void> applyAppPragmas(Database db) async {
|
||||||
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gesamtgröße der DB inkl. WAL-Sidecar in MB.
|
||||||
|
double dbSizeMb(String path) {
|
||||||
|
var bytes = 0;
|
||||||
|
for (final suffix in ['', '-wal']) {
|
||||||
|
final f = File('$path$suffix');
|
||||||
|
if (f.existsSync()) bytes += f.lengthSync();
|
||||||
|
}
|
||||||
|
return bytes / (1024 * 1024);
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
+35
-1
@@ -70,6 +70,39 @@ Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
|
|||||||
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
|
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
|
||||||
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
||||||
|
|
||||||
|
## Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)
|
||||||
|
|
||||||
|
Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):
|
||||||
|
|
||||||
|
- **Pro Raum ein Chat-Verlauf** (Android `MessagingStyle`) mit den letzten 8
|
||||||
|
Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel.
|
||||||
|
Der Verlauf liegt in SharedPreferences (`pyramid_notif_history`), weil bei
|
||||||
|
gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur
|
||||||
|
fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt
|
||||||
|
oder Raum geöffnet → nächster Push beginnt neu).
|
||||||
|
- **Gleiche eventId = Aktualisierung**, nicht neue Nachricht: der
|
||||||
|
„Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen
|
||||||
|
lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln
|
||||||
|
(`setOnlyAlertOnce(!isNew)`).
|
||||||
|
- **Text:** nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat
|
||||||
|
entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw.
|
||||||
|
Logik in `lib/core/notification_content.dart` (Tests:
|
||||||
|
`test/notification_content_test.dart`). Einstellung „Nachrichtenvorschau“
|
||||||
|
gilt jetzt auch bei gekillter App.
|
||||||
|
- **Bilder/Avatare:** Dart legt sie unter `<cache>/downloads/notif/` ab
|
||||||
|
(Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native
|
||||||
|
reicht sie per FileProvider-URI an die System-UI weiter
|
||||||
|
(`grantUriPermission("com.android.systemui")`). Dateien älter als 2 Tage
|
||||||
|
werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt
|
||||||
|
`NotificationHelper` auf reinen Text zurück.
|
||||||
|
- **„Gelesen“** setzt jetzt eine echte Lesebestätigung bis zur neuesten
|
||||||
|
Nachricht (Haupt-Engine `markReadFromNotification` bzw. BgEngine
|
||||||
|
`markRead`), statt nur zu schließen.
|
||||||
|
- **Testen ohne Login (nur Debug-Build):** `DebugNotifyReceiver`
|
||||||
|
(`android/app/src/debug/`), z. B.
|
||||||
|
`adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'"`
|
||||||
|
(`--ez image true` für ein Testbild). Nicht im Release-APK.
|
||||||
|
|
||||||
## Verschlüsselt antworten (gekillt, vollständig still)
|
## Verschlüsselt antworten (gekillt, vollständig still)
|
||||||
|
|
||||||
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
||||||
@@ -103,7 +136,8 @@ Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
|||||||
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
||||||
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
||||||
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
||||||
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) |
|
| `android/.../NotificationHelper.kt` | Native Benachrichtigung: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen |
|
||||||
|
| `lib/core/notification_content.dart` | Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien) |
|
||||||
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
||||||
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
||||||
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
||||||
|
|||||||
+78
-10
@@ -1,6 +1,7 @@
|
|||||||
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||||
|
|
||||||
Stand: 2026-07-03. Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
|
||||||
|
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||||
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
||||||
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
||||||
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
||||||
@@ -8,25 +9,92 @@ Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
|
|||||||
|
|
||||||
Schnellster Weg: `flutter run -d windows`.
|
Schnellster Weg: `flutter run -d windows`.
|
||||||
|
|
||||||
|
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
|
||||||
|
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
|
||||||
|
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
|
||||||
|
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
|
||||||
|
|
||||||
## 0. Vorab (1 Minute)
|
## 0. Vorab (1 Minute)
|
||||||
|
|
||||||
- [ ] `flutter test` – die Regressionstests unter `test/` (Soft-Logout-Guard,
|
- [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
|
||||||
Power-Levels-Schutz, AuthLog, Storage-Fassade `settings_prefs` –
|
SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
|
||||||
Stand 2026-07-03: 24 Tests) müssen grün sein, bevor irgendetwas
|
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
|
||||||
anderes geprüft wird.
|
mehrfach gelaufen (vor/nach jedem Umbau des Tages).
|
||||||
|
|
||||||
|
- [x] **Seit 2026-10-07: alles Automatisierbare mit EINEM Befehl** –
|
||||||
|
`pwsh scripts/test.ps1 -All` (Analyse, 41 Unit-Tests, Live-Test mit
|
||||||
|
Test-Account, Benchmarks). Details: `docs/TESTS_UND_BENCHMARKS.md`.
|
||||||
|
|
||||||
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
||||||
|
|
||||||
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
||||||
|
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
|
||||||
|
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
|
||||||
|
|
||||||
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
||||||
- [ ] `auth_log.txt` (App-Support-Verzeichnis) prüfen: LoginState-Wechsel
|
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
|
||||||
werden mitgeschrieben, keine unerwarteten Warnungen
|
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
|
||||||
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea)
|
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
|
||||||
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?**
|
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
|
||||||
- [ ] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
|
||||||
|
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
|
||||||
|
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
|
||||||
|
Soft-Logout-Eintrag.
|
||||||
|
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
|
||||||
|
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
|
||||||
|
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||||
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
||||||
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
||||||
|
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
|
||||||
|
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
|
||||||
|
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
|
||||||
|
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
|
||||||
|
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
|
||||||
|
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
|
||||||
|
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
|
||||||
|
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
|
||||||
|
Migration). Danach zwei geplante Neustarts für die Refactoring-
|
||||||
|
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
|
||||||
|
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
|
||||||
|
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
|
||||||
|
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
|
||||||
|
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
|
||||||
|
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
|
||||||
|
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
|
||||||
|
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
|
||||||
|
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
|
||||||
|
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
|
||||||
|
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
|
||||||
|
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
|
||||||
|
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
|
||||||
|
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
|
||||||
|
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
|
||||||
|
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
|
||||||
|
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
|
||||||
|
echten Mehr-Stunden-Fenster abhaken.
|
||||||
|
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
|
||||||
|
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
|
||||||
|
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
|
||||||
|
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
|
||||||
|
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
|
||||||
|
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
|
||||||
|
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
|
||||||
|
läuft weiter, nächstes Fenster ab 23:12.
|
||||||
|
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
|
||||||
|
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
|
||||||
|
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
|
||||||
|
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
|
||||||
|
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
|
||||||
|
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
|
||||||
|
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
|
||||||
|
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
|
||||||
|
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
|
||||||
|
Fenster ab 03:06.
|
||||||
|
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
|
||||||
|
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
|
||||||
|
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
|
||||||
|
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
|
||||||
|
Stück.)
|
||||||
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
||||||
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
||||||
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Tests & Benchmarks
|
||||||
|
|
||||||
|
Stand: 2026-10-07. Alles läuft mit **einem Befehl** aus dem Projektordner:
|
||||||
|
|
||||||
|
```
|
||||||
|
pwsh scripts/test.ps1 # Analyse + Unit-Tests (~1 min) – vor jedem Commit
|
||||||
|
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
|
||||||
|
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
||||||
|
pwsh scripts/test.ps1 -Ui # + UI-Durchlauf im Handy-Format mit Screenshots
|
||||||
|
pwsh scripts/test.ps1 -All # alles (~4 min)
|
||||||
|
```
|
||||||
|
|
||||||
|
Am Ende steht eine Zusammenfassung mit OK/FEHLER je Schritt. Fehlt die
|
||||||
|
SQLCipher-Bibliothek (frischer Checkout), baut das Skript einmalig
|
||||||
|
`flutter build windows --debug`.
|
||||||
|
|
||||||
|
**Für Bernd in einem Satz:** Die Tests prüfen automatisch die heiklen Stellen
|
||||||
|
(Login-/Logout-Schutz, Verschlüsselung der Datenbank, Suche, Einstellungen),
|
||||||
|
die Benchmarks messen, ob die App dabei langsamer wird. Was man nur mit Klicken
|
||||||
|
oder zwei echten Geräten prüfen kann, steht weiterhin in `docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
## Tests (`test/`)
|
||||||
|
|
||||||
|
| Datei | Prüft | Läuft wann |
|
||||||
|
|---|---|---|
|
||||||
|
| `app_database_test.dart` | SQLCipher: Bibliothek echt, Header-Erkennung, Klartext→verschlüsselt-Migration, falscher Schlüssel, Fehlschlag lässt Daten unangetastet | immer (braucht die DLL aus dem Windows-Build) |
|
||||||
|
| `soft_logout_guard_test.dart` | Uta-Random-Logout-Fix: Token-Refresh-Fehler enden nie im stillen Logout | immer |
|
||||||
|
| `auth_log_test.dart` | Login-Protokoll (Diagnose für Logout-Bugs) | immer |
|
||||||
|
| `power_levels_safety_test.dart` | Space-Admin kann sich nicht selbst aussperren | immer |
|
||||||
|
| `settings_prefs_test.dart` | VoicePrefs-Fassade (gespeicherte Spracheinstellungen) | immer |
|
||||||
|
| `streaming_presets_test.dart` | 60-FPS-Streaming-Presets (Auflösung/FPS/Bitrate) | immer |
|
||||||
|
| `local_message_search_test.dart` | Lokale Nachrichtensuche (Strg+K): Treffer, Sortierung, Groß-/Kleinschreibung inkl. Umlaute, Medien-Filter, Trefferdeckel | immer (neu 2026-10-07) |
|
||||||
|
| `live_encryption_e2e_test.dart` | Echter Login (pyramidtest1) → Platte verschlüsselt → Neustart → Logout → Re-Login → alte Nachricht lesbar; Utas Update-Fall mit echter Session | nur mit `-Live` |
|
||||||
|
| `live_logout_race_test.dart` | Abmelden + sofortige Neuanmeldung auf demselben Client: neue Session muss bleiben (SDK-Wettlauf, `logoutWithoutStraySync`); mit `PYRAMID_LIVE_RACE_PROOF=1` zusätzlich Nachweis mit altem Weg | nur mit `-Live` |
|
||||||
|
| `live_ui_fixture_test.dart` | Kein Test im engeren Sinn: legt Testinhalte (DM + Gruppe mit Bildern) für den UI-Durchlauf an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
|
||||||
|
| `live_call_setup_test.dart` | Kein Test im engeren Sinn: legt den Voice-Testraum für GUI-Call-Tests mit zwei Test-Accounts an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
|
||||||
|
|
||||||
|
`test/support/synthetic_matrix_data.dart` erzeugt synthetische Räume und
|
||||||
|
Nachrichten über die **echte SDK-Speicherschicht** – komplett offline, ohne
|
||||||
|
Konten. Tests und Benchmarks nutzen sie gemeinsam.
|
||||||
|
|
||||||
|
Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos
|
||||||
|
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
|
||||||
|
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
|
||||||
|
|
||||||
|
## UI-Durchlauf (`integration_test/`, seit 2026-10-07)
|
||||||
|
|
||||||
|
`integration_test/mobile_ui_screens_test.dart` startet die ECHTE App
|
||||||
|
(Windows-Build) mit dem Testprofil `pyramidtest1`
|
||||||
|
(`PYRAMID_PROFILE_DIR=%USERPROFILE%\.pyramid-autopilot\profile1`) in
|
||||||
|
simulierter Handy-Größe (1080×2340, Statusleiste 24 dp, Gestenleiste 16 dp),
|
||||||
|
tippt sich durch Chatliste → Chat → Bild und prüft dabei: Vollansicht öffnet,
|
||||||
|
Tippen daneben schließt, Tippen aufs Bild schließt nicht, Doppeltipp zoomt,
|
||||||
|
Wischen nach unten schließt. Screenshots landen in `build/ui_screens/` – damit
|
||||||
|
lassen sich UI-Änderungen ohne Handy ansehen.
|
||||||
|
|
||||||
|
- Testinhalte (DM von pyramidtest2 mit langem Text, Antwort, Hoch- und
|
||||||
|
Querformat-Bild) legt `test/live_ui_fixture_test.dart` an
|
||||||
|
(`PYRAMID_LIVE_TEST=1`).
|
||||||
|
- Läuft nur, wenn keine Pyramid-Instanz offen ist (nie zwei Clients auf
|
||||||
|
einer Datenbank) – das Skript prüft das.
|
||||||
|
- Simulierte Fensterklicks (PostMessage) erreichen Flutter unter Windows
|
||||||
|
NICHT; deshalb dieser Weg über `integration_test`.
|
||||||
|
|
||||||
|
## Benchmarks (`benchmark/`)
|
||||||
|
|
||||||
|
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
|
||||||
|
`flutter test benchmark --concurrency=1`).
|
||||||
|
|
||||||
|
| Suite | Misst |
|
||||||
|
|---|---|
|
||||||
|
| `db_benchmark_test.dart` | Klartext- vs. SQLCipher-DB mit derselben Bibliothek: Sync-Schreiben (Transaktionen wie beim echten Sync), App-Start (DB öffnen + Raumliste), Chat öffnen (50 Nachrichten), lokale Suche (ohne Treffer = Worst Case, mit Treffern) |
|
||||||
|
| `migration_benchmark_test.dart` | Dauer der einmaligen Klartext→SQLCipher-Migration beim ersten Start nach dem Update (exakt der App-Codepfad), danach Vollständigkeitsprüfung |
|
||||||
|
|
||||||
|
**Wie gemessen wird:** DB im eigenen Isolate wie in der App; ungemessener
|
||||||
|
Aufwärm-Durchlauf; Modi abwechselnd A-B-B-A (ohne das gewann einfach der
|
||||||
|
später gemessene Modus um ±20 %, weil der Dart-JIT während des Laufs schneller
|
||||||
|
wird); Median mehrerer Durchläufe.
|
||||||
|
|
||||||
|
**Baseline & Regressionen:** Jeder Lauf schreibt `build/benchmarks/<suite>.json`
|
||||||
|
und vergleicht mit `benchmark/baseline/<suite>.json`. Mehr als 25 % langsamer
|
||||||
|
(und bei Zeiten mehr als 2 ms absolut) wird als „⚠ LANGSAMER" markiert.
|
||||||
|
Umgebungsvariablen:
|
||||||
|
|
||||||
|
- `-UpdateBaseline` bzw. `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als
|
||||||
|
neue Baseline speichern (nach bewusster Änderung oder auf neuer Hardware).
|
||||||
|
Die Baseline gilt nur für den Rechner, auf dem sie entstand.
|
||||||
|
- `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern.
|
||||||
|
- `PYRAMID_BENCH_SCALE=0.2` – kleinere Datenmenge für einen schnellen Probelauf
|
||||||
|
(andere Mengen sind mit der Baseline nicht vergleichbar).
|
||||||
|
|
||||||
|
### Erste Ergebnisse (2026-10-07, Bernds PC, 12 Kerne, Commit b2413ef)
|
||||||
|
|
||||||
|
20 Räume × 500 Nachrichten (DB-Suite), 20 × 1000 (Migration):
|
||||||
|
|
||||||
|
| Messung | Klartext | SQLCipher |
|
||||||
|
|---|---|---|
|
||||||
|
| Sync-Schreiben je Nachricht | 0,25 ms | 0,26 ms |
|
||||||
|
| App-Start (DB + Raumliste) | 4,1 ms | 4,3 ms |
|
||||||
|
| Chat öffnen (50 Nachrichten) | 0,33 ms | 0,32 ms |
|
||||||
|
| Suche ohne Treffer (10 000 Nachrichten) | 64 ms | 64 ms |
|
||||||
|
| Suche mit Treffern | 32 ms | 32 ms |
|
||||||
|
| Migration Klartext → SQLCipher | – | 325 ms für 7,8 MB (≈ 42 ms/MB) |
|
||||||
|
|
||||||
|
Was das heißt:
|
||||||
|
|
||||||
|
- **Die Verschlüsselung kostet praktisch nichts.** Alle Unterschiede liegen im
|
||||||
|
Messrauschen (±5–10 %, zwei Läufe hintereinander). Die Kosten stecken im
|
||||||
|
Matrix-SDK selbst (JSON-Verarbeitung), nicht in SQLCipher.
|
||||||
|
- **Sync-Schreiben** liegt bei ~0,26 ms pro Nachricht und hängt kaum von der
|
||||||
|
Raumgröße ab (nachgemessen: 250 vs. 4 000 Nachrichten pro Raum ≈ +15 %).
|
||||||
|
- **Suche** wächst linear mit der Zahl der gespeicherten Nachrichten
|
||||||
|
(gedeckelt auf 1 500 pro Raum). Hochrechnung, nicht gemessen: 100 aktive
|
||||||
|
Räume am Deckel ≈ 150 000 Nachrichten ≈ 1 s pro Suche auf diesem PC –
|
||||||
|
auf Handys entsprechend länger. Kandidat für einen Suchindex (FTS), falls
|
||||||
|
das in der Praxis stört.
|
||||||
|
- **Migration**: Hochrechnung für eine 100-MB-DB ≈ 4 s einmalig auf diesem
|
||||||
|
PC; auf Utas Handy vermutlich ein Mehrfaches davon (nicht gemessen).
|
||||||
|
|
||||||
|
## Bekannte Grenzen (ehrlich)
|
||||||
|
|
||||||
|
- Synthetische Daten: unverschlüsselte Räume, keine Olm/Megolm-Sessions
|
||||||
|
(vodozemac läuft im Host-Test nicht). Die DB-Struktur ist aber die echte.
|
||||||
|
- Gemessen auf Windows im JIT-Modus von `flutter test`; die Release-App läuft
|
||||||
|
AOT-kompiliert und ist eher schneller. Für Vergleiche über die Zeit taugt
|
||||||
|
das, für absolute Aussagen über Handys nicht.
|
||||||
|
- Nicht automatisiert: alles mit Klicks, echte Calls/Audio, 60-FPS-Streaming
|
||||||
|
zwischen zwei Geräten, Android-Push → `docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
## Nächste sinnvolle Messungen
|
||||||
|
|
||||||
|
- **Streaming (M4 „Ursache messen"):** WebRTC-`getStats()` des Senders
|
||||||
|
auswerten (`framesPerSecond`, `qualityLimitationReason`, Encoder-Name) –
|
||||||
|
zeigt direkt, ob CPU, Bandbreite oder Encoder bremst. Braucht eine kleine
|
||||||
|
Diagnose-Anzeige im Call und zwei echte Geräte.
|
||||||
|
- **UI-Flüssigkeit:** `integration_test` mit Frame-Timing beim Scrollen der
|
||||||
|
Raumliste/Timeline (Windows, Test-Account-Profil über `PYRAMID_PROFILE_DIR`).
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
|
||||||
|
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
|
||||||
|
///
|
||||||
|
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
|
||||||
|
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
|
||||||
|
/// Bernds echtes Profil. Testinhalte vorher mit
|
||||||
|
/// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2).
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
|
///
|
||||||
|
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
|
||||||
|
/// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum)
|
||||||
|
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
|
||||||
|
///
|
||||||
|
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
|
||||||
|
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
|
||||||
|
const _otherUser = 'pyramidtest2';
|
||||||
|
const _groupName = 'UI-Test Gruppe';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
|
||||||
|
(tester) async {
|
||||||
|
tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625
|
||||||
|
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||||
|
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text(_otherUser),
|
||||||
|
timeout: const Duration(seconds: 90),
|
||||||
|
);
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
await _shot(tester, '01_chatliste');
|
||||||
|
|
||||||
|
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||||
|
await tester.tap(find.text(_otherUser).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 6));
|
||||||
|
await _scrollToNewest(tester);
|
||||||
|
await _shot(tester, '02_chat');
|
||||||
|
|
||||||
|
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '02b_chat_liste');
|
||||||
|
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// ── Bild-Vollansicht ──
|
||||||
|
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
|
||||||
|
Future<void> openViewer() async {
|
||||||
|
final image = _largestImage(tester);
|
||||||
|
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
|
||||||
|
await tester.ensureVisible(image!);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
await tester.tap(image);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openViewer();
|
||||||
|
await _shot(tester, '03_vollansicht');
|
||||||
|
|
||||||
|
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height - 30));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '04_nach_tippen_daneben');
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
|
||||||
|
await openViewer();
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
|
||||||
|
Future<void> doubleTapCenter() async {
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
await doubleTapCenter();
|
||||||
|
final viewer = tester.widget<InteractiveViewer>(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
viewer.transformationController!.value.getMaxScaleOnAxis(),
|
||||||
|
greaterThan(1.5),
|
||||||
|
reason: 'Doppeltipp hat nicht gezoomt',
|
||||||
|
);
|
||||||
|
await _shot(tester, '05_doppeltipp_zoom');
|
||||||
|
await doubleTapCenter();
|
||||||
|
|
||||||
|
// Wischen nach unten schließt.
|
||||||
|
await tester.dragFrom(
|
||||||
|
Offset(size.width / 2, size.height / 2),
|
||||||
|
const Offset(0, 320),
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
|
||||||
|
);
|
||||||
|
await _shot(tester, '06_nach_wischen');
|
||||||
|
|
||||||
|
// ── Farbmodus Hell (nur Zustand) ──
|
||||||
|
final savedVariant = container.read(themeVariantProvider);
|
||||||
|
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '07_chat_hell');
|
||||||
|
container.read(themeVariantProvider.notifier).state = savedVariant;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text(_groupName),
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text(_groupName).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 5));
|
||||||
|
await _scrollToNewest(tester);
|
||||||
|
await _shot(tester, '08_gruppe');
|
||||||
|
|
||||||
|
// ── Einstellungen → Erscheinungsbild ──
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await tester.tap(find.text('Erscheinungsbild').first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '09_einstellungen_erscheinungsbild');
|
||||||
|
await tester.drag(
|
||||||
|
find.text('Schriftgröße').first,
|
||||||
|
const Offset(0, -500),
|
||||||
|
warnIfMissed: false,
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await _shot(tester, '10_einstellungen_text_chat');
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
},
|
||||||
|
skip: profile == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 6)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder? _largestImage(WidgetTester tester) {
|
||||||
|
Element? best;
|
||||||
|
var bestArea = 150.0 * 150.0;
|
||||||
|
for (final element in find.byType(Image).evaluate()) {
|
||||||
|
final box = element.renderObject;
|
||||||
|
if (box is! RenderBox || !box.hasSize) continue;
|
||||||
|
final area = box.size.width * box.size.height;
|
||||||
|
if (area > bestArea) {
|
||||||
|
bestArea = area;
|
||||||
|
best = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best == null ? null : find.byElementPredicate((e) => e == best);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
|
||||||
|
Future<void> _scrollToNewest(WidgetTester tester) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
final lists = find.byType(Scrollable);
|
||||||
|
if (lists.evaluate().isEmpty) return;
|
||||||
|
await tester.fling(lists.first, const Offset(0, -1500), 3000,
|
||||||
|
warnIfMissed: false);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
|
||||||
|
final end = DateTime.now().add(duration);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
required Duration timeout,
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
fail('Zeitüberschreitung: $finder nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump();
|
||||||
|
final renderView = tester.binding.renderViews.first;
|
||||||
|
final layer = renderView.debugLayer! as OffsetLayer;
|
||||||
|
final physical = tester.view.physicalSize;
|
||||||
|
final image = await tester.binding.runAsync(
|
||||||
|
() => layer.toImage(Offset.zero & physical),
|
||||||
|
);
|
||||||
|
final bytes = await tester.binding.runAsync(
|
||||||
|
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final file = File('build/ui_screens/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Screenshot: ${file.path}');
|
||||||
|
}
|
||||||
+24
-6
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
|
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
|
||||||
import 'package:pyramid/core/router.dart';
|
import 'package:pyramid/core/router.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
Future<void> _loadPersistedPrefs() async {
|
Future<void> _loadPersistedPrefs() async {
|
||||||
final saved = await loadThemePrefs();
|
final saved = await loadThemePrefs();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ref.read(themeModeProvider.notifier).state = saved.isDark;
|
ref.read(themeVariantProvider.notifier).state = saved.variant;
|
||||||
|
ref.read(textScaleProvider.notifier).state = saved.textScale;
|
||||||
|
ref.read(messageStyleProvider.notifier).state = saved.messageStyle;
|
||||||
ref.read(accentProvider.notifier).state = saved.accentIdx;
|
ref.read(accentProvider.notifier).state = saved.accentIdx;
|
||||||
ref.read(radiusProvider.notifier).state = saved.radius;
|
ref.read(radiusProvider.notifier).state = saved.radius;
|
||||||
ref.read(motionProvider.notifier).state = saved.motion;
|
ref.read(motionProvider.notifier).state = saved.motion;
|
||||||
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
final isDark = ref.watch(themeModeProvider);
|
final variant = ref.watch(themeVariantProvider);
|
||||||
|
final textScale = ref.watch(textScaleProvider);
|
||||||
final accentIdx = ref.watch(accentProvider);
|
final accentIdx = ref.watch(accentProvider);
|
||||||
final radius = ref.watch(radiusProvider);
|
final radius = ref.watch(radiusProvider);
|
||||||
final density = ref.watch(densityProvider);
|
final density = ref.watch(densityProvider);
|
||||||
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
final accent = accentPresets[accentIdx].color;
|
final accent = accentPresets[accentIdx].color;
|
||||||
|
|
||||||
return PyramidTheme(
|
return PyramidTheme(
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
accent: accent,
|
accent: accent,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
motion: motion,
|
motion: motion,
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: 'Pyramid',
|
title: 'Pyramid',
|
||||||
theme: buildPyramidThemeData(false),
|
theme: buildPyramidThemeData(ThemeVariant.light),
|
||||||
darkTheme: buildPyramidThemeData(true),
|
darkTheme: buildPyramidThemeData(
|
||||||
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
variant.isDark ? variant : ThemeVariant.soft),
|
||||||
|
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
// Schriftgröße: Pyramid-Regler × Handy-/Systemeinstellung, für alle
|
||||||
|
// Seiten und Dialoge (liegt über dem Navigator).
|
||||||
|
builder: (context, child) {
|
||||||
|
final mq = MediaQuery.of(context);
|
||||||
|
final factor = textScale ??
|
||||||
|
defaultTextScale(mobile: isMobileLayout(mq.size));
|
||||||
|
return MediaQuery(
|
||||||
|
data: mq.copyWith(
|
||||||
|
textScaler: PyramidTextScaler(mq.textScaler, factor),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+12
-13
@@ -1,15 +1,21 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
|
||||||
import 'package:pyramid/core/voip_manager.dart';
|
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
|
||||||
// ─── Theme state ──────────────────────────────────────────────────────────────
|
// ─── Theme state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
|
final themeVariantProvider =
|
||||||
|
StateProvider<ThemeVariant>((ref) => ThemeVariant.soft);
|
||||||
|
|
||||||
|
/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0).
|
||||||
|
final textScaleProvider = StateProvider<double?>((ref) => null);
|
||||||
|
|
||||||
|
final messageStyleProvider =
|
||||||
|
StateProvider<MessageStyle>((ref) => MessageStyle.auto);
|
||||||
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
||||||
|
|
||||||
const accentPresets = [
|
const accentPresets = [
|
||||||
@@ -64,16 +70,9 @@ enum ModalKind { none, settings, search }
|
|||||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||||
|
|
||||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||||
|
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||||
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
|
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||||
return LiveKitCallManager.instance;
|
// und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
|
||||||
});
|
|
||||||
|
|
||||||
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
|
|
||||||
return PyramidVoipManager.instance;
|
|
||||||
});
|
|
||||||
|
|
||||||
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
|
|
||||||
|
|
||||||
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Darstellungs-Einstellungen, die nicht reine Farben sind (die stehen in
|
||||||
|
/// theme.dart): Handy-Layout-Erkennung, Schriftgröße, Nachrichten-Darstellung.
|
||||||
|
|
||||||
|
/// Unterhalb dieser Breite (oder sehr flach, z. B. Handy quer) zeigt Pyramid
|
||||||
|
/// das Handy-Layout (eine Spalte, Chat als eigene Seite).
|
||||||
|
bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
|
||||||
|
|
||||||
|
// ── Schriftgröße ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
|
||||||
|
/// Handy größer (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher.
|
||||||
|
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0;
|
||||||
|
|
||||||
|
const minTextScale = 0.85;
|
||||||
|
const maxTextScale = 1.5;
|
||||||
|
|
||||||
|
/// Kombiniert die Schriftgröße des Systems (Handy-Einstellung) mit dem
|
||||||
|
/// Pyramid-eigenen Regler: erst Pyramid-Faktor, dann die (ggf. nichtlineare)
|
||||||
|
/// Systemskalierung.
|
||||||
|
class PyramidTextScaler extends TextScaler {
|
||||||
|
const PyramidTextScaler(this.system, this.factor);
|
||||||
|
|
||||||
|
final TextScaler system;
|
||||||
|
final double factor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double scale(double fontSize) => system.scale(fontSize * factor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
double get textScaleFactor => system.textScaleFactor * factor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is PyramidTextScaler &&
|
||||||
|
other.system == system &&
|
||||||
|
other.factor == factor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(system, factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nachrichten-Darstellung ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wie Nachrichten im Chat aussehen (Einstellungen → Erscheinungsbild).
|
||||||
|
enum MessageStyle {
|
||||||
|
/// Handy: Sprechblasen, PC: Liste.
|
||||||
|
auto,
|
||||||
|
|
||||||
|
/// Sprechblasen wie WhatsApp/Signal (eigene rechts, andere links).
|
||||||
|
bubbles,
|
||||||
|
|
||||||
|
/// Liste wie Discord/Element (Name + Text untereinander).
|
||||||
|
list;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
MessageStyle.auto => 'Automatisch',
|
||||||
|
MessageStyle.bubbles => 'Sprechblasen',
|
||||||
|
MessageStyle.list => 'Liste',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tatsächlich anzuwendende Darstellung.
|
||||||
|
bool useBubbles({required bool mobile}) => switch (this) {
|
||||||
|
MessageStyle.auto => mobile,
|
||||||
|
MessageStyle.bubbles => true,
|
||||||
|
MessageStyle.list => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
static MessageStyle fromName(String? name) => MessageStyle.values
|
||||||
|
.firstWhere((v) => v.name == name, orElse: () => MessageStyle.auto);
|
||||||
|
}
|
||||||
@@ -2,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,10 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:pyramid/core/app_database.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:pyramid/core/notification_content.dart';
|
||||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
|
||||||
|
|
||||||
/// Port name used to locate the main isolate from background isolates.
|
/// Port name used to locate the main isolate from background isolates.
|
||||||
/// Registered by notification_service.dart when the app starts.
|
/// Registered by notification_service.dart when the app starts.
|
||||||
@@ -66,6 +65,11 @@ void notificationEngineMain() {
|
|||||||
args['room_id'] as String?,
|
args['room_id'] as String?,
|
||||||
args['text'] as String?,
|
args['text'] as String?,
|
||||||
);
|
);
|
||||||
|
case 'markRead':
|
||||||
|
return await _bgMarkRead(
|
||||||
|
args['room_id'] as String?,
|
||||||
|
args['event_id'] as String?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
||||||
@@ -90,26 +94,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
|
|||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 20));
|
.timeout(const Duration(seconds: 20));
|
||||||
if (event == null) return false;
|
if (event == null) return false;
|
||||||
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
|
||||||
if (event.body.startsWith('**')) return false;
|
|
||||||
|
|
||||||
final room = client.getRoomById(roomId);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
final content = MessageNotificationContent.fromEvent(
|
||||||
event.senderId.split(':').first.replaceFirst('@', '');
|
event,
|
||||||
final title = (room?.isDirectChat ?? true)
|
showPreview: prefs.getBool('notif_preview') ?? true,
|
||||||
? sender
|
);
|
||||||
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
// Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
|
||||||
|
if (content == null) return false;
|
||||||
|
|
||||||
// Hand the decrypted content back to native so it re-shows the SAME
|
// Hand the decrypted content back to native so it re-shows the SAME
|
||||||
// notification via NotificationHelper — that keeps the reliable
|
// notification via NotificationHelper — that keeps the reliable
|
||||||
// MainActivity-based reply action (the flutter_local_notifications reply
|
// ReplyReceiver-based reply action (the flutter_local_notifications reply
|
||||||
// path doesn't fire dependably when the app is killed).
|
// path doesn't fire dependably when the app is killed). Erst der Text,
|
||||||
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
// dann lautlos Avatar/Bild (deliverMessageNotification).
|
||||||
'title': title,
|
await deliverMessageNotification(
|
||||||
'body': _buildBody(event),
|
event,
|
||||||
'room_id': roomId,
|
content,
|
||||||
'notif_id': notifId ?? _stableId(roomId),
|
notifId: notifId ?? _stableId(roomId),
|
||||||
});
|
send: (a) => const MethodChannel(_kBgEngineChannel)
|
||||||
|
.invokeMethod('showDecrypted', a),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||||
@@ -154,6 +159,27 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
|
||||||
|
/// HTTP-Aufruf, keine Verschlüsselung nötig).
|
||||||
|
Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
|
||||||
|
if (roomId == null || eventId == null) return false;
|
||||||
|
final client = await _buildClient();
|
||||||
|
if (client == null) return false;
|
||||||
|
try {
|
||||||
|
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
|
||||||
|
await room
|
||||||
|
.setReadMarker(eventId, mRead: eventId)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] markRead error: $e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await client.dispose().catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
||||||
/// bootstrap the background engine. Call once at app startup.
|
/// bootstrap the background engine. Call once at app startup.
|
||||||
Future<void> registerBgEngineHandle() async {
|
Future<void> registerBgEngineHandle() async {
|
||||||
@@ -182,18 +208,13 @@ Future<Client?> _buildClient() async {
|
|||||||
await vod.init();
|
await vod.init();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
final appDir = await getApplicationSupportDirectory();
|
// Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
|
||||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
// Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
|
||||||
|
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
|
||||||
final db = await sqflite_native.databaseFactory
|
// Schlüssel fehlt) → Notification behält den nativen Platzhalter.
|
||||||
.openDatabase(dbPath)
|
final db =
|
||||||
.timeout(const Duration(seconds: 5));
|
await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
|
||||||
|
if (db == null) return null;
|
||||||
// Prevent "database is locked" if the main process still has the DB open.
|
|
||||||
try {
|
|
||||||
await db.execute('PRAGMA busy_timeout = 5000');
|
|
||||||
await db.execute('PRAGMA journal_mode = WAL');
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
|
|
||||||
@@ -224,20 +245,6 @@ Future<Client?> _buildClient() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildBody(Event event) {
|
|
||||||
final msgType = event.messageType;
|
|
||||||
final rawBody = event.body;
|
|
||||||
return switch (msgType) {
|
|
||||||
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
|
|
||||||
'm.image' => '📷 Bild',
|
|
||||||
'm.video' => '🎥 Video',
|
|
||||||
'm.audio' => '🎵 Audio',
|
|
||||||
'm.file' => '📄 Datei',
|
|
||||||
'm.sticker' => '🏷️ Sticker',
|
|
||||||
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notification action handler ───────────────────────────────────────────────
|
// ── Notification action handler ───────────────────────────────────────────────
|
||||||
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
||||||
// Must return quickly — Android keeps the notification's "sending" spinner
|
// Must return quickly — Android keeps the notification's "sending" spinner
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:pyramid/core/media_cache.dart';
|
||||||
|
|
||||||
|
/// Was eine Nachrichten-Benachrichtigung zeigt – egal, ob sie aus der
|
||||||
|
/// laufenden App (`notification_service.dart`) oder dem Hintergrund-Isolate
|
||||||
|
/// bei beendeter App (`background_push.dart`) kommt. Beide Wege übergeben
|
||||||
|
/// [toNativeArgs] an `NotificationHelper.kt`, das daraus pro Raum einen
|
||||||
|
/// Chat-Verlauf (Android MessagingStyle) baut.
|
||||||
|
class MessageNotificationContent {
|
||||||
|
const MessageNotificationContent({
|
||||||
|
required this.roomId,
|
||||||
|
required this.eventId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.senderName,
|
||||||
|
required this.roomName,
|
||||||
|
required this.body,
|
||||||
|
required this.timestamp,
|
||||||
|
this.isImage = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
final String eventId;
|
||||||
|
final String senderId;
|
||||||
|
final String senderName;
|
||||||
|
|
||||||
|
/// `null` bei Direktnachrichten – dann zeigt Android nur den Absender.
|
||||||
|
final String? roomName;
|
||||||
|
final String body;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// Bild-Nachricht: Vorschaubild wird nachgeladen ([cacheNotificationImage]).
|
||||||
|
final bool isImage;
|
||||||
|
|
||||||
|
bool get isGroup => roomName != null;
|
||||||
|
|
||||||
|
/// Einzeiliger Titel für Plattformen ohne Chat-Verlauf (Windows).
|
||||||
|
String get title => roomName == null ? senderName : '$senderName · $roomName';
|
||||||
|
|
||||||
|
/// Liest den Inhalt aus einem (entschlüsselten) Event. `null`, wenn es
|
||||||
|
/// nichts Anzeigbares ist (nicht entschlüsselt, gelöscht, kein
|
||||||
|
/// Nachrichten-Event) – dann bleibt ein evtl. Platzhalter stehen.
|
||||||
|
/// Ist [showPreview] aus (Einstellung „Vorschau“), steht nur
|
||||||
|
/// [notificationPlaceholderBody] da und es wird kein Bild geladen.
|
||||||
|
static MessageNotificationContent? fromEvent(
|
||||||
|
Event event, {
|
||||||
|
bool showPreview = true,
|
||||||
|
}) {
|
||||||
|
final body = notificationBody(event);
|
||||||
|
if (body == null) return null;
|
||||||
|
final room = event.room;
|
||||||
|
final displayName = room
|
||||||
|
.unsafeGetUserFromMemoryOrFallback(event.senderId)
|
||||||
|
.displayName
|
||||||
|
?.trim();
|
||||||
|
return MessageNotificationContent(
|
||||||
|
roomId: room.id,
|
||||||
|
eventId: event.eventId,
|
||||||
|
senderId: event.senderId,
|
||||||
|
senderName: (displayName == null || displayName.isEmpty)
|
||||||
|
? _localpart(event.senderId)
|
||||||
|
: displayName,
|
||||||
|
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
||||||
|
body: showPreview ? body : notificationPlaceholderBody,
|
||||||
|
timestamp: event.originServerTs,
|
||||||
|
isImage: showPreview && event.messageType == MessageTypes.Image,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Argumente für den nativen Kanal (`showNativeNotification` bzw.
|
||||||
|
/// `showDecrypted`). `title`/`body` bleiben für ältere Native-Pfade drin.
|
||||||
|
Map<String, Object?> toNativeArgs({
|
||||||
|
required int notifId,
|
||||||
|
String? imagePath,
|
||||||
|
String? avatarPath,
|
||||||
|
}) => {
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'roomId': roomId,
|
||||||
|
'notifId': notifId,
|
||||||
|
'eventId': eventId,
|
||||||
|
'senderId': senderId,
|
||||||
|
'senderName': senderName,
|
||||||
|
'roomName': roomName,
|
||||||
|
'isGroup': isGroup,
|
||||||
|
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||||
|
'imagePath': imagePath,
|
||||||
|
'avatarPath': avatarPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text, den Android anzeigt, solange der echte Inhalt fehlt.
|
||||||
|
const notificationPlaceholderBody = 'Neue Nachricht';
|
||||||
|
|
||||||
|
/// Längere Texte werden gekürzt (aufgeklappt zeigt Android ohnehin nicht mehr).
|
||||||
|
const notificationBodyMaxChars = 1000;
|
||||||
|
|
||||||
|
/// Anzeigetext einer Nachricht für die Benachrichtigung, oder `null`, wenn
|
||||||
|
/// das Event nichts Anzeigbares enthält.
|
||||||
|
String? notificationBody(Event event) {
|
||||||
|
if (event.redacted) return null;
|
||||||
|
if (event.type == EventTypes.Sticker) return '🏷️ Sticker';
|
||||||
|
if (event.type == _pollStartType) return '📊 Umfrage';
|
||||||
|
if (event.type != EventTypes.Message) return null;
|
||||||
|
|
||||||
|
final content = event.content;
|
||||||
|
final caption = _caption(event);
|
||||||
|
final String? text = switch (event.messageType) {
|
||||||
|
MessageTypes.Text || MessageTypes.Notice => _plainText(event),
|
||||||
|
MessageTypes.Emote => '* ${_plainText(event)}',
|
||||||
|
MessageTypes.Image => caption == null ? '📷 Foto' : '📷 $caption',
|
||||||
|
MessageTypes.Video => caption == null ? '🎥 Video' : '🎥 $caption',
|
||||||
|
MessageTypes.Audio =>
|
||||||
|
content.containsKey(_voiceKey) ? '🎤 Sprachnachricht' : '🎵 Audio',
|
||||||
|
MessageTypes.File =>
|
||||||
|
'📎 ${content.tryGet<String>('filename') ?? event.body}',
|
||||||
|
MessageTypes.Location => '📍 Standort',
|
||||||
|
MessageTypes.Sticker => '🏷️ Sticker',
|
||||||
|
MessageTypes.BadEncrypted => null,
|
||||||
|
_ => _plainText(event),
|
||||||
|
};
|
||||||
|
final trimmed = text?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) return null;
|
||||||
|
return trimmed.length > notificationBodyMaxChars
|
||||||
|
? '${trimmed.substring(0, notificationBodyMaxChars)}…'
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _pollStartType = 'org.matrix.msc3381.poll.start';
|
||||||
|
const _voiceKey = 'org.matrix.msc3245.voice';
|
||||||
|
|
||||||
|
/// Klartext ohne Antwort-Zitat („> <@x> …“) und ohne HTML.
|
||||||
|
String _plainText(Event event) => event.calcUnlocalizedBody(
|
||||||
|
hideReply: true,
|
||||||
|
hideEdit: true,
|
||||||
|
plaintextBody: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// MSC2530: Steht neben `filename` ein abweichender `body`, ist das die
|
||||||
|
/// Bildunterschrift.
|
||||||
|
String? _caption(Event event) {
|
||||||
|
final filename = event.content.tryGet<String>('filename');
|
||||||
|
final body = event.content.tryGet<String>('body')?.trim();
|
||||||
|
if (filename == null || body == null || body.isEmpty || body == filename) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localpart(String userId) =>
|
||||||
|
userId.split(':').first.replaceFirst('@', '');
|
||||||
|
|
||||||
|
// ── Bilder für die Benachrichtigung ─────────────────────────────────────────
|
||||||
|
// Android zeigt Bilder in Benachrichtigungen nur über eine content://-URI.
|
||||||
|
// Die Dateien liegen deshalb unter <cache>/downloads/notif/ – genau dort gibt
|
||||||
|
// der FileProvider der App frei (res/xml: cache-path "downloads/").
|
||||||
|
|
||||||
|
const _maxImageBytes = 8 * 1024 * 1024;
|
||||||
|
const _maxAvatarBytes = 2 * 1024 * 1024;
|
||||||
|
const _keepFilesFor = Duration(days: 2);
|
||||||
|
|
||||||
|
/// Lädt das Vorschaubild einer Bild-Nachricht (bei E2EE entschlüsselt) in
|
||||||
|
/// den Cache und liefert den Dateipfad – oder `null`, wenn es nicht klappt.
|
||||||
|
Future<String?> cacheNotificationImage(Event event) async {
|
||||||
|
if (event.messageType != MessageTypes.Image) return null;
|
||||||
|
try {
|
||||||
|
final file = await event
|
||||||
|
.downloadAndDecryptAttachment(getThumbnail: event.hasThumbnail)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
return await _writeNotifFile(
|
||||||
|
'img_${_hash(event.eventId)}',
|
||||||
|
file.bytes,
|
||||||
|
maxBytes: _maxImageBytes,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar des Absenders als Datei (zuerst aus dem Bild-Cache der App).
|
||||||
|
Future<String?> cacheNotificationAvatar(Event event) async {
|
||||||
|
try {
|
||||||
|
final client = event.room.client;
|
||||||
|
final mxc = event.room
|
||||||
|
.unsafeGetUserFromMemoryOrFallback(event.senderId)
|
||||||
|
.avatarUrl;
|
||||||
|
if (mxc == null) return null;
|
||||||
|
final name = 'avatar_${_hash(mxc.toString())}';
|
||||||
|
final existing = File(p.join((await _notifDir()).path, name));
|
||||||
|
if (await existing.exists()) return existing.path;
|
||||||
|
|
||||||
|
final cacheKey = 'img:$mxc';
|
||||||
|
var bytes = await MediaCache.instance.getPersistent(cacheKey);
|
||||||
|
if (bytes == null) {
|
||||||
|
final headers = {'authorization': 'Bearer ${client.accessToken}'};
|
||||||
|
try {
|
||||||
|
final thumb = await mxc.getThumbnailUri(
|
||||||
|
client,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
method: ThumbnailMethod.crop,
|
||||||
|
);
|
||||||
|
final res = await client.httpClient
|
||||||
|
.get(thumb, headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (res.statusCode == 200) bytes = res.bodyBytes;
|
||||||
|
} catch (_) {}
|
||||||
|
if (bytes == null) {
|
||||||
|
// Continuwuity kann keine Thumbnails → Original (Avatare sind klein).
|
||||||
|
final full = await mxc.getDownloadUri(client);
|
||||||
|
final res = await client.httpClient
|
||||||
|
.get(full, headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (res.statusCode == 200) bytes = res.bodyBytes;
|
||||||
|
}
|
||||||
|
if (bytes != null) {
|
||||||
|
await MediaCache.instance.putPersistent(cacheKey, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes == null) return null;
|
||||||
|
return await _writeNotifFile(name, bytes, maxBytes: _maxAvatarBytes);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _notifDir() async {
|
||||||
|
final dir = Directory(
|
||||||
|
p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
|
||||||
|
);
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _writeNotifFile(
|
||||||
|
String name,
|
||||||
|
Uint8List bytes, {
|
||||||
|
required int maxBytes,
|
||||||
|
}) async {
|
||||||
|
if (bytes.isEmpty || bytes.length > maxBytes) return null;
|
||||||
|
final dir = await _notifDir();
|
||||||
|
await _pruneOldFiles(dir);
|
||||||
|
final file = File(p.join(dir.path, name));
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneOldFiles(Directory dir) async {
|
||||||
|
try {
|
||||||
|
final cutoff = DateTime.now().subtract(_keepFilesFor);
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is File && (await entity.lastModified()).isBefore(cutoff)) {
|
||||||
|
await entity.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dateiname aus Event-ID/MXC (enthält `$`, `:` und `/`).
|
||||||
|
String _hash(String input) =>
|
||||||
|
sha1.convert(utf8.encode(input)).toString().substring(0, 20);
|
||||||
|
|
||||||
|
/// Zeigt eine Nachricht auf Android in zwei Schritten: erst sofort den Text
|
||||||
|
/// (damit nichts wartet), dann – nur falls es etwas gibt – lautlos dasselbe
|
||||||
|
/// mit Avatar und Bild. Native erkennt die Aktualisierung an der eventId und
|
||||||
|
/// ersetzt die Nachricht im Verlauf, statt sie doppelt anzuhängen.
|
||||||
|
Future<void> deliverMessageNotification(
|
||||||
|
Event event,
|
||||||
|
MessageNotificationContent content, {
|
||||||
|
required int notifId,
|
||||||
|
required Future<void> Function(Map<String, Object?> args) send,
|
||||||
|
}) async {
|
||||||
|
await send(content.toNativeArgs(notifId: notifId));
|
||||||
|
|
||||||
|
final avatarPath = await cacheNotificationAvatar(event);
|
||||||
|
final imagePath = content.isImage
|
||||||
|
? await cacheNotificationImage(event)
|
||||||
|
: null;
|
||||||
|
if (avatarPath == null && imagePath == null) return;
|
||||||
|
await send(
|
||||||
|
content.toNativeArgs(
|
||||||
|
notifId: notifId,
|
||||||
|
avatarPath: avatarPath,
|
||||||
|
imagePath: imagePath,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import 'package:pyramid/core/app_state.dart';
|
|||||||
import 'package:pyramid/core/background_push.dart';
|
import 'package:pyramid/core/background_push.dart';
|
||||||
import 'package:pyramid/core/fcm_push_service.dart';
|
import 'package:pyramid/core/fcm_push_service.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/notification_content.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||||
@@ -197,6 +198,21 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
if (!share.isEmpty) {
|
if (!share.isEmpty) {
|
||||||
ref.read(pendingShareProvider.notifier).state = share;
|
ref.read(pendingShareProvider.notifier).state = share;
|
||||||
}
|
}
|
||||||
|
} else if (call.method == 'markReadFromNotification') {
|
||||||
|
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
|
||||||
|
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||||
|
final eventId = (args is Map ? args['event_id'] : null) as String?;
|
||||||
|
if (roomId != null && eventId != null) {
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
await client
|
||||||
|
.getRoomById(roomId)
|
||||||
|
?.setReadMarker(eventId, mRead: eventId);
|
||||||
|
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (call.method == 'decryptAndUpdateNotification') {
|
} else if (call.method == 'decryptAndUpdateNotification') {
|
||||||
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
|
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
|
||||||
// We decrypt the event here (the Matrix client has the keys) and overwrite
|
// We decrypt the event here (the Matrix client has the keys) and overwrite
|
||||||
@@ -458,57 +474,37 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
final client = await ref
|
final client = await ref
|
||||||
.read(matrixClientProvider.future)
|
.read(matrixClientProvider.future)
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
var room = client.getRoomById(roomId);
|
if (eventId == null) return;
|
||||||
if (room == null) {
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
|
// Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
|
||||||
|
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
|
||||||
|
final event = await client
|
||||||
|
.getEventByPushNotification(
|
||||||
|
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
|
||||||
|
storeInDatabase: false,
|
||||||
|
returnNullIfSeen: false,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
if (event == null || event.senderId == client.userID) return;
|
||||||
|
|
||||||
|
final content = MessageNotificationContent.fromEvent(
|
||||||
|
event,
|
||||||
|
showPreview: ref.read(notifPreviewProvider),
|
||||||
|
);
|
||||||
|
if (content == null) {
|
||||||
|
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event might not have synced yet — wait for the next sync (max 12 s).
|
// Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
|
||||||
// We use lastEvent as a proxy: after sync it will be the event we want.
|
// Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
|
||||||
Event? event = room.lastEvent;
|
await deliverMessageNotification(
|
||||||
final bool alreadyHave =
|
event,
|
||||||
eventId == null || event?.eventId == eventId;
|
content,
|
||||||
if (!alreadyHave || event == null) {
|
notifId: notifId ?? _stableRoomId(roomId),
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
|
||||||
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
);
|
||||||
room = client.getRoomById(roomId);
|
debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
|
||||||
if (room == null) return;
|
|
||||||
event = room.lastEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event == null) {
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.type != EventTypes.Message) return;
|
|
||||||
if (event.senderId == client.userID) return;
|
|
||||||
|
|
||||||
final rawBody = event.body;
|
|
||||||
// If decryption failed the body starts with "** Unable to decrypt"
|
|
||||||
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final notifBody =
|
|
||||||
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
|
|
||||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
||||||
final displayName = sender.displayName ??
|
|
||||||
event.senderId.split(':').first.replaceFirst('@', '');
|
|
||||||
final title = room.isDirectChat
|
|
||||||
? displayName
|
|
||||||
: '$displayName · ${room.getLocalizedDisplayname()}';
|
|
||||||
|
|
||||||
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
|
|
||||||
// replaces the existing notification rather than stacking a new one.
|
|
||||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
|
||||||
'title': title,
|
|
||||||
'body': notifBody,
|
|
||||||
'roomId': roomId,
|
|
||||||
'notifId': notifId ?? _stableRoomId(roomId),
|
|
||||||
});
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
||||||
}
|
}
|
||||||
@@ -516,27 +512,30 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
|
|
||||||
// ─── Show notification ────────────────────────────────────────────────────────
|
// ─── Show notification ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> showMessageNotification({
|
Future<void> showMessageNotification(
|
||||||
required String roomId,
|
Event event,
|
||||||
String? roomName,
|
MessageNotificationContent content,
|
||||||
required String senderName,
|
) async {
|
||||||
required String body,
|
final roomId = content.roomId;
|
||||||
}) async {
|
|
||||||
final title = roomName != null ? '$senderName · $roomName' : senderName;
|
|
||||||
if (Platform.isAndroid && _androidInitialized) {
|
if (Platform.isAndroid && _androidInitialized) {
|
||||||
// Use native Kotlin notification so the reply PendingIntent targets
|
// Use native Kotlin notification so the reply PendingIntent targets
|
||||||
// ReplyReceiver directly — the app never opens when the user replies.
|
// ReplyReceiver directly — the app never opens when the user replies.
|
||||||
try {
|
try {
|
||||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
await deliverMessageNotification(
|
||||||
'title': title,
|
event,
|
||||||
'body': body,
|
content,
|
||||||
'roomId': roomId,
|
notifId: _stableRoomId(roomId),
|
||||||
'notifId': _stableRoomId(roomId),
|
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
|
||||||
});
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
||||||
}
|
}
|
||||||
} else if (Platform.isWindows) {
|
} else if (Platform.isWindows) {
|
||||||
|
final title = content.title;
|
||||||
|
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
|
||||||
|
final body = content.body.length > 300
|
||||||
|
? '${content.body.substring(0, 300)}…'
|
||||||
|
: content.body;
|
||||||
// Close the previous notification for this room before showing a new one.
|
// Close the previous notification for this room before showing a new one.
|
||||||
_windowsNotifs.remove(roomId)?.close();
|
_windowsNotifs.remove(roomId)?.close();
|
||||||
|
|
||||||
@@ -624,23 +623,13 @@ void _watchEvents(Ref ref, Client client) {
|
|||||||
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
|
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build notification body ───────────────────────────────────────────
|
// ── Build notification content ────────────────────────────────────────
|
||||||
final rawBody = event.body;
|
final content = MessageNotificationContent.fromEvent(
|
||||||
final showPreview = ref.read(notifPreviewProvider);
|
event,
|
||||||
final notifBody = showPreview
|
showPreview: ref.read(notifPreviewProvider),
|
||||||
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
|
|
||||||
: 'Neue Nachricht';
|
|
||||||
|
|
||||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
||||||
final displayName = sender.displayName ??
|
|
||||||
event.senderId.split(':').first.replaceFirst('@', '');
|
|
||||||
|
|
||||||
showMessageNotification(
|
|
||||||
roomId: roomId,
|
|
||||||
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
|
||||||
senderName: displayName,
|
|
||||||
body: notifBody,
|
|
||||||
);
|
);
|
||||||
|
if (content == null) return;
|
||||||
|
showMessageNotification(event, content);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
/// Kennung der Account-Daten, mit denen wir den laufenden Sync aufwecken.
|
||||||
|
const logoutWakeAccountDataType = 'io.pyramid.logout_wake';
|
||||||
|
|
||||||
|
/// Meldet [client] ab, ohne dass eine verspätete Server-Antwort die NÄCHSTE
|
||||||
|
/// Anmeldung wieder löscht.
|
||||||
|
///
|
||||||
|
/// Hintergrund (SDK-Wettlauf, ROADMAP M1, gefunden 2026-10-07): matrix 6.2
|
||||||
|
/// (und Upstream `main`) prüft bei einer FEHLER-Antwort des Sync-Long-Polls
|
||||||
|
/// nicht, ob die Anfrage noch aktuell ist. Läuft beim Abmelden noch ein
|
||||||
|
/// Long-Poll (bis 30 s) mit dem alten Token, kommt dessen 401
|
||||||
|
/// (M_UNKNOWN_TOKEN) später zurück und das SDK ruft `clear()` – meldet sich
|
||||||
|
/// der Nutzer in der Zwischenzeit auf demselben Client neu an (Pyramid
|
||||||
|
/// benutzt nach dem Logout dasselbe Client-Objekt weiter), wird die frische
|
||||||
|
/// Session gelöscht: ein „Random-Logout“ direkt nach dem Login.
|
||||||
|
///
|
||||||
|
/// Ablauf hier:
|
||||||
|
/// 1. Sync-Schleife anhalten (nach der laufenden Anfrage keine neue).
|
||||||
|
/// 2. Laufenden Long-Poll WECKEN: geänderte Account-Daten lässt der Server
|
||||||
|
/// sofort antworten – statt bis zu 30 s zu warten.
|
||||||
|
/// 3. Die laufende Anfrage regulär (mit gültigem Token) zu Ende gehen lassen.
|
||||||
|
/// 4. Erst dann serverseitig abmelden – es ist keine Anfrage mehr unterwegs.
|
||||||
|
///
|
||||||
|
/// Schritt 2/3 sind „best effort“: scheitern sie (offline), wird nach
|
||||||
|
/// höchstens ~8 s trotzdem abgemeldet – nie schlechter als das bisherige
|
||||||
|
/// direkte `logout()`.
|
||||||
|
///
|
||||||
|
/// Live gemessen (test/live_logout_race_test.dart, 2026-10-07): die
|
||||||
|
/// verspätete 401 tritt real auf, kommt aber meist an, BEVOR ein Mensch sich
|
||||||
|
/// neu angemeldet hat – das Risiko ist klein, dieser Weg schließt es aus.
|
||||||
|
Future<void> logoutWithoutStraySync(
|
||||||
|
Client client, {
|
||||||
|
Duration settleTimeout = const Duration(seconds: 5),
|
||||||
|
}) async {
|
||||||
|
client.backgroundSync = false;
|
||||||
|
// Liefert den laufenden Sync (oder startet einen kurzen ohne Long-Poll).
|
||||||
|
final inFlight = client.oneShotSync(timeout: Duration.zero);
|
||||||
|
final userId = client.userID;
|
||||||
|
if (userId != null) {
|
||||||
|
try {
|
||||||
|
await client
|
||||||
|
.setAccountData(userId, logoutWakeAccountDataType, {
|
||||||
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
})
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LOGOUT] Sync-Wecken fehlgeschlagen (egal): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await inFlight.timeout(settleTimeout);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LOGOUT] laufender Sync nicht rechtzeitig fertig: $e');
|
||||||
|
}
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
|
||||||
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
||||||
|
|
||||||
@@ -90,29 +92,111 @@ 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({
|
||||||
bool? isDark,
|
ThemeVariant? variant,
|
||||||
int? accentIdx,
|
int? accentIdx,
|
||||||
double? radius,
|
double? radius,
|
||||||
double? motion,
|
double? motion,
|
||||||
|
double? textScale,
|
||||||
|
bool resetTextScale = false,
|
||||||
|
MessageStyle? messageStyle,
|
||||||
}) async {
|
}) async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
if (isDark != null) await p.setBool('theme_dark', isDark);
|
if (variant != null) {
|
||||||
|
await p.setString('theme_variant', variant.name);
|
||||||
|
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
|
||||||
|
await p.setBool('theme_dark', variant.isDark);
|
||||||
|
}
|
||||||
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
||||||
if (radius != null) await p.setDouble('theme_radius', radius);
|
if (radius != null) await p.setDouble('theme_radius', radius);
|
||||||
if (motion != null) await p.setDouble('theme_motion', motion);
|
if (motion != null) await p.setDouble('theme_motion', motion);
|
||||||
|
if (resetTextScale) {
|
||||||
|
await p.remove('ui_text_scale');
|
||||||
|
} else if (textScale != null) {
|
||||||
|
await p.setDouble('ui_text_scale', textScale);
|
||||||
|
}
|
||||||
|
if (messageStyle != null) {
|
||||||
|
await p.setString('chat_message_style', messageStyle.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({bool isDark, int accentIdx, double radius, double motion})>
|
Future<
|
||||||
loadThemePrefs() async {
|
({
|
||||||
|
ThemeVariant variant,
|
||||||
|
int accentIdx,
|
||||||
|
double radius,
|
||||||
|
double motion,
|
||||||
|
double? textScale,
|
||||||
|
MessageStyle messageStyle,
|
||||||
|
})> loadThemePrefs() async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
|
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
|
||||||
|
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
|
||||||
|
final storedVariant = p.getString('theme_variant');
|
||||||
|
final variant = storedVariant != null
|
||||||
|
? ThemeVariant.fromName(storedVariant)
|
||||||
|
: (p.getBool('theme_dark') ?? true)
|
||||||
|
? ThemeVariant.soft
|
||||||
|
: ThemeVariant.light;
|
||||||
return (
|
return (
|
||||||
isDark: p.getBool('theme_dark') ?? true,
|
variant: variant,
|
||||||
accentIdx: p.getInt('theme_accent') ?? 0,
|
accentIdx: p.getInt('theme_accent') ?? 0,
|
||||||
radius: p.getDouble('theme_radius') ?? 12.0,
|
radius: p.getDouble('theme_radius') ?? 12.0,
|
||||||
motion: p.getDouble('theme_motion') ?? 1.0,
|
motion: p.getDouble('theme_motion') ?? 1.0,
|
||||||
|
textScale: p.getDouble('ui_text_scale'),
|
||||||
|
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
-21
@@ -1,6 +1,103 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// Pyramid design tokens — matching mockup CSS variables exactly
|
/// Farbschema der App (Einstellungen → Erscheinungsbild → Farbmodus).
|
||||||
|
enum ThemeVariant {
|
||||||
|
/// „Weiches Dunkel“ – Dunkelgrau statt Fast-Schwarz, mehr Kontrast bei
|
||||||
|
/// Text. Standard seit 2026-10-07 (Bernds Entscheidung).
|
||||||
|
soft,
|
||||||
|
|
||||||
|
/// Das bisherige Fast-Schwarz (spart auf OLED-Displays etwas Akku).
|
||||||
|
black,
|
||||||
|
|
||||||
|
/// Hell.
|
||||||
|
light;
|
||||||
|
|
||||||
|
bool get isDark => this != ThemeVariant.light;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
ThemeVariant.soft => 'Dunkel',
|
||||||
|
ThemeVariant.black => 'Schwarz',
|
||||||
|
ThemeVariant.light => 'Hell',
|
||||||
|
};
|
||||||
|
|
||||||
|
static ThemeVariant fromName(String? name) =>
|
||||||
|
ThemeVariant.values.firstWhere((v) => v.name == name,
|
||||||
|
orElse: () => ThemeVariant.soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Flächen- und Textfarben eines [ThemeVariant].
|
||||||
|
class PyramidPalette {
|
||||||
|
const PyramidPalette({
|
||||||
|
required this.bg0,
|
||||||
|
required this.bg1,
|
||||||
|
required this.bg2,
|
||||||
|
required this.bg3,
|
||||||
|
required this.bgHover,
|
||||||
|
required this.bgActive,
|
||||||
|
required this.border,
|
||||||
|
required this.borderStrong,
|
||||||
|
required this.fg,
|
||||||
|
required this.fgMuted,
|
||||||
|
required this.fgDim,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color bg0, bg1, bg2, bg3, bgHover, bgActive, border, borderStrong;
|
||||||
|
final Color fg, fgMuted, fgDim;
|
||||||
|
|
||||||
|
/// Weiches Dunkel: alle Flächen ~10 % heller als „Schwarz“, Nebentexte
|
||||||
|
/// deutlich kontrastreicher (fgDim auf bg1 ≈ 5:1 statt ≈ 3,8:1).
|
||||||
|
static const soft = PyramidPalette(
|
||||||
|
bg0: Color(0xFF15161A),
|
||||||
|
bg1: Color(0xFF1B1C21),
|
||||||
|
bg2: Color(0xFF212228),
|
||||||
|
bg3: Color(0xFF282A31),
|
||||||
|
bgHover: Color(0xFF2F3139),
|
||||||
|
bgActive: Color(0xFF373945),
|
||||||
|
border: Color(0xFF2C2E36),
|
||||||
|
borderStrong: Color(0xFF3A3C46),
|
||||||
|
fg: Color(0xFFF2F2F5),
|
||||||
|
fgMuted: Color(0xFFB7B7C2),
|
||||||
|
fgDim: Color(0xFF8B8B99),
|
||||||
|
);
|
||||||
|
|
||||||
|
static const black = PyramidPalette(
|
||||||
|
bg0: PyramidColors.bg0,
|
||||||
|
bg1: PyramidColors.bg1,
|
||||||
|
bg2: PyramidColors.bg2,
|
||||||
|
bg3: PyramidColors.bg3,
|
||||||
|
bgHover: PyramidColors.bgHover,
|
||||||
|
bgActive: PyramidColors.bgActive,
|
||||||
|
border: PyramidColors.border,
|
||||||
|
borderStrong: PyramidColors.borderStrong,
|
||||||
|
fg: PyramidColors.fg,
|
||||||
|
fgMuted: PyramidColors.fgMuted,
|
||||||
|
fgDim: PyramidColors.fgDim,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const light = PyramidPalette(
|
||||||
|
bg0: PyramidColors.bg0Light,
|
||||||
|
bg1: PyramidColors.bg1Light,
|
||||||
|
bg2: PyramidColors.bg2Light,
|
||||||
|
bg3: PyramidColors.bg3Light,
|
||||||
|
bgHover: PyramidColors.bgHoverLight,
|
||||||
|
bgActive: PyramidColors.bgActiveLight,
|
||||||
|
border: PyramidColors.borderLight,
|
||||||
|
borderStrong: PyramidColors.borderStrongLight,
|
||||||
|
fg: PyramidColors.fgLight,
|
||||||
|
fgMuted: PyramidColors.fgMutedLight,
|
||||||
|
fgDim: PyramidColors.fgDimLight,
|
||||||
|
);
|
||||||
|
|
||||||
|
static PyramidPalette of(ThemeVariant variant) => switch (variant) {
|
||||||
|
ThemeVariant.soft => soft,
|
||||||
|
ThemeVariant.black => black,
|
||||||
|
ThemeVariant.light => light,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pyramid design tokens — matching mockup CSS variables exactly.
|
||||||
|
// Die dunklen Werte hier sind die Variante „Schwarz“; Widgets sollten die
|
||||||
|
// Farben über PyramidTheme.of(context) holen (folgt dem gewählten Schema).
|
||||||
class PyramidColors {
|
class PyramidColors {
|
||||||
const PyramidColors._();
|
const PyramidColors._();
|
||||||
|
|
||||||
@@ -49,7 +146,7 @@ class PyramidColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PyramidTheme extends InheritedWidget {
|
class PyramidTheme extends InheritedWidget {
|
||||||
final bool isDark;
|
final ThemeVariant variant;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final double radius;
|
final double radius;
|
||||||
final double density;
|
final double density;
|
||||||
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
const PyramidTheme({
|
const PyramidTheme({
|
||||||
super.key,
|
super.key,
|
||||||
required super.child,
|
required super.child,
|
||||||
this.isDark = true,
|
this.variant = ThemeVariant.soft,
|
||||||
this.accent = PyramidColors.accentAmber,
|
this.accent = PyramidColors.accentAmber,
|
||||||
this.radius = 12,
|
this.radius = 12,
|
||||||
this.density = 1,
|
this.density = 1,
|
||||||
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
|
bool get isDark => variant.isDark;
|
||||||
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
PyramidPalette get palette => PyramidPalette.of(variant);
|
||||||
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
|
|
||||||
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
|
Color get bg0 => palette.bg0;
|
||||||
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
|
Color get bg1 => palette.bg1;
|
||||||
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
|
Color get bg2 => palette.bg2;
|
||||||
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
|
Color get bg3 => palette.bg3;
|
||||||
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
|
Color get bgHover => palette.bgHover;
|
||||||
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
Color get bgActive => palette.bgActive;
|
||||||
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
|
Color get border => palette.border;
|
||||||
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
|
Color get borderStrong => palette.borderStrong;
|
||||||
|
Color get fg => palette.fg;
|
||||||
|
Color get fgMuted => palette.fgMuted;
|
||||||
|
Color get fgDim => palette.fgDim;
|
||||||
Color get danger => PyramidColors.danger;
|
Color get danger => PyramidColors.danger;
|
||||||
Color get online => PyramidColors.online;
|
Color get online => PyramidColors.online;
|
||||||
Color get away => PyramidColors.away;
|
Color get away => PyramidColors.away;
|
||||||
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
||||||
isDark != oldWidget.isDark ||
|
variant != oldWidget.variant ||
|
||||||
accent != oldWidget.accent ||
|
accent != oldWidget.accent ||
|
||||||
radius != oldWidget.radius ||
|
radius != oldWidget.radius ||
|
||||||
density != oldWidget.density ||
|
density != oldWidget.density ||
|
||||||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
||||||
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
||||||
|
|
||||||
ThemeData buildPyramidThemeData(bool isDark) {
|
ThemeData buildPyramidThemeData(ThemeVariant variant) {
|
||||||
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
final palette = PyramidPalette.of(variant);
|
||||||
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
final isDark = variant.isDark;
|
||||||
|
final fg = palette.fg;
|
||||||
|
final bg1 = palette.bg1;
|
||||||
final accent = PyramidColors.accentAmber;
|
final accent = PyramidColors.accentAmber;
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
|
|||||||
),
|
),
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
||||||
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
|
bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
|
||||||
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
|
labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
|
color: palette.border,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 1,
|
space: 1,
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/voip_manager.dart';
|
||||||
|
|
||||||
|
/// Öffentliche Schnittstelle des Moduls `call_signaling` – natives
|
||||||
|
/// Matrix-1:1-VoIP (`m.call.*`-Events, direkte WebRTC-Peer-Connection,
|
||||||
|
/// kein SFU). Schnitt nach `docs/M2_MODULE_SCHNITT.md`.
|
||||||
|
///
|
||||||
|
/// Andere Module (UI, Settings, Shell) konsumieren AUSSCHLIESSLICH diese
|
||||||
|
/// Schnittstelle über [callSignalingProvider] – nie `voip_manager.dart`
|
||||||
|
/// direkt. Einzige Ausnahme ist der Kompositions-Punkt in
|
||||||
|
/// `matrix_client.dart`, der die Implementierung konstruiert.
|
||||||
|
///
|
||||||
|
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
|
||||||
|
/// Schönheit, heiliger Call-Pfad):
|
||||||
|
/// * ChangeNotifier statt Streams – die gesamte UI hängt heute an
|
||||||
|
/// `ref.watch(...)` mit notifyListeners; ein Stream-Umbau wäre eine
|
||||||
|
/// Verhaltensänderung ohne Not.
|
||||||
|
/// * [currentCall] bleibt als matrix-SDK-`CallSession` sichtbar (die UI
|
||||||
|
/// braucht state/room/answer/reject), ebenso die WebRTC-Renderer –
|
||||||
|
/// das Doc sieht Letzteres ausdrücklich vor.
|
||||||
|
abstract class CallSignalingService extends ChangeNotifier {
|
||||||
|
/// Der laufende 1:1-Call (null = keiner). UI liest state/room und
|
||||||
|
/// beantwortet eingehende Calls über [safeAction] + answer()/reject().
|
||||||
|
CallSession? get currentCall;
|
||||||
|
|
||||||
|
rtc.RTCVideoRenderer get localRenderer;
|
||||||
|
rtc.RTCVideoRenderer get remoteRenderer;
|
||||||
|
|
||||||
|
bool get isMicMuted;
|
||||||
|
bool get isCameraMuted;
|
||||||
|
bool get isScreensharing;
|
||||||
|
bool get isRemoteScreensharing;
|
||||||
|
bool get isDeafened;
|
||||||
|
|
||||||
|
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
|
||||||
|
String get currentQualityKey;
|
||||||
|
|
||||||
|
Future<void> startCall(String roomId, {bool video});
|
||||||
|
Future<void> hangup();
|
||||||
|
Future<void> toggleMic();
|
||||||
|
Future<void> toggleCamera();
|
||||||
|
Future<void> toggleDeafen();
|
||||||
|
|
||||||
|
/// Auf Windows öffnet ein [context] den Quellen-Picker fürs Screensharing.
|
||||||
|
Future<void> toggleScreenSharing([BuildContext? context]);
|
||||||
|
|
||||||
|
Future<void> changeQuality(String key);
|
||||||
|
|
||||||
|
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||||
|
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||||
|
Future<void> applyOutputVolume([double? value]);
|
||||||
|
|
||||||
|
/// Führt eine Call-Aktion (answer/reject/…) fehlertolerant aus und stößt
|
||||||
|
/// danach die interne Zustands-/Renderer-Aktualisierung an.
|
||||||
|
Future<void> safeAction(Future<dynamic> Function() action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einziger Zugriffsweg der UI auf das 1:1-VoIP-Modul. Die Instanz wird beim
|
||||||
|
/// Client-Aufbau in `matrix_client.dart` erzeugt (existiert also, sobald die
|
||||||
|
/// eingeloggte Shell steht).
|
||||||
|
final callSignalingProvider =
|
||||||
|
ChangeNotifierProvider<CallSignalingService>((ref) {
|
||||||
|
return PyramidVoipManager.instance;
|
||||||
|
});
|
||||||
@@ -3,20 +3,28 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
import 'package:pyramid/core/ice_servers.dart';
|
import 'package:pyramid/core/ice_servers.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||||
|
|
||||||
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
/// Implementierung von [CallSignalingService] (Modul `call_signaling`).
|
||||||
|
/// Wird ausschließlich vom Kompositions-Punkt (`matrix_client.dart`)
|
||||||
|
/// konstruiert; alle anderen gehen über `callSignalingProvider`.
|
||||||
|
class PyramidVoipManager extends CallSignalingService
|
||||||
|
implements WebRTCDelegate {
|
||||||
static PyramidVoipManager? _instance;
|
static PyramidVoipManager? _instance;
|
||||||
static PyramidVoipManager get instance => _instance!;
|
static PyramidVoipManager get instance => _instance!;
|
||||||
|
|
||||||
final Client client;
|
final Client client;
|
||||||
late final VoIP voip;
|
late final VoIP voip;
|
||||||
|
@override
|
||||||
CallSession? currentCall;
|
CallSession? currentCall;
|
||||||
|
|
||||||
|
@override
|
||||||
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
|
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
|
||||||
|
@override
|
||||||
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
|
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
|
||||||
bool _renderersInitialized = false;
|
bool _renderersInitialized = false;
|
||||||
|
|
||||||
@@ -29,11 +37,17 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
String? _selectedSourceId;
|
String? _selectedSourceId;
|
||||||
String _currentQualityKey = 'hd';
|
String _currentQualityKey = 'hd';
|
||||||
|
|
||||||
|
@override
|
||||||
bool get isMicMuted => _isMicMuted;
|
bool get isMicMuted => _isMicMuted;
|
||||||
|
@override
|
||||||
bool get isCameraMuted => _isCameraMuted;
|
bool get isCameraMuted => _isCameraMuted;
|
||||||
|
@override
|
||||||
bool get isScreensharing => _isScreensharing;
|
bool get isScreensharing => _isScreensharing;
|
||||||
|
@override
|
||||||
bool get isRemoteScreensharing => _isRemoteScreensharing;
|
bool get isRemoteScreensharing => _isRemoteScreensharing;
|
||||||
|
@override
|
||||||
bool get isDeafened => _isDeafened;
|
bool get isDeafened => _isDeafened;
|
||||||
|
@override
|
||||||
String get currentQualityKey => _currentQualityKey;
|
String get currentQualityKey => _currentQualityKey;
|
||||||
|
|
||||||
PyramidVoipManager(this.client) {
|
PyramidVoipManager(this.client) {
|
||||||
@@ -253,12 +267,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
|
|
||||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||||
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||||
|
@override
|
||||||
Future<void> applyOutputVolume([double? value]) async {
|
Future<void> applyOutputVolume([double? value]) async {
|
||||||
var v = value;
|
var v = value;
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
v = await VoicePrefs.loadOutputVolume();
|
||||||
v = prefs.getDouble('voice_output_volume') ?? 1.0;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
v = 1.0;
|
v = 1.0;
|
||||||
}
|
}
|
||||||
@@ -295,6 +309,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
@override
|
@override
|
||||||
Future<void> registerListeners(CallSession session) async {}
|
Future<void> registerListeners(CallSession session) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
@@ -306,6 +321,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleMic() async {
|
Future<void> toggleMic() async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
_isMicMuted = !_isMicMuted;
|
_isMicMuted = !_isMicMuted;
|
||||||
@@ -318,6 +334,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleCamera() async {
|
Future<void> toggleCamera() async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
_isCameraMuted = !_isCameraMuted;
|
_isCameraMuted = !_isCameraMuted;
|
||||||
@@ -337,6 +354,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleScreenSharing([BuildContext? context]) async {
|
Future<void> toggleScreenSharing([BuildContext? context]) async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
final call = currentCall!;
|
final call = currentCall!;
|
||||||
@@ -359,11 +377,13 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleDeafen() async {
|
Future<void> toggleDeafen() async {
|
||||||
_isDeafened = !_isDeafened;
|
_isDeafened = !_isDeafened;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> hangup() async {
|
Future<void> hangup() async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
await safeAction(() async {
|
await safeAction(() async {
|
||||||
@@ -372,6 +392,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> startCall(String roomId, {bool video = false}) async {
|
Future<void> startCall(String roomId, {bool video = false}) async {
|
||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
@@ -394,6 +415,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> changeQuality(String key) async {
|
Future<void> changeQuality(String key) async {
|
||||||
_currentQualityKey = key;
|
_currentQualityKey = key;
|
||||||
if (currentCall == null) {
|
if (currentCall == null) {
|
||||||
@@ -444,10 +466,10 @@ class MediaDevicesWrapper extends MediaDevices {
|
|||||||
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
|
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
|
||||||
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
|
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final devicePrefs = await VoicePrefs.loadDevices();
|
||||||
final mic = prefs.getString('voice_mic_device');
|
final mic = devicePrefs.mic;
|
||||||
final cam = prefs.getString('voice_camera_device');
|
final cam = devicePrefs.camera;
|
||||||
final speaker = prefs.getString('voice_speaker_device');
|
final speaker = devicePrefs.speaker;
|
||||||
if (mic != null &&
|
if (mic != null &&
|
||||||
improvedConstraints['audio'] != null &&
|
improvedConstraints['audio'] != null &&
|
||||||
improvedConstraints['audio'] != false) {
|
improvedConstraints['audio'] != false) {
|
||||||
+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';
|
||||||
@@ -303,8 +303,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
|
|
||||||
final roomName = widget.room.getLocalizedDisplayname();
|
final roomName = widget.room.getLocalizedDisplayname();
|
||||||
final placeholder = widget.room.isDirectChat
|
final placeholder = widget.room.isDirectChat
|
||||||
? 'Message…'
|
? 'Nachricht…'
|
||||||
: 'Message #$roomName';
|
: 'Nachricht an #$roomName';
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||||
child: Text(title,
|
child: Text(title,
|
||||||
style: TextStyle(color: PyramidColors.fgDim,
|
style: TextStyle(color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.6)),
|
letterSpacing: 0.6)),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
|
|||||||
//
|
//
|
||||||
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
||||||
|
|
||||||
|
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
|
||||||
|
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
|
||||||
|
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
|
||||||
|
double inlineMediaMaxHeight(BuildContext context) {
|
||||||
|
final screen = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = screen.width < 900 || screen.height < 500;
|
||||||
|
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
|
||||||
|
}
|
||||||
|
|
||||||
class PyramidVideoPlayer extends StatefulWidget {
|
class PyramidVideoPlayer extends StatefulWidget {
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
final String filename;
|
final String filename;
|
||||||
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
late final Player _player;
|
late final Player _player;
|
||||||
late final VideoController _controller;
|
late final VideoController _controller;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
|
||||||
|
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
|
||||||
|
double? _aspect;
|
||||||
|
final _subs = <StreamSubscription<int?>>[];
|
||||||
double _speed = 1.0;
|
double _speed = 1.0;
|
||||||
String _quality = 'Auto';
|
String _quality = 'Auto';
|
||||||
|
|
||||||
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_player = Player();
|
_player = Player();
|
||||||
_controller = VideoController(_player);
|
_controller = VideoController(_player);
|
||||||
|
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
|
||||||
|
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateAspect() {
|
||||||
|
final w = _player.state.width;
|
||||||
|
final h = _player.state.height;
|
||||||
|
if (w == null || h == null || w <= 0 || h <= 0) return;
|
||||||
|
final aspect = w / h;
|
||||||
|
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final s in _subs) {
|
||||||
|
s.cancel();
|
||||||
|
}
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 420,
|
||||||
|
maxHeight: inlineMediaMaxHeight(context),
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
borderRadius: BorderRadius.circular(pt.rLg),
|
borderRadius: BorderRadius.circular(pt.rLg),
|
||||||
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: _aspect ?? 16 / 9,
|
||||||
child: StreamBuilder<bool>(
|
child: StreamBuilder<bool>(
|
||||||
stream: _player.stream.playing,
|
stream: _player.stream.playing,
|
||||||
builder: (_, playSnap) => StreamBuilder<Duration>(
|
builder: (_, playSnap) => StreamBuilder<Duration>(
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import 'package:pyramid/features/chat/media_player.dart';
|
|||||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||||
|
|
||||||
/// Overlay viewer for images, GIFs and videos.
|
/// Overlay viewer for images, GIFs and videos.
|
||||||
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
///
|
||||||
|
/// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt):
|
||||||
|
/// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig).
|
||||||
|
/// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus.
|
||||||
|
/// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet
|
||||||
|
/// dabei aus.
|
||||||
|
/// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei
|
||||||
|
/// Fingern aufziehen geht weiterhin.
|
||||||
class MediaViewer extends StatefulWidget {
|
class MediaViewer extends StatefulWidget {
|
||||||
final String senderName;
|
final String senderName;
|
||||||
final Uint8List? bytes;
|
final Uint8List? bytes;
|
||||||
@@ -40,7 +47,9 @@ class MediaViewer extends StatefulWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: 'Schließen',
|
barrierLabel: 'Schließen',
|
||||||
barrierColor: Colors.black.withAlpha(178),
|
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
||||||
|
// ausblenden kann).
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: const Duration(milliseconds: 180),
|
||||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
@@ -59,21 +68,127 @@ class MediaViewer extends StatefulWidget {
|
|||||||
State<MediaViewer> createState() => _MediaViewerState();
|
State<MediaViewer> createState() => _MediaViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MediaViewerState extends State<MediaViewer> {
|
class _MediaViewerState extends State<MediaViewer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
/// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen.
|
||||||
|
static const _dismissDistance = 120.0;
|
||||||
|
static const _dismissVelocity = 1000.0;
|
||||||
|
static const _doubleTapScale = 2.5;
|
||||||
|
|
||||||
final _tc = TransformationController();
|
final _tc = TransformationController();
|
||||||
|
final _contentKey = GlobalKey();
|
||||||
|
late final AnimationController _zoomAnim;
|
||||||
|
Animation<Matrix4>? _zoomTween;
|
||||||
|
|
||||||
bool _controlsVisible = true;
|
bool _controlsVisible = true;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _savingSticker = false;
|
bool _savingSticker = false;
|
||||||
|
bool _zoomed = false;
|
||||||
|
bool _dragging = false;
|
||||||
|
double _dragOffset = 0;
|
||||||
|
Offset? _doubleTapGlobal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_zoomAnim = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
)..addListener(() {
|
||||||
|
final tween = _zoomTween;
|
||||||
|
if (tween != null) _tc.value = tween.value;
|
||||||
|
});
|
||||||
|
_tc.addListener(_onTransform);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_zoomAnim.dispose();
|
||||||
|
_tc.removeListener(_onTransform);
|
||||||
_tc.dispose();
|
_tc.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTransform() {
|
||||||
|
final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01;
|
||||||
|
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() => Navigator.of(context).maybePop();
|
||||||
|
|
||||||
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
||||||
|
|
||||||
void _resetZoom() => _tc.value = Matrix4.identity();
|
void _animateTo(Matrix4 target) {
|
||||||
|
_zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate(
|
||||||
|
CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic),
|
||||||
|
);
|
||||||
|
_zoomAnim.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetZoom() => _animateTo(Matrix4.identity());
|
||||||
|
|
||||||
|
/// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus.
|
||||||
|
void _toggleZoom() {
|
||||||
|
final global = _doubleTapGlobal;
|
||||||
|
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (_zoomed || global == null || box == null) {
|
||||||
|
_resetZoom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final p = box.globalToLocal(global);
|
||||||
|
_animateTo(Matrix4.identity()
|
||||||
|
..translateByDouble(
|
||||||
|
-p.dx * (_doubleTapScale - 1),
|
||||||
|
-p.dy * (_doubleTapScale - 1),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es
|
||||||
|
// nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ─────────────
|
||||||
|
|
||||||
|
void _onInteractionStart(ScaleStartDetails d) {
|
||||||
|
_dragging = !_zoomed && d.pointerCount == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionUpdate(ScaleUpdateDetails d) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
if (d.pointerCount > 1) {
|
||||||
|
// Zweiter Finger dazu → das ist ein Zoom, kein Wischen.
|
||||||
|
setState(() {
|
||||||
|
_dragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _dragOffset += d.focalPointDelta.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionEnd(ScaleEndDetails d) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
_dragging = false;
|
||||||
|
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||||
|
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video: kein InteractiveViewer, also normales vertikales Wischen.
|
||||||
|
void _onVideoDragUpdate(DragUpdateDetails d) =>
|
||||||
|
setState(() => _dragOffset += d.delta.dy);
|
||||||
|
|
||||||
|
void _onVideoDragEnd(DragEndDetails d) {
|
||||||
|
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||||
|
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveAsSticker() async {
|
Future<void> _saveAsSticker() async {
|
||||||
final event = widget.event;
|
final event = widget.event;
|
||||||
@@ -127,9 +242,16 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final isVideo = widget.isVideo && widget.bytes != null;
|
final isVideo = widget.isVideo && widget.bytes != null;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
|
||||||
|
// Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist.
|
||||||
|
final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0);
|
||||||
|
final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress);
|
||||||
|
// Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt.
|
||||||
|
final margin = isMobile ? 0.0 : 24.0;
|
||||||
|
|
||||||
final header = AnimatedOpacity(
|
final header = AnimatedOpacity(
|
||||||
opacity: _controlsVisible ? 1 : 0,
|
opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_controlsVisible,
|
ignoring: !_controlsVisible,
|
||||||
@@ -146,7 +268,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: _close,
|
||||||
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
||||||
tooltip: 'Schließen',
|
tooltip: 'Schließen',
|
||||||
),
|
),
|
||||||
@@ -181,7 +303,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
: const Icon(Icons.download_rounded, color: Colors.white),
|
: const Icon(Icons.download_rounded, color: Colors.white),
|
||||||
),
|
),
|
||||||
if (!isVideo)
|
if (!isVideo && _zoomed)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _resetZoom,
|
onPressed: _resetZoom,
|
||||||
tooltip: 'Zoom zurücksetzen',
|
tooltip: 'Zoom zurücksetzen',
|
||||||
@@ -194,30 +316,14 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Content area — constrained so the transparent Material outside it lets
|
final Widget content;
|
||||||
// pointer events through to the barrier (which dismisses on tap).
|
|
||||||
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
|
||||||
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return Material(
|
content = GestureDetector(
|
||||||
type: MaterialType.transparency,
|
onVerticalDragUpdate: _onVideoDragUpdate,
|
||||||
child: Center(
|
onVerticalDragEnd: _onVideoDragEnd,
|
||||||
child: SizedBox(
|
child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||||
width: contentMaxW,
|
|
||||||
height: contentMaxH,
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
|
||||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
Widget img;
|
Widget img;
|
||||||
if (widget.bytes != null) {
|
if (widget.bytes != null) {
|
||||||
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
||||||
@@ -226,30 +332,56 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
} else {
|
} else {
|
||||||
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
||||||
}
|
}
|
||||||
|
content = InteractiveViewer(
|
||||||
return Material(
|
transformationController: _tc,
|
||||||
type: MaterialType.transparency,
|
minScale: 1.0,
|
||||||
|
maxScale: 10.0,
|
||||||
|
// Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung
|
||||||
|
// gehört dann dem Wischen zum Schließen.
|
||||||
|
panEnabled: _zoomed,
|
||||||
|
onInteractionStart: _onInteractionStart,
|
||||||
|
onInteractionUpdate: _onInteractionUpdate,
|
||||||
|
onInteractionEnd: _onInteractionEnd,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
key: _contentKey,
|
||||||
width: contentMaxW,
|
// Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
|
||||||
height: contentMaxH,
|
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: _toggle,
|
onTap: _toggle,
|
||||||
child: Stack(
|
onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
|
||||||
fit: StackFit.expand,
|
onDoubleTap: _toggleZoom,
|
||||||
children: [
|
child: img,
|
||||||
InteractiveViewer(
|
|
||||||
transformationController: _tc,
|
|
||||||
minScale: 0.5,
|
|
||||||
maxScale: 10.0,
|
|
||||||
child: Center(child: img),
|
|
||||||
),
|
|
||||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Hintergrund + „daneben tippen schließt“.
|
||||||
|
GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _close,
|
||||||
|
child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.all(margin),
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, _dragOffset),
|
||||||
|
child: GestureDetector(
|
||||||
|
// Tipps auf die leere Fläche um das Bild (innerhalb des
|
||||||
|
// Inhaltsbereichs) schließen ebenfalls.
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: _close,
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
part of '../message_group.dart';
|
||||||
|
|
||||||
|
// Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem
|
||||||
|
// Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts
|
||||||
|
// in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in
|
||||||
|
// Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe.
|
||||||
|
// Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen
|
||||||
|
// und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste.
|
||||||
|
|
||||||
|
const _kBubbleMaxWidthFactor = 0.82;
|
||||||
|
const _kBubbleAvatarSize = 30.0;
|
||||||
|
const _kBubbleAvatarGap = 8.0;
|
||||||
|
|
||||||
|
extension _BubbleBuilder on _MessageGroupState {
|
||||||
|
Widget _buildBubbles(BuildContext context, PyramidTheme pt) {
|
||||||
|
final firstEvent = widget.events.first;
|
||||||
|
final isOwn = firstEvent.senderId == widget.currentUserId;
|
||||||
|
final isDm = widget.room?.isDirectChat ?? false;
|
||||||
|
// Name + Avatar nur bei fremden Nachrichten in Gruppenräumen.
|
||||||
|
final showSender = !isOwn && !isDm;
|
||||||
|
final sender = firstEvent.senderFromMemoryOrFallback;
|
||||||
|
final name = sender.calcDisplayname();
|
||||||
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||||
|
final color = _colorFromId(firstEvent.senderId);
|
||||||
|
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts
|
||||||
|
final senderSpace =
|
||||||
|
showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0;
|
||||||
|
final maxBubbleWidth =
|
||||||
|
((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0);
|
||||||
|
|
||||||
|
final rows = <Widget>[];
|
||||||
|
for (var j = 0; j < widget.events.length; j++) {
|
||||||
|
final event = widget.events[j];
|
||||||
|
final isFirst = j == 0;
|
||||||
|
final isLast = j == widget.events.length - 1;
|
||||||
|
|
||||||
|
final bubbleColumn = Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_Bubble(
|
||||||
|
event: event,
|
||||||
|
pt: pt,
|
||||||
|
isOwn: isOwn,
|
||||||
|
isFirst: isFirst,
|
||||||
|
isLast: isLast,
|
||||||
|
senderName: showSender && isFirst ? name : null,
|
||||||
|
senderColor: color,
|
||||||
|
maxWidth: maxBubbleWidth,
|
||||||
|
timeline: widget.timeline,
|
||||||
|
),
|
||||||
|
if (widget.timeline != null)
|
||||||
|
_ReactionsRow(
|
||||||
|
event: event,
|
||||||
|
timeline: widget.timeline!,
|
||||||
|
room: widget.room,
|
||||||
|
currentUserId: widget.currentUserId,
|
||||||
|
pt: pt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final line = Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
isOwn ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (showSender) ...[
|
||||||
|
SizedBox(
|
||||||
|
width: _kBubbleAvatarSize,
|
||||||
|
child: isFirst
|
||||||
|
? _Avatar(
|
||||||
|
initial: initial,
|
||||||
|
color: color,
|
||||||
|
pt: pt,
|
||||||
|
size: _kBubbleAvatarSize,
|
||||||
|
avatarUri: sender.avatarUrl,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: _kBubbleAvatarGap),
|
||||||
|
],
|
||||||
|
Flexible(child: bubbleColumn),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.add(_selectableRow(
|
||||||
|
event,
|
||||||
|
pt,
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: isFirst ? pt.msgGap / 2 : 3,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
),
|
||||||
|
child: line,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
if (widget.readEventIds.contains(event.eventId)) {
|
||||||
|
rows.add(_ReadMarker(pt: pt));
|
||||||
|
}
|
||||||
|
if (widget.fullyReadId == event.eventId && !isLast) {
|
||||||
|
rows.add(_InlineUnreadDivider(pt: pt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.showStatusIndicator &&
|
||||||
|
isOwn &&
|
||||||
|
widget.room != null &&
|
||||||
|
!widget.readEventIds.contains(widget.events.last.eventId)) {
|
||||||
|
rows.add(Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16, bottom: 1),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: _MessageStatus(lastEvent: widget.events.last, pt: pt),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rows.add(const SizedBox(height: 2));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: rows,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase).
|
||||||
|
class _Bubble extends StatelessWidget {
|
||||||
|
final Event event;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final bool isOwn;
|
||||||
|
final bool isFirst;
|
||||||
|
final bool isLast;
|
||||||
|
final String? senderName;
|
||||||
|
final Color senderColor;
|
||||||
|
final double maxWidth;
|
||||||
|
final Timeline? timeline;
|
||||||
|
|
||||||
|
const _Bubble({
|
||||||
|
required this.event,
|
||||||
|
required this.pt,
|
||||||
|
required this.isOwn,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.isLast,
|
||||||
|
required this.senderName,
|
||||||
|
required this.senderColor,
|
||||||
|
required this.maxWidth,
|
||||||
|
this.timeline,
|
||||||
|
});
|
||||||
|
|
||||||
|
Event get _displayEvent =>
|
||||||
|
timeline != null ? event.getDisplayEvent(timeline!) : event;
|
||||||
|
|
||||||
|
bool get _isVisualMedia {
|
||||||
|
final e = _displayEvent;
|
||||||
|
if (e.type == EventTypes.Sticker) return true;
|
||||||
|
return e.type == EventTypes.Message &&
|
||||||
|
(e.messageType == MessageTypes.Image ||
|
||||||
|
e.messageType == MessageTypes.Video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar).
|
||||||
|
static Color ownColor(PyramidTheme pt) => Color.alphaBlend(
|
||||||
|
pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24),
|
||||||
|
pt.bg1,
|
||||||
|
);
|
||||||
|
|
||||||
|
BorderRadius get _radius {
|
||||||
|
const big = Radius.circular(18);
|
||||||
|
const small = Radius.circular(6);
|
||||||
|
// Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen.
|
||||||
|
final topSide = isFirst ? big : small;
|
||||||
|
final bottomSide = isLast ? big : small;
|
||||||
|
return isOwn
|
||||||
|
? BorderRadius.only(
|
||||||
|
topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide)
|
||||||
|
: BorderRadius.only(
|
||||||
|
topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final time = Text(
|
||||||
|
_formatMessageTime(event.originServerTs),
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||||||
|
);
|
||||||
|
final failed = event.status.isError
|
||||||
|
? _SendFailedRow(event: event, pt: pt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (_isVisualMedia) {
|
||||||
|
// Ohne Blase: Bilder/Videos sollen so groß wie möglich sein.
|
||||||
|
final box = _reservedImageSize(
|
||||||
|
_displayEvent,
|
||||||
|
maxW: maxWidth.clamp(80.0, 420.0),
|
||||||
|
maxH: inlineMediaMaxHeight(context),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (senderName != null) _senderLabel(),
|
||||||
|
SizedBox(
|
||||||
|
width: box?.width ?? maxWidth,
|
||||||
|
child: _MessageContent(event: event, pt: pt, timeline: timeline),
|
||||||
|
),
|
||||||
|
?failed,
|
||||||
|
Padding(padding: const EdgeInsets.only(top: 2), child: time),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 7, 12, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isOwn ? ownColor(pt) : pt.bg3,
|
||||||
|
borderRadius: _radius,
|
||||||
|
),
|
||||||
|
// Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit
|
||||||
|
// sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen.
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (senderName != null) _senderLabel(),
|
||||||
|
_MessageContent(event: event, pt: pt, timeline: timeline),
|
||||||
|
?failed,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
time,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _senderLabel() => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
senderName!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: senderColor,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,23 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Darstellung laut Einstellung (Erscheinungsbild → Nachrichten):
|
||||||
|
// Sprechblasen (message_bubbles.dart) oder Liste wie bisher.
|
||||||
|
return Consumer(builder: (context, ref, _) {
|
||||||
|
final style = ref.watch(messageStyleProvider);
|
||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
|
final bubbles =
|
||||||
|
style.useBubbles(mobile: isMobileLayout(MediaQuery.sizeOf(context)));
|
||||||
|
return _withSwipeToReply(
|
||||||
|
pt,
|
||||||
|
bubbles ? _buildBubbles(context, pt) : _buildList(context, pt),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen-Darstellung (Discord-Stil): Avatar + Name + Zeit, darunter die
|
||||||
|
// Nachrichten des Absenders bündig untereinander.
|
||||||
|
Widget _buildList(BuildContext context, PyramidTheme pt) {
|
||||||
final firstEvent = widget.events.first;
|
final firstEvent = widget.events.first;
|
||||||
final sender = firstEvent.senderFromMemoryOrFallback;
|
final sender = firstEvent.senderFromMemoryOrFallback;
|
||||||
final name = sender.calcDisplayname();
|
final name = sender.calcDisplayname();
|
||||||
@@ -168,11 +184,14 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
|
|
||||||
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
||||||
// group is just the column of rows.
|
// group is just the column of rows.
|
||||||
Widget content = Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: rows,
|
children: rows,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handy: nach links wischen = antworten (beide Darstellungen).
|
||||||
|
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
|
||||||
if (!_isMobile) return content;
|
if (!_isMobile) return content;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
|
|||||||
final maxW = constraints.maxWidth.isFinite
|
final maxW = constraints.maxWidth.isFinite
|
||||||
? constraints.maxWidth.clamp(80.0, 420.0)
|
? constraints.maxWidth.clamp(80.0, 420.0)
|
||||||
: 420.0;
|
: 420.0;
|
||||||
const maxH = 320.0;
|
final maxH = inlineMediaMaxHeight(context);
|
||||||
// Reserve the final display box up-front from the event's info dimensions,
|
// Reserve the final display box up-front from the event's info dimensions,
|
||||||
// so the layout doesn't shift (and yank the scroll position) when the image
|
// so the layout doesn't shift (and yank the scroll position) when the image
|
||||||
// finishes loading. Null when dimensions are absent.
|
// finishes loading. Null when dimensions are absent.
|
||||||
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
|
|||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final isSticker = widget.event.type == EventTypes.Sticker;
|
final isSticker = widget.event.type == EventTypes.Sticker;
|
||||||
final maxW = isSticker ? 160.0 : 420.0;
|
final maxW = isSticker ? 160.0 : 420.0;
|
||||||
final maxH = isSticker ? 160.0 : 320.0;
|
final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
|
||||||
|
|
||||||
return FutureBuilder<_MediaResult>(
|
return FutureBuilder<_MediaResult>(
|
||||||
future: _future,
|
future: _future,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class _MatrixHtmlText extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final spans = _parseHtml(html, baseStyle, linkColor);
|
final spans = _parseHtml(html, baseStyle, linkColor);
|
||||||
return SelectionArea(
|
return SelectionArea(
|
||||||
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
|
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ class _PlainLinkText extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SelectionArea(
|
return SelectionArea(
|
||||||
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
|
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import 'package:pyramid/features/chat/media_player.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart' show messageStyleProvider;
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/media_cache.dart';
|
import 'package:pyramid/core/media_cache.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
@@ -22,6 +24,7 @@ import 'package:pyramid/widgets/mxc_image.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
part 'message/message_group_core.dart';
|
part 'message/message_group_core.dart';
|
||||||
|
part 'message/message_bubbles.dart';
|
||||||
part 'message/message_text.dart';
|
part 'message/message_text.dart';
|
||||||
part 'message/message_link_preview.dart';
|
part 'message/message_link_preview.dart';
|
||||||
part 'message/message_media.dart';
|
part 'message/message_media.dart';
|
||||||
|
|||||||
@@ -144,13 +144,13 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.search_rounded),
|
icon: const Icon(Icons.search_rounded),
|
||||||
tooltip: 'Search',
|
tooltip: 'Suchen',
|
||||||
onPressed: onOpenSearch,
|
onPressed: onOpenSearch,
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.group_rounded),
|
icon: const Icon(Icons.group_rounded),
|
||||||
tooltip: 'Members',
|
tooltip: 'Mitglieder',
|
||||||
active: membersOpen,
|
active: membersOpen,
|
||||||
onPressed: onToggleMembers,
|
onPressed: onToggleMembers,
|
||||||
),
|
),
|
||||||
@@ -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}',
|
||||||
@@ -540,13 +540,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.search_rounded),
|
icon: const Icon(Icons.search_rounded),
|
||||||
tooltip: 'Search',
|
tooltip: 'Suchen',
|
||||||
onPressed: widget.onOpenSearch,
|
onPressed: widget.onOpenSearch,
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.group_rounded),
|
icon: const Icon(Icons.group_rounded),
|
||||||
tooltip: 'Members',
|
tooltip: 'Mitglieder',
|
||||||
active: widget.membersOpen,
|
active: widget.membersOpen,
|
||||||
onPressed: widget.onToggleMembers,
|
onPressed: widget.onToggleMembers,
|
||||||
),
|
),
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class NoChatSelected extends StatelessWidget {
|
|||||||
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
|
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Select a room to start chatting',
|
'Wähle einen Chat aus',
|
||||||
style: TextStyle(color: pt.fgMuted, fontSize: 16),
|
style: TextStyle(color: pt.fgMuted, fontSize: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class MembersPanel extends ConsumerWidget {
|
|||||||
Icon(Icons.group_rounded, size: 16, color: pt.fg),
|
Icon(Icons.group_rounded, size: 16, color: pt.fg),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Members',
|
'Mitglieder',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: pt.fg,
|
color: pt.fg,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
final name = isRealSpace
|
final name = isRealSpace
|
||||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||||
: (spaceId == 'dms' ? 'Chats' : 'Rooms');
|
: (spaceId == 'dms' ? 'Chats' : 'Räume');
|
||||||
|
|
||||||
void handleMenu(_SpaceMenuItem item) {
|
void handleMenu(_SpaceMenuItem item) {
|
||||||
switch (item) {
|
switch (item) {
|
||||||
@@ -478,7 +478,7 @@ class _FilterInput extends StatelessWidget {
|
|||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Filter rooms',
|
hintText: 'Chats durchsuchen',
|
||||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
||||||
filled: true,
|
filled: true,
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ class _RoomsList extends ConsumerWidget {
|
|||||||
if (filtered.isEmpty && invites.isEmpty) {
|
if (filtered.isEmpty && invites.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child:
|
child:
|
||||||
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
||||||
}
|
}
|
||||||
|
|
||||||
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms';
|
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
|
||||||
final sectionId = isDms ? 'dms' : 'rooms';
|
final sectionId = isDms ? 'dms' : 'rooms';
|
||||||
final isCollapsed = collapsed[sectionId] ?? false;
|
final isCollapsed = collapsed[sectionId] ?? false;
|
||||||
|
|
||||||
@@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget {
|
|||||||
context: context, builder: (_) => const CreateJoinDialog()),
|
context: context, builder: (_) => const CreateJoinDialog()),
|
||||||
),
|
),
|
||||||
if (!isCollapsed)
|
if (!isCollapsed)
|
||||||
...filtered.map((room) => _RoomItem(
|
...filtered.map((room) {
|
||||||
|
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;
|
||||||
@@ -477,6 +513,18 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
|||||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
||||||
|
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return _MobileRoomTile(
|
||||||
|
room: room,
|
||||||
|
isActive: active,
|
||||||
|
pt: pt,
|
||||||
|
myUserId: client?.userID,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: _showContextMenu,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
|
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
part of '../rooms_panel.dart';
|
||||||
|
|
||||||
|
// Chatlisten-Eintrag im Handy-Layout (Bernds Feedback 2026-10-07: die Handy-App
|
||||||
|
// soll sich wie ein gewohnter Messenger anfühlen): großer Avatar, Name mit
|
||||||
|
// Uhrzeit der letzten Nachricht, darunter die letzte Nachricht als Vorschau
|
||||||
|
// und der Ungelesen-Zähler. Am PC bleibt die kompakte Discord-Liste
|
||||||
|
// (_RoomItem); Voice-Kanäle behalten ihre eigene Darstellung.
|
||||||
|
|
||||||
|
const _kTileAvatar = 46.0;
|
||||||
|
|
||||||
|
class _MobileRoomTile extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final bool isActive;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final String? myUserId;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _MobileRoomTile({
|
||||||
|
required this.room,
|
||||||
|
required this.isActive,
|
||||||
|
required this.pt,
|
||||||
|
required this.myUserId,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = room.getLocalizedDisplayname();
|
||||||
|
final unread = room.notificationCount;
|
||||||
|
final mention = room.highlightCount > 0;
|
||||||
|
final last = room.lastEvent;
|
||||||
|
final preview = _roomPreview(room, myUserId);
|
||||||
|
final time = last == null ? null : chatListTime(last.originServerTs);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
color: isActive ? pt.accentSoft : Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
room.isDirectChat
|
||||||
|
? _DmAvatar(room: room, pt: pt, size: _kTileAvatar)
|
||||||
|
: _RoomTileAvatar(room: room, pt: pt),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight:
|
||||||
|
unread > 0 ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (time != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: TextStyle(
|
||||||
|
color: unread > 0 ? pt.accent : pt.fgDim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
unread > 0 ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
preview ?? '',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: unread > 0 ? pt.fgMuted : pt.fgDim,
|
||||||
|
fontSize: 13.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (unread > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_UnreadBadge(count: unread, isMention: mention, pt: pt),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
|
||||||
|
class _RoomTileAvatar extends ConsumerWidget {
|
||||||
|
final Room room;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _RoomTileAvatar({required this.room, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
final fallback = Container(
|
||||||
|
width: _kTileAvatar,
|
||||||
|
height: _kTileAvatar,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg3,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||||
|
size: 22,
|
||||||
|
color: pt.fgMuted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (client == null || room.avatar == null) return fallback;
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: room.avatar,
|
||||||
|
client: client,
|
||||||
|
size: _kTileAvatar,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
placeholder: (_) => fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorschau der letzten Nachricht: gleiche Texte wie in Benachrichtigungen
|
||||||
|
/// (Medien als „📷 Foto“ usw., Antwort-Zitat entfernt), in Gruppen mit
|
||||||
|
/// Absender, eigene Nachrichten mit „Du:“.
|
||||||
|
String? _roomPreview(Room room, String? myUserId) {
|
||||||
|
final e = room.lastEvent;
|
||||||
|
if (e == null) return null;
|
||||||
|
if (e.type == EventTypes.Encrypted) return '🔒 Verschlüsselte Nachricht';
|
||||||
|
final body = notificationBody(e);
|
||||||
|
if (body == null) return null;
|
||||||
|
if (e.senderId == myUserId) return 'Du: $body';
|
||||||
|
if (room.isDirectChat) return body;
|
||||||
|
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
||||||
|
}
|
||||||
@@ -27,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' &&
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ part of '../rooms_panel.dart';
|
|||||||
class _DmAvatar extends ConsumerStatefulWidget {
|
class _DmAvatar extends ConsumerStatefulWidget {
|
||||||
final Room room;
|
final Room room;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _DmAvatar({required this.room, required this.pt});
|
final double size;
|
||||||
|
const _DmAvatar({required this.room, required this.pt, this.size = 22});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_DmAvatar> createState() => _DmAvatarState();
|
ConsumerState<_DmAvatar> createState() => _DmAvatarState();
|
||||||
@@ -69,13 +70,13 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
|
|||||||
avatar = MxcAvatar(
|
avatar = MxcAvatar(
|
||||||
mxcUri: widget.room.avatar,
|
mxcUri: widget.room.avatar,
|
||||||
client: client,
|
client: client,
|
||||||
size: 22,
|
size: widget.size,
|
||||||
borderRadius: BorderRadius.circular(11),
|
borderRadius: BorderRadius.circular(widget.size / 2),
|
||||||
placeholder: (_) =>
|
placeholder: (_) =>
|
||||||
_InitialCircle(initial: initial, color: color, size: 22),
|
_InitialCircle(initial: initial, color: color, size: widget.size),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
avatar = _InitialCircle(initial: initial, color: color, size: 22);
|
avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dot = _dotColor();
|
final dot = _dotColor();
|
||||||
@@ -83,11 +84,11 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
|
|||||||
return Stack(clipBehavior: Clip.none, children: [
|
return Stack(clipBehavior: Clip.none, children: [
|
||||||
avatar,
|
avatar,
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -2,
|
right: widget.size > 30 ? 0 : -2,
|
||||||
bottom: -2,
|
bottom: widget.size > 30 ? 0 : -2,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 9,
|
width: widget.size > 30 ? 13 : 9,
|
||||||
height: 9,
|
height: widget.size > 30 ? 13 : 9,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: dot,
|
color: dot,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -123,9 +124,9 @@ class _InitialCircle extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(initial,
|
child: Text(initial,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 10,
|
fontSize: size * 0.45,
|
||||||
fontWeight: FontWeight.w600))),
|
fontWeight: FontWeight.w600))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
|||||||
if (jr.isVoice) {
|
if (jr.isVoice) {
|
||||||
// LiveKit only needs the room id + a display name, so connect even if the
|
// LiveKit only needs the room id + a display name, so connect even if the
|
||||||
// matrix room object hasn't synced locally yet.
|
// matrix room object hasn't synced locally yet.
|
||||||
final call = ref.read(callStateProvider);
|
final call = ref.read(voiceChannelProvider);
|
||||||
if (call.isActive) await call.hangUp();
|
if (call.isActive) await call.hangUp();
|
||||||
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
|
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
|
||||||
await call.startCall(
|
await call.startCall(
|
||||||
@@ -199,7 +199,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void joinVoiceChannel(Room room) async {
|
void joinVoiceChannel(Room room) async {
|
||||||
final call = ref.read(callStateProvider);
|
final call = ref.read(voiceChannelProvider);
|
||||||
final activeVoice = ref.read(activeVoiceRoomIdProvider);
|
final activeVoice = ref.read(activeVoiceRoomIdProvider);
|
||||||
// Toggle: leave if already in this channel
|
// Toggle: leave if already in this channel
|
||||||
if (call.isVoiceChannel && activeVoice == room.id) {
|
if (call.isVoiceChannel && activeVoice == room.id) {
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import 'package:matrix/matrix.dart';
|
|||||||
// ignore: implementation_imports
|
// ignore: implementation_imports
|
||||||
import 'package:matrix/src/utils/space_child.dart';
|
import 'package:matrix/src/utils/space_child.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
import 'package:pyramid/features/call/mini_call_widget.dart';
|
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
import 'package:pyramid/features/rooms/user_panel.dart';
|
import 'package:pyramid/features/rooms/user_panel.dart';
|
||||||
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
||||||
|
import 'package:pyramid/utils/date_labels.dart';
|
||||||
import 'package:pyramid/widgets/create_join_dialog.dart';
|
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||||
import 'package:pyramid/widgets/hover_region.dart';
|
import 'package:pyramid/widgets/hover_region.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
@@ -29,3 +34,4 @@ part 'panel/rooms_list.dart';
|
|||||||
part 'panel/rooms_voice.dart';
|
part 'panel/rooms_voice.dart';
|
||||||
part 'panel/rooms_invite_item.dart';
|
part 'panel/rooms_invite_item.dart';
|
||||||
part 'panel/rooms_shared.dart';
|
part 'panel/rooms_shared.dart';
|
||||||
|
part 'panel/rooms_mobile_tile.dart';
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
/// Lokale Nachrichtensuche über die entschlüsselten Events in der Client-DB.
|
||||||
|
///
|
||||||
|
/// Die server-seitige `/search`-API sieht bei E2EE nur Chiffretext und liefert
|
||||||
|
/// für verschlüsselte Räume nichts – deshalb wird (wie bei Element) die lokale
|
||||||
|
/// Datenbank durchsucht. Aus `search_modal.dart` herausgelöst, damit Tests und
|
||||||
|
/// `benchmark/` genau diesen Codepfad prüfen/messen können.
|
||||||
|
///
|
||||||
|
/// Bei [mediaOnly] werden nur Medien-Nachrichten geliefert, deren
|
||||||
|
/// Dateiname/Beschriftung passt. Ergebnis: neueste zuerst, höchstens
|
||||||
|
/// [searchTotalCap] Treffer; pro Raum werden höchstens
|
||||||
|
/// [searchPerRoomScanCap] Events gescannt.
|
||||||
|
Future<List<Event>> localMessageSearch(
|
||||||
|
Client client,
|
||||||
|
String query, {
|
||||||
|
bool mediaOnly = false,
|
||||||
|
}) async {
|
||||||
|
final db = client.database;
|
||||||
|
final lower = query.toLowerCase();
|
||||||
|
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
|
||||||
|
|
||||||
|
final results = <Event>[];
|
||||||
|
for (final room in client.rooms.where((r) => !r.isSpace)) {
|
||||||
|
var start = 0;
|
||||||
|
var scanned = 0;
|
||||||
|
try {
|
||||||
|
while (scanned < searchPerRoomScanCap) {
|
||||||
|
final events = await db.getEventList(
|
||||||
|
room,
|
||||||
|
start: start,
|
||||||
|
limit: searchScanChunk,
|
||||||
|
);
|
||||||
|
if (events.isEmpty) break;
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.Message || e.redacted) continue;
|
||||||
|
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
|
||||||
|
if (mediaOnly && !mediaTypes.contains(msgtype)) continue;
|
||||||
|
if (!e.body.toLowerCase().contains(lower)) continue;
|
||||||
|
results.add(e);
|
||||||
|
}
|
||||||
|
start += searchScanChunk;
|
||||||
|
scanned += events.length;
|
||||||
|
final roomExhausted = events.length < searchScanChunk;
|
||||||
|
if (roomExhausted || results.length >= searchTotalCap) break;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (results.length >= searchTotalCap) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
|
||||||
|
return results.take(searchTotalCap).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Höchstens so viele Events werden pro Raum gescannt.
|
||||||
|
const searchPerRoomScanCap = 1500;
|
||||||
|
|
||||||
|
/// Höchstens so viele Treffer liefert die Suche insgesamt.
|
||||||
|
const searchTotalCap = 60;
|
||||||
|
|
||||||
|
/// So viele Events werden pro DB-Abfrage geladen.
|
||||||
|
const searchScanChunk = 500;
|
||||||
@@ -71,7 +71,7 @@ class SpacesRail extends ConsumerWidget {
|
|||||||
id: 'rooms',
|
id: 'rooms',
|
||||||
label: 'R',
|
label: 'R',
|
||||||
color: const Color(0xFFF59E0B),
|
color: const Color(0xFFF59E0B),
|
||||||
name: 'Rooms',
|
name: 'Räume',
|
||||||
active: activeSpaceId == 'rooms',
|
active: activeSpaceId == 'rooms',
|
||||||
unread: 0,
|
unread: 0,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
@@ -179,7 +179,7 @@ class _HomeButtonState extends State<_HomeButton> {
|
|||||||
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
|
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
|
||||||
: Matrix4.identity(),
|
: Matrix4.identity(),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: 'Direct Messages',
|
message: 'Direktnachrichten',
|
||||||
waitDuration: const Duration(milliseconds: 400),
|
waitDuration: const Duration(milliseconds: 400),
|
||||||
preferBelow: false,
|
preferBelow: false,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
|||||||
+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;
|
||||||
|
});
|
||||||
+29
-12
@@ -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) {
|
||||||
@@ -343,7 +344,18 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
body = const RoomsPanel();
|
body = const RoomsPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return PopScope(
|
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||||
|
final overlayStyle = (pt.isDark
|
||||||
|
? SystemUiOverlayStyle.light
|
||||||
|
: SystemUiOverlayStyle.dark)
|
||||||
|
.copyWith(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarColor: Colors.transparent,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
|
value: overlayStyle,
|
||||||
|
child: PopScope(
|
||||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop) {
|
if (!didPop) {
|
||||||
@@ -357,13 +369,17 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: pt.bg0,
|
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||||
|
// geht nahtlos in sie über.
|
||||||
|
backgroundColor: pt.bg1,
|
||||||
drawer: Drawer(
|
drawer: Drawer(
|
||||||
width: 80,
|
width: 80,
|
||||||
backgroundColor: pt.bg0,
|
backgroundColor: pt.bg0,
|
||||||
child: const SpacesRail(),
|
child: const SafeArea(child: SpacesRail()),
|
||||||
),
|
),
|
||||||
body: Stack(
|
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
||||||
|
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
||||||
|
body: SafeArea(child: Stack(
|
||||||
children: [
|
children: [
|
||||||
body,
|
body,
|
||||||
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
|
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
|
||||||
@@ -399,6 +415,7 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
child: _NotificationBlockedBanner(),
|
child: _NotificationBlockedBanner(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -456,7 +473,7 @@ class _NotificationBlockedBanner extends ConsumerWidget {
|
|||||||
|
|
||||||
class _IncomingCallOverlay extends ConsumerWidget {
|
class _IncomingCallOverlay extends ConsumerWidget {
|
||||||
final CallSession call;
|
final CallSession call;
|
||||||
final PyramidVoipManager voip;
|
final CallSignalingService voip;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
|
||||||
const _IncomingCallOverlay({
|
const _IncomingCallOverlay({
|
||||||
@@ -575,7 +592,7 @@ class NoChatSelected extends StatelessWidget {
|
|||||||
Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)),
|
Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Select a chat to start messaging',
|
'Wähle einen Chat aus',
|
||||||
style: TextStyle(color: pt.fgMuted, fontSize: 15),
|
style: TextStyle(color: pt.fgMuted, fontSize: 15),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+10
-2
@@ -33,8 +33,16 @@ void main() async {
|
|||||||
|
|
||||||
await vod.init();
|
await vod.init();
|
||||||
|
|
||||||
// Hide status bar and navigation bar for immersive feel
|
// Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
// zeichnet darunter, hält per SafeArea (Handy-Layout in app_shell.dart)
|
||||||
|
// aber Abstand. Vorher immersiveSticky – da schob sich die Statusleiste
|
||||||
|
// beim Herunterwischen über Kopfzeile und Text (Bernds Feedback 2026-10-07).
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarContrastEnforced: false,
|
||||||
|
));
|
||||||
|
|
||||||
runApp(const ProviderScope(child: PyramidBootstrap()));
|
runApp(const ProviderScope(child: PyramidBootstrap()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/// Kurze Datums-/Zeitangaben für Listen (deutsch).
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Zeit in der Chatliste: heute „14:05“, gestern „Gestern“, diese Woche der
|
||||||
|
/// Wochentag, sonst das Datum.
|
||||||
|
String chatListTime(DateTime t, {DateTime? now}) {
|
||||||
|
final n = now ?? DateTime.now();
|
||||||
|
final today = DateTime(n.year, n.month, n.day);
|
||||||
|
final day = DateTime(t.year, t.month, t.day);
|
||||||
|
// Über Stunden runden: an Zeitumstellungs-Tagen hat ein Tag 23/25 Stunden,
|
||||||
|
// inDays würde dann „gestern“ als „heute“ zählen.
|
||||||
|
final diff = (today.difference(day).inHours / 24).round();
|
||||||
|
if (diff <= 0) {
|
||||||
|
return '${t.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${t.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
if (diff == 1) return 'Gestern';
|
||||||
|
if (diff < 7) {
|
||||||
|
const days = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
return days[t.weekday - 1];
|
||||||
|
}
|
||||||
|
return '${t.day.toString().padLeft(2, '0')}.'
|
||||||
|
'${t.month.toString().padLeft(2, '0')}.'
|
||||||
|
'${(t.year % 100).toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
@@ -104,9 +104,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: popoverW,
|
width: popoverW,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: PyramidColors.bg0,
|
color: PyramidTheme.of(context).bg0,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: PyramidColors.border),
|
border: Border.all(color: PyramidTheme.of(context).border),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
|
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
|
||||||
],
|
],
|
||||||
@@ -172,7 +172,7 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: PyramidColors.bg0, width: 3),
|
border: Border.all(color: PyramidTheme.of(context).bg0, width: 3),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: avatarUrl != null
|
child: avatarUrl != null
|
||||||
@@ -212,14 +212,14 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(displayName,
|
Text(displayName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fg,
|
color: PyramidTheme.of(context).fg,
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w700)),
|
fontWeight: FontWeight.w700)),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(handle,
|
Text(handle,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgMuted, fontSize: 12)),
|
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Action buttons
|
// Action buttons
|
||||||
Row(
|
Row(
|
||||||
@@ -248,9 +248,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
),
|
),
|
||||||
if (powerLevel > 0) ...[
|
if (powerLevel > 0) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text('Rollen',
|
Text('Rollen',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgDim,
|
color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.8)),
|
letterSpacing: 0.8)),
|
||||||
@@ -270,16 +270,16 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
],
|
],
|
||||||
if (joinDate != null) ...[
|
if (joinDate != null) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text('Mitglied seit',
|
Text('Mitglied seit',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgDim,
|
color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.8)),
|
letterSpacing: 0.8)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(joinDate,
|
Text(joinDate,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgMuted, fontSize: 12)),
|
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -337,7 +337,7 @@ class _PresenceDot extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: PyramidColors.bg0, width: 2),
|
border: Border.all(color: PyramidTheme.of(context).bg0, width: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -380,10 +380,10 @@ class _ProfileBtnState extends State<_ProfileBtn> {
|
|||||||
? (_hover
|
? (_hover
|
||||||
? PyramidTheme.of(context).accent.withAlpha(200)
|
? PyramidTheme.of(context).accent.withAlpha(200)
|
||||||
: PyramidTheme.of(context).accent)
|
: PyramidTheme.of(context).accent)
|
||||||
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2),
|
: (_hover ? PyramidTheme.of(context).bgHover : PyramidTheme.of(context).bg2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: widget.primary ? Colors.transparent : PyramidColors.border,
|
color: widget.primary ? Colors.transparent : PyramidTheme.of(context).border,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -393,14 +393,14 @@ class _ProfileBtnState extends State<_ProfileBtn> {
|
|||||||
size: 14,
|
size: 14,
|
||||||
color: widget.primary
|
color: widget.primary
|
||||||
? PyramidColors.accentFg
|
? PyramidColors.accentFg
|
||||||
: PyramidColors.fg),
|
: PyramidTheme.of(context).fg),
|
||||||
if (hasLabel) ...[
|
if (hasLabel) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(widget.label!,
|
Text(widget.label!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: widget.primary
|
color: widget.primary
|
||||||
? PyramidColors.accentFg
|
? PyramidColors.accentFg
|
||||||
: PyramidColors.fg,
|
: PyramidTheme.of(context).fg,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500)),
|
fontWeight: FontWeight.w500)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
|
|||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/search/local_message_search.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
@@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState<SearchModal> {
|
|||||||
List<Event> messageResults = [];
|
List<Event> messageResults = [];
|
||||||
if (_filter != _Filter.people) {
|
if (_filter != _Filter.people) {
|
||||||
try {
|
try {
|
||||||
messageResults = await _localMessageSearch(client, q);
|
messageResults = await localMessageSearch(
|
||||||
|
client,
|
||||||
|
q,
|
||||||
|
mediaOnly: _filter == _Filter.media,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _state = _state.copyWith(
|
setState(() => _state = _state.copyWith(
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState<SearchModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scans the local (decrypted) event database across all rooms for messages
|
|
||||||
// whose body matches the query. For the media filter, only media messages
|
|
||||||
// whose filename/caption matches are returned.
|
|
||||||
Future<List<Event>> _localMessageSearch(Client client, String q) async {
|
|
||||||
final db = client.database;
|
|
||||||
final lower = q.toLowerCase();
|
|
||||||
final media = _filter == _Filter.media;
|
|
||||||
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
|
|
||||||
const perRoomScanCap = 1500;
|
|
||||||
const totalCap = 60;
|
|
||||||
const chunk = 500;
|
|
||||||
|
|
||||||
final results = <Event>[];
|
|
||||||
for (final room in client.rooms.where((r) => !r.isSpace)) {
|
|
||||||
var start = 0;
|
|
||||||
var scanned = 0;
|
|
||||||
try {
|
|
||||||
while (scanned < perRoomScanCap) {
|
|
||||||
final events = await db.getEventList(room, start: start, limit: chunk);
|
|
||||||
if (events.isEmpty) break;
|
|
||||||
for (final e in events) {
|
|
||||||
if (e.type != EventTypes.Message || e.redacted) continue;
|
|
||||||
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
|
|
||||||
if (media && !mediaTypes.contains(msgtype)) continue;
|
|
||||||
if (!e.body.toLowerCase().contains(lower)) continue;
|
|
||||||
results.add(e);
|
|
||||||
}
|
|
||||||
start += chunk;
|
|
||||||
scanned += events.length;
|
|
||||||
if (events.length < chunk || results.length >= totalCap) break;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
if (results.length >= totalCap) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
|
|
||||||
return results.take(totalCap).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openRoom(String roomId, {String? eventId}) {
|
void _openRoom(String roomId, {String? eventId}) {
|
||||||
widget.onClose();
|
widget.onClose();
|
||||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||||
@@ -657,6 +623,7 @@ class _MessageItemState extends State<_MessageItem> {
|
|||||||
]),
|
]),
|
||||||
const SizedBox(height: 1),
|
const SizedBox(height: 1),
|
||||||
RichText(
|
RichText(
|
||||||
|
textScaler: MediaQuery.textScalerOf(context),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
text: TextSpan(children: [
|
text: TextSpan(children: [
|
||||||
|
|||||||
@@ -0,0 +1,659 @@
|
|||||||
|
part of '../../settings_modal.dart';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum _EncState { idle, loading, needsKey, unlocking, done, error }
|
||||||
|
|
||||||
|
enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error }
|
||||||
|
|
||||||
|
class _EncryptionSection extends ConsumerStatefulWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _EncryptionSection({required this.pt, required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EncryptionSectionState extends ConsumerState<_EncryptionSection> {
|
||||||
|
_EncState _state = _EncState.idle;
|
||||||
|
Bootstrap? _bootstrap;
|
||||||
|
final _keyCtrl = TextEditingController();
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// Status loaded from encryption object
|
||||||
|
bool? _crossSigningEnabled;
|
||||||
|
bool? _crossSigningCached;
|
||||||
|
bool? _keyBackupEnabled;
|
||||||
|
bool _statusLoaded = false;
|
||||||
|
|
||||||
|
// Key file export / import
|
||||||
|
bool _exportLoading = false;
|
||||||
|
bool _importLoading = false;
|
||||||
|
String? _keyFileMsg;
|
||||||
|
bool _keyFileMsgIsError = false;
|
||||||
|
bool _diagUploading = false;
|
||||||
|
String? _diagMsg;
|
||||||
|
bool _diagMsgIsError = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keyCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStatus() async {
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
final enc = client.encryption;
|
||||||
|
if (enc == null) return;
|
||||||
|
final cached = await enc.crossSigning.isCached().catchError((_) => false);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_crossSigningEnabled = enc.crossSigning.enabled;
|
||||||
|
_crossSigningCached = cached;
|
||||||
|
_keyBackupEnabled = enc.keyManager.enabled;
|
||||||
|
_statusLoaded = true;
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _start() async {
|
||||||
|
setState(() { _state = _EncState.loading; _error = null; });
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
if (client.encryption == null) {
|
||||||
|
setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final enc = client.encryption!;
|
||||||
|
final cached = await enc.keyManager.isCached().catchError((_) => false);
|
||||||
|
if (cached) {
|
||||||
|
await enc.keyManager.loadAllKeys().catchError((_) {});
|
||||||
|
if (mounted) setState(() { _state = _EncState.done; _error = null; });
|
||||||
|
await _loadStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.roomsLoading;
|
||||||
|
await client.accountDataLoading;
|
||||||
|
await client.userDeviceKeysLoading;
|
||||||
|
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
_bootstrap = enc.bootstrap(onUpdate: (bs) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (bs.state) {
|
||||||
|
case BootstrapState.askWipeSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false));
|
||||||
|
case BootstrapState.askBadSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
|
||||||
|
case BootstrapState.askUseExistingSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true));
|
||||||
|
case BootstrapState.openExistingSsss:
|
||||||
|
if (!completer.isCompleted) completer.complete(true);
|
||||||
|
case BootstrapState.askNewSsss:
|
||||||
|
if (!completer.isCompleted) completer.complete(false);
|
||||||
|
case BootstrapState.error:
|
||||||
|
if (!completer.isCompleted) completer.complete(false);
|
||||||
|
if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; });
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final needsKey = await completer.future
|
||||||
|
.timeout(const Duration(seconds: 30), onTimeout: () => false);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done);
|
||||||
|
await _loadStatus();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unlock() async {
|
||||||
|
final bs = _bootstrap;
|
||||||
|
if (bs == null || bs.newSsssKey == null) return;
|
||||||
|
final key = _keyCtrl.text.trim();
|
||||||
|
if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; }
|
||||||
|
setState(() { _state = _EncState.unlocking; _error = null; });
|
||||||
|
try {
|
||||||
|
await bs.newSsssKey!.unlock(keyOrPassphrase: key);
|
||||||
|
await bs.openExistingSsss();
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
if (bs.encryption.crossSigning.enabled) {
|
||||||
|
await client.encryption!.crossSigning.selfSign(recoveryKey: key);
|
||||||
|
}
|
||||||
|
await client.encryption!.keyManager.loadAllKeys();
|
||||||
|
if (mounted) setState(() => _state = _EncState.done);
|
||||||
|
await _loadStatus();
|
||||||
|
} on InvalidPassphraseException catch (_) {
|
||||||
|
if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; });
|
||||||
|
} on FormatException catch (_) {
|
||||||
|
if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; });
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetSecurity() async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: pt.bg2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(pt.rXl),
|
||||||
|
side: BorderSide(color: pt.border)),
|
||||||
|
title: Text('Sicherheit zurücksetzen?',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||||
|
content: Text(
|
||||||
|
'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Zurücksetzen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirm != true || !mounted) return;
|
||||||
|
setState(() { _state = _EncState.loading; _error = null; });
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
final enc = client.encryption;
|
||||||
|
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar');
|
||||||
|
await client.roomsLoading;
|
||||||
|
await client.accountDataLoading;
|
||||||
|
await client.userDeviceKeysLoading;
|
||||||
|
final completer = Completer<void>();
|
||||||
|
enc.bootstrap(onUpdate: (bs) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (bs.state) {
|
||||||
|
case BootstrapState.askWipeSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true));
|
||||||
|
case BootstrapState.askBadSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
|
||||||
|
case BootstrapState.askUseExistingSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false));
|
||||||
|
case BootstrapState.askNewSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss());
|
||||||
|
case BootstrapState.askWipeCrossSigning:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true));
|
||||||
|
case BootstrapState.askSetupCrossSigning:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning(
|
||||||
|
setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true));
|
||||||
|
case BootstrapState.askWipeOnlineKeyBackup:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true));
|
||||||
|
case BootstrapState.askSetupOnlineKeyBackup:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true));
|
||||||
|
case BootstrapState.done:
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
case BootstrapState.error:
|
||||||
|
if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler');
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await completer.future.timeout(const Duration(seconds: 60));
|
||||||
|
if (mounted) setState(() => _state = _EncState.done);
|
||||||
|
await _loadStatus();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _showPassphraseDialog({required bool forExport}) {
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportKeys() async {
|
||||||
|
final passphrase = await _showPassphraseDialog(forExport: true);
|
||||||
|
if (passphrase == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() { _exportLoading = true; _keyFileMsg = null; });
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
final db = client.database;
|
||||||
|
|
||||||
|
final stored = await db.getAllInboundGroupSessions();
|
||||||
|
final sessions = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
for (final s in stored) {
|
||||||
|
try {
|
||||||
|
final content = jsonDecode(s.content) as Map<String, dynamic>;
|
||||||
|
final sessionKey = content['session_key'] as String?;
|
||||||
|
if (sessionKey == null || sessionKey.isEmpty) continue;
|
||||||
|
|
||||||
|
final claimedKeys = s.senderClaimedKeys.isNotEmpty
|
||||||
|
? (jsonDecode(s.senderClaimedKeys) as Map<String, dynamic>)
|
||||||
|
.map((k, v) => MapEntry(k, v.toString()))
|
||||||
|
: <String, String>{};
|
||||||
|
|
||||||
|
sessions.add({
|
||||||
|
'algorithm': 'm.megolm.v1.aes-sha2',
|
||||||
|
'forwarding_curve25519_key_chain': <dynamic>[],
|
||||||
|
'room_id': s.roomId,
|
||||||
|
'sender_key': s.senderKey,
|
||||||
|
'sender_claimed_keys': claimedKeys,
|
||||||
|
'session_id': s.sessionId,
|
||||||
|
'session_key': sessionKey,
|
||||||
|
});
|
||||||
|
} catch (_) { continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.');
|
||||||
|
|
||||||
|
final encrypted = await compute(_encryptMegolmKeys, {
|
||||||
|
'passphrase': passphrase,
|
||||||
|
'sessions': sessions,
|
||||||
|
});
|
||||||
|
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final stamp = '${now.year}'
|
||||||
|
'${now.month.toString().padLeft(2, '0')}'
|
||||||
|
'${now.day.toString().padLeft(2, '0')}';
|
||||||
|
final file = File('${dir.path}/pyramid_keys_$stamp.txt');
|
||||||
|
await file.writeAsString(encrypted);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_exportLoading = false;
|
||||||
|
_keyFileMsgIsError = false;
|
||||||
|
_keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_exportLoading = false;
|
||||||
|
_keyFileMsgIsError = true;
|
||||||
|
_keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadDiagnostics() async {
|
||||||
|
setState(() { _diagUploading = true; _diagMsg = null; });
|
||||||
|
try {
|
||||||
|
final notifier = ref.read(e2eeDiagnosticsProvider.notifier);
|
||||||
|
final msg = await notifier.upload(
|
||||||
|
serverUrl: 'https://dashboard.steggi-matrix.work',
|
||||||
|
// Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an,
|
||||||
|
// KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token –
|
||||||
|
// der darf niemals im Client landen. Passendes Gegenstueck in
|
||||||
|
// /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi.
|
||||||
|
token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn',
|
||||||
|
);
|
||||||
|
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; });
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importKeys() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['txt', 'key', 'keys'],
|
||||||
|
allowMultiple: false,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty || result.files.first.path == null) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final passphrase = await _showPassphraseDialog(forExport: false);
|
||||||
|
if (passphrase == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() { _importLoading = true; _keyFileMsg = null; });
|
||||||
|
try {
|
||||||
|
final fileContent = await File(result.files.first.path!).readAsString();
|
||||||
|
final trimmed = fileContent.trim();
|
||||||
|
|
||||||
|
if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) {
|
||||||
|
throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final lines = trimmed.split('\n');
|
||||||
|
// Strip \r so Windows line endings (\r\n) don't corrupt the base64 string.
|
||||||
|
final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), '');
|
||||||
|
final padded = b64 + '=' * ((4 - b64.length % 4) % 4);
|
||||||
|
final dataBytes = base64.decode(padded);
|
||||||
|
|
||||||
|
final sessions = await compute(_decryptMegolmKeys, {
|
||||||
|
'passphrase': passphrase,
|
||||||
|
'data': dataBytes.toList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
final km = client.encryption?.keyManager;
|
||||||
|
if (km == null) throw Exception('Verschlüsselung nicht initialisiert.');
|
||||||
|
|
||||||
|
var imported = 0;
|
||||||
|
for (final raw in sessions) {
|
||||||
|
try {
|
||||||
|
final session = raw as Map<String, dynamic>;
|
||||||
|
final roomId = session['room_id'] as String?;
|
||||||
|
final sessionId = session['session_id'] as String?;
|
||||||
|
final senderKey = session['sender_key'] as String?;
|
||||||
|
if (roomId == null || sessionId == null || senderKey == null) continue;
|
||||||
|
|
||||||
|
final claimedKeys = (session['sender_claimed_keys'] as Map?)
|
||||||
|
?.map((k, v) => MapEntry(k.toString(), v.toString())) ??
|
||||||
|
<String, String>{};
|
||||||
|
|
||||||
|
await km.setInboundGroupSession(
|
||||||
|
roomId, sessionId, senderKey,
|
||||||
|
Map<String, dynamic>.from(session),
|
||||||
|
forwarded: true,
|
||||||
|
senderClaimedKeys: claimedKeys,
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
} catch (_) { continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_importLoading = false;
|
||||||
|
_keyFileMsgIsError = false;
|
||||||
|
_keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_importLoading = false;
|
||||||
|
_keyFileMsgIsError = true;
|
||||||
|
_keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeRecoveryKey() async {
|
||||||
|
final changed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt),
|
||||||
|
);
|
||||||
|
if (changed == true && mounted) {
|
||||||
|
await _loadStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SectionTitle(
|
||||||
|
title: 'Verschlüsselung',
|
||||||
|
subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.',
|
||||||
|
pt: pt),
|
||||||
|
|
||||||
|
// ── Status overview ───────────────────────────────────────────────
|
||||||
|
if (_statusLoaded) ...[
|
||||||
|
_SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Cross-Signing',
|
||||||
|
desc: _crossSigningEnabled == true
|
||||||
|
? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache')
|
||||||
|
: 'Nicht eingerichtet',
|
||||||
|
pt: pt,
|
||||||
|
control: Icon(
|
||||||
|
_crossSigningEnabled == true && _crossSigningCached == true
|
||||||
|
? Icons.verified_rounded
|
||||||
|
: (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined),
|
||||||
|
size: 16,
|
||||||
|
color: _crossSigningEnabled == true && _crossSigningCached == true
|
||||||
|
? PyramidColors.success
|
||||||
|
: (_crossSigningEnabled == true ? pt.away : pt.danger),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Online-Schlüsselbackup',
|
||||||
|
desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert',
|
||||||
|
pt: pt,
|
||||||
|
control: Icon(
|
||||||
|
_keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_FingerprintGroup(pt: pt),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Key recovery / unlock ─────────────────────────────────────────
|
||||||
|
_SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Schlüssel wiederherstellen',
|
||||||
|
desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln',
|
||||||
|
pt: pt,
|
||||||
|
showArrow: true,
|
||||||
|
onTap: _state == _EncState.loading ? null : _start,
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Wiederherstellungsschlüssel ändern',
|
||||||
|
desc: 'Neuen Schlüssel generieren, notieren und aktivieren',
|
||||||
|
pt: pt,
|
||||||
|
showArrow: true,
|
||||||
|
onTap: _state == _EncState.loading ? null : _changeRecoveryKey,
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Sicherheit neu einrichten',
|
||||||
|
desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen',
|
||||||
|
pt: pt,
|
||||||
|
showArrow: true,
|
||||||
|
destructive: true,
|
||||||
|
onTap: _state == _EncState.loading ? null : _resetSecurity,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── Key file export / import ───────────────────────────────────────
|
||||||
|
_SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Schlüssel exportieren',
|
||||||
|
desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)',
|
||||||
|
pt: pt,
|
||||||
|
showArrow: !_exportLoading,
|
||||||
|
control: _exportLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: (_exportLoading || _importLoading) ? null : _exportKeys,
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Schlüssel importieren',
|
||||||
|
desc: 'Sitzungsschlüssel aus exportierter Datei laden',
|
||||||
|
pt: pt,
|
||||||
|
showArrow: !_importLoading,
|
||||||
|
control: _importLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: (_exportLoading || _importLoading) ? null : _importKeys,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
if (_keyFileMsg != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_StatusCard(
|
||||||
|
pt: pt,
|
||||||
|
icon: _keyFileMsgIsError
|
||||||
|
? Icons.error_outline_rounded
|
||||||
|
: Icons.check_circle_outline_rounded,
|
||||||
|
color: _keyFileMsgIsError ? pt.danger : PyramidColors.success,
|
||||||
|
text: _keyFileMsg!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── E2EE Diagnostics ──────────────────────────────────────────────
|
||||||
|
Consumer(builder: (context, ref, _) {
|
||||||
|
final count = ref.watch(e2eeDiagnosticsProvider).length;
|
||||||
|
return _SettingsGroup(title: 'Diagnose', pt: pt, children: [
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Entschlüsselungsfehler hochladen',
|
||||||
|
desc: count == 0
|
||||||
|
? 'Keine Fehler gesammelt'
|
||||||
|
: '$count Fehler gesammelt – zum Server hochladen',
|
||||||
|
pt: pt,
|
||||||
|
showArrow: !_diagUploading && count > 0,
|
||||||
|
control: _diagUploading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: count > 0
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.danger.withAlpha(30),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
|
||||||
|
if (_diagMsg != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_StatusCard(
|
||||||
|
pt: pt,
|
||||||
|
icon: _diagMsgIsError
|
||||||
|
? Icons.error_outline_rounded
|
||||||
|
: Icons.cloud_done_rounded,
|
||||||
|
color: _diagMsgIsError ? pt.danger : PyramidColors.success,
|
||||||
|
text: _diagMsg!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── Inline state UI ───────────────────────────────────────────────
|
||||||
|
if (_state == _EncState.loading)
|
||||||
|
const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())),
|
||||||
|
|
||||||
|
if (_state == _EncState.done) ...[
|
||||||
|
_StatusCard(
|
||||||
|
pt: pt,
|
||||||
|
icon: Icons.check_circle_outline_rounded,
|
||||||
|
color: PyramidColors.success,
|
||||||
|
text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[
|
||||||
|
_StatusCard(
|
||||||
|
pt: pt,
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
color: pt.accent,
|
||||||
|
text: 'Wiederherstellungsschlüssel erforderlich.'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _keyCtrl,
|
||||||
|
readOnly: _state == _EncState.unlocking,
|
||||||
|
autofocus: true,
|
||||||
|
autocorrect: false,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'EsXX XXXX XXXX XXXX …',
|
||||||
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||||
|
labelText: 'Wiederherstellungsschlüssel',
|
||||||
|
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||||
|
errorText: _error,
|
||||||
|
errorMaxLines: 2,
|
||||||
|
filled: true,
|
||||||
|
fillColor: pt.bg2,
|
||||||
|
prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
),
|
||||||
|
cursorColor: pt.accent,
|
||||||
|
onSubmitted: (_) => _unlock(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: _PrimaryBtn(
|
||||||
|
label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln',
|
||||||
|
icon: Icons.lock_open_outlined,
|
||||||
|
pt: pt,
|
||||||
|
loading: _state == _EncState.unlocking,
|
||||||
|
onPressed: _unlock,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (_state == _EncState.error) ...[
|
||||||
|
_StatusCard(
|
||||||
|
pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger,
|
||||||
|
text: _error ?? 'Unbekannter Fehler.'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fg,
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onPressed: _start,
|
||||||
|
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||||
|
label: const Text('Erneut versuchen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (_state == _EncState.idle && !_statusLoaded)
|
||||||
|
_StatusCard(
|
||||||
|
pt: pt,
|
||||||
|
icon: Icons.shield_outlined,
|
||||||
|
color: pt.fgMuted,
|
||||||
|
text: 'Lade Sicherheitsstatus…'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
part of '../../settings_modal.dart';
|
||||||
|
|
||||||
|
class _FingerprintGroup extends ConsumerWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _FingerprintGroup({required this.pt});
|
||||||
|
|
||||||
|
static String _fmt(String key) {
|
||||||
|
final clean = key.replaceAll(RegExp(r'\s'), '');
|
||||||
|
final groups = <String>[];
|
||||||
|
for (var i = 0; i < clean.length; i += 4) {
|
||||||
|
groups.add(clean.substring(i, (i + 4).clamp(0, clean.length)));
|
||||||
|
}
|
||||||
|
return groups.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final clientAsync = ref.watch(matrixClientProvider);
|
||||||
|
final client = clientAsync.valueOrNull;
|
||||||
|
if (client == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final fingerprint = _fmt(client.fingerprintKey);
|
||||||
|
final deviceId = client.deviceID ?? '';
|
||||||
|
|
||||||
|
return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [
|
||||||
|
_SettingsRow(
|
||||||
|
title: 'Geräte-ID',
|
||||||
|
desc: deviceId,
|
||||||
|
pt: pt,
|
||||||
|
control: IconButton(
|
||||||
|
icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
|
||||||
|
tooltip: 'Kopieren',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||||
|
onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('Ed25519-Fingerabdruck',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
|
||||||
|
tooltip: 'Fingerabdruck kopieren',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||||
|
onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
fingerprint,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fgMuted, fontSize: 11,
|
||||||
|
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passphrase dialog (for key export/import)
|
||||||
|
class _PassphraseDialog extends StatefulWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final bool forExport;
|
||||||
|
const _PassphraseDialog({required this.pt, required this.forExport});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PassphraseDialog> createState() => _PassphraseDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PassphraseDialogState extends State<_PassphraseDialog> {
|
||||||
|
final _ctrl1 = TextEditingController();
|
||||||
|
final _ctrl2 = TextEditingController();
|
||||||
|
bool _obscure = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl1.dispose();
|
||||||
|
_ctrl2.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final pass = _ctrl1.text.trim();
|
||||||
|
if (pass.isEmpty) {
|
||||||
|
setState(() => _error = 'Bitte ein Passwort eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.forExport && pass != _ctrl2.text.trim()) {
|
||||||
|
setState(() => _error = 'Passwörter stimmen nicht überein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(context, pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||||
|
errorText: _error,
|
||||||
|
filled: true,
|
||||||
|
fillColor: pt.bg3,
|
||||||
|
prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||||||
|
isDense: true,
|
||||||
|
errorMaxLines: 2,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: pt.fgDim,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _obscure = !_obscure),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: pt.bg2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(pt.rXl),
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 340,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.forExport
|
||||||
|
? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.'
|
||||||
|
: 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _ctrl1,
|
||||||
|
obscureText: _obscure,
|
||||||
|
autofocus: true,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||||
|
decoration: _inputDeco('Passwort', pt).copyWith(
|
||||||
|
// Only show error on confirm field if export, else here
|
||||||
|
errorText: widget.forExport ? null : _error,
|
||||||
|
),
|
||||||
|
cursorColor: pt.accent,
|
||||||
|
onSubmitted: widget.forExport ? null : (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (widget.forExport) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _ctrl2,
|
||||||
|
obscureText: _obscure,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||||
|
decoration: _inputDeco('Passwort bestätigen', pt),
|
||||||
|
cursorColor: pt.accent,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent,
|
||||||
|
foregroundColor: pt.accentFg,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onPressed: _submit,
|
||||||
|
child: Text(widget.forExport ? 'Exportieren' : 'Importieren'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
part of '../../settings_modal.dart';
|
||||||
|
|
||||||
|
// ── Megolm key export / import – top-level so compute() can use them ─────────
|
||||||
|
|
||||||
|
const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----';
|
||||||
|
const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----';
|
||||||
|
|
||||||
|
// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int}
|
||||||
|
Uint8List _runPbkdf2(Map<String, dynamic> args) {
|
||||||
|
final pass = args['pass'] as Uint8List;
|
||||||
|
final salt = args['salt'] as Uint8List;
|
||||||
|
final iterations = args['iter'] as int;
|
||||||
|
const blockLen = 64; // SHA-512 digest size
|
||||||
|
const dkLen = 64;
|
||||||
|
|
||||||
|
final hmac = mcrypto.Hmac(mcrypto.sha512, pass);
|
||||||
|
final blockCount = (dkLen / blockLen).ceil();
|
||||||
|
final dk = Uint8List(blockCount * blockLen);
|
||||||
|
|
||||||
|
for (var i = 1; i <= blockCount; i++) {
|
||||||
|
final saltI = Uint8List(salt.length + 4);
|
||||||
|
saltI.setRange(0, salt.length, salt);
|
||||||
|
ByteData.sublistView(saltI, salt.length).setUint32(0, i);
|
||||||
|
|
||||||
|
var u = Uint8List.fromList(hmac.convert(saltI).bytes);
|
||||||
|
final block = Uint8List.fromList(u);
|
||||||
|
|
||||||
|
for (var j = 1; j < iterations; j++) {
|
||||||
|
u = Uint8List.fromList(hmac.convert(u).bytes);
|
||||||
|
for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; }
|
||||||
|
}
|
||||||
|
dk.setRange((i - 1) * blockLen, i * blockLen, block);
|
||||||
|
}
|
||||||
|
return dk.sublist(0, dkLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) {
|
||||||
|
final cipher = pc.SICStreamCipher(pc.AESEngine())
|
||||||
|
..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv));
|
||||||
|
final out = Uint8List(data.length);
|
||||||
|
cipher.processBytes(data, 0, data.length, out, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// args: {passphrase: String, sessions: List<dynamic>} → encrypted String
|
||||||
|
String _encryptMegolmKeys(Map<String, dynamic> args) {
|
||||||
|
final pass = args['passphrase'] as String;
|
||||||
|
final sessions = args['sessions'] as List<dynamic>;
|
||||||
|
|
||||||
|
final rng = Random.secure();
|
||||||
|
final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256)));
|
||||||
|
// IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0)
|
||||||
|
final iv = Uint8List(16);
|
||||||
|
for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); }
|
||||||
|
|
||||||
|
const iterations = 100000;
|
||||||
|
final dk = _runPbkdf2({
|
||||||
|
'pass': Uint8List.fromList(utf8.encode(pass)),
|
||||||
|
'salt': salt,
|
||||||
|
'iter': iterations,
|
||||||
|
});
|
||||||
|
final aesKey = dk.sublist(0, 32);
|
||||||
|
final hmacKey = dk.sublist(32, 64);
|
||||||
|
|
||||||
|
final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions)));
|
||||||
|
final encrypted = _aesCtr(aesKey, iv, plaintext);
|
||||||
|
|
||||||
|
final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations);
|
||||||
|
|
||||||
|
// header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n)
|
||||||
|
final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length);
|
||||||
|
var off = 0;
|
||||||
|
header[off++] = 0x01;
|
||||||
|
header.setRange(off, off += 16, salt);
|
||||||
|
header.setRange(off, off += 16, iv);
|
||||||
|
header.setRange(off, off += 4, iterBe);
|
||||||
|
header.setRange(off, off + encrypted.length, encrypted);
|
||||||
|
|
||||||
|
final mac = Uint8List.fromList(
|
||||||
|
mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
final full = Uint8List(header.length + 32);
|
||||||
|
full.setRange(0, header.length, header);
|
||||||
|
full.setRange(header.length, full.length, mac);
|
||||||
|
|
||||||
|
return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter';
|
||||||
|
}
|
||||||
|
|
||||||
|
// args: {passphrase: String, data: List<int>} → List<dynamic> sessions
|
||||||
|
List<dynamic> _decryptMegolmKeys(Map<String, dynamic> args) {
|
||||||
|
final pass = args['passphrase'] as String;
|
||||||
|
final raw = Uint8List.fromList(args['data'] as List<int>);
|
||||||
|
|
||||||
|
if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) {
|
||||||
|
throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).');
|
||||||
|
}
|
||||||
|
|
||||||
|
final salt = raw.sublist(1, 17);
|
||||||
|
final iv = raw.sublist(17, 33);
|
||||||
|
final iterations = raw.buffer.asByteData().getUint32(33);
|
||||||
|
final encrypted = raw.sublist(37, raw.length - 32);
|
||||||
|
final storedMac = raw.sublist(raw.length - 32);
|
||||||
|
|
||||||
|
if (iterations < 1 || iterations > 10000000) {
|
||||||
|
throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final dk = _runPbkdf2({
|
||||||
|
'pass': Uint8List.fromList(utf8.encode(pass)),
|
||||||
|
'salt': Uint8List.fromList(salt),
|
||||||
|
'iter': iterations,
|
||||||
|
});
|
||||||
|
final aesKey = dk.sublist(0, 32);
|
||||||
|
final hmacKey = dk.sublist(32, 64);
|
||||||
|
|
||||||
|
// Verify MAC over all bytes except the appended MAC itself
|
||||||
|
final toMac = raw.sublist(0, raw.length - 32);
|
||||||
|
final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes;
|
||||||
|
var macOk = true;
|
||||||
|
for (var i = 0; i < 32; i++) {
|
||||||
|
if (expectedMac[i] != storedMac[i]) { macOk = false; break; }
|
||||||
|
}
|
||||||
|
if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.');
|
||||||
|
|
||||||
|
final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted));
|
||||||
|
return jsonDecode(utf8.decode(decrypted)) as List<dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
part of '../../settings_modal.dart';
|
||||||
|
|
||||||
|
class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _ChangeRecoveryKeyDialog({required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ChangeRecoveryKeyDialog> createState() =>
|
||||||
|
_ChangeRecoveryKeyDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChangeRecoveryKeyDialogState
|
||||||
|
extends ConsumerState<_ChangeRecoveryKeyDialog> {
|
||||||
|
_RecoveryStep _step = _RecoveryStep.loading;
|
||||||
|
String? _generatedKey;
|
||||||
|
String? _error;
|
||||||
|
bool _copied = false;
|
||||||
|
// Completer signals when bootstrap reaches `done`
|
||||||
|
final _doneCompleter = Completer<void>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startBootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startBootstrap() async {
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
final enc = client.encryption;
|
||||||
|
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.');
|
||||||
|
await client.roomsLoading;
|
||||||
|
await client.accountDataLoading;
|
||||||
|
await client.userDeviceKeysLoading;
|
||||||
|
enc.bootstrap(onUpdate: (bs) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_onBootstrapUpdate(bs);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_step = _RecoveryStep.error;
|
||||||
|
_error = e.toString().split('\n').first;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After newSsss() calls checkCrossSigning(), the bootstrap immediately
|
||||||
|
// transitions to askWipeCrossSigning (or askSetupCrossSigning). All post-
|
||||||
|
// newSsss states must therefore auto-advance unconditionally — guarding on
|
||||||
|
// _step would leave them stuck.
|
||||||
|
void _onBootstrapUpdate(Bootstrap bs) {
|
||||||
|
switch (bs.state) {
|
||||||
|
case BootstrapState.askWipeSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true);
|
||||||
|
});
|
||||||
|
case BootstrapState.askBadSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true);
|
||||||
|
});
|
||||||
|
case BootstrapState.askUseExistingSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false);
|
||||||
|
});
|
||||||
|
case BootstrapState.askUnlockSsss:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss();
|
||||||
|
});
|
||||||
|
case BootstrapState.askNewSsss:
|
||||||
|
// Only generate once
|
||||||
|
if (_step == _RecoveryStep.loading) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (bs.state != BootstrapState.askNewSsss) return;
|
||||||
|
try {
|
||||||
|
await bs.newSsss();
|
||||||
|
// newSsss() internally calls checkCrossSigning() which already
|
||||||
|
// transitions to askWipeCrossSigning; capture key after it returns.
|
||||||
|
final key = bs.newSsssKey?.recoveryKey;
|
||||||
|
if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; });
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case BootstrapState.askWipeCrossSigning:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false);
|
||||||
|
});
|
||||||
|
case BootstrapState.askSetupCrossSigning:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askSetupCrossSigning) {
|
||||||
|
bs.askSetupCrossSigning(
|
||||||
|
setupMasterKey: true,
|
||||||
|
setupSelfSigningKey: true,
|
||||||
|
setupUserSigningKey: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
case BootstrapState.askWipeOnlineKeyBackup:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true);
|
||||||
|
});
|
||||||
|
case BootstrapState.askSetupOnlineKeyBackup:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true);
|
||||||
|
});
|
||||||
|
case BootstrapState.done:
|
||||||
|
if (!_doneCompleter.isCompleted) _doneCompleter.complete();
|
||||||
|
// Only auto-advance to done if user already clicked through (applying).
|
||||||
|
// If still on showKey/confirmKey, the user must finish reading first.
|
||||||
|
if (mounted && _step == _RecoveryStep.applying) {
|
||||||
|
setState(() => _step = _RecoveryStep.done);
|
||||||
|
}
|
||||||
|
case BootstrapState.error:
|
||||||
|
if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler');
|
||||||
|
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; });
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen.
|
||||||
|
void _onKeySaved() {
|
||||||
|
setState(() => _step = _RecoveryStep.confirmKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when user submits the correct key confirmation.
|
||||||
|
// Bootstrap may still be running (cross-signing + key backup); wait for done.
|
||||||
|
Future<void> _onKeyConfirmed() async {
|
||||||
|
setState(() => _step = _RecoveryStep.applying);
|
||||||
|
try {
|
||||||
|
await _doneCompleter.future.timeout(const Duration(seconds: 90));
|
||||||
|
if (mounted) setState(() => _step = _RecoveryStep.done);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: pt.bg2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(pt.rXl),
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440, minWidth: 320),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.key_rounded, size: 18, color: pt.accent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Wiederherstellungsschlüssel',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canClose)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||||||
|
onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(color: pt.border, height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: _buildBody(pt),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(PyramidTheme pt) {
|
||||||
|
return switch (_step) {
|
||||||
|
_RecoveryStep.loading => const Center(
|
||||||
|
child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()),
|
||||||
|
),
|
||||||
|
_RecoveryStep.showKey => _ShowKeyBody(
|
||||||
|
pt: pt,
|
||||||
|
generatedKey: _generatedKey ?? '',
|
||||||
|
copied: _copied,
|
||||||
|
onCopy: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: _generatedKey ?? ''));
|
||||||
|
setState(() => _copied = true);
|
||||||
|
},
|
||||||
|
onKeySaved: _onKeySaved,
|
||||||
|
),
|
||||||
|
_RecoveryStep.confirmKey => _ConfirmKeyBody(
|
||||||
|
pt: pt,
|
||||||
|
expectedKey: _generatedKey ?? '',
|
||||||
|
onConfirmed: _onKeyConfirmed,
|
||||||
|
),
|
||||||
|
_RecoveryStep.applying => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator.adaptive(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_RecoveryStep.done => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Wiederherstellungsschlüssel wurde erfolgreich geändert.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||||||
|
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Fertig'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_RecoveryStep.error => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_error ?? 'Ein unbekannter Fehler ist aufgetreten.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fg, side: BorderSide(color: pt.border),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Schließen'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShowKeyBody extends StatelessWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final String generatedKey;
|
||||||
|
final bool copied;
|
||||||
|
final VoidCallback onCopy;
|
||||||
|
final VoidCallback onKeySaved;
|
||||||
|
|
||||||
|
const _ShowKeyBody({
|
||||||
|
required this.pt,
|
||||||
|
required this.generatedKey,
|
||||||
|
required this.copied,
|
||||||
|
required this.onCopy,
|
||||||
|
required this.onKeySaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Dein neuer Wiederherstellungsschlüssel',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg3,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: SelectableText(
|
||||||
|
generatedKey,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent, fontSize: 13,
|
||||||
|
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onCopy,
|
||||||
|
icon: Icon(
|
||||||
|
copied ? Icons.check_rounded : Icons.copy_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: copied ? PyramidColors.success : pt.fgDim,
|
||||||
|
),
|
||||||
|
tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withAlpha(28),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange.withAlpha(80)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.',
|
||||||
|
style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||||||
|
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onPressed: onKeySaved,
|
||||||
|
child: const Text('Schlüssel gespeichert – weiter'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfirmKeyBody extends StatefulWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final String expectedKey;
|
||||||
|
final VoidCallback onConfirmed;
|
||||||
|
|
||||||
|
const _ConfirmKeyBody({
|
||||||
|
required this.pt,
|
||||||
|
required this.expectedKey,
|
||||||
|
required this.onConfirmed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
bool _mismatch = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final entered = _controller.text.trim();
|
||||||
|
if (entered == widget.expectedKey) {
|
||||||
|
widget.onConfirmed();
|
||||||
|
} else {
|
||||||
|
setState(() => _mismatch = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Schlüssel bestätigen',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg, fontSize: 13,
|
||||||
|
fontFamily: 'monospace', letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'EsTX XXXX XXXX …',
|
||||||
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
|
filled: true,
|
||||||
|
fillColor: pt.bg3,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: pt.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5),
|
||||||
|
),
|
||||||
|
errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); },
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||||||
|
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Aktivieren'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ class _AboutSection extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (confirm == true) {
|
if (confirm == true) {
|
||||||
unawaited(AuthLog.write('User-initiated logout (Settings)'));
|
unawaited(AuthLog.write('User-initiated logout (Settings)'));
|
||||||
await client.logout().catchError((_) {});
|
await logoutWithoutStraySync(client).catchError((_) {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> {
|
|||||||
}
|
}
|
||||||
// Konto wurde gelöscht — ausloggen
|
// Konto wurde gelöscht — ausloggen
|
||||||
unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)'));
|
unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)'));
|
||||||
try { await client.logout(); } catch (_) {}
|
try { await logoutWithoutStraySync(client); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ part of '../settings_modal.dart';
|
|||||||
|
|
||||||
class _AppearanceSection extends StatelessWidget {
|
class _AppearanceSection extends StatelessWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final bool isDark;
|
final ThemeVariant variant;
|
||||||
|
final double? textScale;
|
||||||
|
final MessageStyle messageStyle;
|
||||||
final int accentIdx;
|
final int accentIdx;
|
||||||
final double radius;
|
final double radius;
|
||||||
final double density;
|
final double density;
|
||||||
@@ -11,7 +13,9 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
|
|
||||||
const _AppearanceSection({
|
const _AppearanceSection({
|
||||||
required this.pt,
|
required this.pt,
|
||||||
required this.isDark,
|
required this.variant,
|
||||||
|
required this.textScale,
|
||||||
|
required this.messageStyle,
|
||||||
required this.accentIdx,
|
required this.accentIdx,
|
||||||
required this.radius,
|
required this.radius,
|
||||||
required this.density,
|
required this.density,
|
||||||
@@ -21,6 +25,12 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
|
final autoScale = defaultTextScale(mobile: mobile);
|
||||||
|
final effectiveScale = textScale ?? autoScale;
|
||||||
|
final variants = ThemeVariant.values;
|
||||||
|
final styles = MessageStyle.values;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -30,18 +40,18 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
|
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
|
||||||
pt: pt),
|
pt: pt),
|
||||||
_SettingsGroup(title: 'Theme', pt: pt, children: [
|
_SettingsGroup(title: 'Theme', pt: pt, children: [
|
||||||
_SettingsRow(
|
_AdaptiveSettingsRow(
|
||||||
title: 'Farbmodus',
|
title: 'Farbmodus',
|
||||||
desc: 'Hell oder dunkel',
|
desc: 'Dunkel = weiches Dunkelgrau, Schwarz = wie früher (spart '
|
||||||
|
'auf OLED-Displays Akku)',
|
||||||
pt: pt,
|
pt: pt,
|
||||||
control: _SegControl(
|
control: _SegControl(
|
||||||
options: const ['Dunkel', 'Hell'],
|
options: [for (final v in variants) v.label],
|
||||||
selected: isDark ? 0 : 1,
|
selected: variants.indexOf(variant),
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onSelect: (i) {
|
onSelect: (i) {
|
||||||
final dark = i == 0;
|
ref.read(themeVariantProvider.notifier).state = variants[i];
|
||||||
ref.read(themeModeProvider.notifier).state = dark;
|
saveThemePrefs(variant: variants[i]);
|
||||||
saveThemePrefs(isDark: dark);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -80,6 +90,85 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
_SettingsGroup(title: 'Text & Chat', pt: pt, children: [
|
||||||
|
_AdaptiveSettingsRow(
|
||||||
|
title: 'Schriftgröße',
|
||||||
|
desc: textScale == null
|
||||||
|
? 'Automatisch (${(autoScale * 100).round()} %) – zusätzlich '
|
||||||
|
'zur Schriftgröße des Geräts'
|
||||||
|
: '${(effectiveScale * 100).round()} %',
|
||||||
|
pt: pt,
|
||||||
|
control: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: mobile ? 250 : 170,
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderThemeData(
|
||||||
|
activeTrackColor: pt.accent,
|
||||||
|
thumbColor: pt.accent,
|
||||||
|
inactiveTrackColor: pt.border,
|
||||||
|
trackHeight: 3,
|
||||||
|
thumbShape:
|
||||||
|
const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: effectiveScale.clamp(minTextScale, maxTextScale),
|
||||||
|
min: minTextScale,
|
||||||
|
max: maxTextScale,
|
||||||
|
divisions: ((maxTextScale - minTextScale) / 0.05).round(),
|
||||||
|
onChanged: (v) {
|
||||||
|
final rounded = (v * 20).round() / 20;
|
||||||
|
ref.read(textScaleProvider.notifier).state = rounded;
|
||||||
|
saveThemePrefs(textScale: rounded);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (textScale != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(textScaleProvider.notifier).state = null;
|
||||||
|
saveThemePrefs(resetTextScale: true);
|
||||||
|
},
|
||||||
|
child: Text('Standard',
|
||||||
|
style: TextStyle(color: pt.accent, fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg3,
|
||||||
|
borderRadius: BorderRadius.circular(pt.rBase),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Vorschau: So groß erscheinen Nachrichten im Chat.',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
|
_AdaptiveSettingsRow(
|
||||||
|
title: 'Nachrichten',
|
||||||
|
desc: 'Automatisch: Handy Sprechblasen, PC Liste',
|
||||||
|
pt: pt,
|
||||||
|
control: _SegControl(
|
||||||
|
options: [for (final m in styles) m.label],
|
||||||
|
selected: styles.indexOf(messageStyle),
|
||||||
|
pt: pt,
|
||||||
|
onSelect: (i) {
|
||||||
|
ref.read(messageStyleProvider.notifier).state = styles[i];
|
||||||
|
saveThemePrefs(messageStyle: styles[i]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||||
_SettingsRow(
|
_SettingsRow(
|
||||||
title: 'Eckenrundung',
|
title: 'Eckenrundung',
|
||||||
@@ -185,3 +274,51 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wie [_SettingsRow], aber auf schmalen Bildschirmen steht das Bedienelement
|
||||||
|
/// UNTER dem Text statt daneben (sonst quetschen sich Schalter mit drei
|
||||||
|
/// Optionen auf dem Handy).
|
||||||
|
class _AdaptiveSettingsRow extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? desc;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Widget control;
|
||||||
|
|
||||||
|
const _AdaptiveSettingsRow({
|
||||||
|
required this.title,
|
||||||
|
required this.pt,
|
||||||
|
required this.control,
|
||||||
|
this.desc,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth >= 520) {
|
||||||
|
return _SettingsRow(title: title, desc: desc, pt: pt, control: control);
|
||||||
|
}
|
||||||
|
// Volle Breite: _SettingsGroup zentriert seine Kinder sonst.
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
|
if (desc != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: control,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -218,7 +218,7 @@ class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> {
|
|||||||
try {
|
try {
|
||||||
widget.onClose();
|
widget.onClose();
|
||||||
unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)'));
|
unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)'));
|
||||||
await client.logout();
|
await logoutWithoutStraySync(client);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:pointycastle/export.dart' as pc;
|
import 'package:pointycastle/export.dart' as pc;
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.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/session_logout.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 +38,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 +48,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';
|
||||||
|
|
||||||
@@ -83,7 +87,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final isDark = ref.watch(themeModeProvider);
|
final isDark = ref.watch(themeVariantProvider).isDark;
|
||||||
final accentIdx = ref.watch(accentProvider);
|
final accentIdx = ref.watch(accentProvider);
|
||||||
final radius = ref.watch(radiusProvider);
|
final radius = ref.watch(radiusProvider);
|
||||||
final density = ref.watch(densityProvider);
|
final density = ref.watch(densityProvider);
|
||||||
@@ -99,15 +103,21 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
|
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
|
||||||
|
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
constraints: isMobile
|
||||||
width: isMobile ? size.width * 0.97 : 740,
|
? null
|
||||||
height: isMobile ? size.height * 0.92 : 560,
|
: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
||||||
|
width: isMobile ? size.width : 740,
|
||||||
|
height: isMobile ? size.height : 560,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.bg1,
|
color: pt.bg1,
|
||||||
borderRadius: BorderRadius.circular(pt.rXl),
|
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||||
border: Border.all(color: pt.border),
|
border: isMobile ? null : Border.all(color: pt.border),
|
||||||
boxShadow: [
|
boxShadow: isMobile
|
||||||
|
? null
|
||||||
|
: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withAlpha(130),
|
color: Colors.black.withAlpha(130),
|
||||||
blurRadius: 40,
|
blurRadius: 40,
|
||||||
@@ -116,7 +126,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(pt.rXl),
|
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||||
child: isMobile
|
child: isMobile
|
||||||
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
|
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
|
||||||
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
|
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
|
||||||
@@ -240,10 +250,15 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
|
|
||||||
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
|
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
|
||||||
double radius, double density, double motion, String section) {
|
double radius, double density, double motion, String section) {
|
||||||
|
final variant = ref.watch(themeVariantProvider);
|
||||||
|
final textScale = ref.watch(textScaleProvider);
|
||||||
|
final messageStyle = ref.watch(messageStyleProvider);
|
||||||
return switch (section) {
|
return switch (section) {
|
||||||
'appearance' => _AppearanceSection(
|
'appearance' => _AppearanceSection(
|
||||||
pt: pt,
|
pt: pt,
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
|
textScale: textScale,
|
||||||
|
messageStyle: messageStyle,
|
||||||
accentIdx: accentIdx,
|
accentIdx: accentIdx,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
@@ -259,7 +274,9 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
'about' => _AboutSection(pt: pt),
|
'about' => _AboutSection(pt: pt),
|
||||||
_ => _AppearanceSection(
|
_ => _AppearanceSection(
|
||||||
pt: pt,
|
pt: pt,
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
|
textScale: textScale,
|
||||||
|
messageStyle: messageStyle,
|
||||||
accentIdx: accentIdx,
|
accentIdx: accentIdx,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
|
|||||||
+40
-1
@@ -350,6 +350,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -517,6 +522,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -581,6 +591,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.8.0"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1005,6 +1020,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.6.0"
|
version: "3.6.0"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.6"
|
||||||
protobuf:
|
protobuf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1235,7 +1258,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"
|
||||||
@@ -1274,6 +1297,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sync_http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sync_http
|
||||||
|
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1515,6 +1546,14 @@ packages:
|
|||||||
url: "https://github.com/google/webcrypto.dart.git"
|
url: "https://github.com/google/webcrypto.dart.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.6.0"
|
version: "0.6.0"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
webrtc_interface:
|
webrtc_interface:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+7
-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.37+38
|
||||||
|
|
||||||
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
|
||||||
@@ -80,6 +82,9 @@ dependencies:
|
|||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# UI-Durchläufe mit der echten App (integration_test/), Teil des Flutter-SDKs.
|
||||||
|
integration_test:
|
||||||
|
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
|
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
# Pyramid – Tests & Benchmarks mit einem Befehl
|
||||||
|
#
|
||||||
|
# pwsh scripts/test.ps1 Analyse + Unit-Tests (Standard, ~2 min)
|
||||||
|
# pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work
|
||||||
|
# (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta)
|
||||||
|
# pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
||||||
|
# pwsh scripts/test.ps1 -Ui + UI-Durchlauf im Handy-Format mit Screenshots
|
||||||
|
# (echte App, Testprofil pyramidtest1 → build/ui_screens/)
|
||||||
|
# pwsh scripts/test.ps1 -All alles zusammen
|
||||||
|
# pwsh scripts/test.ps1 -Bench -UpdateBaseline
|
||||||
|
# Benchmark-Werte als neue Baseline speichern
|
||||||
|
# (nach bewusster Änderung / auf neuer Hardware)
|
||||||
|
# -SkipAnalyze flutter analyze überspringen
|
||||||
|
#
|
||||||
|
# Details: docs/TESTS_UND_BENCHMARKS.md
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$Live,
|
||||||
|
[switch]$Bench,
|
||||||
|
[switch]$Ui,
|
||||||
|
[switch]$All,
|
||||||
|
[switch]$UpdateBaseline,
|
||||||
|
[switch]$SkipAnalyze
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird.
|
||||||
|
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||||
|
|
||||||
|
if ($All) { $Live = $true; $Bench = $true; $Ui = $true }
|
||||||
|
|
||||||
|
$results = [System.Collections.Generic.List[object]]::new()
|
||||||
|
|
||||||
|
function Invoke-Step([string]$Name, [scriptblock]$Action) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "━━ $Name" -ForegroundColor Cyan
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
|
& $Action
|
||||||
|
$ok = ($LASTEXITCODE -eq 0)
|
||||||
|
$results.Add([pscustomobject]@{
|
||||||
|
Schritt = $Name
|
||||||
|
Ergebnis = if ($ok) { "OK" } else { "FEHLER" }
|
||||||
|
Dauer = "{0:n0} s" -f $watch.Elapsed.TotalSeconds
|
||||||
|
})
|
||||||
|
if (-not $ok) { Write-Host "✗ $Name fehlgeschlagen (Exit-Code $LASTEXITCODE)" -ForegroundColor Red }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Skipped([string]$Name, [string]$Reason) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "━━ $Name – übersprungen: $Reason" -ForegroundColor Yellow
|
||||||
|
$results.Add([pscustomobject]@{ Schritt = $Name; Ergebnis = "ÜBERSPRUNGEN"; Dauer = "–" })
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── SQLCipher-Bibliothek ─────────────────────────────────────────────────────
|
||||||
|
# Die Datenbank-Tests und Benchmarks benutzen dieselbe sqlite3.dll (SQLCipher)
|
||||||
|
# wie die App – sie entsteht beim Windows-Build. Fehlt sie, einmal bauen.
|
||||||
|
$cipherDll = Get-ChildItem "build/windows/x64/runner/*/sqlite3.dll" -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $cipherDll) {
|
||||||
|
Invoke-Step "Windows-Build (einmalig, liefert die SQLCipher-DLL)" {
|
||||||
|
flutter build windows --debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipAnalyze) {
|
||||||
|
# Infos (z. B. der bekannte onUpdate-Hinweis) werden angezeigt, lassen den
|
||||||
|
# Lauf aber nicht scheitern – Warnungen und Fehler schon.
|
||||||
|
Invoke-Step "Statische Analyse (flutter analyze)" { flutter analyze --no-fatal-infos }
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Unit-Tests (test/)" { flutter test }
|
||||||
|
|
||||||
|
if ($Live) {
|
||||||
|
$accounts = if ($env:PYRAMID_TEST_ACCOUNTS) { $env:PYRAMID_TEST_ACCOUNTS }
|
||||||
|
else { Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt" }
|
||||||
|
if (-not (Test-Path $accounts)) {
|
||||||
|
Add-Skipped "Live-Test (echter Homeserver)" "Zugangsdatei fehlt: $accounts"
|
||||||
|
} else {
|
||||||
|
$env:PYRAMID_LIVE_TEST = "1"
|
||||||
|
try {
|
||||||
|
Invoke-Step "Live-Test: SQLCipher mit echtem Login (pyramidtest1)" {
|
||||||
|
flutter test test/live_encryption_e2e_test.dart --concurrency=1
|
||||||
|
}
|
||||||
|
Invoke-Step "Live-Test: Logout + sofortiger Re-Login (SDK-Wettlauf)" {
|
||||||
|
flutter test test/live_logout_race_test.dart --concurrency=1
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Remove-Item Env:PYRAMID_LIVE_TEST -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Bench) {
|
||||||
|
if ($UpdateBaseline) { $env:PYRAMID_BENCH_UPDATE_BASELINE = "1" }
|
||||||
|
try {
|
||||||
|
# concurrency=1: Benchmarks dürfen sich nicht gegenseitig die CPU klauen.
|
||||||
|
Invoke-Step "Benchmarks (benchmark/)" { flutter test benchmark --concurrency=1 }
|
||||||
|
} finally {
|
||||||
|
Remove-Item Env:PYRAMID_BENCH_UPDATE_BASELINE -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Ui) {
|
||||||
|
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
||||||
|
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
||||||
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
||||||
|
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
|
||||||
|
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
|
||||||
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
|
||||||
|
} else {
|
||||||
|
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
||||||
|
try {
|
||||||
|
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
||||||
|
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan
|
||||||
|
$results | Format-Table -AutoSize | Out-String | Write-Host
|
||||||
|
|
||||||
|
$failed = @($results | Where-Object { $_.Ergebnis -eq "FEHLER" })
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Write-Host "✗ $($failed.Count) Schritt(e) fehlgeschlagen." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Alles grün." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
@@ -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,40 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pyramid/utils/date_labels.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Mittwoch, 7. Oktober 2026, 15:00
|
||||||
|
final now = DateTime(2026, 10, 7, 15);
|
||||||
|
|
||||||
|
test('heute: Uhrzeit', () {
|
||||||
|
expect(chatListTime(DateTime(2026, 10, 7, 9, 5), now: now), '09:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gestern: „Gestern“ (auch kurz nach Mitternacht)', () {
|
||||||
|
expect(chatListTime(DateTime(2026, 10, 6, 23, 59), now: now), 'Gestern');
|
||||||
|
expect(
|
||||||
|
chatListTime(DateTime(2026, 10, 6, 22), now: DateTime(2026, 10, 7, 0, 5)),
|
||||||
|
'Gestern',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diese Woche: Wochentag', () {
|
||||||
|
expect(chatListTime(DateTime(2026, 10, 5, 12), now: now), 'Mo');
|
||||||
|
expect(chatListTime(DateTime(2026, 10, 1, 12), now: now), 'Do');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('älter: Datum', () {
|
||||||
|
expect(chatListTime(DateTime(2026, 9, 30, 12), now: now), '30.09.26');
|
||||||
|
expect(chatListTime(DateTime(2025, 12, 24, 18), now: now), '24.12.25');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zeitumstellung (25.10.2026): gestern bleibt gestern', () {
|
||||||
|
expect(
|
||||||
|
chatListTime(DateTime(2026, 10, 24, 20), now: DateTime(2026, 10, 25, 10)),
|
||||||
|
'Gestern',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
chatListTime(DateTime(2026, 3, 28, 20), now: DateTime(2026, 3, 29, 10)),
|
||||||
|
'Gestern',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user