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:webrtc_interface/webrtc_interface.dart'; import 'support/test_partner.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 = loadTestAccounts(); testWidgets( '1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen', (tester) async { Logs().level = Level.verbose; // app.main() ersetzt FlutterError.onError – danach den Empfänger des // Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen. final testOnError = FlutterError.onError; app.main(); await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty); FlutterError.onError = testOnError; 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( () => TestPartner.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)', )); logStep('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()); logStep('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“) logStep('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); // Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus. bool partnerSeesMuted() => partner.delegate.call?.remoteUserMediaStream?.audioMuted == true; await tester.tap(_ctrlBtn(Icons.mic_rounded)); await pumpUntil(tester, partnerSeesMuted, timeout: const Duration(seconds: 15), what: 'Gegenseite sieht: App ist stumm'); expect(voip.isMicMuted, isTrue); expect( voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled, isFalse, reason: 'Mikrofonspur ist trotz Stumm noch an', ); await tester.tap(_ctrlBtn(Icons.mic_off_rounded)); await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted, timeout: const Duration(seconds: 15), what: 'Stumm wieder aufgehoben'); logStep('Stumm/Entstummen über den Knopf: ok'); // Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück. await tester.tap(_ctrlBtn(Icons.headphones_rounded)); await pumpUntil( tester, () => voip.isDeafened && partnerSeesMuted(), timeout: const Duration(seconds: 15), what: 'Taub: selbst stumm', ); final remoteAudio = voip.currentCall!.getRemoteStreams .expand((w) => w.stream?.getAudioTracks() ?? const []); expect(remoteAudio, isNotEmpty); expect(remoteAudio.every((t) => !t.enabled), isTrue, reason: 'Ton der Gegenseite ist trotz Taub noch an'); await tester.tap(_ctrlBtn(Icons.headset_off_rounded)); await pumpUntil( tester, () => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted, timeout: const Duration(seconds: 15), what: 'Taub aufgehoben, Mikro wieder an', ); expect( voip.currentCall!.getRemoteStreams .expand((w) => w.stream?.getAudioTracks() ?? const []) .every((t) => t.enabled), isTrue, ); logStep('Taub/Hören über den Knopf: ok'); // 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'), ))!; logStep('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'), ))!; logStep('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 ═══ logStep('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, TestPartner 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; logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | ' 'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}'); }, ); logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms'); } Future _checkAudio( WidgetTester tester, CallSignalingService voip, TestPartner 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'), ))!; logStep('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); } /// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der /// Teilnehmer-Kachel, die dasselbe Symbol trägt). Finder _ctrlBtn(IconData icon) => find.descendant( of: find.byWidgetPredicate((w) => w.runtimeType.toString() == '_VoiceCtrlBtn' && (w as dynamic).icon == icon), matching: find.byIcon(icon), ).first;