feat: Call-Robustheit + Standalone-Voice-Channel per Klick beitretbar (M3)

Mit zwei echten Test-Accounts (zwei GUI-Instanzen via PYRAMID_PROFILE_DIR)
durchgeklickt: Beitreten, Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitaetswechsel (Full HD 60fps), sauberes Verlassen.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-06 22:50:58 +02:00
parent ffd70caffd
commit f8f5332d90
7 changed files with 407 additions and 10 deletions
+25
View File
@@ -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: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: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: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
+76
View File
@@ -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) ## 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 **Erledigt:** Bernds Fokus (A) für die Nacht die Verschlüsselung nicht nur
+24
View File
@@ -258,6 +258,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger, - [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
Code abgesichert). Nicht abgehakt.
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht) - [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den - [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
Einstellungen auswählbar machen geht aktuell gar nicht) Einstellungen auswählbar machen geht aktuell gar nicht)
@@ -271,7 +283,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings? - [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
Software- statt Hardware-Encoding?), dann gezielt fixen Software- statt Hardware-Encoding?), dann gezielt fixen
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
Vier Ruckel-Ursachen adressiert FPS-Deckel (jetzt hd60/fhd60 mit
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
durchgereicht), degradationPreference (60-FPS-Modi
maintainFramerate, kein Simulcast). contentHint='motion' in
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl) - [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
**2026-07-06:** FPS-Wahl umgesetzt SENDEN-Menü hat jetzt „HD 60fps"
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen) - [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos - [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
+39 -3
View File
@@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget {
context: context, builder: (_) => const CreateJoinDialog()), context: context, builder: (_) => const CreateJoinDialog()),
), ),
if (!isCollapsed) if (!isCollapsed)
...filtered.map((room) => _RoomItem( ...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
room: room, room: room,
isActive: activeRoomId == room.id, isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: pt, pt: pt,
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste)
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
onTap: () { onTap: () {
if (isVoice) {
_toggleVoiceChannel(ref, room);
} else {
ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat; ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
}, },
)), );
}),
], ],
); );
} }
} }
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste exakt das
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// aktiven Kanal, Wechsel legt vorher auf).
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
if (call.isVoiceChannel && activeVoice == room.id) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
await call.hangUp();
return;
}
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: room.client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: room.client,
matrixRoomId: room.id,
);
}
class _SectionHeader extends StatefulWidget { class _SectionHeader extends StatefulWidget {
final String title; final String title;
final int count; final int count;
@@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final _filterCtrl = TextEditingController(); final _filterCtrl = TextEditingController();
String _filter = ''; String _filter = '';
final Map<String, bool> _collapsed = {}; final Map<String, bool> _collapsed = {};
String? _lastShownCallError;
@override @override
void dispose() { void dispose() {
@@ -37,6 +38,30 @@ 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(voiceChannelProvider); final call = ref.watch(voiceChannelProvider);
@@ -73,6 +73,12 @@ class LiveKitCallManager extends VoiceChannelService {
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 @override
bool get isActive => _room != null || isConnecting; bool get isActive => _room != null || isConnecting;
@override @override
@@ -93,6 +99,7 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners(); void _onRoomChanged() => notifyListeners();
void _cleanUpAfterDisconnect() { void _cleanUpAfterDisconnect() {
_callGeneration++;
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -105,7 +112,9 @@ class LiveKitCallManager extends VoiceChannelService {
_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;
@@ -162,6 +171,7 @@ class LiveKitCallManager extends VoiceChannelService {
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';
@@ -223,6 +233,17 @@ class LiveKitCallManager extends VoiceChannelService {
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();
@@ -263,7 +284,11 @@ class LiveKitCallManager extends VoiceChannelService {
..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();
}); });
@@ -318,9 +343,16 @@ class LiveKitCallManager extends VoiceChannelService {
_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();
} }
} }
@@ -556,6 +588,8 @@ class LiveKitCallManager extends VoiceChannelService {
@override @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;
+177
View File
@@ -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)));
}