diff --git a/CHANGES.md b/CHANGES.md index 9076ada..2ab972a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1455,3 +1455,28 @@ 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 diff --git a/PROGRESS.md b/PROGRESS.md index ecd337d..0e29494 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,82 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 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 diff --git a/ROADMAP.md b/ROADMAP.md index 75eb502..fdb66fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -258,6 +258,18 @@ lassen, ohne dass andere Module angefasst werden müssen. - [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger, 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) - [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den Einstellungen auswählbar machen – geht aktuell gar nicht) @@ -271,7 +283,19 @@ lassen, ohne dass andere Module angefasst werden müssen. - [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings? 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) + **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) - [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos diff --git a/lib/features/rooms/panel/rooms_list.dart b/lib/features/rooms/panel/rooms_list.dart index a204167..88b0d5d 100644 --- a/lib/features/rooms/panel/rooms_list.dart +++ b/lib/features/rooms/panel/rooms_list.dart @@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget { context: context, builder: (_) => const CreateJoinDialog()), ), if (!isCollapsed) - ...filtered.map((room) => _RoomItem( - room: room, - isActive: activeRoomId == room.id, - pt: pt, - onTap: () { + ...filtered.map((room) { + final isVoice = _isVoiceRoom(room); + return _RoomItem( + room: room, + isActive: isVoice ? false : activeRoomId == room.id, + isInVoice: + isVoice && ref.read(activeVoiceRoomIdProvider) == room.id, + pt: pt, + // Voice-Räume treten dem Kanal bei (wie in der Space-Liste) – + // vorher öffnete der Klick hier nur die Chat-Ansicht, ein + // Standalone-Voice-Channel war per Klick gar nicht beitretbar. + onTap: () { + if (isVoice) { + _toggleVoiceChannel(ref, room); + } else { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(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 _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 { final String title; final int count; diff --git a/lib/features/rooms/panel/rooms_panel_core.dart b/lib/features/rooms/panel/rooms_panel_core.dart index 6fdd806..2f9a78d 100644 --- a/lib/features/rooms/panel/rooms_panel_core.dart +++ b/lib/features/rooms/panel/rooms_panel_core.dart @@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState { final _filterCtrl = TextEditingController(); String _filter = ''; final Map _collapsed = {}; + String? _lastShownCallError; @override void dispose() { @@ -37,6 +38,30 @@ class _RoomsPanelState extends ConsumerState { @override Widget build(BuildContext 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(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 activeSpaceId = ref.watch(activeSpaceIdProvider); final call = ref.watch(voiceChannelProvider); diff --git a/lib/features/voice_channel/livekit_call_manager.dart b/lib/features/voice_channel/livekit_call_manager.dart index ad4d93f..3be99ba 100644 --- a/lib/features/voice_channel/livekit_call_manager.dart +++ b/lib/features/voice_channel/livekit_call_manager.dart @@ -73,6 +73,12 @@ class LiveKitCallManager extends VoiceChannelService { 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; @override @@ -93,6 +99,7 @@ class LiveKitCallManager extends VoiceChannelService { void _onRoomChanged() => notifyListeners(); void _cleanUpAfterDisconnect() { + _callGeneration++; _stopStatsLogging(); _presenceHeartbeat?.cancel(); _presenceHeartbeat = null; @@ -105,7 +112,9 @@ class LiveKitCallManager extends VoiceChannelService { _room = null; _displayName = ''; 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; isCameraOff = false; isScreenSharing = false; @@ -162,6 +171,7 @@ class LiveKitCallManager extends VoiceChannelService { String? matrixRoomId, }) async { if (isActive) return; + final gen = ++_callGeneration; final url = 'wss://livekit.steggi-matrix.work'; @@ -223,6 +233,17 @@ class LiveKitCallManager extends VoiceChannelService { 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; isConnecting = false; notifyListeners(); @@ -263,7 +284,11 @@ class LiveKitCallManager extends VoiceChannelService { ..on((_) => notifyListeners()) ..on((_) => notifyListeners()) ..on((_) => notifyListeners()) - ..on((_) { + ..on((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(); }); @@ -318,9 +343,16 @@ class LiveKitCallManager extends VoiceChannelService { _startStatsLogging(); } catch (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; - error = e.toString(); _cleanUpAfterDisconnect(); + // NACH dem Aufräumen setzen, damit der Join-Fehler sichtbar bleibt. + error = 'Beitritt fehlgeschlagen: $e'; + notifyListeners(); } } @@ -556,6 +588,8 @@ class LiveKitCallManager extends VoiceChannelService { @override Future hangUp() async { + // Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration). + _callGeneration++; _stopStatsLogging(); _presenceHeartbeat?.cancel(); _presenceHeartbeat = null; diff --git a/test/live_call_setup_test.dart b/test/live_call_setup_test.dart new file mode 100644 index 0000000..c52b0dc --- /dev/null +++ b/test/live_call_setup_test.dart @@ -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? _loadAccounts() { + final profile = Platform.environment['USERPROFILE'] ?? ''; + final file = File('$profile\\.pyramid-autopilot\\test-accounts.txt'); + if (!file.existsSync()) return null; + final map = {}; + 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 _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.from(plRaw as Map); + final events = Map.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))); +}