From 807492850538aa984cfee1e17e2bce2a8c3512bc Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Thu, 8 Oct 2026 11:05:46 +0200 Subject: [PATCH] fix(calls): Bildschirm teilen sichtbar, Verbindung trotz SDK-Wettlauf, Sprech-Ring; Trenner in Gruppen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest (zwei Testkonten im selben Prozess) nachgestellt und behoben: - Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet). - Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid puffert alle Kandidaten der Gegenseite und reicht sie direkt an die Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen) bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen. - SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler per Completer über die Zonengrenze (sonst ewiges Warten). - Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt, ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes Bild springt nicht mehr bei jedem Sprechwechsel. - „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test. - „Anruf läuft“-Leiste nicht schon beim Klingeln. Co-Authored-By: Claude Opus 5.5 --- integration_test/live_call_test.dart | 478 ++++++++++++++++++ .../live_receipts_invite_test.dart | 38 +- .../call_signaling_service.dart | 5 + lib/features/call_signaling/voip_manager.dart | 313 +++++++++--- lib/features/call_ui/view/call_view.dart | 9 +- .../call_ui/view/voice_channel_core.dart | 2 + .../chat/message/message_bubbles.dart | 2 +- .../chat/message/message_group_core.dart | 9 +- lib/features/chat/message/message_shared.dart | 13 +- lib/features/chat/view/chat_message_list.dart | 12 +- lib/features/chat/view/chat_misc.dart | 5 +- scripts/test.ps1 | 4 + 12 files changed, 793 insertions(+), 97 deletions(-) create mode 100644 integration_test/live_call_test.dart diff --git a/integration_test/live_call_test.dart b/integration_test/live_call_test.dart new file mode 100644 index 0000000..6c63cd9 --- /dev/null +++ b/integration_test/live_call_test.dart @@ -0,0 +1,478 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:integration_test/integration_test.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; +import 'package:pyramid/main.dart' as app; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:webrtc_interface/webrtc_interface.dart'; + +/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem +/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät, +/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht +/// an, Bildschirm teilen ging weder am PC noch am Handy. +/// +/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes), +/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann +/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der +/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein +/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.) +/// +/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1" +/// flutter test integration_test/live_call_test.dart -d windows +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final profile = Platform.environment['PYRAMID_PROFILE_DIR']; + final accounts = _loadAccounts(); + + testWidgets( + '1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen', + (tester) async { + Logs().level = Level.verbose; + app.main(); + await _pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty); + final container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp).first), + ); + final me = (await tester.runAsync( + () => container.read(matrixClientProvider.future), + ))!; + expect(me.userID, '@pyramidtest1:steggi-matrix.work'); + + final partner = (await tester.runAsync( + () => _Partner.start(accounts!['user2']!, accounts['pass2']!), + ))!; + String? roomId; + addTearDown(() => tester.runAsync(() async { + await partner.dispose(roomId); + final r = roomId == null ? null : me.getRoomById(roomId); + await r?.leave().catchError((_) {}); + await r?.forget().catchError((_) {}); + })); + + // Eigener, unverschlüsselter Testraum (nur für diesen Lauf). + roomId = await tester.runAsync(() => partner.client.createRoom( + invite: [me.userID!], + preset: CreateRoomPreset.privateChat, + name: 'Anruftest (automatisch)', + )); + _log('Raum angelegt: $roomId'); + await _pumpUntil( + tester, + () => me.getRoomById(roomId!)?.membership == Membership.invite, + what: 'Einladung kommt bei der App an', + ); + await tester.runAsync(() => me.getRoomById(roomId!)!.join()); + _log('App ist beigetreten'); + // Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache + // wird vom SDK nur bei Bedarf geladen – kurz warten genügt. + await _pumpFor(tester, const Duration(seconds: 3)); + + final voip = container.read(callSignalingProvider); + + // ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══ + // (prüft die Kandidaten-Pufferung der App mit einem ungepatchten + // Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“) + _log('Teil 1: Gegenseite ruft an'); + await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall( + partner.client.getRoomById(roomId!)!, + CallType.kVoice, + ))); + final answerBtn = find.byWidgetPredicate( + (w) => w is FloatingActionButton && w.heroTag == 'answer_call', + ); + await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty, + what: 'Annehmen-Knopf erscheint'); + await _pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen + await tester.tap(answerBtn.first); + await _waitConnected(tester, voip, partner); + + await _checkAudio(tester, voip, partner); + + // Gegenseite teilt Bildschirm → App zeigt ihn + await tester.runAsync( + () => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)), + ); + await _pumpUntil( + tester, + () => + voip.isRemoteScreensharing && + (voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false), + timeout: const Duration(seconds: 30), + what: 'Bildschirm der Gegenseite erscheint in der App', + ); + await _pumpFor(tester, const Duration(seconds: 3)); + final appVideoIn = (await tester.runAsync( + () => _rtp(voip.currentCall!.pc!, 'video'), + ))!; + _log('BILD bei der App (von der Gegenseite): $appVideoIn'); + expect(appVideoIn.received, greaterThan(0), + reason: 'Bildschirm der Gegenseite kommt nicht an'); + await tester.runAsync( + () => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)), + ); + await _pumpUntil(tester, () => !voip.isRemoteScreensharing, + what: 'Bildschirm der Gegenseite verschwindet wieder'); + + // App teilt ihren Bildschirm über den echten Auswähler + await _pumpUntil( + tester, + () => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty, + what: 'Teilen-Knopf in der Anrufleiste', + ); + await tester.tap(find.byIcon(Icons.screen_share_rounded).first); + final tile = find.byWidgetPredicate( + (w) => w.runtimeType.toString() == '_SourceTile', + ); + await _pumpUntil(tester, () => tile.evaluate().isNotEmpty, + timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler'); + await tester.tap(tile.first); + await _pumpUntil( + tester, + () { + final s = partner.delegate.call?.remoteScreenSharingStream?.stream; + return voip.isScreensharing && + s != null && + s.getVideoTracks().isNotEmpty; + }, + timeout: const Duration(seconds: 30), + what: 'Bildschirm der App kommt bei der Gegenseite an', + ); + await _pumpFor(tester, const Duration(seconds: 3)); + final partnerVideoIn = (await tester.runAsync( + () => _rtp(partner.delegate.call!.pc!, 'video'), + ))!; + _log('BILD bei der Gegenseite (von der App): $partnerVideoIn'); + expect(partnerVideoIn.received, greaterThan(0), + reason: 'Bildschirm der App kommt nicht an'); + + await tester.runAsync(() => voip.hangup()); + await _pumpUntil(tester, () => voip.currentCall == null, + what: 'aufgelegt (Teil 1)'); + await _pumpFor(tester, const Duration(seconds: 3)); + + // ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══ + _log('Teil 2: App ruft an'); + partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid + await tester.runAsync(() => voip.startCall(roomId!, video: false)); + await _waitConnected(tester, voip, partner); + await _checkAudio(tester, voip, partner); + await tester.runAsync(() => voip.hangup()); + await _pumpUntil(tester, () => voip.currentCall == null, + what: 'aufgelegt (Teil 2)'); + }, + skip: profile == null || accounts == null, + timeout: const Timeout(Duration(minutes: 5)), + ); +} + +Future _waitConnected( + WidgetTester tester, + CallSignalingService voip, + _Partner partner, +) async { + final t0 = DateTime.now(); + await _pumpUntil( + tester, + () => + voip.currentCall?.state == CallState.kConnected && + partner.delegate.call?.state == CallState.kConnected, + timeout: const Duration(seconds: 60), + what: 'Anruf verbunden', + every5s: () { + final a = voip.currentCall; + final b = partner.delegate.call; + _log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | ' + 'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}'); + }, + ); + _log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms'); +} + +Future _checkAudio( + WidgetTester tester, + CallSignalingService voip, + _Partner partner, +) async { + await _pumpFor(tester, const Duration(seconds: 4)); + final appAudio = (await tester.runAsync( + () => _rtp(voip.currentCall!.pc!, 'audio'), + ))!; + final partnerAudio = (await tester.runAsync( + () => _rtp(partner.delegate.call!.pc!, 'audio'), + ))!; + _log('TON App: $appAudio | Gegenseite: $partnerAudio'); + expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton'); + expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton'); + expect(partnerAudio.received, greaterThan(0), + reason: 'Gegenseite empfängt keinen Ton der App'); +} + +class _Rtp { + _Rtp(this.sent, this.received); + final num sent; + final num received; + @override + String toString() => 'gesendet=$sent B, empfangen=$received B'; +} + +Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async { + num sent = 0; + num received = 0; + for (final r in await pc.getStats()) { + if (r.values['kind'] != kind) continue; + if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0; + if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0; + } + return _Rtp(sent, received); +} + +/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess. +class _Partner { + _Partner(this.client, this.delegate, this.voip, this.zone) { + _sub = client.onCallEvents.stream.listen((events) { + for (final e in events) { + if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) { + continue; + } + final list = e.content.tryGetList>('candidates'); + if (list != null) _buffer.addAll(list); + } + }); + _timer = Timer.periodic(const Duration(milliseconds: 500), (_) async { + final pc = delegate.call?.pc; + if (!fixCandidates || pc == null) return; + try { + if (await pc.getRemoteDescription() == null) return; + for (final c in List.of(_buffer)) { + final cand = c['candidate'] as String?; + if (cand == null || !_applied.add(cand)) continue; + await pc.addCandidate(RTCIceCandidate( + cand, + c['sdpMid']?.toString() ?? '', + (c['sdpMLineIndex'] as num?)?.round() ?? 0, + )); + } + } catch (_) {} + }); + } + final Client client; + final _PartnerDelegate delegate; + final VoIP voip; + final Zone zone; + + /// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer). + Future run(Future Function() f) { + final done = Completer(); + zone.run(() async { + try { + done.complete(await f()); + } catch (e, s) { + done.completeError(e, s); + } + }); + return done.future; + } + + /// Gegenseite reicht Kandidaten nach wie die gepatchte App. + bool fixCandidates = false; + final _buffer = >[]; + final _applied = {}; + StreamSubscription? _sub; + Timer? _timer; + + static Future<_Partner> start(String user, String pass) async { + final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + final client = Client( + 'pyramid-call-partner', + database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db), + ); + await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false); + await client.checkHomeserver(Uri.parse('https://steggi-matrix.work')); + await client.login( + LoginType.mLoginPassword, + identifier: AuthenticationUserIdentifier(user: user), + password: pass, + initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)', + ); + final delegate = _PartnerDelegate(); + // Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie + // im Testrahmen zu verlieren. + final zone = Zone.current.fork( + specification: ZoneSpecification( + handleUncaughtError: (self, parent, z, e, s) => + _log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'), + ), + ); + final voip = zone.run(() => VoIP(client, delegate)); + return _Partner(client, delegate, voip, zone); + } + + Future dispose(String? roomId) async { + _timer?.cancel(); + await _sub?.cancel(); + try { + await delegate.call?.hangup(reason: CallErrorCode.userHangup); + } catch (_) {} + if (roomId != null) { + try { + await client.leaveRoom(roomId); + await client.forgetRoom(roomId); + } catch (_) {} + } + try { + client.backgroundSync = false; + await client.logout(); + } catch (_) {} + } +} + +class _PartnerDelegate implements WebRTCDelegate { + CallSession? call; + + @override + MediaDevices get mediaDevices => _PartnerMedia(rtc.navigator.mediaDevices); + + @override + Future createPeerConnection( + Map configuration, [ + Map constraints = const {}, + ]) => + rtc.createPeerConnection(configuration, constraints); + + @override + Future handleNewCall(CallSession session) async { + call = session; + if (session.direction == CallDirection.kIncoming) { + // Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen + // trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer() + // eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet + // wird (CallSession.onCandidatesReceived vs. answer()). + unawaited(Future.delayed(const Duration(seconds: 2), () async { + try { + await session.answer(); + } catch (e) { + _log('Gegenseite: Annehmen fehlgeschlagen: $e'); + } + })); + } + } + + @override + Future handleCallEnded(CallSession session) async {} + @override + Future handleMissedCall(CallSession session) async {} + @override + Future handleNewGroupCall(GroupCallSession groupCall) async {} + @override + Future handleGroupCallEnded(GroupCallSession groupCall) async {} + @override + Future playRingtone() async {} + @override + Future stopRingtone() async {} + @override + Future registerListeners(CallSession session) async {} + @override + bool get isWeb => false; + @override + bool get canHandleNewCall => true; + @override + EncryptionKeyProvider? get keyProvider => null; +} + +/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor). +class _PartnerMedia extends MediaDevices { + _PartnerMedia(this._d); + final MediaDevices _d; + + @override + Future getUserMedia(Map c) => _d.getUserMedia(c); + + @override + Future getDisplayMedia(Map c) async { + final sources = await rtc.desktopCapturer + .getSources(types: [rtc.SourceType.Screen]); + return _d.getDisplayMedia({ + 'video': { + 'mandatory': { + 'chromeMediaSource': 'desktop', + 'chromeMediaSourceId': sources.first.id, + }, + 'optional': [], + }, + 'audio': false, + }); + } + + @override + Future> enumerateDevices() => _d.enumerateDevices(); + @override + MediaTrackSupportedConstraints getSupportedConstraints() => + _d.getSupportedConstraints(); + @override + set ondevicechange(Function(dynamic event)? callback) => + _d.ondevicechange = callback; + @override + Function(dynamic event)? get ondevicechange => _d.ondevicechange; + @override + // ignore: deprecated_member_use + Future> getSources() => _d.getSources(); + @override + Future selectAudioOutput([AudioOutputOptions? options]) => + _d.selectAudioOutput(options); +} + +Map? _loadAccounts() { + final home = Platform.environment['USERPROFILE'] ?? ''; + final file = File('$home\\.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; +} + +Future _pumpFor(WidgetTester tester, Duration d) async { + final end = DateTime.now().add(d); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +void _log(String m) { + // ignore: avoid_print + print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m'); +} + +Future _pumpUntil( + WidgetTester tester, + bool Function() condition, { + Duration timeout = const Duration(seconds: 60), + String what = '', + void Function()? onTimeout, + void Function()? every5s, +}) async { + final end = DateTime.now().add(timeout); + var next = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + if (condition()) return; + if (every5s != null && DateTime.now().isAfter(next)) { + every5s(); + next = DateTime.now().add(const Duration(seconds: 5)); + } + } + _log('ZEITÜBERSCHREITUNG: $what'); + onTimeout?.call(); + fail('Zeitüberschreitung: $what'); +} diff --git a/integration_test/live_receipts_invite_test.dart b/integration_test/live_receipts_invite_test.dart index 80afcda..818e414 100644 --- a/integration_test/live_receipts_invite_test.dart +++ b/integration_test/live_receipts_invite_test.dart @@ -121,6 +121,39 @@ void main() { ); expect(find.text('Annehmen'), findsNothing); + // ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ── + // (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten + // desselben Absenders hintereinander, gelesen bis zur ersten.) + container.read(activeRoomIdProvider.notifier).state = null; + await _pumpFor(tester, const Duration(seconds: 1)); + final ids = (await tester.runAsync(() async => [ + await partner!.sendText(inviteRoomId, 'Gruppe 1'), + await partner.sendText(inviteRoomId, 'Gruppe 2'), + await partner.sendText(inviteRoomId, 'Gruppe 3'), + ]))!; + final room = client.getRoomById(inviteRoomId)!; + // Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden. + final tEnd = DateTime.now().add(const Duration(seconds: 30)); + while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) { + await _pumpFor(tester, const Duration(milliseconds: 300)); + } + await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0])); + final fEnd = DateTime.now().add(const Duration(seconds: 30)); + while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) { + await _pumpFor(tester, const Duration(milliseconds: 300)); + } + expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an'); + container.read(activeRoomIdProvider.notifier).state = inviteRoomId; + await _pumpUntil(tester, find.text('Neue Nachrichten'), + timeout: const Duration(seconds: 20)); + // Nach der Verweildauer (6 s) + Ausblenden muss er weg sein. + await _pumpUntil( + tester, + find.text('Neue Nachrichten'), + gone: true, + timeout: const Duration(seconds: 15), + ); + // Aufräumen: Testraum verlassen + vergessen. await tester.runAsync(() async { final r = client.getRoomById(inviteRoomId); @@ -197,11 +230,12 @@ class _RestUser { 'preset': 'private_chat', }))['room_id'] as String; - Future sendText(String roomId, String text) => _call( + Future sendText(String roomId, String text) async => + (await _call( 'PUT', '/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}', {'msgtype': 'm.text', 'body': text}, - ); + ))['event_id'] as String; Future invite(String roomId, String userId) => _call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId}); diff --git a/lib/features/call_signaling/call_signaling_service.dart b/lib/features/call_signaling/call_signaling_service.dart index d703d13..b53388f 100644 --- a/lib/features/call_signaling/call_signaling_service.dart +++ b/lib/features/call_signaling/call_signaling_service.dart @@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier { Future changeQuality(String key); + /// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den + /// grünen Sprech-Ring.) + bool get isLocalSpeaking => false; + bool get isRemoteSpeaking => false; + /// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false). /// Auf dem PC ohne Bedeutung. bool get isSpeakerOn => true; diff --git a/lib/features/call_signaling/voip_manager.dart b/lib/features/call_signaling/voip_manager.dart index 20804d1..6cc750c 100644 --- a/lib/features/call_signaling/voip_manager.dart +++ b/lib/features/call_signaling/voip_manager.dart @@ -20,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService final Client client; late final VoIP voip; + late final Zone _sdkZone; @override CallSession? currentCall; @@ -81,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService PyramidVoipManager(this.client) { _instance = this; - voip = VoIP(client, this); + // VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe + // _bufferRemoteCandidates). + _callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates); + // Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler, + // die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten + // unter Windows, getRemoteDescription wirft statt null), werden + // protokolliert statt als „unbehandelt“ durchzuschlagen. + _sdkZone = Zone.current.fork( + specification: ZoneSpecification( + handleUncaughtError: (self, parent, zone, error, stack) { + debugPrint('[VOIP] SDK-Fehler (abgefangen): $error'); + }, + ), + ); + voip = _sdkZone.run(() => VoIP(client, this)); // Suppress speakerphone call — not supported on Windows/Desktop try { (voip as dynamic).onConfigSpeakerphone = (bool _) async {}; @@ -89,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService _ensureRenderers(); } + /// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen + /// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht + /// überqueren, und der Aufrufer würde ewig warten. + Future _inSdkZone(Future Function() f) { + final done = Completer(); + _sdkZone.run(() async { + try { + done.complete(await f()); + } catch (e, s) { + done.completeError(e, s); + } + }); + return done.future; + } + + @override + void dispose() { + _callEventSub?.cancel(); + _levelTimer?.cancel(); + _renderRefreshTimer?.cancel(); + super.dispose(); + } + Future _ensureRenderers() async { if (_renderersInitialized) return; try { @@ -137,10 +175,82 @@ class PyramidVoipManager extends CallSignalingService _setupCallListeners(call); _updateInternalStates(); notifyListeners(); + unawaited(_applyBufferedCandidates(call)); + } + + // ── Netzwerk-Kandidaten der Gegenseite puffern ─────────────────────────── + // Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor + // die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig + // („we do not have the call“); Kandidaten, die WÄHREND des Annehmens + // eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet + // wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das + // oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT + // dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest + // nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die + // Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert + // WebRTC. + StreamSubscription>? _callEventSub; + final _candidateBuffer = >>{}; + final _appliedCandidates = {}; + + void _bufferRemoteCandidates(List events) { + for (final e in events) { + if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) { + continue; + } + final callId = e.content.tryGet('call_id'); + final list = e.content.tryGetList>('candidates'); + if (callId == null || list == null) continue; + final party = e.content.tryGet('party_id'); + (_candidateBuffer[callId] ??= []).addAll( + list.map((c) => {...c, '_party': party}), + ); + // Nur die letzten Anrufe behalten. + while (_candidateBuffer.length > 8) { + _candidateBuffer.remove(_candidateBuffer.keys.first); + } + final call = currentCall; + if (call != null && call.callId == callId) { + unawaited(_applyBufferedCandidates(call)); + } + } + } + + Future _applyBufferedCandidates(CallSession call) async { + final pc = call.pc; + final list = _candidateBuffer[call.callId]; + if (pc == null || list == null || list.isEmpty) return; + try { + if (await pc.getRemoteDescription() == null) return; + } catch (_) { + return; + } + for (final c in List.of(list)) { + final cand = c['candidate']; + if (cand is! String || cand.isEmpty) continue; + final party = c['_party']; + final remoteParty = call.remotePartyId; + if (remoteParty != null && party != null && party != remoteParty) continue; + if (!_appliedCandidates.add('${call.callId}|$cand')) continue; + try { + await pc.addCandidate(rtc.RTCIceCandidate( + cand, + c['sdpMid']?.toString() ?? '', + (c['sdpMLineIndex'] as num?)?.round() ?? 0, + )); + } catch (e) { + debugPrint('[VOIP] Kandidat nachreichen: $e'); + } + } } Timer? _renderRefreshTimer; + String? _listenedCallId; void _setupCallListeners(CallSession call) { + // Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall – + // Listener nur einmal je Anruf anhängen. + if (_listenedCallId == call.callId) return; + _listenedCallId = call.callId; call.onCallStateChanged.stream.listen((state) { debugPrint('[VOIP] State: $state'); _updateInternalStates(); @@ -151,11 +261,15 @@ class PyramidVoipManager extends CallSignalingService call.setLocalVideoMuted(false); } _applySpeaker(); + _startLevelPolling(); // Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung. CallForeground.callStarted('voip', title: call.room.getLocalizedDisplayname()); } - if (state == CallState.kEnded) CallForeground.callEnded('voip'); + if (state == CallState.kEnded) { + _stopLevelPolling(); + CallForeground.callEnded('voip'); + } _updateRenderers(); }); // Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel – @@ -167,7 +281,15 @@ class PyramidVoipManager extends CallSignalingService _renderRefreshTimer?.cancel(); _renderRefreshTimer = Timer.periodic( const Duration(milliseconds: 1000), // Faster polling to catch feeds - (_) => _updateRenderers(), + (_) { + _updateRenderers(); + // Gepufferte Kandidaten nachreichen, sobald die Verbindung die + // Gegenseite kennt (nach Annehmen bzw. Antwort). + final c = currentCall; + if (c != null && c.state != CallState.kConnected) { + _applyBufferedCandidates(c); + } + }, ); _updateRenderers(); @@ -184,99 +306,66 @@ class PyramidVoipManager extends CallSignalingService } Future _updateRenderers() async { - if (currentCall == null || _isUpdating) return; + final call = currentCall; + if (call == null || _isUpdating) return; _isUpdating = true; try { await _ensureRenderers(); - final dynamic c = currentCall; - rtc.MediaStream? remote; - rtc.MediaStream? local; - - rtc.MediaStream? extract(dynamic obj) { - if (obj == null) return null; - if (obj is rtc.MediaStream) return obj; - try { return obj.stream as rtc.MediaStream?; } catch (_) {} - return null; - } - - final List feeds = []; - try { - final dynamic sdkFeeds = c.getFeeds(); - if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList()); - } catch (_) {} - - bool remoteScreenFound = false; - bool localScreenFound = false; - - for (final dynamic f in feeds) { - final s = extract(f); - if (s == null) continue; - - bool isLocal = false; - try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {} - - String purpose = 'm.usermedia'; - try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {} - - if (isLocal) { - if (local == null || purpose == 'm.screenshare') { - local = s; - if (purpose == 'm.screenshare') localScreenFound = true; - } - } else { - if (remote == null || purpose == 'm.screenshare') { - remote = s; - if (purpose == 'm.screenshare') remoteScreenFound = true; - } + // Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches + // `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde + // geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger + // angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08). + rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) { + final s = w?.stream; + // stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den + // Strom stehen und markiert ihn nur). + if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) { + return null; } + if (respectMute && w.videoMuted) return null; + return s; } - _isScreensharing = localScreenFound; - _isRemoteScreensharing = remoteScreenFound; + final remoteScreen = video(call.remoteScreenSharingStream); + final localScreen = video(call.localScreenSharingStream); + final remoteUser = call.remoteUserMediaStream?.stream; - if (remote == null) { - try { remote = extract(c.remoteUserMediaStream); } catch (_) {} - } - if (remote == null) { - try { remote = extract(c.remoteStream); } catch (_) {} - } - if (local == null) { - try { local = extract(c.localUserMediaStream); } catch (_) {} + var changed = false; + final remoteSharing = remoteScreen != null; + final localSharing = call.localScreenSharingStream != null; + if (remoteSharing != _isRemoteScreensharing || + localSharing != _isScreensharing) { + changed = true; } + _isRemoteScreensharing = remoteSharing; + _isScreensharing = localSharing; - // Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden — + // Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite – // nur einmal pro Stream, _updateRenderers läuft auch per Timer. - if (remote != null && remote.id != _volumeAppliedStreamId) { - _volumeAppliedStreamId = remote.id; - _remoteAudioStream = remote; + if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) { + _volumeAppliedStreamId = remoteUser.id; + _remoteAudioStream = remoteUser; applyOutputVolume(); } - // Final check for tracks before assigning - bool changed = false; - if (remote != null && remote.getVideoTracks().isNotEmpty) { - if (remoteRenderer.srcObject?.id != remote.id) { - debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)'); - remoteRenderer.srcObject = remote; - changed = true; - } - } else if (remoteRenderer.srcObject != null) { - remoteRenderer.srcObject = null; + // Bildschirm hat Vorrang vor der Kamera. + final remoteVideo = + remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true); + final localVideo = + localScreen ?? video(call.localUserMediaStream, respectMute: true); + + if (remoteRenderer.srcObject?.id != remoteVideo?.id) { + debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)'); + remoteRenderer.srcObject = remoteVideo; + changed = true; + } + if (localRenderer.srcObject?.id != localVideo?.id) { + debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)'); + localRenderer.srcObject = localVideo; changed = true; } - if (local != null && local.getVideoTracks().isNotEmpty) { - if (localRenderer.srcObject?.id != local.id) { - debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)'); - localRenderer.srcObject = local; - changed = true; - } - } else if (localRenderer.srcObject != null) { - localRenderer.srcObject = null; - changed = true; - } - if (changed) notifyListeners(); } catch (e) { debugPrint('[VOIP] Error in _updateRenderers: $e'); @@ -285,11 +374,64 @@ class PyramidVoipManager extends CallSignalingService } } + // ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ─────────────── + // Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo + // Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen + // Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an. + Timer? _levelTimer; + bool _localSpeaking = false; + bool _remoteSpeaking = false; + + @override + bool get isLocalSpeaking => _localSpeaking; + @override + bool get isRemoteSpeaking => _remoteSpeaking; + + void _startLevelPolling() { + _levelTimer?.cancel(); + _levelTimer = Timer.periodic( + const Duration(milliseconds: 300), + (_) => _pollLevels(), + ); + } + + void _stopLevelPolling() { + _levelTimer?.cancel(); + _levelTimer = null; + _localSpeaking = false; + _remoteSpeaking = false; + } + + Future _pollLevels() async { + final pc = currentCall?.pc; + if (pc == null) return; + try { + double local = 0; + double remote = 0; + for (final r in await pc.getStats()) { + if (r.values['kind'] != 'audio') continue; + final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0; + if (r.type == 'media-source' && level > local) local = level; + if (r.type == 'inbound-rtp' && level > remote) remote = level; + } + // Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern. + bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03; + final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local); + final rs = speaking(_remoteSpeaking, remote); + if (ls != _localSpeaking || rs != _remoteSpeaking) { + _localSpeaking = ls; + _remoteSpeaking = rs; + notifyListeners(); + } + } catch (_) {} + } + @override Future handleCallEnded(CallSession session) async { if (currentCall?.callId == session.callId) { _renderRefreshTimer?.cancel(); _renderRefreshTimer = null; + _stopLevelPolling(); localRenderer.srcObject = null; remoteRenderer.srcObject = null; _remoteAudioStream = null; @@ -354,7 +496,7 @@ class PyramidVoipManager extends CallSignalingService @override Future safeAction(Future Function() action) async { try { - await action(); + await _inSdkZone(action); await Future.delayed(const Duration(milliseconds: 1000)); } catch (e) { debugPrint('[VOIP] Action error: $e'); @@ -369,7 +511,8 @@ class PyramidVoipManager extends CallSignalingService _isMicMuted = !_isMicMuted; notifyListeners(); try { - await currentCall!.setMicrophoneMuted(_isMicMuted); + final c = currentCall!; + await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted)); } catch (e) { _isMicMuted = !_isMicMuted; notifyListeners(); @@ -382,7 +525,8 @@ class PyramidVoipManager extends CallSignalingService _isCameraMuted = !_isCameraMuted; notifyListeners(); try { - await currentCall!.setLocalVideoMuted(_isCameraMuted); + final c = currentCall!; + await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted)); for (var i = 0; i < 5; i++) { await Future.delayed(Duration(milliseconds: 300 + (i * 200))); await _updateRenderers(); @@ -445,14 +589,15 @@ class PyramidVoipManager extends CallSignalingService _speakerOn = true; debugPrint('[VOIP] Starting call in room $roomId (video=$video)'); - final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice); + final call = await _inSdkZone( + () => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice)); currentCall = call; _setupCallListeners(call); if (video) { await Future.delayed(const Duration(milliseconds: 800)); - await call.setLocalVideoMuted(false); + await _inSdkZone(() => call.setLocalVideoMuted(false)); await _updateRenderers(); } diff --git a/lib/features/call_ui/view/call_view.dart b/lib/features/call_ui/view/call_view.dart index 43eae7f..7fe7fbb 100644 --- a/lib/features/call_ui/view/call_view.dart +++ b/lib/features/call_ui/view/call_view.dart @@ -87,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> { 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); + // Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt + // das große Bild bei jedem Sprechwechsel hin und her. + final stable = [...widget.participants]..sort((a, b) { + int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2); + return r(a).compareTo(r(b)); + }); + final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? + (stable.isNotEmpty ? stable.first : null); if (widget.isConnecting) { return Scaffold( diff --git a/lib/features/call_ui/view/voice_channel_core.dart b/lib/features/call_ui/view/voice_channel_core.dart index 68a544a..4816d27 100644 --- a/lib/features/call_ui/view/voice_channel_core.dart +++ b/lib/features/call_ui/view/voice_channel_core.dart @@ -151,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget { name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote', videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, isMuted: false, + isSpeaking: voip.isRemoteSpeaking, hasVideo: hasRemoteVideo, isScreenShare: isRemoteScreen, avatarColor: _avatarColors[1], @@ -165,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget { name: 'Du', videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, isMuted: voip.isMicMuted, + isSpeaking: voip.isLocalSpeaking, hasVideo: hasLocalVideo, isScreenShare: isLocalScreen, avatarColor: _avatarColors[0], diff --git a/lib/features/chat/message/message_bubbles.dart b/lib/features/chat/message/message_bubbles.dart index f020c54..89fcec6 100644 --- a/lib/features/chat/message/message_bubbles.dart +++ b/lib/features/chat/message/message_bubbles.dart @@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState { rows.add(_ReadMarker(pt: pt)); } if (widget.fullyReadId == event.eventId && !isLast) { - rows.add(_InlineUnreadDivider(pt: pt)); + rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading)); } } diff --git a/lib/features/chat/message/message_group_core.dart b/lib/features/chat/message/message_group_core.dart index 39a40ba..06de2f5 100644 --- a/lib/features/chat/message/message_group_core.dart +++ b/lib/features/chat/message/message_group_core.dart @@ -32,6 +32,9 @@ class MessageGroup extends StatefulWidget { /// When non-null and pointing to a non-last event in this group, an inline /// "Neue Nachrichten" divider is rendered after that event's row. final String? fullyReadId; + /// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm. + final bool unreadFading; + final GlobalKey? unreadDividerKey; /// Jump-to-message (from search): the row matching [jumpEventId] gets /// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted. final String? jumpEventId; @@ -55,6 +58,8 @@ class MessageGroup extends StatefulWidget { this.showStatusIndicator = false, this.readEventIds = const {}, this.fullyReadId, + this.unreadFading = false, + this.unreadDividerKey, this.jumpEventId, this.jumpKey, this.highlightEventId, @@ -155,7 +160,7 @@ class _MessageGroupState extends State { // Inline unread divider after the first event when it's the fullyRead // marker and there are more events in this group. if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) { - rows.add(_InlineUnreadDivider(pt: pt)); + rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading)); } // ── Continuation event rows (siblings, same indentation) ── @@ -174,7 +179,7 @@ class _MessageGroupState extends State { if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt)); // Inline divider after this event if it's the fullyRead and not the last. if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) { - rows.add(_InlineUnreadDivider(pt: pt)); + rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading)); } } diff --git a/lib/features/chat/message/message_shared.dart b/lib/features/chat/message/message_shared.dart index 535ed44..cf3f574 100644 --- a/lib/features/chat/message/message_shared.dart +++ b/lib/features/chat/message/message_shared.dart @@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget { // fullyRead event is in the middle of the group (same sender, <5 min window). class _InlineUnreadDivider extends StatelessWidget { final PyramidTheme pt; - const _InlineUnreadDivider({required this.pt}); + final bool fading; + const _InlineUnreadDivider({super.key, required this.pt, this.fading = false}); @override Widget build(BuildContext context) { - return Padding( + // Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue + // Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen + // stehen – Bernd 2026-10-08). + return AnimatedOpacity( + opacity: fading ? 0.0 : 1.0, + duration: const Duration(milliseconds: 700), + curve: Curves.easeOut, + child: Padding( padding: const EdgeInsets.fromLTRB(0, 8, 0, 4), child: Row( children: [ @@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget { Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), ], ), + ), ); } } diff --git a/lib/features/chat/view/chat_message_list.dart b/lib/features/chat/view/chat_message_list.dart index 13a1a09..8abf12a 100644 --- a/lib/features/chat/view/chat_message_list.dart +++ b/lib/features/chat/view/chat_message_list.dart @@ -216,10 +216,11 @@ class _MessageList extends StatelessWidget { // Pass fullyReadId to the group only when the event is mid-group // (not the last event) so MessageGroup can render an inline divider. // groupContainsFullyRead already guarantees fullyReadId != null. - final passedFullyReadId = - (groupContainsFullyRead && group.last.eventId != fullyReadId) - ? fullyReadId - : null; + final passedFullyReadId = (hasUnread && + groupContainsFullyRead && + group.last.eventId != fullyReadId) + ? fullyReadId + : null; itemFullyReadIds.add(passedFullyReadId); if (isLastOfDay) { @@ -292,6 +293,9 @@ class _MessageList extends StatelessWidget { showStatusIndicator: showStatus, readEventIds: readEventIds, fullyReadId: itemFullyReadIds[i], + unreadFading: unreadFading, + unreadDividerKey: + itemFullyReadIds[i] != null ? unreadDividerKey : null, jumpEventId: jumpEventId, jumpKey: jumpKey, highlightEventId: highlightEventId, diff --git a/lib/features/chat/view/chat_misc.dart b/lib/features/chat/view/chat_misc.dart index 9033f2d..6fb0764 100644 --- a/lib/features/chat/view/chat_misc.dart +++ b/lib/features/chat/view/chat_misc.dart @@ -305,7 +305,10 @@ class _ActiveCallBar extends ConsumerWidget { final lk = ref.watch(voiceChannelProvider); final voipCall = voip.currentCall; final String? name; - if (voipCall != null && voipCall.state != CallState.kEnded) { + // Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster. + if (voipCall != null && + voipCall.state != CallState.kEnded && + voipCall.state != CallState.kRinging) { name = voipCall.room.getLocalizedDisplayname(); } else if (lk.isActive || lk.isConnecting) { name = lk.displayName; diff --git a/scripts/test.ps1 b/scripts/test.ps1 index 256c901..7df1a59 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -126,6 +126,10 @@ if ($Ui) { Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" { flutter test integration_test/live_receipts_invite_test.dart -d windows } + # Nutzt kurz Mikrofon + Bildschirm (nur lokal zwischen zwei Testkonten). + Invoke-Step "Live: 1:1-Anruf, Ton + Bildschirm teilen (2 Testkonten)" { + flutter test integration_test/live_call_test.dart -d windows + } } else { Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt" }