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:
+25
@@ -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
|
||||
|
||||
+76
@@ -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
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
|
||||
@@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget {
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
if (!isCollapsed)
|
||||
...filtered.map((room) => _RoomItem(
|
||||
...filtered.map((room) {
|
||||
final isVoice = _isVoiceRoom(room);
|
||||
return _RoomItem(
|
||||
room: room,
|
||||
isActive: activeRoomId == room.id,
|
||||
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<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 {
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
final _filterCtrl = TextEditingController();
|
||||
String _filter = '';
|
||||
final Map<String, bool> _collapsed = {};
|
||||
String? _lastShownCallError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -37,6 +38,30 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
@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<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 activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
|
||||
@@ -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<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantConnectedEvent>((_) => 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();
|
||||
});
|
||||
|
||||
@@ -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<void> hangUp() async {
|
||||
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||
_callGeneration++;
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user