import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:matrix/matrix.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:webrtc_interface/webrtc_interface.dart'; // Gemeinsame Hilfen für Live-Tests mit zwei Testkonten (Anruftest, // UI-Tour): zweiter Matrix-Client im selben Prozess, Zugangsdaten, // Warte-Helfer. Kein eigenständiger Test. /// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess. class TestPartner { TestPartner(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 TestPartnerDelegate 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 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 = TestPartnerDelegate(); // 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) => logStep('Gegenseite (abgefangen): ${e.toString().split('\n').first}'), ), ); final voip = zone.run(() => VoIP(client, delegate)); return TestPartner(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 TestPartnerDelegate implements WebRTCDelegate { CallSession? call; @override MediaDevices get mediaDevices => TestPartnerMedia(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) { logStep('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 TestPartnerMedia extends MediaDevices { TestPartnerMedia(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? loadTestAccounts() { 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 logStep(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)); } } logStep('ZEITÜBERSCHREITUNG: $what'); onTimeout?.call(); fail('Zeitüberschreitung: $what'); }