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'); }